`
+ - **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/backend/codincod_api/Dockerfile b/libs/backend/codincod_api/Dockerfile
new file mode 100644
index 00000000..ad5704f2
--- /dev/null
+++ b/libs/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/backend/codincod_api/README.md b/libs/backend/codincod_api/README.md
new file mode 100644
index 00000000..269273c0
--- /dev/null
+++ b/libs/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/backend/codincod_api/config/config.exs b/libs/backend/codincod_api/config/config.exs
new file mode 100644
index 00000000..276b6266
--- /dev/null
+++ b/libs/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/backend/codincod_api/config/dev.exs b/libs/backend/codincod_api/config/dev.exs
new file mode 100644
index 00000000..6532b9a2
--- /dev/null
+++ b/libs/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/backend/codincod_api/config/prod.exs b/libs/backend/codincod_api/config/prod.exs
new file mode 100644
index 00000000..30c73506
--- /dev/null
+++ b/libs/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/backend/codincod_api/config/runtime.exs b/libs/backend/codincod_api/config/runtime.exs
new file mode 100644
index 00000000..e003089f
--- /dev/null
+++ b/libs/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/backend/codincod_api/config/test.exs b/libs/backend/codincod_api/config/test.exs
new file mode 100644
index 00000000..5381ef9a
--- /dev/null
+++ b/libs/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/backend/codincod_api/cookies.txt b/libs/backend/codincod_api/cookies.txt
new file mode 100644
index 00000000..c31d9899
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api.ex b/libs/backend/codincod_api/lib/codincod_api.ex
new file mode 100644
index 00000000..f3714ff9
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts.ex b/libs/backend/codincod_api/lib/codincod_api/accounts.ex
new file mode 100644
index 00000000..6829208c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts/email.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/email.ex
new file mode 100644
index 00000000..46a6f939
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts/password.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/password.ex
new file mode 100644
index 00000000..a2f84e5c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts/password_reset.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/password_reset.ex
new file mode 100644
index 00000000..1e94691d
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts/preference.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/preference.ex
new file mode 100644
index 00000000..523afd04
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts/user.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/user.ex
new file mode 100644
index 00000000..9e41815c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/accounts/user_ban.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/user_ban.ex
new file mode 100644
index 00000000..0ac3ea19
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/application.ex b/libs/backend/codincod_api/lib/codincod_api/application.ex
new file mode 100644
index 00000000..e4baad9c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/chat.ex b/libs/backend/codincod_api/lib/codincod_api/chat.ex
new file mode 100644
index 00000000..73da0c44
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/chat/chat_message.ex b/libs/backend/codincod_api/lib/codincod_api/chat/chat_message.ex
new file mode 100644
index 00000000..dce8aa49
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/comments.ex b/libs/backend/codincod_api/lib/codincod_api/comments.ex
new file mode 100644
index 00000000..73d20900
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/comments/comment.ex b/libs/backend/codincod_api/lib/codincod_api/comments/comment.ex
new file mode 100644
index 00000000..8553b349
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/comments/comment_vote.ex b/libs/backend/codincod_api/lib/codincod_api/comments/comment_vote.ex
new file mode 100644
index 00000000..758e7962
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/games.ex b/libs/backend/codincod_api/lib/codincod_api/games.ex
new file mode 100644
index 00000000..fa95ca6f
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/games/game.ex b/libs/backend/codincod_api/lib/codincod_api/games/game.ex
new file mode 100644
index 00000000..8177992e
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/games/game_player.ex b/libs/backend/codincod_api/lib/codincod_api/games/game_player.ex
new file mode 100644
index 00000000..c01bf2c4
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/languages.ex b/libs/backend/codincod_api/lib/codincod_api/languages.ex
new file mode 100644
index 00000000..2699c905
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/languages/programming_language.ex b/libs/backend/codincod_api/lib/codincod_api/languages/programming_language.ex
new file mode 100644
index 00000000..3af4d011
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/mailer.ex b/libs/backend/codincod_api/lib/codincod_api/mailer.ex
new file mode 100644
index 00000000..cc90f56f
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/metrics.ex b/libs/backend/codincod_api/lib/codincod_api/metrics.ex
new file mode 100644
index 00000000..e1892ede
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex b/libs/backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex
new file mode 100644
index 00000000..a105f45e
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/metrics/user_metric.ex b/libs/backend/codincod_api/lib/codincod_api/metrics/user_metric.ex
new file mode 100644
index 00000000..2348a984
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/moderation.ex b/libs/backend/codincod_api/lib/codincod_api/moderation.ex
new file mode 100644
index 00000000..8b2f41fd
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex b/libs/backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex
new file mode 100644
index 00000000..3565ee6a
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/moderation/report.ex b/libs/backend/codincod_api/lib/codincod_api/moderation/report.ex
new file mode 100644
index 00000000..e8ef8a60
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/piston.ex b/libs/backend/codincod_api/lib/codincod_api/piston.ex
new file mode 100644
index 00000000..8254fb4b
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/piston/client.ex b/libs/backend/codincod_api/lib/codincod_api/piston/client.ex
new file mode 100644
index 00000000..81d7bfe7
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/piston/mock.ex b/libs/backend/codincod_api/lib/codincod_api/piston/mock.ex
new file mode 100644
index 00000000..dccc65de
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/puzzles.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles.ex
new file mode 100644
index 00000000..577d768e
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex
new file mode 100644
index 00000000..d50f1b8d
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex
new file mode 100644
index 00000000..40540eaa
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex
new file mode 100644
index 00000000..e5fe95de
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex
new file mode 100644
index 00000000..446bd442
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex
new file mode 100644
index 00000000..7536ed7f
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/repo.ex b/libs/backend/codincod_api/lib/codincod_api/repo.ex
new file mode 100644
index 00000000..a1a5210c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/submissions.ex b/libs/backend/codincod_api/lib/codincod_api/submissions.ex
new file mode 100644
index 00000000..09333943
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/submissions/evaluator.ex b/libs/backend/codincod_api/lib/codincod_api/submissions/evaluator.ex
new file mode 100644
index 00000000..da72e785
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/submissions/submission.ex b/libs/backend/codincod_api/lib/codincod_api/submissions/submission.ex
new file mode 100644
index 00000000..a1a49943
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api/typegen.ex b/libs/backend/codincod_api/lib/codincod_api/typegen.ex
new file mode 100644
index 00000000..b6fb71fb
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web.ex b/libs/backend/codincod_api/lib/codincod_api_web.ex
new file mode 100644
index 00000000..b2fbf967
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex b/libs/backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex
new file mode 100644
index 00000000..220967b9
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/auth/guardian.ex b/libs/backend/codincod_api/lib/codincod_api_web/auth/guardian.ex
new file mode 100644
index 00000000..8592c855
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex b/libs/backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex
new file mode 100644
index 00000000..8b790174
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex b/libs/backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex
new file mode 100644
index 00000000..a6381a51
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex b/libs/backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex
new file mode 100644
index 00000000..0eea4ee8
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex
new file mode 100644
index 00000000..2228945d
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex
new file mode 100644
index 00000000..002a84a8
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex
new file mode 100644
index 00000000..74ec32f8
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex
new file mode 100644
index 00000000..deaddaf3
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex
new file mode 100644
index 00000000..9eaaca75
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex
new file mode 100644
index 00000000..48f76181
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex
new file mode 100644
index 00000000..6086c4a8
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex
new file mode 100644
index 00000000..e96f0b87
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex
new file mode 100644
index 00000000..c29db530
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex
new file mode 100644
index 00000000..303ce246
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex
new file mode 100644
index 00000000..ffa92e28
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex
new file mode 100644
index 00000000..3393771b
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex
new file mode 100644
index 00000000..36ff49e8
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex
new file mode 100644
index 00000000..31058fff
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex
new file mode 100644
index 00000000..bc491547
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex
new file mode 100644
index 00000000..a683acf6
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex
new file mode 100644
index 00000000..0ea83b99
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex
new file mode 100644
index 00000000..4fe8fbac
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex
new file mode 100644
index 00000000..8c56e51c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/endpoint.ex b/libs/backend/codincod_api/lib/codincod_api_web/endpoint.ex
new file mode 100644
index 00000000..56c6c83a
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/gettext.ex b/libs/backend/codincod_api/lib/codincod_api_web/gettext.ex
new file mode 100644
index 00000000..072e3ff7
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/open_api.ex b/libs/backend/codincod_api/lib/codincod_api_web/open_api.ex
new file mode 100644
index 00000000..eb84e804
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex
new file mode 100644
index 00000000..4edb7b23
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex
new file mode 100644
index 00000000..0f9177ab
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex
new file mode 100644
index 00000000..3a118432
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex
new file mode 100644
index 00000000..ccdaebf5
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex
new file mode 100644
index 00000000..9937ca78
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex
new file mode 100644
index 00000000..f6d2b296
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex
new file mode 100644
index 00000000..c18202d9
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex
new file mode 100644
index 00000000..a2c46a1e
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex
new file mode 100644
index 00000000..1291e5d2
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex
new file mode 100644
index 00000000..3dea107f
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex
new file mode 100644
index 00000000..e233b19f
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex
new file mode 100644
index 00000000..6d41d8d6
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex
new file mode 100644
index 00000000..1bd6748a
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex
new file mode 100644
index 00000000..fc138e7b
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex
new file mode 100644
index 00000000..125c50c0
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex
new file mode 100644
index 00000000..262581f7
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex
new file mode 100644
index 00000000..cce04ab3
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex
new file mode 100644
index 00000000..61bcc7d5
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/router.ex b/libs/backend/codincod_api/lib/codincod_api_web/router.ex
new file mode 100644
index 00000000..32f0b735
--- /dev/null
+++ b/libs/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"]
+
+ 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/backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex
new file mode 100644
index 00000000..3ff6458a
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex
new file mode 100644
index 00000000..6eaa281d
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex
new file mode 100644
index 00000000..fc59de38
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex
new file mode 100644
index 00000000..f37d31c8
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/codincod_api_web/telemetry.ex b/libs/backend/codincod_api/lib/codincod_api_web/telemetry.ex
new file mode 100644
index 00000000..8cf1ef41
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex b/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex
new file mode 100644
index 00000000..e7974f3c
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex b/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex
new file mode 100644
index 00000000..d47945a0
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/mix/tasks/migrate_mongo.ex b/libs/backend/codincod_api/lib/mix/tasks/migrate_mongo.ex
new file mode 100644
index 00000000..95b3f100
--- /dev/null
+++ b/libs/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/backend/codincod_api/lib/mix/tasks/mongo.inspect.ex b/libs/backend/codincod_api/lib/mix/tasks/mongo.inspect.ex
new file mode 100644
index 00000000..f0249eef
--- /dev/null
+++ b/libs/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/backend/codincod_api/mix.exs b/libs/backend/codincod_api/mix.exs
new file mode 100644
index 00000000..d93264ea
--- /dev/null
+++ b/libs/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/backend/codincod_api/mix.lock b/libs/backend/codincod_api/mix.lock
new file mode 100644
index 00000000..99f0cecd
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po b/libs/backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po
new file mode 100644
index 00000000..844c4f5c
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/gettext/errors.pot b/libs/backend/codincod_api/priv/gettext/errors.pot
new file mode 100644
index 00000000..eef2de2b
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/.formatter.exs b/libs/backend/codincod_api/priv/repo/migrations/.formatter.exs
new file mode 100644
index 00000000..49f9151e
--- /dev/null
+++ b/libs/backend/codincod_api/priv/repo/migrations/.formatter.exs
@@ -0,0 +1,4 @@
+[
+ import_deps: [:ecto_sql],
+ inputs: ["*.exs"]
+]
diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs
new file mode 100644
index 00000000..f6ae5128
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs
new file mode 100644
index 00000000..f553b073
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs
new file mode 100644
index 00000000..fece76d5
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs
new file mode 100644
index 00000000..e743d599
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs
new file mode 100644
index 00000000..824f252b
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs
new file mode 100644
index 00000000..fba90797
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs
new file mode 100644
index 00000000..bbf9eb91
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs
new file mode 100644
index 00000000..95f4e11d
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs b/libs/backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs
new file mode 100644
index 00000000..1c09ce34
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs b/libs/backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs
new file mode 100644
index 00000000..58132f2c
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs b/libs/backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs
new file mode 100644
index 00000000..6df3b2d2
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs b/libs/backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs
new file mode 100644
index 00000000..4c3d5cdf
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs b/libs/backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs
new file mode 100644
index 00000000..e8571071
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/scripts/verify_migration.exs b/libs/backend/codincod_api/priv/repo/scripts/verify_migration.exs
new file mode 100644
index 00000000..2b3dc88f
--- /dev/null
+++ b/libs/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/backend/codincod_api/priv/repo/seeds.exs b/libs/backend/codincod_api/priv/repo/seeds.exs
new file mode 100644
index 00000000..2e945373
--- /dev/null
+++ b/libs/backend/codincod_api/priv/repo/seeds.exs
@@ -0,0 +1,433 @@
+# Script for populating the database with test data
+#
+# Run with: mix run priv/repo/seeds.exs
+#
+# This will create test users, puzzles, and other data for development
+
+alias CodincodApi.Repo
+alias CodincodApi.Accounts
+alias CodincodApi.Accounts.User
+alias CodincodApi.Puzzles
+alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator}
+alias CodincodApi.Languages
+alias CodincodApi.Languages.ProgrammingLanguage
+
+require Logger
+
+# Helper to safely insert or find existing record
+defmodule SeedHelpers do
+ def insert_or_find(module, attrs, unique_field) do
+ case Repo.get_by(module, [{unique_field, Map.get(attrs, unique_field)}]) do
+ nil ->
+ %{module.__struct__() | id: Ecto.UUID.generate()}
+ |> module.changeset(attrs)
+ |> Repo.insert!()
+
+ existing ->
+ Logger.info("#{module} with #{unique_field}=#{Map.get(attrs, unique_field)} already exists")
+ existing
+ end
+ end
+end
+
+Logger.info("🌱 Starting seed process...")
+
+# ============================================================================
+# PROGRAMMING LANGUAGES
+# ============================================================================
+Logger.info("Creating programming languages...")
+
+languages = [
+ %{
+ name: "python",
+ version: "3.12.0",
+ runtime: "python",
+ piston_name: "python"
+ },
+ %{
+ name: "javascript",
+ version: "18.15.0",
+ runtime: "node",
+ piston_name: "javascript"
+ },
+ %{
+ name: "ruby",
+ version: "3.2.0",
+ runtime: "ruby",
+ piston_name: "ruby"
+ },
+ %{
+ name: "rust",
+ version: "1.68.2",
+ runtime: "rust",
+ piston_name: "rust"
+ },
+ %{
+ name: "elixir",
+ version: "1.14.0",
+ runtime: "elixir",
+ piston_name: "elixir"
+ },
+ %{
+ name: "go",
+ version: "1.21.0",
+ runtime: "go",
+ piston_name: "go"
+ }
+]
+
+_created_languages =
+ Enum.map(languages, fn lang_attrs ->
+ SeedHelpers.insert_or_find(ProgrammingLanguage, lang_attrs, :name)
+ end)
+
+# ============================================================================
+# TEST USERS
+# ============================================================================
+Logger.info("Creating test users...")
+
+# Main test user (matches mongo_testdata.py)
+codincoder =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "codincoder",
+ email: "codincoder@example.com",
+ password: "strongpassword123!",
+ password_confirmation: "strongpassword123!",
+ profile: %{
+ bio: "I love coding challenges!",
+ location: "Code City",
+ picture: nil,
+ socials: %{
+ github: "codincoder",
+ twitter: "codincoder"
+ }
+ },
+ role: "user"
+ },
+ :username
+ )
+
+# Additional test users for variety
+alice =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "alice",
+ email: "alice@example.com",
+ password: "alicepassword123!",
+ password_confirmation: "alicepassword123!",
+ profile: %{
+ bio: "Algorithm enthusiast",
+ location: "Wonderland"
+ },
+ role: "user"
+ },
+ :username
+ )
+
+bob =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "bob",
+ email: "bob@example.com",
+ password: "bobpassword123!",
+ password_confirmation: "bobpassword123!",
+ profile: %{
+ bio: "Puzzle solver extraordinaire"
+ },
+ role: "user"
+ },
+ :username
+ )
+
+moderator =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "moderator",
+ email: "moderator@example.com",
+ password: "modpassword123!",
+ password_confirmation: "modpassword123!",
+ profile: %{
+ bio: "Keeping the platform safe"
+ },
+ role: "moderator"
+ },
+ :username
+ )
+
+# ============================================================================
+# PUZZLES
+# ============================================================================
+Logger.info("Creating test puzzles...")
+
+# Easy puzzle - Print 42
+easy_puzzle =
+ case Repo.get_by(Puzzle, title: "Print 42") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Print 42",
+ statement: "Print the number 42.",
+ constraints: "No input required",
+ difficulty: "BEGINNER",
+ visibility: "APPROVED",
+ tags: ["beginner", "output"],
+ solution: %{
+ code: "print(42)",
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: codincoder.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ # Add validators
+ validators = [
+ %{input: "", output: "42"},
+ %{input: "", output: "42"},
+ %{input: "", output: "42"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Print 42' already exists")
+ existing
+ end
+
+# FizzBuzz puzzle (from mongo_testdata.py)
+fizzbuzz_puzzle =
+ case Repo.get_by(Puzzle, title: "FizzBuzz") do
+ nil ->
+ puzzle_attrs = %{
+ title: "FizzBuzz",
+ statement: """
+ Print numbers from N to M except for:
+ - Every number divisible by 3: print "Fizz"
+ - Every number divisible by 5: print "Buzz"
+ - Numbers divisible by both 3 and 5: print "FizzBuzz"
+
+ ## Input Format
+ Two space-separated integers: N and M
+
+ ## Output Format
+ Print each result on a new line.
+ """,
+ constraints: "0 <= N < M <= 1000",
+ difficulty: "INTERMEDIATE",
+ visibility: "DRAFT",
+ tags: ["loops", "conditionals", "classic"],
+ solution: %{
+ code: """
+ n, m = [int(x) for x in input().split()]
+ for i in range(n, m+1):
+ fizz = i % 3 == 0
+ buzz = i % 5 == 0
+ print("Fizz" * fizz + "Buzz" * buzz + str(i) * (not fizz and not buzz))
+ """,
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: codincoder.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ # Add validators
+ validators = [
+ %{
+ input: "1 3",
+ output: "1\n2\nFizz"
+ },
+ %{
+ input: "3 5",
+ output: "Fizz\n4\nBuzz"
+ },
+ %{
+ input: "1 16",
+ output: "1\n2\nFizz\n4\nBuzz\nFizz\n7\n8\nFizz\nBuzz\n11\nFizz\n13\n14\nFizzBuzz\n16"
+ }
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'FizzBuzz' already exists")
+ existing
+ end
+
+# Reverse String puzzle
+_reverse_puzzle =
+ case Repo.get_by(Puzzle, title: "Reverse String") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Reverse String",
+ statement: """
+ Given a string, output it reversed.
+
+ ## Input Format
+ A single line containing the string to reverse.
+
+ ## Output Format
+ The reversed string.
+ """,
+ constraints: "1 <= string length <= 1000",
+ difficulty: "EASY",
+ visibility: "APPROVED",
+ tags: ["strings", "beginner"],
+ solution: %{
+ code: "print(input()[::-1])",
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: alice.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ validators = [
+ %{input: "hello", output: "olleh"},
+ %{input: "world", output: "dlrow"},
+ %{input: "racecar", output: "racecar"},
+ %{input: "a", output: "a"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Reverse String' already exists")
+ existing
+ end
+
+# Sum of Numbers puzzle
+_sum_puzzle =
+ case Repo.get_by(Puzzle, title: "Sum of Numbers") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Sum of Numbers",
+ statement: """
+ Calculate the sum of all integers from 1 to N (inclusive).
+
+ ## Input Format
+ A single integer N.
+
+ ## Output Format
+ The sum of integers from 1 to N.
+ """,
+ constraints: "1 <= N <= 10000",
+ difficulty: "BEGINNER",
+ visibility: "APPROVED",
+ tags: ["math", "beginner", "loops"],
+ solution: %{
+ code: """
+ n = int(input())
+ print(sum(range(1, n + 1)))
+ """,
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: bob.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ validators = [
+ %{input: "1", output: "1"},
+ %{input: "5", output: "15"},
+ %{input: "10", output: "55"},
+ %{input: "100", output: "5050"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Sum of Numbers' already exists")
+ existing
+ end
+
+# Palindrome Check puzzle
+_palindrome_puzzle =
+ case Repo.get_by(Puzzle, title: "Palindrome Check") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Palindrome Check",
+ statement: """
+ Determine if a given string is a palindrome.
+
+ A palindrome reads the same forwards and backwards (ignoring case).
+
+ ## Input Format
+ A single string.
+
+ ## Output Format
+ Print "YES" if it's a palindrome, "NO" otherwise.
+ """,
+ constraints: "1 <= string length <= 1000",
+ difficulty: "EASY",
+ visibility: "APPROVED",
+ tags: ["strings", "palindrome"],
+ solution: %{
+ code: """
+ s = input().strip().lower()
+ print("YES" if s == s[::-1] else "NO")
+ """,
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: alice.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ validators = [
+ %{input: "racecar", output: "YES"},
+ %{input: "hello", output: "NO"},
+ %{input: "A man a plan a canal Panama", output: "NO"},
+ %{input: "aabbaa", output: "YES"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Palindrome Check' already exists")
+ existing
+ end
+
+Logger.info("✅ Seed data created successfully!")
+Logger.info(" Users: codincoder, alice, bob, moderator")
+Logger.info(" Puzzles: 5 puzzles with validators")
+Logger.info(" Programming Languages: 6 languages")
diff --git a/libs/backend/codincod_api/priv/static/favicon.ico b/libs/backend/codincod_api/priv/static/favicon.ico
new file mode 100644
index 00000000..7f372bfc
Binary files /dev/null and b/libs/backend/codincod_api/priv/static/favicon.ico differ
diff --git a/libs/backend/codincod_api/priv/static/robots.txt b/libs/backend/codincod_api/priv/static/robots.txt
new file mode 100644
index 00000000..26e06b5f
--- /dev/null
+++ b/libs/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/backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs b/libs/backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs
new file mode 100644
index 00000000..afd407f1
--- /dev/null
+++ b/libs/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/backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs b/libs/backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs
new file mode 100644
index 00000000..28ed8194
--- /dev/null
+++ b/libs/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/backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs b/libs/backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs
new file mode 100644
index 00000000..392a55b2
--- /dev/null
+++ b/libs/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/backend/codincod_api/test/support/conn_case.ex b/libs/backend/codincod_api/test/support/conn_case.ex
new file mode 100644
index 00000000..3f1faa27
--- /dev/null
+++ b/libs/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/backend/codincod_api/test/support/data_case.ex b/libs/backend/codincod_api/test/support/data_case.ex
new file mode 100644
index 00000000..34f06c13
--- /dev/null
+++ b/libs/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/backend/codincod_api/test/test_helper.exs b/libs/backend/codincod_api/test/test_helper.exs
new file mode 100644
index 00000000..fa96ea11
--- /dev/null
+++ b/libs/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/backend/codincod_api/test_migration.sh b/libs/backend/codincod_api/test_migration.sh
new file mode 100644
index 00000000..b26d5eec
--- /dev/null
+++ b/libs/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/backend/complete_migration.exs b/libs/backend/complete_migration.exs
new file mode 100644
index 00000000..095436bb
--- /dev/null
+++ b/libs/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/backend/docker-compose.yml b/libs/backend/docker-compose.yml
index d027cb46..5eaae194 100644
--- a/libs/backend/docker-compose.yml
+++ b/libs/backend/docker-compose.yml
@@ -1,10 +1,124 @@
services:
- mongo:
+ 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
- restart: always
+ container_name: codincod_mongodb
environment:
- MONGO_INITDB_ROOT_USERNAME: "${CODINCOD_MONGODB_USERNAME}"
- MONGO_INITDB_ROOT_PASSWORD: "${CODINCOD_MONGODB_PASSWORD}"
- MONGO_INITDB_DATABASE: codincod
+ 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/backend/eslint.config.js b/libs/backend/eslint.config.js
deleted file mode 100644
index 582528d9..00000000
--- a/libs/backend/eslint.config.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import globals from "globals";
-import pluginJs from "@eslint/js";
-import eslintConfigPrettier from "eslint-config-prettier";
-import sortDestructureKeys from "eslint-plugin-sort-destructure-keys";
-
-export default [
- {
- languageOptions: {
- globals: globals.node
- }
- },
- pluginJs.configs.recommended,
- eslintConfigPrettier,
- {
- ignores: [
- "**/node_modules",
- "**/dist",
- "**/build",
- "**/__snapshots__",
- "**/mocks",
- "**/coverage"
- ]
- },
- {
- plugins: {
- "eslint-plugin-sort-destructure-keys": sortDestructureKeys
- },
- rules: {
- "no-undef": "warn",
- "no-unused-vars": "warn",
- "sort-keys": [
- "error",
- "asc",
- { caseSensitive: true, minKeys: 2, natural: false }
- ],
- yoda: "error"
- }
- }
-];
diff --git a/libs/backend/init.sql b/libs/backend/init.sql
new file mode 100644
index 00000000..4aadf218
--- /dev/null
+++ b/libs/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/backend/migrate.sh b/libs/backend/migrate.sh
new file mode 100644
index 00000000..ee47c761
--- /dev/null
+++ b/libs/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/backend/package.json b/libs/backend/package.json
deleted file mode 100644
index 6788bd5e..00000000
--- a/libs/backend/package.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "name": "backend",
- "version": "0.0.1",
- "main": "dist/index.js",
- "type": "module",
- "types": "dist/index.d.ts",
- "scripts": {
- "dev": "tsx watch src/index.ts",
- "build": "pkgroll",
- "start": "tsx src/index.ts",
- "lint": "npx eslint .",
- "lint:fix": "npm run lint -- --fix",
- "prettier": "npx prettier . --check",
- "prettier:fix": "npm run prettier -- --write",
- "format": "npm run prettier:fix && npm run lint:fix",
- "test": "vitest",
- "seed": "tsx src/seeds/index.ts",
- "seed:force": "tsx src/seeds/index.ts --force",
- "seed:clear": "tsx src/seeds/clear.ts",
- "seed:clear:force": "tsx src/seeds/clear.ts --force",
- "migrate": "tsx src/migrations/migrate.ts run",
- "migrate:list": "tsx src/migrations/migrate.ts list",
- "migrate:rollback": "tsx src/migrations/migrate.ts rollback"
- },
- "author": "",
- "license": "ISC",
- "description": "",
- "dependencies": {
- "@fastify/cookie": "^9.3.1",
- "@fastify/cors": "^9.0.1",
- "@fastify/formbody": "^7.4.0",
- "@fastify/jwt": "^8.0.1",
- "@fastify/rate-limit": "9.1.0",
- "@fastify/websocket": "^10.0.1",
- "@types/node-cron": "^3.0.11",
- "bcryptjs": "^3.0.2",
- "dotenv": "^16.4.5",
- "fastify": "^4.28.1",
- "fastify-plugin": "^4.5.1",
- "mongodb": "^6.8.0",
- "mongoose": "^8.5.1",
- "node-cron": "^4.2.1",
- "zod": "^4.1.12"
- },
- "devDependencies": {
- "@eslint/js": "^9.7.0",
- "@faker-js/faker": "^10.1.0",
- "@tsconfig/recommended": "^1.0.7",
- "@types/bcrypt": "^5.0.2",
- "@types/node": "^24.0.1",
- "@types/ws": "^8.5.12",
- "eslint": "9.x",
- "eslint-config-prettier": "^10.0.1",
- "eslint-plugin-sort-destructure-keys": "^2.0.0",
- "globals": "^16.2.0",
- "pkgroll": "^2.4.1",
- "prettier": "3.3.3",
- "tsx": "^4.16.2",
- "types": "workspace:*",
- "typescript": "^5.5.4",
- "vite-tsconfig-paths": "^5.1.4",
- "vitest": "^3.0.8"
- },
- "lint-staged": {
- "**/*": "prettier --write --ignore-unknown"
- }
-}
diff --git a/libs/backend/src/app.ts b/libs/backend/src/app.ts
deleted file mode 100644
index 57509563..00000000
--- a/libs/backend/src/app.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-// require external modules
-import dotenv from "dotenv";
-dotenv.config();
-
-import websocket from "@fastify/websocket";
-import Fastify from "fastify";
-import cors from "./plugins/config/cors.js";
-import jwt from "./plugins/config/jwt.js";
-import fastifyFormbody from "@fastify/formbody";
-import mongooseConnector from "./plugins/config/mongoose.js";
-import router from "./router.js";
-import fastifyCookie, { FastifyCookieOptions } from "@fastify/cookie";
-import piston from "./plugins/decorators/piston.js";
-import { setupWebSockets } from "./plugins/config/setup-web-sockets.js";
-import fastifyRateLimit from "@fastify/rate-limit";
-import requestLogger from "./plugins/middleware/request-logger.js";
-import { httpResponseCodes } from "types";
-import { initializeLeaderboardCron } from "./config/cron.js";
-
-const server = Fastify({
- logger: true
-});
-
-// register fastify ecosystem plugins
-server.register(fastifyCookie, {
- secret: process.env.COOKIE_SECRET
-} as FastifyCookieOptions);
-server.register(requestLogger);
-server.register(fastifyRateLimit, {
- max: 100,
- timeWindow: "1 minute",
- errorResponseBuilder: (request, context) => {
- return {
- statusCode: httpResponseCodes.CLIENT_ERROR.TOO_MANY_REQUESTS,
- error: "Too Many Requests",
- message: `Rate limit exceeded. Please try again in ${Math.ceil(context.ttl / 1000)} seconds.`,
- retryAfter: Math.ceil(context.ttl / 1000)
- };
- }
-});
-server.register(cors);
-server.register(jwt);
-server.register(fastifyFormbody);
-server.register(mongooseConnector);
-server.register(piston);
-server.register(websocket, {
- options: {
- verifyClient: (info, next) => {
- // Allow WebSocket connections from the configured frontend URL
- const origin = info.origin || info.req.headers.origin;
- const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173";
-
- server.log.info(
- { origin, allowedOrigin },
- "WebSocket connection attempt"
- );
-
- // Allow if origin matches exactly OR if no origin is provided (some clients don't send it)
- if (!origin || origin === allowedOrigin) {
- server.log.info({ origin }, "WebSocket connection accepted");
- next(true);
- } else {
- server.log.warn(
- { origin, allowedOrigin },
- "WebSocket connection rejected"
- );
- next(false, 403, "Forbidden");
- }
- }
- }
-});
-server.register(setupWebSockets);
-
-// routes
-server.register(router);
-
-// Initialize cron jobs after all plugins are registered
-server.ready(() => {
- initializeLeaderboardCron(server);
-});
-
-export default server;
diff --git a/libs/backend/src/config/cron.ts b/libs/backend/src/config/cron.ts
deleted file mode 100644
index 67a7c0c9..00000000
--- a/libs/backend/src/config/cron.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import cron from "node-cron";
-import { leaderboardService } from "../services/leaderboard.service.js";
-import { FastifyInstance } from "fastify";
-
-/**
- * Initialize cron jobs for leaderboard calculations
- * Runs every hour on the hour
- */
-export function initializeLeaderboardCron(fastify: FastifyInstance): void {
- // Run every hour at minute 0
- // Cron format: minute hour day month weekday
- const cronExpression = "0 * * * *"; // Every hour at :00
-
- cron.schedule(cronExpression, async () => {
- fastify.log.info("Starting hourly leaderboard recalculation...");
-
- try {
- const startTime = Date.now();
- const results = await leaderboardService.recalculateAllLeaderboards();
- const duration = Date.now() - startTime;
-
- fastify.log.info(
- {
- processedGames: results.processedGames,
- totalProcessed: results.totalProcessed,
- durationMs: duration
- },
- "Leaderboard recalculation completed"
- );
- } catch (error) {
- fastify.log.error(
- {
- err: error
- },
- "Error during leaderboard recalculation"
- );
- }
- });
-
- fastify.log.info(
- {
- schedule: cronExpression,
- description: "Hourly leaderboard recalculation"
- },
- "Leaderboard cron job initialized"
- );
-}
diff --git a/libs/backend/src/config/generic-return-messages.ts b/libs/backend/src/config/generic-return-messages.ts
deleted file mode 100644
index 10667d42..00000000
--- a/libs/backend/src/config/generic-return-messages.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { httpResponseCodes } from "types";
-
-export const genericReturnMessages = {
- [httpResponseCodes.CLIENT_ERROR.BAD_REQUEST]: {
- IS_INVALID: "is invalid",
- CONTAINS_INVALID_DATA: "contains invalid data"
- },
- [httpResponseCodes.SUCCESSFUL.OK]: {
- WAS_FOUND: "was found"
- },
- [httpResponseCodes.CLIENT_ERROR.NOT_FOUND]: {
- COULD_NOT_BE_FOUND: "couldn't be found"
- },
- [httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR]: {
- WENT_WRONG: "went wrong"
- }
-} as const;
-
-export const userProperties = {
- USERNAME: "username"
-} as const;
diff --git a/libs/backend/src/index.ts b/libs/backend/src/index.ts
deleted file mode 100644
index 8c699d83..00000000
--- a/libs/backend/src/index.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import server from "./app.js";
-
-const FASTIFY_HOST = process.env.FASTIFY_HOST ?? "0.0.0.0";
-const FASTIFY_PORT = Number(process.env.FASTIFY_PORT) || 8888;
-
-// Debug: Log environment variables at startup
-console.log("=== Environment Configuration ===");
-console.log("NODE_ENV:", process.env.NODE_ENV);
-console.log("FRONTEND_URL:", process.env.FRONTEND_URL);
-console.log("================================");
-
-// start server
-server.listen({ port: FASTIFY_PORT, host: FASTIFY_HOST }, (err, address) => {
- if (err) {
- server.log.error(err);
- process.exit(1);
- }
-
- console.log(`🚀 Fastify server running on port ${address}`);
-});
diff --git a/libs/backend/src/migrations/framework/migration-runner.ts b/libs/backend/src/migrations/framework/migration-runner.ts
deleted file mode 100644
index e6cd140b..00000000
--- a/libs/backend/src/migrations/framework/migration-runner.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { Migration } from "./migration.interface.js";
-import { migrationStatus, MigrationTracker } from "./migration-tracker.js";
-
-/**
- * Migration runner that executes migrations in order
- */
-export class MigrationRunner {
- private migrations: Migration[] = [];
-
- /**
- * Register a migration to be run
- */
- register(migration: Migration): void {
- this.migrations.push(migration);
- }
-
- /**
- * Run all pending migrations
- */
- async runAll(): Promise {
- console.log("🔄 Checking for pending migrations...\n");
-
- // Sort migrations by name (which should be date-prefixed)
- this.migrations.sort((a, b) => a.name.localeCompare(b.name));
-
- // Get already applied migrations
- const appliedMigrations = await MigrationTracker.find({
- status: migrationStatus.APPLIED
- }).lean();
- const appliedNames = new Set(appliedMigrations.map((m) => m.name));
-
- // Filter to pending migrations
- const pendingMigrations = this.migrations.filter(
- (m) => !appliedNames.has(m.name)
- );
-
- if (pendingMigrations.length === 0) {
- console.log("✅ No pending migrations. Database is up to date.\n");
- return;
- }
-
- console.log(`Found ${pendingMigrations.length} pending migration(s):\n`);
- pendingMigrations.forEach((m, i) => {
- console.log(` ${i + 1}. ${m.name}`);
- console.log(` ${m.description}\n`);
- });
-
- // Run each pending migration
- for (const migration of pendingMigrations) {
- await this.runOne(migration);
- }
-
- console.log("\n✨ All migrations completed successfully!\n");
- }
-
- /**
- * Run a specific migration
- */
- async runOne(migration: Migration): Promise {
- console.log(`\n${"=".repeat(60)}`);
- console.log(`Running migration: ${migration.name}`);
- console.log(`Description: ${migration.description}`);
- console.log("=".repeat(60));
-
- const startTime = Date.now();
-
- try {
- // Run the migration
- await migration.up();
-
- // Record success
- await MigrationTracker.create({
- name: migration.name,
- description: migration.description,
- appliedAt: new Date(),
- status: migrationStatus.APPLIED
- });
-
- const duration = ((Date.now() - startTime) / 1000).toFixed(2);
- console.log(`\n✅ Migration completed successfully in ${duration}s`);
- } catch (error) {
- // Record failure
- await MigrationTracker.create({
- name: migration.name,
- description: migration.description,
- appliedAt: new Date(),
- status: migrationStatus.FAILED,
- error: error instanceof Error ? error.message : String(error)
- });
-
- console.error(`\n❌ Migration failed:`, error);
- throw error;
- }
- }
-
- /**
- * Rollback the last applied migration
- */
- async rollbackLast(): Promise {
- const lastMigration = await MigrationTracker.findOne({
- status: migrationStatus.APPLIED
- })
- .sort({ appliedAt: -1 })
- .lean();
-
- if (!lastMigration) {
- console.log("No migrations to rollback.");
- return;
- }
-
- const migration = this.migrations.find(
- (m) => m.name === lastMigration.name
- );
-
- if (!migration) {
- throw new Error(
- `Migration ${lastMigration.name} not found in registered migrations`
- );
- }
-
- if (!migration.down) {
- throw new Error(`Migration ${migration.name} does not support rollback`);
- }
-
- console.log(`\nRolling back migration: ${migration.name}\n`);
-
- try {
- await migration.down();
-
- await MigrationTracker.findOneAndUpdate(
- { name: migration.name },
- {
- rollbackAt: new Date(),
- status: migrationStatus.ROLLED_BACK
- }
- );
-
- console.log(`✅ Rollback completed successfully`);
- } catch (error) {
- console.error(`❌ Rollback failed:`, error);
- throw error;
- }
- }
-
- /**
- * List all migrations and their status
- */
- async list(): Promise {
- const applied = await MigrationTracker.find().sort({ appliedAt: 1 }).lean();
-
- console.log("\n📋 Migration Status:\n");
-
- // Sort all migrations by name
- const sortedMigrations = [...this.migrations].sort((a, b) =>
- a.name.localeCompare(b.name)
- );
-
- for (const migration of sortedMigrations) {
- const record = applied.find((m) => m.name === migration.name);
-
- if (record) {
- const status =
- record.status === migrationStatus.APPLIED
- ? "✅ Applied"
- : record.status === migrationStatus.ROLLED_BACK
- ? "⏪ Rolled back"
- : "❌ Failed";
- const date = record.appliedAt.toISOString().split("T")[0];
- console.log(` ${status} - ${migration.name} (${date})`);
- } else {
- console.log(` ⏳ Pending - ${migration.name}`);
- }
- console.log(` ${migration.description}\n`);
- }
- }
-}
diff --git a/libs/backend/src/migrations/framework/migration-tracker.ts b/libs/backend/src/migrations/framework/migration-tracker.ts
deleted file mode 100644
index f6c427ba..00000000
--- a/libs/backend/src/migrations/framework/migration-tracker.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import mongoose, { Schema, Document } from "mongoose";
-import { ValueOf } from "types";
-
-export const migrationStatus = {
- APPLIED: "applied",
- ROLLED_BACK: "rolled-back",
- FAILED: "failed"
-} as const;
-export type MigrationStatus = ValueOf;
-
-export interface MigrationRecord extends Document {
- name: string;
- description: string;
- appliedAt: Date;
- rollbackAt?: Date;
- status: MigrationStatus;
- error?: string;
-}
-
-const migrationRecordSchema = new Schema({
- name: {
- type: String,
- required: true,
- unique: true,
- index: true
- },
- description: {
- type: String,
- required: true
- },
- appliedAt: {
- type: Date,
- required: true,
- default: Date.now
- },
- rollbackAt: {
- type: Date,
- required: false
- },
- status: {
- type: String,
- enum: Object.values(migrationStatus),
- required: true,
- default: migrationStatus.APPLIED
- },
- error: {
- type: String,
- required: false
- }
-});
-
-export const MigrationTracker = mongoose.model(
- "MigrationTracker",
- migrationRecordSchema
-);
diff --git a/libs/backend/src/migrations/framework/migration.interface.ts b/libs/backend/src/migrations/framework/migration.interface.ts
deleted file mode 100644
index 18c3d53c..00000000
--- a/libs/backend/src/migrations/framework/migration.interface.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Interface for all database migrations
- * Each migration must implement this interface to be run by the migration system
- */
-export interface Migration {
- /**
- * Unique name/identifier for this migration
- * Format: YYYY-MM-DD-descriptive-name
- * Example: "2025-10-26-add-programming-language-entity"
- */
- name: string;
-
- /**
- * Description of what this migration does
- */
- description: string;
-
- /**
- * Execute the migration
- * This should be idempotent - safe to run multiple times
- */
- up(): Promise;
-
- /**
- * Rollback the migration (optional)
- * If not implemented, rollback is not supported for this migration
- */
- down?(): Promise;
-}
diff --git a/libs/backend/src/migrations/migrate-to-programming-language.ts b/libs/backend/src/migrations/migrate-to-programming-language.ts
deleted file mode 100644
index 09f5155b..00000000
--- a/libs/backend/src/migrations/migrate-to-programming-language.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-import { config } from "dotenv";
-config();
-
-import {
- connectToDatabase,
- disconnectFromDatabase
-} from "../seeds/utils/db-connection.js";
-import ProgrammingLanguage from "../models/programming-language/language.js";
-import Submission from "../models/submission/submission.js";
-import Puzzle from "../models/puzzle/puzzle.js";
-import Game from "../models/game/game.js";
-import {
- arePistonRuntimes,
- httpRequestMethod,
- isString,
- PistonRuntime,
- pistonUrls
-} from "types";
-import { buildPistonUri } from "@/utils/functions/build-piston-uri.js";
-
-interface OldSubmission {
- _id: any;
- language?: string;
- languageVersion?: string;
- programmingLanguage?: string;
-}
-
-interface OldPuzzleSolution {
- language?: string;
- languageVersion?: string;
- programmingLanguage?: string;
- code?: string;
- explanation?: string;
-}
-
-interface OldPuzzle {
- _id: any;
- solution?: OldPuzzleSolution;
-}
-
-interface OldGameLanguage {
- language: string;
- version: string;
- aliases?: string[];
- runtime?: string;
-}
-
-interface OldGame {
- _id: any;
- options?: {
- allowedLanguages?: (string | OldGameLanguage)[];
- };
-}
-
-/**
- * Migration script to:
- * 1. Fetch programming languages from Piston
- * 2. Create ProgrammingLanguage documents
- * 3. Migrate existing data (Submissions, Puzzles/Solutions, Games) to reference ProgrammingLanguage ObjectIds
- */
-async function migrate() {
- console.log("🔄 Starting migration to ProgrammingLanguage entity...\n");
- console.log("=".repeat(50));
-
- try {
- await connectToDatabase();
-
- // Step 1: Fetch Piston runtimes
- console.log("\n📡 Fetching available runtimes from Piston...");
- const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES), {
- method: httpRequestMethod.GET,
- headers: {
- "Content-Type": "application/json"
- }
- });
- const runtimes = await response.json();
-
- if (!arePistonRuntimes(runtimes)) {
- throw new Error("Failed to fetch valid Piston runtimes");
- }
-
- console.log(`✓ Found ${runtimes.length} runtimes from Piston`);
-
- // Step 2: Create ProgrammingLanguage documents
- console.log("\n🗄️ Creating ProgrammingLanguage documents...");
-
- // Check if ProgrammingLanguages already exist
- const existingCount = await ProgrammingLanguage.countDocuments({});
- if (existingCount > 0) {
- console.log(
- ` ⚠️ Found ${existingCount} existing programming languages`
- );
- console.log(" ℹ️ Skipping recreation to preserve existing references");
- console.log(
- " 💡 If you need to re-seed languages, clear the database first"
- );
- } else {
- // Insert all runtimes as programming languages
- const languageDocs = runtimes.map((runtime: PistonRuntime) => ({
- language: runtime.language,
- version: runtime.version,
- aliases: runtime.aliases || [],
- runtime: runtime.runtime
- }));
-
- const insertedLanguages =
- await ProgrammingLanguage.insertMany(languageDocs);
- console.log(
- ` ✓ Created ${insertedLanguages.length} programming languages`
- );
- }
-
- // Build language map from current database state
- const allLanguages = await ProgrammingLanguage.find({});
- console.log(
- ` ✓ Loaded ${allLanguages.length} programming languages for migration`
- );
-
- // Create a map for quick lookup: "language:version" -> ObjectId
- const languageMap = new Map();
- allLanguages.forEach((lang) => {
- const key = `${lang.language}:${lang.version}`;
- languageMap.set(key, lang._id.toString());
- });
-
- // Step 3: Migrate Submissions
- console.log("\n📝 Migrating Submissions...");
- const submissions = (await Submission.find({
- language: { $exists: true },
- languageVersion: { $exists: true }
- }).lean()) as unknown as OldSubmission[];
- console.log(` Found ${submissions.length} submissions to migrate`);
-
- let submissionsMigrated = 0;
- let submissionsCreated = 0;
- for (const submission of submissions) {
- if (!submission.language || !submission.languageVersion) {
- console.warn(
- ` ⚠️ Skipping submission ${submission._id} - missing language data`
- );
- continue;
- }
-
- const key = `${submission.language}:${submission.languageVersion}`;
- let languageId = languageMap.get(key);
-
- // If language doesn't exist, create it
- if (!languageId) {
- console.log(` 📝 Creating missing language: ${key}`);
- const newLanguage = await ProgrammingLanguage.create({
- language: submission.language,
- version: submission.languageVersion,
- aliases: []
- });
- languageId = newLanguage._id.toString();
- languageMap.set(key, languageId);
- submissionsCreated++;
- }
-
- await Submission.findByIdAndUpdate(submission._id, {
- $set: { programmingLanguage: languageId },
- $unset: { language: "", languageVersion: "" }
- });
- submissionsMigrated++;
- }
- console.log(
- ` ✓ Migrated ${submissionsMigrated} submissions (${submissionsCreated} languages created)`
- );
-
- // Step 4: Migrate Puzzle Solutions
- console.log("\n🧩 Migrating Puzzle Solutions...");
-
- // First, check total puzzles
- const totalPuzzles = await Puzzle.countDocuments({});
- console.log(` Total puzzles in database: ${totalPuzzles}`);
-
- // Check puzzles with solution field
- const puzzlesWithSolution = await Puzzle.countDocuments({
- solution: { $exists: true, $ne: null }
- });
- console.log(` Puzzles with solution field: ${puzzlesWithSolution}`);
-
- // Find puzzles with old language fields
- const puzzles = (await Puzzle.find({
- "solution.language": { $exists: true }
- })
- .select("+solution")
- .lean()) as unknown as OldPuzzle[];
- console.log(
- ` Found ${puzzles.length} puzzles with solution.language to migrate`
- );
-
- let solutionsMigrated = 0;
- let solutionsCreated = 0;
- let solutionsSkipped = 0;
-
- for (const puzzle of puzzles) {
- if (!puzzle.solution?.language || !puzzle.solution?.languageVersion) {
- console.log(
- ` ⚠️ Skipping puzzle ${puzzle._id} - missing language: ${puzzle.solution?.language}, version: ${puzzle.solution?.languageVersion}`
- );
- solutionsSkipped++;
- continue;
- }
-
- const key = `${puzzle.solution.language}:${puzzle.solution.languageVersion}`;
- let languageId = languageMap.get(key);
-
- // If language doesn't exist, create it
- if (!languageId) {
- console.log(` 📝 Creating missing language: ${key}`);
- const newLanguage = await ProgrammingLanguage.create({
- language: puzzle.solution.language,
- version: puzzle.solution.languageVersion,
- aliases: []
- });
- languageId = newLanguage._id.toString();
- languageMap.set(key, languageId);
- solutionsCreated++;
- }
-
- await Puzzle.findByIdAndUpdate(puzzle._id, {
- $set: { "solution.programmingLanguage": languageId },
- $unset: { "solution.language": "", "solution.languageVersion": "" }
- });
- solutionsMigrated++;
- }
- console.log(
- ` ✓ Migrated ${solutionsMigrated} puzzle solutions (${solutionsCreated} languages created, ${solutionsSkipped} skipped)`
- );
-
- // Step 5: Migrate Game allowedLanguages
- console.log("\n🎮 Migrating Game allowedLanguages...");
- const games = (await Game.find({
- "options.allowedLanguages": { $exists: true, $ne: [] }
- }).lean()) as unknown as OldGame[];
- console.log(` Found ${games.length} games to migrate`);
-
- let gamesMigrated = 0;
- let gamesCreated = 0;
- let gamesSkipped = 0;
-
- for (const game of games) {
- if (
- !game.options?.allowedLanguages ||
- game.options.allowedLanguages.length === 0
- ) {
- continue;
- }
-
- // Check if already migrated (first element is a string ObjectId)
- const firstLang = game.options.allowedLanguages[0];
- if (isString(firstLang)) {
- // Already migrated, skip
- console.log(` ⏭️ Game ${game._id} already migrated, skipping`);
- gamesSkipped++;
- continue;
- }
-
- const allowedLanguageIds: string[] = [];
- for (const allowedLang of game.options.allowedLanguages) {
- if (isString(allowedLang)) {
- // Already an ObjectId, keep it
- allowedLanguageIds.push(allowedLang);
- continue;
- }
-
- // Skip if language or version is missing
- if (!allowedLang.language || !allowedLang.version) {
- console.warn(
- ` ⚠️ Skipping invalid language in game ${game._id}: language=${allowedLang.language}, version=${allowedLang.version}`
- );
- continue;
- }
-
- const key = `${allowedLang.language}:${allowedLang.version}`;
- let languageId = languageMap.get(key);
-
- // If language doesn't exist, create it
- if (!languageId) {
- console.log(` 📝 Creating missing language: ${key}`);
- const newLanguage = await ProgrammingLanguage.create({
- language: allowedLang.language,
- version: allowedLang.version,
- aliases: allowedLang.aliases || [],
- runtime: allowedLang.runtime
- });
- languageId = newLanguage._id.toString();
- languageMap.set(key, languageId);
- gamesCreated++;
- }
-
- allowedLanguageIds.push(languageId);
- }
-
- if (allowedLanguageIds.length > 0) {
- await Game.findByIdAndUpdate(game._id, {
- $set: { "options.allowedLanguages": allowedLanguageIds }
- });
- gamesMigrated++;
- }
- }
- console.log(
- ` ✓ Migrated ${gamesMigrated} games (${gamesCreated} languages created, ${gamesSkipped} skipped)`
- );
-
- console.log("\n" + "=".repeat(50));
- console.log("✨ Migration completed successfully!\n");
- console.log("Summary:");
- console.log(` - Programming Languages: ${allLanguages.length}`);
- console.log(` - Submissions migrated: ${submissionsMigrated}`);
- console.log(` - Solutions migrated: ${solutionsMigrated}`);
- console.log(` - Games migrated: ${gamesMigrated}`);
- console.log(
- "\n⚠️ IMPORTANT: Update your schemas and models before deploying!"
- );
- } catch (error) {
- console.error("\n❌ Migration failed:", error);
- process.exit(1);
- } finally {
- await disconnectFromDatabase();
- }
-}
-
-migrate();
diff --git a/libs/backend/src/migrations/migrate.ts b/libs/backend/src/migrations/migrate.ts
deleted file mode 100644
index 0c26cb5b..00000000
--- a/libs/backend/src/migrations/migrate.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env node
-import { config } from "dotenv";
-config();
-
-import {
- connectToDatabase,
- disconnectFromDatabase
-} from "../seeds/utils/db-connection.js";
-import { MigrationRunner } from "./framework/migration-runner.js";
-import { AddProgrammingLanguageEntityMigration } from "./migrations/2025-10-26-add-programming-language-entity.js";
-import { MigrateUserRolesToRoleMigration } from "./migrations/2025-10-26-migrate-user-roles-to-role.js";
-
-/**
- * Main migration CLI tool
- *
- * Usage:
- * pnpm migrate - Run all pending migrations
- * pnpm migrate:list - List all migrations and their status
- * pnpm migrate:rollback - Rollback the last migration
- */
-async function main() {
- const command = process.argv[2] || "run";
-
- console.log("🔧 CodinCod Migration Tool\n");
-
- try {
- // Connect to database
- await connectToDatabase();
-
- // Create migration runner
- const runner = new MigrationRunner();
-
- // Register all migrations here (in chronological order)
- runner.register(new MigrateUserRolesToRoleMigration());
- runner.register(new AddProgrammingLanguageEntityMigration());
-
- // Execute command
- switch (command) {
- case "run":
- case "up":
- await runner.runAll();
- break;
-
- case "list":
- case "status":
- await runner.list();
- break;
-
- case "rollback":
- case "down":
- await runner.rollbackLast();
- break;
-
- default:
- console.error(`Unknown command: ${command}`);
- console.log("\nAvailable commands:");
- console.log(" run, up - Run all pending migrations");
- console.log(" list, status - List all migrations and their status");
- console.log(" rollback, down - Rollback the last migration");
- process.exit(1);
- }
- } catch (error) {
- console.error("\n❌ Migration failed:", error);
- process.exit(1);
- } finally {
- await disconnectFromDatabase();
- }
-}
-
-main();
diff --git a/libs/backend/src/migrations/migrations/2025-10-26-add-programming-language-entity.ts b/libs/backend/src/migrations/migrations/2025-10-26-add-programming-language-entity.ts
deleted file mode 100644
index ad682086..00000000
--- a/libs/backend/src/migrations/migrations/2025-10-26-add-programming-language-entity.ts
+++ /dev/null
@@ -1,310 +0,0 @@
-import { Migration } from "../framework/migration.interface.js";
-import ProgrammingLanguage from "../../models/programming-language/language.js";
-import Submission from "../../models/submission/submission.js";
-import Puzzle from "../../models/puzzle/puzzle.js";
-import Game from "../../models/game/game.js";
-import {
- arePistonRuntimes,
- httpRequestMethod,
- isObjectId,
- isString,
- PistonRuntime,
- pistonUrls
-} from "types";
-import { buildPistonUri } from "../../utils/functions/build-piston-uri.js";
-
-interface OldSubmission {
- _id: any;
- language?: string;
- languageVersion?: string;
- programmingLanguage?: string;
-}
-
-interface OldPuzzleSolution {
- language?: string;
- languageVersion?: string;
- programmingLanguage?: string;
- code?: string;
- explanation?: string;
-}
-
-interface OldPuzzle {
- _id: any;
- solution?: OldPuzzleSolution;
-}
-
-interface OldGameLanguage {
- language: string;
- version: string;
- aliases?: string[];
- runtime?: string;
-}
-
-interface OldGame {
- _id: any;
- options?: {
- allowedLanguages?: (string | OldGameLanguage)[];
- };
-}
-
-/**
- * Migration: Add ProgrammingLanguage entity and migrate existing data
- *
- * This migration:
- * 1. Fetches programming languages from Piston
- * 2. Creates ProgrammingLanguage documents
- * 3. Migrates existing Submissions to reference ProgrammingLanguage ObjectIds
- * 4. Migrates existing Puzzle Solutions to reference ProgrammingLanguage ObjectIds
- * 5. Migrates existing Game allowedLanguages to reference ProgrammingLanguage ObjectIds
- */
-export class AddProgrammingLanguageEntityMigration implements Migration {
- name = "2025-10-26-add-programming-language-entity";
- description =
- "Create ProgrammingLanguage collection and migrate all language references from embedded strings to ObjectId references";
-
- async up(): Promise {
- // Step 1: Fetch Piston runtimes
- console.log("\n📡 Fetching available runtimes from Piston...");
- const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES), {
- method: httpRequestMethod.GET,
- headers: {
- "Content-Type": "application/json"
- }
- });
- const runtimes = await response.json();
-
- if (!arePistonRuntimes(runtimes)) {
- throw new Error("Failed to fetch valid Piston runtimes");
- }
-
- console.log(` ✓ Found ${runtimes.length} runtimes from Piston`);
-
- // Step 2: Create ProgrammingLanguage documents
- console.log("\n🗄️ Creating ProgrammingLanguage documents...");
-
- // Check if languages already exist
- const existingCount = await ProgrammingLanguage.countDocuments();
- if (existingCount > 0) {
- console.log(
- ` ℹ️ Found ${existingCount} existing languages, skipping creation`
- );
- } else {
- // Insert all runtimes as programming languages
- const languageDocs = runtimes.map((runtime: PistonRuntime) => ({
- language: runtime.language,
- version: runtime.version,
- aliases: runtime.aliases || [],
- runtime: runtime.runtime
- }));
-
- const insertedLanguages =
- await ProgrammingLanguage.insertMany(languageDocs);
- console.log(
- ` ✓ Created ${insertedLanguages.length} programming languages`
- );
- }
-
- // Get all languages for mapping
- const allLanguages = await ProgrammingLanguage.find().lean();
-
- // Create a map for quick lookup: "language:version" -> ObjectId
- const languageMap = new Map();
- allLanguages.forEach((lang) => {
- const key = `${lang.language}:${lang.version}`;
- languageMap.set(key, lang._id.toString());
- });
-
- // Step 3: Migrate Submissions
- await this.migrateSubmissions(languageMap);
-
- // Step 4: Migrate Puzzle Solutions
- await this.migratePuzzleSolutions(languageMap);
-
- // Step 5: Migrate Game allowedLanguages
- await this.migrateGameLanguages(languageMap);
- }
-
- private async migrateSubmissions(
- languageMap: Map
- ): Promise {
- console.log("\n📝 Migrating Submissions...");
-
- // Find submissions that still have the old fields
- const submissions = (await Submission.find({
- language: { $exists: true },
- languageVersion: { $exists: true }
- }).lean()) as unknown as OldSubmission[];
-
- console.log(` Found ${submissions.length} submissions to migrate`);
-
- let migrated = 0;
- let skipped = 0;
- let created = 0;
-
- for (const submission of submissions) {
- if (!submission.language || !submission.languageVersion) {
- skipped++;
- continue;
- }
-
- const key = `${submission.language}:${submission.languageVersion}`;
- let languageId = languageMap.get(key);
-
- // If language doesn't exist, create it
- if (!languageId) {
- console.log(` 📝 Creating missing language: ${key}`);
- const newLanguage = await ProgrammingLanguage.create({
- language: submission.language,
- version: submission.languageVersion,
- aliases: []
- });
- languageId = newLanguage._id.toString();
- languageMap.set(key, languageId);
- created++;
- }
-
- await Submission.findByIdAndUpdate(submission._id, {
- $set: { programmingLanguage: languageId },
- $unset: { language: "", languageVersion: "" }
- });
- migrated++;
- }
-
- console.log(
- ` ✓ Migrated ${migrated} submissions (${skipped} skipped, ${created} languages created)`
- );
- }
-
- private async migratePuzzleSolutions(
- languageMap: Map
- ): Promise {
- console.log("\n🧩 Migrating Puzzle Solutions...");
-
- // Find puzzles with solutions that have old fields
- const puzzles = (await Puzzle.find({
- "solution.language": { $exists: true }
- }).lean()) as unknown as OldPuzzle[];
-
- console.log(` Found ${puzzles.length} puzzles with solutions to migrate`);
-
- let migrated = 0;
- let skipped = 0;
- let created = 0;
-
- for (const puzzle of puzzles) {
- if (!puzzle.solution?.language || !puzzle.solution?.languageVersion) {
- skipped++;
- continue;
- }
-
- const key = `${puzzle.solution.language}:${puzzle.solution.languageVersion}`;
- let languageId = languageMap.get(key);
-
- // If language doesn't exist, create it
- if (!languageId) {
- console.log(` 📝 Creating missing language: ${key}`);
- const newLanguage = await ProgrammingLanguage.create({
- language: puzzle.solution.language,
- version: puzzle.solution.languageVersion,
- aliases: []
- });
- languageId = newLanguage._id.toString();
- languageMap.set(key, languageId);
- created++;
- }
-
- await Puzzle.findByIdAndUpdate(puzzle._id, {
- $set: { "solution.programmingLanguage": languageId },
- $unset: { "solution.language": "", "solution.languageVersion": "" }
- });
- migrated++;
- }
-
- console.log(
- ` ✓ Migrated ${migrated} puzzle solutions (${skipped} skipped, ${created} languages created)`
- );
- }
-
- private async migrateGameLanguages(
- languageMap: Map
- ): Promise {
- console.log("\n🎮 Migrating Game allowedLanguages...");
-
- // Find games that might have old-style allowedLanguages
- const games = (await Game.find({
- "options.allowedLanguages": { $exists: true, $ne: [] }
- }).lean()) as unknown as OldGame[];
-
- console.log(` Found ${games.length} games to check`);
-
- let migrated = 0;
- let skipped = 0;
- let created = 0;
-
- for (const game of games) {
- if (
- !game.options?.allowedLanguages ||
- game.options.allowedLanguages.length === 0
- ) {
- skipped++;
- continue;
- }
-
- // Check if first element is a string (ObjectId) or object
- const firstLang = game.options.allowedLanguages[0];
- if (isString(firstLang)) {
- // Already migrated
- skipped++;
- continue;
- }
-
- const allowedLanguageIds: string[] = [];
- for (const allowedLang of game.options.allowedLanguages) {
- if (isObjectId(allowedLang)) {
- allowedLanguageIds.push(allowedLang);
- continue;
- }
-
- const key = `${allowedLang.language}:${allowedLang.version}`;
- let languageId = languageMap.get(key);
-
- // If language doesn't exist, create it
- if (!languageId) {
- console.log(` 📝 Creating missing language: ${key}`);
- const newLanguage = await ProgrammingLanguage.create({
- language: allowedLang.language,
- version: allowedLang.version,
- aliases: allowedLang.aliases || [],
- runtime: allowedLang.runtime
- });
- languageId = newLanguage._id.toString();
- languageMap.set(key, languageId);
- created++;
- }
-
- allowedLanguageIds.push(languageId);
- }
-
- if (allowedLanguageIds.length > 0) {
- await Game.findByIdAndUpdate(game._id, {
- $set: { "options.allowedLanguages": allowedLanguageIds }
- });
- migrated++;
- } else {
- skipped++;
- }
- }
-
- console.log(
- ` ✓ Migrated ${migrated} games (${skipped} skipped, ${created} languages created)`
- );
- }
-
- async down(): Promise {
- throw new Error(
- "Rollback not supported for programming language migration. " +
- "Original data is removed during migration. " +
- "Please restore from backup if rollback is needed."
- );
- }
-}
diff --git a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts b/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts
deleted file mode 100644
index 3995dbf3..00000000
--- a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Migration } from "../framework/migration.interface.js";
-import User from "../../models/user/user.js";
-import { DEFAULT_USER_ROLE } from "types";
-
-interface OldUser {
- _id: any;
- roles?: string[];
- role?: string;
-}
-
-/**
- * Migration: Convert user roles array to single role
- *
- * This migration:
- * 1. Finds users with the old 'roles' array field
- * 2. Takes the first role from the array (or uses default if empty)
- * 3. Sets it as the new 'role' string field
- * 4. Removes the old 'roles' array field
- */
-export class MigrateUserRolesToRoleMigration implements Migration {
- name = "2025-10-26-migrate-user-roles-to-role";
- description =
- "Migrate user 'roles' array field to singular 'role' string field";
-
- async up(): Promise {
- console.log("\n👥 Migrating User roles to role...");
-
- // Find users that still have the old 'roles' array field
- const users = (await User.find({
- roles: { $exists: true }
- }).lean()) as unknown as OldUser[];
-
- console.log(` Found ${users.length} users to migrate`);
-
- let migrated = 0;
- let skipped = 0;
-
- for (const user of users) {
- if (!user.roles || !Array.isArray(user.roles)) {
- skipped++;
- continue;
- }
-
- const newRole = user.roles.length > 0 ? user.roles[0] : DEFAULT_USER_ROLE;
-
- await User.findByIdAndUpdate(user._id, {
- $set: { role: newRole },
- $unset: { roles: "" }
- });
-
- migrated++;
- }
-
- console.log(` ✓ Migrated ${migrated} users (${skipped} skipped)`);
- }
-
- async down(): Promise {
- console.log("\n👥 Rolling back User role to roles...");
-
- // Find users with the singular 'role' field
- const users = await User.find({
- role: { $exists: true }
- }).lean();
-
- console.log(` Found ${users.length} users to rollback`);
-
- let rolledBack = 0;
-
- for (const user of users) {
- const currentRole = user.role;
-
- if (!currentRole) {
- continue;
- }
-
- // Convert singular role to array
- await User.findByIdAndUpdate(user._id, {
- $set: { roles: [currentRole] },
- $unset: { role: "" }
- });
-
- rolledBack++;
- }
-
- console.log(` ✓ Rolled back ${rolledBack} users`);
- }
-}
diff --git a/libs/backend/src/models/chat/chat-message.ts b/libs/backend/src/models/chat/chat-message.ts
deleted file mode 100644
index 27b99c7c..00000000
--- a/libs/backend/src/models/chat/chat-message.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import mongoose, { Document, ObjectId, Schema } from "mongoose";
-import { ChatMessageEntity } from "types";
-import { CHAT_MESSAGE, GAME, USER } from "../../utils/constants/model.js";
-
-export interface ChatMessageDocument
- extends Document,
- Omit {
- gameId: ObjectId;
- userId: ObjectId;
-}
-
-const chatMessageSchema = new Schema({
- gameId: {
- type: Schema.Types.ObjectId,
- ref: GAME,
- required: true
- },
- userId: {
- type: Schema.Types.ObjectId,
- ref: USER,
- required: true
- },
- username: {
- type: String,
- required: true
- },
- message: {
- type: String,
- required: true
- },
- isDeleted: {
- type: Boolean,
- default: false
- },
- createdAt: {
- type: Date,
- default: Date.now
- },
- updatedAt: {
- type: Date,
- default: Date.now
- }
-});
-
-// Indexes for efficient querying
-chatMessageSchema.index({ gameId: 1, createdAt: -1 });
-chatMessageSchema.index({ userId: 1 });
-chatMessageSchema.index({ createdAt: 1 });
-
-const ChatMessage = mongoose.model(
- CHAT_MESSAGE,
- chatMessageSchema
-);
-
-export default ChatMessage;
diff --git a/libs/backend/src/models/comment/comment.ts b/libs/backend/src/models/comment/comment.ts
deleted file mode 100644
index c303ff30..00000000
--- a/libs/backend/src/models/comment/comment.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { COMMENT, USER } from "@/utils/constants/model.js";
-import mongoose, { Document, ObjectId, Schema } from "mongoose";
-import { commentTypeEnum, type CommentEntity } from "types";
-
-export interface CommentDocument
- extends Document,
- Omit {
- _id: ObjectId;
- author: ObjectId;
- parentId: ObjectId;
-}
-
-export const commentSchema = new Schema({
- author: {
- ref: USER,
- type: Schema.Types.ObjectId,
- required: true
- },
- downvote: {
- type: Number,
- required: true,
- default: 0
- },
- upvote: {
- type: Number,
- required: true,
- default: 0
- },
- text: {
- type: String,
- required: true
- },
- createdAt: {
- default: Date.now,
- type: Date
- },
- updatedAt: {
- default: Date.now,
- type: Date
- },
- comments: [
- {
- type: Schema.Types.ObjectId,
- ref: COMMENT
- }
- ],
- commentType: {
- type: String,
- required: true,
- default: commentTypeEnum.COMMENT
- },
- parentId: {
- type: Schema.Types.ObjectId,
- ref: COMMENT,
- required: false
- }
-});
-
-commentSchema.pre(
- "deleteOne",
- { document: true, query: false },
- async function (next) {
- await Comment.deleteMany({ _id: { $in: this.comments } });
-
- next();
- }
-);
-
-const Comment = mongoose.model(COMMENT, commentSchema);
-export default Comment;
diff --git a/libs/backend/src/models/game/game-config.ts b/libs/backend/src/models/game/game-config.ts
deleted file mode 100644
index 171740d4..00000000
--- a/libs/backend/src/models/game/game-config.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import mongoose, { Schema } from "mongoose";
-import { GameOptions } from "types";
-import { PROGRAMMING_LANGUAGE } from "../../utils/constants/model.js";
-
-/**
- * Game options schema
- * Defines configuration for game sessions including allowed languages, duration, visibility, and mode
- */
-const gameOptionsSchema = new Schema({
- maxGameDurationInSeconds: {
- required: true,
- type: Number
- },
- visibility: {
- required: true,
- type: String
- },
- allowedLanguages: [
- {
- ref: PROGRAMMING_LANGUAGE,
- required: false,
- type: mongoose.Schema.Types.ObjectId
- }
- ],
- mode: {
- required: true,
- type: String
- }
-});
-
-export default gameOptionsSchema;
diff --git a/libs/backend/src/models/game/game.ts b/libs/backend/src/models/game/game.ts
deleted file mode 100644
index 22fd48e8..00000000
--- a/libs/backend/src/models/game/game.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import mongoose, { Schema, Document } from "mongoose";
-import { GAME, PUZZLE, SUBMISSION, USER } from "../../utils/constants/model.js";
-import { DEFAULT_GAME_LENGTH_IN_MILLISECONDS, GameEntity } from "types";
-import gameOptionsSchema from "./game-config.js";
-
-export interface GameDocument extends Document, GameEntity {}
-
-const gameSchema = new Schema({
- players: [
- {
- ref: USER,
- required: true,
- type: Schema.Types.ObjectId
- }
- ],
- createdAt: {
- default: Date.now,
- type: Date
- },
- owner: {
- ref: USER,
- required: true,
- type: mongoose.Schema.Types.ObjectId
- },
- startTime: {
- default: Date.now,
- type: Date,
- required: true
- },
- endTime: {
- default: () => Date.now() + DEFAULT_GAME_LENGTH_IN_MILLISECONDS,
- type: Date,
- required: true
- },
- options: gameOptionsSchema,
- puzzle: {
- ref: PUZZLE,
- required: true,
- type: Schema.Types.ObjectId
- },
- playerSubmissions: [
- {
- ref: SUBMISSION,
- required: false,
- type: Schema.Types.ObjectId
- }
- ]
-});
-
-const Game = mongoose.model(GAME, gameSchema);
-export default Game;
diff --git a/libs/backend/src/models/moderation/user-ban.ts b/libs/backend/src/models/moderation/user-ban.ts
deleted file mode 100644
index 11db1f39..00000000
--- a/libs/backend/src/models/moderation/user-ban.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import mongoose, { Document, ObjectId, Schema } from "mongoose";
-import { UserBanEntity, banTypeEnum } from "types";
-import { USER_BAN, USER } from "../../utils/constants/model.js";
-
-export interface UserBanDocument
- extends Document,
- Omit {
- userId: ObjectId;
- bannedBy: ObjectId;
-}
-
-const userBanSchema = new Schema({
- userId: {
- type: Schema.Types.ObjectId,
- ref: USER,
- required: true
- },
- bannedBy: {
- type: Schema.Types.ObjectId,
- ref: USER,
- required: true
- },
- banType: {
- type: String,
- enum: [banTypeEnum.TEMPORARY, banTypeEnum.PERMANENT],
- required: true
- },
- reason: {
- type: String,
- required: true,
- minlength: 10,
- maxlength: 500
- },
- startDate: {
- type: Date,
- required: true,
- default: Date.now
- },
- endDate: {
- type: Date,
- required: false // Not required for permanent bans
- },
- isActive: {
- type: Boolean,
- default: true
- },
- createdAt: {
- type: Date,
- default: Date.now
- },
- updatedAt: {
- type: Date,
- default: Date.now
- }
-});
-
-// Indexes for efficient querying
-userBanSchema.index({ userId: 1, isActive: 1 });
-userBanSchema.index({ userId: 1, createdAt: -1 });
-userBanSchema.index({ endDate: 1, isActive: 1 }); // For expiration checks
-
-const UserBan = mongoose.model(USER_BAN, userBanSchema);
-
-export default UserBan;
diff --git a/libs/backend/src/models/preferences/preferences-editor.ts b/libs/backend/src/models/preferences/preferences-editor.ts
deleted file mode 100644
index 79f69084..00000000
--- a/libs/backend/src/models/preferences/preferences-editor.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { Schema } from "mongoose";
-import { EditorPreferences } from "types";
-
-export const preferencesEditor = new Schema({
- keymap: {
- type: String,
- required: false
- },
- allowMultipleSelections: {
- type: Boolean,
- required: false
- },
- autocompletion: {
- type: Boolean,
- required: false
- },
- bracketMatching: {
- type: Boolean,
- required: false
- },
- closeBrackets: {
- type: Boolean,
- required: false
- },
- completionKeymap: {
- type: Boolean,
- required: false
- },
- crosshairCursor: {
- type: Boolean,
- required: false
- },
- defaultKeymap: {
- type: Boolean,
- required: false
- },
- drawSelection: {
- type: Boolean,
- required: false
- },
- dropCursor: {
- type: Boolean,
- required: false
- },
- foldGutter: {
- type: Boolean,
- required: false
- },
- foldKeymap: {
- type: Boolean,
- required: false
- },
- highlightActiveLine: {
- type: Boolean,
- required: false
- },
- highlightActiveLineGutter: {
- type: Boolean,
- required: false
- },
- highlightSelectionMatches: {
- type: Boolean,
- required: false
- },
- highlightSpecialChars: {
- type: Boolean,
- required: false
- },
- history: {
- type: Boolean,
- required: false
- },
- indentOnInput: {
- type: Boolean,
- required: false
- },
- lineNumbers: {
- type: Boolean,
- required: false
- },
- lintKeymap: {
- type: Boolean,
- required: false
- },
- rectangularSelection: {
- type: Boolean,
- required: false
- },
- searchKeymap: {
- type: Boolean,
- required: false
- }
-});
diff --git a/libs/backend/src/models/preferences/preferences.ts b/libs/backend/src/models/preferences/preferences.ts
deleted file mode 100644
index f14889c4..00000000
--- a/libs/backend/src/models/preferences/preferences.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import mongoose, { Document, ObjectId, Schema } from "mongoose";
-import { PreferencesEntity } from "types";
-import { PREFERENCES, USER } from "../../utils/constants/model.js";
-import { preferencesEditor } from "./preferences-editor.js";
-
-export interface PreferencesDocument
- extends Document,
- Omit {
- owner: ObjectId;
-}
-
-const preferencesSchema = new Schema(
- {
- owner: {
- ref: USER,
- required: true,
- type: mongoose.Schema.Types.ObjectId,
- index: true,
- unique: true
- },
- blockedUsers: {
- required: false,
- type: [
- {
- ref: USER,
- required: false,
- type: mongoose.Schema.Types.ObjectId
- }
- ]
- },
- preferredLanguage: {
- type: String,
- required: false
- },
- theme: {
- type: String,
- required: false
- },
- editor: {
- type: preferencesEditor,
- required: false
- }
- },
- { timestamps: true }
-);
-
-const Preferences = mongoose.model(
- PREFERENCES,
- preferencesSchema
-);
-export default Preferences;
diff --git a/libs/backend/src/models/programming-language/language.ts b/libs/backend/src/models/programming-language/language.ts
deleted file mode 100644
index 5222a5b9..00000000
--- a/libs/backend/src/models/programming-language/language.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import mongoose, { Document, Schema } from "mongoose";
-import { ProgrammingLanguageEntity } from "types";
-import { PROGRAMMING_LANGUAGE } from "../../utils/constants/model.js";
-
-export interface ProgrammingLanguageDocument
- extends Document,
- Omit {
- _id: mongoose.Types.ObjectId;
-}
-
-const programmingLanguageSchema = new Schema(
- {
- language: {
- required: true,
- type: String,
- trim: true,
- index: true
- },
- version: {
- required: true,
- type: String,
- trim: true,
- index: true
- },
- aliases: {
- type: [String],
- default: [],
- required: false
- },
- runtime: {
- type: String,
- required: false,
- trim: true
- }
- },
- {
- timestamps: true
- }
-);
-
-// Compound unique index to ensure language+version combination is unique
-programmingLanguageSchema.index({ language: 1, version: 1 }, { unique: true });
-
-const ProgrammingLanguage = mongoose.model(
- PROGRAMMING_LANGUAGE,
- programmingLanguageSchema
-);
-
-export default ProgrammingLanguage;
diff --git a/libs/backend/src/models/puzzle/puzzle.ts b/libs/backend/src/models/puzzle/puzzle.ts
deleted file mode 100644
index 7f7c8a41..00000000
--- a/libs/backend/src/models/puzzle/puzzle.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import mongoose, { Schema, Document, ObjectId } from "mongoose";
-import { PUZZLE, USER, METRICS, COMMENT } from "../../utils/constants/model.js";
-import {
- DifficultyEnum,
- PuzzleEntity,
- puzzleVisibilityEnum,
- Solution
-} from "types";
-import solutionSchema from "./solution.js";
-import validatorSchema from "./validator.js";
-import Comment from "../comment/comment.js";
-
-export interface PuzzleDocument
- extends Document,
- Omit {
- author: ObjectId;
- solution?: Solution;
-}
-
-/**
- * IDEA: Eventually add puzzle types
- * offering different play modes and play styles
- */
-const puzzleSchema = new Schema({
- title: {
- required: true,
- trim: true,
- type: String
- },
- statement: {
- trim: true,
- type: String
- },
- constraints: {
- trim: true,
- type: String
- },
- author: {
- ref: USER,
- required: true,
- type: mongoose.Schema.Types.ObjectId
- },
- validators: [validatorSchema],
- difficulty: {
- enum: Object.values(DifficultyEnum),
- default: DifficultyEnum.INTERMEDIATE,
- required: true,
- type: String
- },
- visibility: {
- enum: Object.values(puzzleVisibilityEnum),
- default: puzzleVisibilityEnum.DRAFT,
- required: true,
- type: String
- },
- createdAt: {
- default: Date.now,
- type: Date
- },
- updatedAt: {
- default: Date.now,
- type: Date
- },
- solution: {
- type: solutionSchema,
- select: false,
- default: () => ({ code: "", programmingLanguage: undefined })
- },
- puzzleMetrics: {
- ref: METRICS,
- type: Schema.Types.ObjectId,
- select: false
- },
- tags: [
- {
- type: String
- }
- ],
- comments: [
- {
- ref: COMMENT,
- type: Schema.Types.ObjectId
- }
- ],
- moderationFeedback: {
- type: String,
- required: false
- }
-});
-
-puzzleSchema.pre(
- "deleteOne",
- { document: true, query: false },
- async function (next) {
- await Comment.deleteMany({ _id: { $in: this.comments } });
-
- next();
- }
-);
-
-const Puzzle = mongoose.model(PUZZLE, puzzleSchema);
-export default Puzzle;
diff --git a/libs/backend/src/models/puzzle/solution.ts b/libs/backend/src/models/puzzle/solution.ts
deleted file mode 100644
index 38d2db49..00000000
--- a/libs/backend/src/models/puzzle/solution.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import mongoose, { Schema } from "mongoose";
-import { Solution } from "types";
-import { PROGRAMMING_LANGUAGE } from "../../utils/constants/model.js";
-
-const solutionSchema = new Schema({
- code: {
- required: false,
- type: String
- },
- programmingLanguage: {
- ref: PROGRAMMING_LANGUAGE,
- required: false,
- type: mongoose.Schema.Types.ObjectId
- }
-});
-
-export default solutionSchema;
diff --git a/libs/backend/src/models/puzzle/validator.ts b/libs/backend/src/models/puzzle/validator.ts
deleted file mode 100644
index 26d291ad..00000000
--- a/libs/backend/src/models/puzzle/validator.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Schema } from "mongoose";
-
-/**
- * IDEA: Eventually add validator types
- * there could be different validators, hidden generated by the system, and public testcases
- * this could mitigate users that just look at the public test cases and thus help catch code that technically is wrong, but passes the visible validators (to all users).
- */
-const validatorSchema = new Schema({
- createdAt: {
- default: Date.now,
- type: Date
- },
- input: {
- required: true,
- type: String
- },
- output: {
- required: true,
- type: String
- },
- updatedAt: {
- default: Date.now,
- type: Date
- }
-});
-
-export default validatorSchema;
diff --git a/libs/backend/src/models/report/report.ts b/libs/backend/src/models/report/report.ts
deleted file mode 100644
index f744710d..00000000
--- a/libs/backend/src/models/report/report.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import mongoose, { Document, Schema } from "mongoose";
-import { ProblemTypeEnum, ReportEntity, reviewStatusEnum } from "types";
-import { REPORT, USER } from "../../utils/constants/model.js";
-
-export interface ReportDocument
- extends Document,
- Omit {
- reportedBy: mongoose.Types.ObjectId;
- resolvedBy?: mongoose.Types.ObjectId;
- problematicIdentifier: mongoose.Types.ObjectId;
-}
-
-const reportSchema = new Schema({
- problematicIdentifier: {
- type: Schema.Types.ObjectId,
- required: true,
- refPath: "problemType"
- },
- problemType: {
- type: String,
- required: true,
- enum: [
- ProblemTypeEnum.PUZZLE,
- ProblemTypeEnum.USER,
- ProblemTypeEnum.COMMENT,
- ProblemTypeEnum.GAME_CHAT
- ]
- },
- reportedBy: {
- type: Schema.Types.ObjectId,
- ref: USER,
- required: true
- },
- explanation: {
- type: String,
- required: true
- },
- status: {
- type: String,
- enum: [
- reviewStatusEnum.PENDING,
- reviewStatusEnum.RESOLVED,
- reviewStatusEnum.REJECTED
- ],
- default: reviewStatusEnum.PENDING,
- required: true
- },
- resolvedBy: {
- type: Schema.Types.ObjectId,
- ref: USER,
- required: false
- },
- createdAt: {
- type: Date,
- default: Date.now
- },
- updatedAt: {
- type: Date,
- default: Date.now
- }
-});
-
-// Create indexes for common queries
-reportSchema.index({ status: 1, createdAt: -1 });
-reportSchema.index({ problemType: 1, status: 1 });
-reportSchema.index({ reportedBy: 1 });
-
-const Report = mongoose.model(REPORT, reportSchema);
-export default Report;
diff --git a/libs/backend/src/models/submission/result-info.ts b/libs/backend/src/models/submission/result-info.ts
deleted file mode 100644
index 7b8456d8..00000000
--- a/libs/backend/src/models/submission/result-info.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Schema } from "mongoose";
-import { PuzzleResultInformation } from "types";
-
-export const resultInfoSchema = new Schema({
- result: {
- type: String,
- required: true
- },
- successRate: {
- type: Number,
- required: true
- }
-});
diff --git a/libs/backend/src/models/submission/submission.ts b/libs/backend/src/models/submission/submission.ts
deleted file mode 100644
index cc4031c7..00000000
--- a/libs/backend/src/models/submission/submission.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import mongoose, { Document, ObjectId, Schema } from "mongoose";
-import {
- PROGRAMMING_LANGUAGE,
- PUZZLE,
- SUBMISSION,
- USER
-} from "../../utils/constants/model.js";
-import { SubmissionEntity } from "types";
-import { resultInfoSchema } from "./result-info.js";
-
-export interface SubmissionDocument
- extends Document,
- Omit {
- puzzle: ObjectId;
- user: ObjectId;
- programmingLanguage: ObjectId;
-}
-
-const submissionSchema = new Schema({
- code: {
- required: true,
- type: String,
- select: false
- },
- createdAt: {
- default: Date.now,
- type: Date
- },
- puzzle: {
- ref: PUZZLE,
- required: true,
- type: mongoose.Schema.Types.ObjectId
- },
- result: {
- required: true,
- type: resultInfoSchema
- },
- user: {
- ref: USER,
- required: true,
- type: mongoose.Schema.Types.ObjectId
- },
- programmingLanguage: {
- ref: PROGRAMMING_LANGUAGE,
- required: true,
- type: mongoose.Schema.Types.ObjectId
- }
-});
-
-const Submission = mongoose.model(
- SUBMISSION,
- submissionSchema
-);
-export default Submission;
diff --git a/libs/backend/src/models/user-metrics/user-metrics.ts b/libs/backend/src/models/user-metrics/user-metrics.ts
deleted file mode 100644
index 65759490..00000000
--- a/libs/backend/src/models/user-metrics/user-metrics.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import mongoose, { Document, Schema } from "mongoose";
-import { UserMetricsEntity } from "types";
-import { USER_METRICS, USER } from "../../utils/constants/model.js";
-
-export interface UserMetricsDocument
- extends Document,
- Omit {
- userId: mongoose.Types.ObjectId;
-}
-
-const glickoRatingSchema = new Schema(
- {
- rating: {
- type: Number,
- default: 1500
- },
- rd: {
- type: Number,
- default: 350
- },
- volatility: {
- type: Number,
- default: 0.06
- },
- lastUpdated: {
- type: Date,
- default: Date.now
- }
- },
- { _id: false }
-);
-
-const gameModeMetricsSchema = new Schema(
- {
- gamesPlayed: {
- type: Number,
- default: 0,
- min: 0
- },
- gamesWon: {
- type: Number,
- default: 0,
- min: 0
- },
- bestScore: {
- type: Number,
- default: 0,
- min: 0
- },
- averageScore: {
- type: Number,
- default: 0,
- min: 0
- },
- totalScore: {
- type: Number,
- default: 0,
- min: 0
- },
- glickoRating: {
- type: glickoRatingSchema,
- default: () => ({})
- },
- rank: {
- type: Number,
- required: false
- },
- lastGameDate: {
- type: Date,
- required: false
- }
- },
- { _id: false }
-);
-
-const userMetricsSchema = new Schema({
- userId: {
- type: Schema.Types.ObjectId,
- ref: USER,
- required: true,
- unique: true,
- index: true
- },
-
- // Metrics per game mode
- fastest: {
- type: gameModeMetricsSchema,
- required: false
- },
- shortest: {
- type: gameModeMetricsSchema,
- required: false
- },
- backwards: {
- type: gameModeMetricsSchema,
- required: false
- },
- hardcore: {
- type: gameModeMetricsSchema,
- required: false
- },
- debug: {
- type: gameModeMetricsSchema,
- required: false
- },
- typeracer: {
- type: gameModeMetricsSchema,
- required: false
- },
- efficiency: {
- type: gameModeMetricsSchema,
- required: false
- },
- incremental: {
- type: gameModeMetricsSchema,
- required: false
- },
- random: {
- type: gameModeMetricsSchema,
- required: false
- },
-
- // Overall stats
- totalGamesPlayed: {
- type: Number,
- default: 0,
- min: 0
- },
- totalGamesWon: {
- type: Number,
- default: 0,
- min: 0
- },
-
- // Tracking for incremental updates
- lastProcessedGameDate: {
- type: Date,
- default: () => new Date(0) // Unix epoch
- },
- lastCalculationDate: {
- type: Date,
- default: Date.now
- },
-
- createdAt: {
- type: Date,
- default: Date.now
- },
- updatedAt: {
- type: Date,
- default: Date.now
- }
-});
-
-// Update timestamp on save
-userMetricsSchema.pre("save", function (next) {
- this.updatedAt = new Date();
- next();
-});
-
-// Compound index for leaderboard queries per game mode
-userMetricsSchema.index({ "fastest.glickoRating.rating": -1 });
-userMetricsSchema.index({ "shortest.glickoRating.rating": -1 });
-userMetricsSchema.index({ "backwards.glickoRating.rating": -1 });
-userMetricsSchema.index({ "hardcore.glickoRating.rating": -1 });
-userMetricsSchema.index({ "debug.glickoRating.rating": -1 });
-userMetricsSchema.index({ "typeracer.glickoRating.rating": -1 });
-userMetricsSchema.index({ "efficiency.glickoRating.rating": -1 });
-userMetricsSchema.index({ "incremental.glickoRating.rating": -1 });
-userMetricsSchema.index({ "random.glickoRating.rating": -1 });
-
-const UserMetrics = mongoose.model(
- USER_METRICS,
- userMetricsSchema
-);
-
-export default UserMetrics;
diff --git a/libs/backend/src/models/user/user-profile.ts b/libs/backend/src/models/user/user-profile.ts
deleted file mode 100644
index 3e700e1f..00000000
--- a/libs/backend/src/models/user/user-profile.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Schema } from "mongoose";
-import { UserProfile } from "types";
-
-export const profileSchema = new Schema({
- picture: {
- type: String,
- required: false,
- trim: true
- },
- bio: {
- type: String,
- required: false,
- trim: true
- },
- location: {
- type: String,
- required: false,
- trim: true
- },
- socials: {
- required: false,
- type: [
- {
- type: String,
- trim: true
- }
- ]
- }
-});
diff --git a/libs/backend/src/models/user/user-vote.ts b/libs/backend/src/models/user/user-vote.ts
deleted file mode 100644
index 03e0a405..00000000
--- a/libs/backend/src/models/user/user-vote.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { USER, USER_VOTE } from "@/utils/constants/model.js";
-import mongoose, { Document, ObjectId, Schema } from "mongoose";
-import { UserVoteEntity } from "types";
-
-interface UserVoteDocument extends Document, Omit {
- author: ObjectId;
-}
-
-const userVoteSchema = new Schema({
- createdAt: {
- default: Date.now,
- type: Date
- },
- type: {
- type: String,
- required: true
- },
- votedOn: {
- type: String,
- required: true
- },
- author: {
- ref: USER,
- type: Schema.Types.ObjectId,
- required: true
- }
-});
-
-userVoteSchema.index({ author: 1, votedOn: 1 }, { background: true });
-userVoteSchema.index({ votedOn: 1 }, { background: true });
-
-const UserVote = mongoose.model(USER_VOTE, userVoteSchema);
-export default UserVote;
diff --git a/libs/backend/src/models/user/user.ts b/libs/backend/src/models/user/user.ts
deleted file mode 100644
index 6d49fcbd..00000000
--- a/libs/backend/src/models/user/user.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import mongoose, { Document, Schema } from "mongoose";
-import { DEFAULT_USER_ROLE, UserEntity } from "types";
-import bcrypt from "bcryptjs";
-import { USER, USER_BAN } from "../../utils/constants/model.js";
-import { profileSchema } from "./user-profile.js";
-
-export interface UserDocument extends Document, Omit {
- currentBan?: mongoose.Types.ObjectId | null;
-}
-
-const userSchema = new Schema({
- createdAt: {
- default: Date.now,
- type: Date
- },
- email: {
- lowercase: true,
- required: true,
- trim: true,
- type: String,
- unique: true,
- select: false,
- index: true
- },
- password: {
- required: true,
- type: String,
- select: false
- },
- updatedAt: {
- default: Date.now,
- type: Date
- },
- username: {
- required: true,
- trim: true,
- type: String,
- unique: true,
- index: true
- },
- profile: {
- type: profileSchema,
- required: false
- },
- role: {
- type: String,
- trim: true,
- required: false,
- default: () => DEFAULT_USER_ROLE
- },
- reportCount: {
- type: Number,
- default: 0,
- min: 0,
- select: false
- },
- banCount: {
- type: Number,
- default: 0,
- min: 0,
- select: false
- },
- currentBan: {
- type: Schema.Types.ObjectId,
- ref: USER_BAN,
- required: false,
- default: null
- }
-});
-
-// Pre-save hook to hashlutino password
-userSchema.pre("save", async function (next) {
- if (this.isModified("password")) {
- this.password = await bcrypt.hash(this.password, 10);
- }
-
- next();
-});
-
-const User = mongoose.model(USER, userSchema);
-export default User;
diff --git a/libs/backend/src/plugins/config/cors.ts b/libs/backend/src/plugins/config/cors.ts
deleted file mode 100644
index 363612a8..00000000
--- a/libs/backend/src/plugins/config/cors.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import cors from "@fastify/cors";
-import { FastifyInstance } from "fastify";
-import fastifyPlugin from "fastify-plugin";
-
-async function corsSetup(fastify: FastifyInstance) {
- fastify.register(cors, {
- allowedHeaders: ["Authorization", "Content-Type"],
- credentials: true,
- origin: process.env.FRONTEND_URL ?? "http://localhost:5173",
- // Allow WebSocket upgrade headers
- exposedHeaders: ["Upgrade", "Connection"]
- });
-}
-
-export default fastifyPlugin(corsSetup);
diff --git a/libs/backend/src/plugins/config/jwt.ts b/libs/backend/src/plugins/config/jwt.ts
deleted file mode 100644
index 1f8060d5..00000000
--- a/libs/backend/src/plugins/config/jwt.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import fastifyPlugin from "fastify-plugin";
-import jwt from "@fastify/jwt";
-import { FastifyInstance } from "fastify";
-import { cookieKeys } from "types";
-
-async function jwtSetup(fastify: FastifyInstance) {
- const JWT_SECRET = process.env.JWT_SECRET;
-
- if (!JWT_SECRET) {
- throw new Error("JWT secret is not defined in environment variables");
- }
-
- fastify.register(jwt, {
- secret: JWT_SECRET,
- cookie: {
- cookieName: cookieKeys.TOKEN,
- signed: false
- },
- formatUser: function (payload) {
- // Return the payload as-is without any transformation or validation
- // This prevents @fastify/jwt from doing any automatic schema validation
- return payload;
- },
- // Add decode option to see raw decoded payload
- decode: { complete: false }
- });
-}
-
-export default fastifyPlugin(jwtSetup);
diff --git a/libs/backend/src/plugins/config/mongoose.ts b/libs/backend/src/plugins/config/mongoose.ts
deleted file mode 100644
index a48068c8..00000000
--- a/libs/backend/src/plugins/config/mongoose.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { FastifyInstance } from "fastify";
-import mongoose from "mongoose";
-
-export default async function mongooseConnector(fastify: FastifyInstance) {
- const uri = process.env.MONGO_URI;
- const dbName = process.env.MONGO_DB_NAME;
-
- if (!uri) {
- throw new Error("MONGO_URI is not defined in environment variables");
- }
-
- try {
- console.log("Connecting to MongoDB...");
- await mongoose.connect(uri, {
- dbName: dbName ?? "codincod",
- serverSelectionTimeoutMS: 5 * 1000,
- connectTimeoutMS: 10 * 1000
- });
- console.log("MongoDB connected successfully!");
- mongoose.connection.on("connected", () => {
- fastify.log.info({ actor: "MongoDB" }, "connected");
- });
- mongoose.connection.on("disconnected", () => {
- fastify.log.error({ actor: "MongoDB" }, "disconnected");
- });
- } catch (error) {
- console.error(`MongoDB connection error:`, error);
- fastify.log.error(`MongoDB connection error (${error})`);
- process.exit(1);
- }
-
- fastify.decorate("mongoose", mongoose);
-}
diff --git a/libs/backend/src/plugins/config/setup-web-sockets.ts b/libs/backend/src/plugins/config/setup-web-sockets.ts
deleted file mode 100644
index f52ea6f2..00000000
--- a/libs/backend/src/plugins/config/setup-web-sockets.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { waitingRoomSetup } from "@/websocket/waiting-room/waiting-room-setup.js";
-import { gameSetup } from "@/websocket/game/game-setup.js";
-import authenticated from "../middleware/authenticated.js";
-import { webSocketParams, webSocketUrls } from "types";
-import { ParamsId } from "@/types/types.js";
-import { ConnectionManager } from "@/websocket/connection-manager.js";
-
-export async function setupWebSockets(fastify: FastifyInstance) {
- const connectionManager = new ConnectionManager();
-
- // Rate limit config for WebSocket upgrade requests
- // This limits the initial connection attempts, not the messages sent over the connection
- const wsRateLimit = {
- max: 20,
- timeWindow: "1 minute"
- };
-
- fastify.get(
- webSocketUrls.WAITING_ROOM,
- {
- websocket: true,
- preHandler: authenticated,
- config: {
- rateLimit: wsRateLimit
- }
- },
- (...props) => waitingRoomSetup(...props, fastify)
- );
-
- fastify.get(
- webSocketUrls.gameById(webSocketParams.ID),
- {
- websocket: true,
- preHandler: authenticated,
- config: {
- rateLimit: wsRateLimit
- }
- },
- (...props) => gameSetup(...props, fastify)
- );
-
- fastify.addHook("onClose", async () => {
- fastify.log.info("Shutting down WebSocket connections...");
- connectionManager.destroy();
- });
-}
diff --git a/libs/backend/src/plugins/decorators/piston.ts b/libs/backend/src/plugins/decorators/piston.ts
deleted file mode 100644
index 93cc077e..00000000
--- a/libs/backend/src/plugins/decorators/piston.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { buildPistonUri } from "@/utils/functions/build-piston-uri.js";
-import { FastifyInstance } from "fastify";
-import fastifyPlugin from "fastify-plugin";
-import {
- arePistonRuntimes,
- ErrorResponse,
- httpRequestMethod,
- isPistonExecutionResponse,
- PistonExecutionRequest,
- pistonUrls,
- POST
-} from "types";
-
-async function piston(fastify: FastifyInstance) {
- fastify.decorate(
- "piston",
- async (pistonExecutionRequestObject: PistonExecutionRequest) => {
- const res = await fetch(buildPistonUri(pistonUrls.EXECUTE), {
- method: POST,
- headers: {
- "Content-Type": "application/json"
- },
- body: JSON.stringify(pistonExecutionRequestObject)
- });
-
- const executionResponse = await res.json();
-
- if (!isPistonExecutionResponse(executionResponse)) {
- const error: ErrorResponse = {
- error: "Unknown error with piston",
- message: "response is not a piston execution response"
- };
-
- return error;
- }
-
- return executionResponse;
- }
- );
-
- fastify.decorate("runtimes", async () => {
- const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES), {
- method: httpRequestMethod.GET,
- headers: {
- "Content-Type": "application/json"
- }
- });
-
- if (!response.ok) {
- throw new Error(
- `Failed to execute code: ${response.status} - ${response.statusText}`
- );
- }
-
- const pistonRuntimesResponse = await response.json();
-
- if (!arePistonRuntimes(pistonRuntimesResponse)) {
- const error: ErrorResponse = {
- error: "Unknown error with piston",
- message: "response are not a piston runtimes"
- };
-
- return error;
- }
-
- return pistonRuntimesResponse;
- });
-}
-
-export default fastifyPlugin(piston);
diff --git a/libs/backend/src/plugins/middleware/authenticated.ts b/libs/backend/src/plugins/middleware/authenticated.ts
deleted file mode 100644
index 12dc75a1..00000000
--- a/libs/backend/src/plugins/middleware/authenticated.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { FastifyReply, FastifyRequest } from "fastify";
-import { httpResponseCodes, cookieKeys, AuthenticatedInfo } from "types";
-
-export default async function authenticated(
- request: FastifyRequest,
- reply: FastifyReply
-) {
- try {
- const token = request.cookies[cookieKeys.TOKEN];
-
- if (!token) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ message: "No authentication token provided" });
- }
-
- await request.jwtVerify();
- } catch (err) {
- if (err instanceof Error) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: err.message });
- }
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ message: "An unexpected error occurred." });
- }
-}
diff --git a/libs/backend/src/plugins/middleware/check-user-ban.ts b/libs/backend/src/plugins/middleware/check-user-ban.ts
deleted file mode 100644
index 0414fa3c..00000000
--- a/libs/backend/src/plugins/middleware/check-user-ban.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { FastifyReply, FastifyRequest } from "fastify";
-import { httpResponseCodes, isAuthenticatedInfo, banTypeEnum } from "types";
-import { checkUserBanStatus } from "../../utils/moderation/escalation.js";
-
-export default async function checkUserBan(
- request: FastifyRequest,
- reply: FastifyReply
-): Promise {
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Authentication required" });
- }
-
- const { isBanned, ban } = await checkUserBanStatus(request.user.userId);
-
- if (isBanned && ban) {
- const message =
- ban.banType === banTypeEnum.PERMANENT
- ? `You have been permanently banned. Reason: ${ban.reason}`
- : `You are temporarily banned until ${ban.endDate?.toISOString()}. Reason: ${ban.reason}`;
-
- return reply.status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN).send({
- error: message,
- banDetails: {
- type: ban.banType,
- reason: ban.reason,
- endDate: ban.endDate
- }
- });
- }
-}
diff --git a/libs/backend/src/plugins/middleware/decode-token.ts b/libs/backend/src/plugins/middleware/decode-token.ts
deleted file mode 100644
index fff60e7e..00000000
--- a/libs/backend/src/plugins/middleware/decode-token.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { FastifyRequest } from "fastify";
-import { AuthenticatedInfo } from "types";
-
-export default async function decodeToken(request: FastifyRequest) {
- try {
- // Set the Authorization header for jwt.verify to use
- // for some dumb reason it searches on headers.authorization, lost quite a bit of time on this one
- request.headers.authorization = `bearer ${request.cookies.token}`;
-
- const decoded = await request.jwtVerify();
-
- request.user = decoded;
- } catch (err) {}
-}
diff --git a/libs/backend/src/plugins/middleware/moderator-only.ts b/libs/backend/src/plugins/middleware/moderator-only.ts
deleted file mode 100644
index 3449acaf..00000000
--- a/libs/backend/src/plugins/middleware/moderator-only.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { FastifyReply, FastifyRequest } from "fastify";
-import { httpResponseCodes, isAuthenticatedInfo, isModerator } from "types";
-import User from "../../models/user/user.js";
-
-export default async function moderatorOnly(
- request: FastifyRequest,
- reply: FastifyReply
-) {
- try {
- const token = request.cookies.token;
-
- if (!token) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ message: "No authentication token provided" });
- }
-
- await request.jwtVerify();
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ message: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
- const user = await User.findById(userId);
-
- if (!user || !isModerator(user.role)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN)
- .send({ message: "Moderator access required" });
- }
- } catch (err) {
- if (err instanceof Error) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: err.message });
- }
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ message: "An unexpected error occurred." });
- }
-}
diff --git a/libs/backend/src/plugins/middleware/request-logger.ts b/libs/backend/src/plugins/middleware/request-logger.ts
deleted file mode 100644
index fad3f1d0..00000000
--- a/libs/backend/src/plugins/middleware/request-logger.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import fastifyPlugin from "fastify-plugin";
-import { FastifyInstance } from "fastify";
-
-async function requestLogger(fastify: FastifyInstance) {
- fastify.addHook("preHandler", async (request) => {
- console.log(
- `[${new Date().toISOString()}] ${request.method} ${request.url}`
- );
- });
-}
-
-export default fastifyPlugin(requestLogger);
diff --git a/libs/backend/src/plugins/middleware/validate-body.ts b/libs/backend/src/plugins/middleware/validate-body.ts
deleted file mode 100644
index 83e9a280..00000000
--- a/libs/backend/src/plugins/middleware/validate-body.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { FastifyReply, FastifyRequest } from "fastify";
-import { z, ZodSchema } from "zod";
-import { httpResponseCodes, type ErrorResponse } from "types";
-
-/**
- * Creates a validation middleware for request body validation using Zod schemas
- *
- * This middleware provides consistent error handling across all routes
- * and ensures type safety at runtime.
- *
- * @param schema - Zod schema to validate the request body against
- * @returns Fastify preHandler hook function
- *
- * @example
- * ```typescript
- * import { validateBody } from '@/plugins/middleware/validate-body.js';
- * import { registerSchema } from 'types';
- *
- * fastify.post(
- * '/',
- * { preHandler: validateBody(registerSchema) },
- * async (request, reply) => {
- * // request.body is now typed and validated
- * const { email, password, username } = request.body;
- * // ...
- * }
- * );
- * ```
- */
-export function validateBody(schema: T) {
- return async (
- request: FastifyRequest,
- reply: FastifyReply
- ): Promise => {
- try {
- // Parse and validate the request body
- request.body = schema.parse(request.body);
- } catch (error) {
- if (error instanceof z.ZodError) {
- // Format Zod validation errors for client
- const formattedErrors = error.issues.map((issue) => ({
- path: issue.path.join("."),
- message: issue.message,
- code: issue.code
- }));
-
- const errorResponse: ErrorResponse = {
- error: "Validation Error",
- message: "Request validation failed",
- details: formattedErrors
- };
-
- reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send(errorResponse);
- return;
- }
-
- // Unexpected error during validation
- request.log.error({ err: error }, "Unexpected validation error");
- const errorResponse: ErrorResponse = {
- error: "Internal Server Error",
- message: "An unexpected error occurred during validation"
- };
-
- reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send(errorResponse);
- }
- };
-}
-
-/**
- * Creates a validation middleware for query parameters using Zod schemas
- *
- * @param schema - Zod schema to validate the query parameters against
- * @returns Fastify preHandler hook function
- *
- * @example
- * ```typescript
- * import { validateQuery } from '@/plugins/middleware/validate-body.js';
- * import { paginatedQuerySchema } from 'types';
- *
- * fastify.get(
- * '/',
- * { preHandler: validateQuery(paginatedQuerySchema) },
- * async (request, reply) => {
- * const { page, pageSize } = request.query;
- * // ...
- * }
- * );
- * ```
- */
-export function validateQuery(schema: T) {
- return async (
- request: FastifyRequest,
- reply: FastifyReply
- ): Promise => {
- try {
- request.query = schema.parse(request.query);
- } catch (error) {
- if (error instanceof z.ZodError) {
- const formattedErrors = error.issues.map((issue) => ({
- path: issue.path.join("."),
- message: issue.message,
- code: issue.code
- }));
-
- const errorResponse: ErrorResponse = {
- error: "Validation Error",
- message: "Query parameter validation failed",
- details: formattedErrors
- };
-
- reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send(errorResponse);
- return;
- }
-
- request.log.error({ err: error }, "Unexpected query validation error");
- const errorResponse: ErrorResponse = {
- error: "Internal Server Error",
- message: "An unexpected error occurred during query validation"
- };
-
- reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send(errorResponse);
- }
- };
-}
-
-/**
- * Creates a validation middleware for route parameters using Zod schemas
- *
- * @param schema - Zod schema to validate the route params against
- * @returns Fastify preHandler hook function
- *
- * @example
- * ```typescript
- * import { validateParams } from '@/plugins/middleware/validate-body.js';
- * import { z } from 'zod';
- *
- * const paramsSchema = z.object({
- * id: z.string().min(1)
- * });
- *
- * fastify.get(
- * '/:id',
- * { preHandler: validateParams(paramsSchema) },
- * async (request, reply) => {
- * const { id } = request.params;
- * // ...
- * }
- * );
- * ```
- */
-export function validateParams(schema: T) {
- return async (
- request: FastifyRequest,
- reply: FastifyReply
- ): Promise => {
- try {
- request.params = schema.parse(request.params);
- } catch (error) {
- if (error instanceof z.ZodError) {
- const formattedErrors = error.issues.map((issue) => ({
- path: issue.path.join("."),
- message: issue.message,
- code: issue.code
- }));
-
- const errorResponse: ErrorResponse = {
- error: "Validation Error",
- message: "Route parameter validation failed",
- details: formattedErrors
- };
-
- reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send(errorResponse);
- return;
- }
-
- request.log.error({ err: error }, "Unexpected params validation error");
- const errorResponse: ErrorResponse = {
- error: "Internal Server Error",
- message: "An unexpected error occurred during parameter validation"
- };
-
- reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send(errorResponse);
- }
- };
-}
diff --git a/libs/backend/src/router.ts b/libs/backend/src/router.ts
deleted file mode 100644
index fbd7731a..00000000
--- a/libs/backend/src/router.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { FastifyInstance } from "fastify";
-import healthRoutes from "./routes/health/index.js";
-import puzzleRoutes from "./routes/puzzle/index.js";
-import submissionRoutes from "./routes/submission/index.js";
-import indexRoutes from "./routes/index.js";
-import registerRoutes from "./routes/register/index.js";
-import loginRoutes from "./routes/login/index.js";
-import logoutRoutes from "./routes/logout/index.js";
-import userRoutes from "./routes/user/index.js";
-import { backendParams, backendUrls, banTypeEnum } from "types";
-import puzzleByIdRoutes from "./routes/puzzle/[id]/index.js";
-import executeRoutes from "./routes/execute/index.js";
-import userByUsernameIsAvailableRoutes from "./routes/user/[username]/isAvailable/index.js";
-import userByUsernameRoutes from "./routes/user/[username]/index.js";
-import userByUsernameActivityRoutes from "./routes/user/[username]/activity/index.js";
-import submissionGameRoutes from "./routes/submission/game/index.js";
-import submissionByIdRoutes from "./routes/submission/[id]/index.js";
-import preferencesRoutes from "./routes/account/preferences/index.js";
-import puzzleByIdSolutionRoutes from "./routes/puzzle/[id]/solution/index.js";
-import puzzleByIdCommentRoutes from "./routes/puzzle/[id]/comment/index.js";
-import commentByIdRoutes from "./routes/comment/[id]/index.js";
-import commentByIdVoteRoutes from "./routes/comment/[id]/vote/index.js";
-import commentByIdCommentRoutes from "./routes/comment/[id]/comment/index.js";
-import userByUsernamePuzzleRoutes from "./routes/user/[username]/puzzle/index.js";
-import accountRoutes from "./routes/account/index.js";
-import reportRoutes from "./routes/report/index.js";
-import moderationReportByIdRoutes from "./routes/moderation/report/[id]/resolve/index.js";
-import moderationReviewRoutes from "./routes/moderation/review/index.js";
-import moderationPuzzleByIdApproveRoutes from "./routes/moderation/puzzle/[id]/approve/index.js";
-import moderationPuzzleByIdReviseRoutes from "./routes/moderation/puzzle/[id]/revise/index.js";
-import moderationUserByIdBanUnbanRoutes from "./routes/moderation/user/[id]/unban/index.js";
-import moderationUserByIdBanHistoryRoutes from "./routes/moderation/user/[id]/ban/history/index.js";
-import moderationUserByIdBanPermanentRoutes from "./routes/moderation/user/[id]/ban/permanent/index.js";
-import moderationUserByIdBanTemporaryRoutes from "./routes/moderation/user/[id]/ban/temporary/index.js";
-import programmingLanguageRoutes from "./routes/programming-language/index.js";
-import programmingLanguageByIdRoutes from "./routes/programming-language/[id]/index.js";
-import leaderboardByGameModeRoutes from "./routes/leaderboard/[gameMode]/index.js";
-import leaderboardRecalculateRoutes from "./routes/leaderboard/recalculate/index.js";
-import leaderboardUserByIdRoutes from "./routes/leaderboard/user/[id]/index.js";
-
-export default async function router(fastify: FastifyInstance) {
- fastify.register(indexRoutes, { prefix: backendUrls.ROOT });
- fastify.register(registerRoutes, { prefix: backendUrls.REGISTER });
- fastify.register(loginRoutes, { prefix: backendUrls.LOGIN });
- fastify.register(logoutRoutes, { prefix: backendUrls.LOGOUT });
- fastify.register(userRoutes, { prefix: backendUrls.USER });
- fastify.register(accountRoutes, { prefix: backendUrls.ACCOUNT });
- fastify.register(userByUsernameRoutes, {
- prefix: backendUrls.userByUsername(backendParams.USERNAME)
- });
- fastify.register(userByUsernameActivityRoutes, {
- prefix: backendUrls.userByUsernameActivity(backendParams.USERNAME)
- });
- fastify.register(userByUsernamePuzzleRoutes, {
- prefix: backendUrls.userByUsernamePuzzle(backendParams.USERNAME)
- });
- fastify.register(userByUsernameIsAvailableRoutes, {
- prefix: backendUrls.userByUsernameIsAvailable(backendParams.USERNAME)
- });
- fastify.register(puzzleRoutes, { prefix: backendUrls.PUZZLE });
- fastify.register(healthRoutes, { prefix: backendUrls.HEALTH });
- fastify.register(executeRoutes, { prefix: backendUrls.EXECUTE });
- fastify.register(submissionRoutes, { prefix: backendUrls.SUBMISSION });
- fastify.register(submissionByIdRoutes, {
- prefix: backendUrls.submissionById(backendParams.ID)
- });
- fastify.register(submissionGameRoutes, {
- prefix: backendUrls.SUBMISSION_GAME
- });
- fastify.register(programmingLanguageRoutes, {
- prefix: backendUrls.PROGRAMMING_LANGUAGE
- });
- fastify.register(programmingLanguageByIdRoutes, {
- prefix: backendUrls.programmingLanguageById(backendParams.ID)
- });
- fastify.register(puzzleByIdRoutes, {
- prefix: backendUrls.puzzleById(backendParams.ID)
- });
- fastify.register(puzzleByIdCommentRoutes, {
- prefix: backendUrls.puzzleByIdComment(backendParams.ID)
- });
- fastify.register(puzzleByIdSolutionRoutes, {
- prefix: backendUrls.puzzleByIdSolution(backendParams.ID)
- });
- fastify.register(commentByIdRoutes, {
- prefix: backendUrls.commentById(backendParams.ID)
- });
- fastify.register(commentByIdCommentRoutes, {
- prefix: backendUrls.commentByIdComment(backendParams.ID)
- });
- fastify.register(commentByIdVoteRoutes, {
- prefix: backendUrls.commentByIdVote(backendParams.ID)
- });
- fastify.register(preferencesRoutes, {
- prefix: backendUrls.ACCOUNT_PREFERENCES
- });
- fastify.register(reportRoutes, { prefix: backendUrls.REPORT });
- fastify.register(moderationReviewRoutes, {
- prefix: backendUrls.MODERATION_REVIEW
- });
- fastify.register(moderationPuzzleByIdApproveRoutes, {
- prefix: backendUrls.moderationPuzzleApprove(backendParams.ID)
- });
- fastify.register(moderationPuzzleByIdReviseRoutes, {
- prefix: backendUrls.moderationPuzzleRevise(backendParams.ID)
- });
- fastify.register(moderationReportByIdRoutes, {
- prefix: backendUrls.moderationReportResolve(backendParams.ID)
- });
- fastify.register(moderationUserByIdBanUnbanRoutes, {
- prefix: backendUrls.moderationUserByIdUnban(backendParams.ID)
- });
- fastify.register(moderationUserByIdBanHistoryRoutes, {
- prefix: backendUrls.moderationUserByIdBanHistory(backendParams.ID)
- });
- fastify.register(moderationUserByIdBanPermanentRoutes, {
- prefix: backendUrls.moderationUserByIdBanByType(
- backendParams.ID,
- banTypeEnum.PERMANENT
- )
- });
- fastify.register(moderationUserByIdBanTemporaryRoutes, {
- prefix: backendUrls.moderationUserByIdBanByType(
- backendParams.ID,
- banTypeEnum.TEMPORARY
- )
- });
- fastify.register(leaderboardByGameModeRoutes, {
- prefix: backendUrls.leaderboardByGameMode(backendParams.GAME_MODE)
- });
- fastify.register(leaderboardRecalculateRoutes, {
- prefix: backendUrls.LEADERBOARD_RECALCULATE
- });
- fastify.register(leaderboardUserByIdRoutes, {
- prefix: backendUrls.leaderboardUserById(backendParams.ID)
- });
-}
diff --git a/libs/backend/src/routes/account/index.ts b/libs/backend/src/routes/account/index.ts
deleted file mode 100644
index ae787e5a..00000000
--- a/libs/backend/src/routes/account/index.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { FastifyInstance } from "fastify";
-import authenticated from "../../plugins/middleware/authenticated.js";
-import checkUserBan from "../../plugins/middleware/check-user-ban.js";
-import { AuthenticatedInfo, DEFAULT_USER_ROLE, httpResponseCodes } from "types";
-import User from "../../models/user/user.js";
-import { validateBody } from "@/plugins/middleware/validate-body.js";
-import { z } from "zod";
-
-const updateProfileSchema = z.object({
- bio: z.string().max(500).optional(),
- location: z.string().max(100).optional(),
- picture: z.string().url().optional().or(z.literal("")),
- socials: z.array(z.string().url()).max(5).optional()
-});
-
-export default async function accountRoutes(fastify: FastifyInstance) {
- // GET /account - Get current user info
- fastify.get(
- "/",
- {
- preHandler: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const user = request.user as AuthenticatedInfo | undefined;
-
- if (!user) {
- return reply.status(401).send({
- isAuthenticated: false,
- message: "Not authenticated"
- });
- }
-
- try {
- // Fetch the user from database to get the role
- const dbUser = await User.findById(user.userId);
-
- return reply.status(200).send({
- isAuthenticated: true,
- userId: user.userId,
- username: user.username,
- role: dbUser?.role || DEFAULT_USER_ROLE
- });
- } catch (error) {
- fastify.log.error(error, "Failed to fetch user data");
- return reply.status(500).send({
- isAuthenticated: false,
- message: "Failed to fetch user data"
- });
- }
- }
- );
-
- // PATCH /account/profile - Update user profile
- fastify.patch(
- "/profile",
- {
- preHandler: [
- authenticated,
- checkUserBan,
- validateBody(updateProfileSchema)
- ]
- },
- async (request, reply) => {
- const user = request.user as AuthenticatedInfo | undefined;
-
- if (!user) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED).send({
- message: "Not authenticated"
- });
- }
-
- try {
- const updates = request.body as z.infer;
-
- const dbUser = await User.findById(user.userId);
-
- if (!dbUser) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- message: "User not found"
- });
- }
-
- // Update profile fields
- if (!dbUser.profile) {
- dbUser.profile = {};
- }
-
- if (updates.bio !== undefined) dbUser.profile.bio = updates.bio;
- if (updates.location !== undefined)
- dbUser.profile.location = updates.location;
- if (updates.picture !== undefined)
- dbUser.profile.picture = updates.picture;
- if (updates.socials !== undefined)
- dbUser.profile.socials = updates.socials;
-
- await dbUser.save();
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- message: "Profile updated successfully",
- profile: dbUser.profile
- });
- } catch (error) {
- fastify.log.error(error, "Failed to update profile");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- message: "Failed to update profile"
- });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/account/preferences/index.ts b/libs/backend/src/routes/account/preferences/index.ts
deleted file mode 100644
index f9d05a47..00000000
--- a/libs/backend/src/routes/account/preferences/index.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- isAuthenticatedInfo,
- preferencesDtoSchema
-} from "types";
-import Preferences from "../../../models/preferences/preferences.js";
-import authenticated from "../../../plugins/middleware/authenticated.js";
-import checkUserBan from "../../../plugins/middleware/check-user-ban.js";
-
-export default async function preferencesRoutes(fastify: FastifyInstance) {
- fastify.get(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
-
- try {
- const preferences = await Preferences.findOne({ owner: userId });
-
- if (!preferences) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Preferences not found" });
- }
-
- return reply.send(preferences);
- } catch (error) {
- console.error("Failed to fetch preferences:", error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch preferences" });
- }
- }
- );
-
- fastify.put(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const parseResult = preferencesDtoSchema.safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- const userId = request.user.userId;
-
- try {
- const preferences = await Preferences.findOneAndUpdate(
- { owner: userId },
- { ...parseResult.data, owner: userId },
- { new: true, runValidators: true, upsert: true }
- );
-
- return reply.send(preferences);
- } catch (error) {
- console.error("Failed to update preferences:", error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to update preferences" });
- }
- }
- );
-
- fastify.delete(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
-
- try {
- const deleted = await Preferences.findOneAndDelete({ owner: userId });
-
- if (!deleted) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Preferences not found" });
- }
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.NO_CONTENT)
- .send(deleted);
- } catch (error) {
- console.error("Failed to delete preferences:", error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to delete preferences" });
- }
- }
- );
-
- fastify.patch(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const parseResult = preferencesDtoSchema
- .partial()
- .safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
-
- try {
- const preferences = await Preferences.findOneAndUpdate(
- { owner: userId },
- { $set: parseResult.data },
- { new: true, runValidators: true, upsert: true }
- );
-
- return reply.send(preferences);
- } catch (error) {
- console.error("Failed to update preferences (PATCH):", error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to update preferences" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/comment/[id]/comment/index.ts b/libs/backend/src/routes/comment/[id]/comment/index.ts
deleted file mode 100644
index 28b25dcd..00000000
--- a/libs/backend/src/routes/comment/[id]/comment/index.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import Comment from "@/models/comment/comment.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { ParamsId } from "@/types/types.js";
-import { FastifyInstance } from "fastify";
-import {
- CommentEntity,
- commentTypeEnum,
- createCommentSchema,
- httpResponseCodes,
- isAuthenticatedInfo
-} from "types";
-
-export default async function commentByIdCommentRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const parseResult = createCommentSchema.safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const id = request.params.id;
- const user = request.user;
- const userId = user.userId;
-
- const newCommentData: CommentEntity = {
- ...parseResult.data,
- author: userId,
- upvote: 0,
- downvote: 0,
- comments: [],
- commentType: commentTypeEnum.COMMENT,
- parentId: id
- };
- try {
- const newComment = new Comment(newCommentData);
- await newComment.save();
-
- await Comment.findByIdAndUpdate(
- id,
- { $push: { comments: newComment._id } },
- { new: true }
- );
-
- const comment = await Comment.findById(newComment.id).populate(
- "author"
- );
-
- return reply.status(httpResponseCodes.SUCCESSFUL.CREATED).send(comment);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to create comment" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/comment/[id]/index.ts b/libs/backend/src/routes/comment/[id]/index.ts
deleted file mode 100644
index b3f193ef..00000000
--- a/libs/backend/src/routes/comment/[id]/index.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import Comment from "@/models/comment/comment.js";
-import Puzzle from "@/models/puzzle/puzzle.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { ParamsId } from "@/types/types.js";
-import { FastifyInstance } from "fastify";
-import { commentTypeEnum, httpResponseCodes, objectIdSchema } from "types";
-
-export default async function commentByIdRoutes(fastify: FastifyInstance) {
- fastify.get("/", async (request, reply) => {
- const parseResult = objectIdSchema.safeParse(request.params.id);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- try {
- const comment = await Comment.findById(request.params.id)
- .populate("author")
- .populate("comments")
- .populate({
- path: "comments",
- populate: {
- path: "author"
- }
- });
-
- if (!comment) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Comment not found" });
- }
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(comment);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch comment" });
- }
- });
-
- fastify.delete(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- try {
- const comment = await Comment.findById(request.params.id);
-
- if (!comment) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Comment not found" });
- }
-
- if (comment.commentType === commentTypeEnum.COMMENT) {
- await Comment.findOneAndUpdate(
- { comments: comment._id },
- // Remove the comment._id from the comments array
- { $pull: { comments: comment._id } },
- { new: true }
- );
- } else {
- await Puzzle.findOneAndUpdate(
- { comments: comment._id },
- { $pull: { comments: comment._id } },
- { new: true }
- );
- }
-
- await comment.deleteOne();
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.NO_CONTENT)
- .send(comment);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch comment" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/comment/[id]/vote/index.ts b/libs/backend/src/routes/comment/[id]/vote/index.ts
deleted file mode 100644
index 9cbcbc60..00000000
--- a/libs/backend/src/routes/comment/[id]/vote/index.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import Comment from "@/models/comment/comment.js";
-import UserVote from "@/models/user/user-vote.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { validateBody } from "@/plugins/middleware/validate-body.js";
-import { ParamsId } from "@/types/types.js";
-import { FastifyInstance } from "fastify";
-import {
- CommentVoteRequest,
- commentVoteRequestSchema,
- httpResponseCodes,
- isAuthenticatedInfo,
- voteTypeEnum
-} from "types";
-
-export default async function commentByIdVoteRoutes(fastify: FastifyInstance) {
- fastify.post(
- "/",
- {
- onRequest: [authenticated, checkUserBan],
- preHandler: validateBody(commentVoteRequestSchema),
- config: {
- rateLimit: {
- max: 20,
- timeWindow: "1 minute"
- }
- }
- },
- async (request, reply) => {
- const { type } = request.body;
-
- // Ensure user is authenticated
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
- const commentId = request.params.id;
-
- try {
- const comment = await Comment.findById(commentId);
-
- if (!comment) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Comment not found" });
- }
-
- // Check if user already voted on this comment
- let existingVote = await UserVote.findOne({
- votedOn: commentId,
- author: userId
- });
-
- // Handle vote toggle/update
- if (existingVote && existingVote.type === type) {
- await existingVote.deleteOne();
- } else if (existingVote) {
- existingVote.type = type;
- await existingVote.save();
- } else {
- await UserVote.create({
- type,
- votedOn: commentId,
- author: userId,
- createdAt: new Date()
- });
- }
-
- const voteCounts = await UserVote.aggregate([
- {
- $match: { votedOn: commentId }
- },
- {
- $group: {
- _id: null,
- upvote: {
- $sum: {
- $cond: [{ $eq: ["$type", voteTypeEnum.UPVOTE] }, 1, 0]
- }
- },
- downvote: {
- $sum: {
- $cond: [{ $eq: ["$type", voteTypeEnum.DOWNVOTE] }, 1, 0]
- }
- }
- }
- }
- ]);
-
- // Extract counts from aggregation result
- const { upvote = 0, downvote = 0 } = voteCounts[0] || {};
-
- comment.upvote = upvote;
- comment.downvote = downvote;
-
- await comment.save();
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(comment);
- } catch (error) {
- fastify.log.error(error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Internal server error" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/execute/index.ts b/libs/backend/src/routes/execute/index.ts
deleted file mode 100644
index 6f64e5c1..00000000
--- a/libs/backend/src/routes/execute/index.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- arePistonRuntimes,
- ERROR_MESSAGES,
- ErrorResponse,
- httpResponseCodes,
- isFetchError,
- isPistonExecutionResponseSuccess,
- PistonExecutionRequest,
- PistonExecutionResponse,
- ExecuteAPI
-} from "types";
-import { findRuntime } from "@/utils/functions/findRuntimeInfo.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { calculateResults } from "@/utils/functions/calculate-result.js";
-import { validateBody } from "@/plugins/middleware/validate-body.js";
-
-export const executionResponseErrors = {
- UNSUPPORTED_LANGUAGE: {
- error: "Unsupported language",
- message: "At the moment we don't support this language."
- },
- SERVICE_UNAVAILABLE: {
- error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR,
- message: ERROR_MESSAGES.FETCH.NETWORK_ERROR
- },
- INTERNAL_SERVER_ERROR: {
- error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR,
- message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG
- },
- PISTON_ERROR: {
- error: "Piston error"
- }
-} as const;
-
-export default async function executeRoutes(fastify: FastifyInstance) {
- /**
- * POST /execute - Execute code without creating a submission
- * Uses specific ExecuteAPI types
- */
- fastify.post<{ Body: ExecuteAPI.ExecuteCodeRequest }>(
- "/",
- {
- onRequest: [authenticated, checkUserBan],
- preHandler: validateBody(ExecuteAPI.executeCodeRequestSchema),
- config: {
- rateLimit: {
- max: 30,
- timeWindow: "1 minute"
- }
- }
- },
- async (request, reply) => {
- const { code, language, testInput, testOutput } = request.body;
-
- const runtimes = await fastify.runtimes();
-
- if (!arePistonRuntimes(runtimes)) {
- const error: ErrorResponse = runtimes;
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE)
- .send(error);
- }
-
- const runtimeInfo = findRuntime(runtimes, language);
-
- if (!runtimeInfo) {
- const error: ErrorResponse =
- executionResponseErrors.UNSUPPORTED_LANGUAGE;
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send(error);
- }
-
- const requestObject: PistonExecutionRequest = {
- language: runtimeInfo.language,
- version: runtimeInfo.version,
- files: [{ content: code }],
- stdin: testInput
- };
-
- let executionRes: PistonExecutionResponse;
- try {
- executionRes = await fastify.piston(requestObject);
- } catch (err: unknown) {
- request.log.error(
- {
- err,
- requestBody: request.body
- },
- "Error during code execution"
- );
-
- if (isFetchError(err) && err.cause?.code === "ECONNREFUSED") {
- const error: ErrorResponse =
- executionResponseErrors.SERVICE_UNAVAILABLE;
- return reply
- .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE)
- .send(error);
- }
-
- const error: ErrorResponse =
- executionResponseErrors.INTERNAL_SERVER_ERROR;
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send(error);
- }
-
- if (!isPistonExecutionResponseSuccess(executionRes)) {
- const error: ErrorResponse = {
- error: executionResponseErrors.PISTON_ERROR.error,
- message: executionRes.message
- };
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send(error);
- }
-
- const codeExecutionResponse: ExecuteAPI.ExecuteCodeResponse = {
- run: executionRes.run,
- compile: executionRes.compile,
- puzzleResultInformation: calculateResults([testOutput], [executionRes])
- };
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.OK)
- .send(codeExecutionResponse);
- }
- );
-}
diff --git a/libs/backend/src/routes/game/leaderboard/index.ts b/libs/backend/src/routes/game/leaderboard/index.ts
deleted file mode 100644
index b1bec370..00000000
--- a/libs/backend/src/routes/game/leaderboard/index.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { httpResponseCodes } from "types";
-import { gameService } from "@/services/game.service.js";
-import { gameModeService } from "@/services/game-mode.service.js";
-import Submission from "@/models/submission/submission.js";
-
-/**
- * Game leaderboard and statistics routes
- */
-export default async function gameLeaderboardRoutes(fastify: FastifyInstance) {
- /**
- * GET /game/:id/leaderboard - Get ranked leaderboard for a game
- */
- fastify.get<{ Params: { id: string } }>(
- "/:id/leaderboard",
- async (request, reply) => {
- try {
- const { id } = request.params;
-
- const game = await gameService.findByIdPopulated(id);
-
- if (!game) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: `Game with id ${id} not found` });
- }
-
- // Fetch all submissions for this game
- const submissionIds = (game.playerSubmissions ?? []).map((sub) =>
- typeof sub === "string" ? sub : sub._id
- );
-
- const submissions = await Submission.find({
- _id: { $in: submissionIds }
- })
- .populate("user")
- .populate("programmingLanguage")
- .exec();
-
- // Build leaderboard using game mode service
- const leaderboard = gameModeService.getGameLeaderboard(
- game,
- submissions
- );
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- gameId: id,
- mode: game.options?.mode,
- leaderboard,
- totalPlayers: leaderboard.length
- });
- } catch (error) {
- fastify.log.error(error, "Error fetching game leaderboard");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch leaderboard" });
- }
- }
- );
-
- /**
- * GET /game/:id/stats - Get game statistics
- */
- fastify.get<{ Params: { id: string } }>(
- "/:id/stats",
- async (request, reply) => {
- try {
- const { id } = request.params;
-
- const game = await gameService.findByIdPopulated(id);
-
- if (!game) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: `Game with id ${id} not found` });
- }
-
- const mode = game.options?.mode;
- const displayMetrics = mode
- ? gameModeService.getDisplayMetricsForMode(mode)
- : ["score", "time"];
-
- const description = mode
- ? gameModeService.getGameModeDescription(mode)
- : "Standard game";
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- gameId: id,
- mode,
- description,
- displayMetrics,
- playerCount: game.players?.length ?? 0,
- submissionCount: game.playerSubmissions?.length ?? 0,
- createdAt: game.createdAt,
- options: game.options
- });
- } catch (error) {
- fastify.log.error(error, "Error fetching game stats");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch game statistics" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/health/index.ts b/libs/backend/src/routes/health/index.ts
deleted file mode 100644
index 961f081c..00000000
--- a/libs/backend/src/routes/health/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
-
-export const healthResponse = "OK";
-
-export default async function healthRoutes(fastify: FastifyInstance) {
- fastify.get("/", async (request: FastifyRequest, reply: FastifyReply) => {
- reply.send({ status: healthResponse });
- });
-}
diff --git a/libs/backend/src/routes/index.ts b/libs/backend/src/routes/index.ts
deleted file mode 100644
index 48af445a..00000000
--- a/libs/backend/src/routes/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { FastifyInstance } from "fastify";
-
-export default async function indexRoutes(_fastify: FastifyInstance) {
- // No routes defined yet
-}
diff --git a/libs/backend/src/routes/leaderboard/[gameMode]/index.ts b/libs/backend/src/routes/leaderboard/[gameMode]/index.ts
deleted file mode 100644
index e62e8436..00000000
--- a/libs/backend/src/routes/leaderboard/[gameMode]/index.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { genericReturnMessages } from "@/config/generic-return-messages.js";
-import { leaderboardService } from "@/services/leaderboard.service.js";
-import { FastifyInstance } from "fastify";
-import {
- DEFAULT_PAGE,
- ERROR_MESSAGES,
- httpResponseCodes,
- isGameMode,
- LeaderboardAPI,
- PAGINATION_CONFIG
-} from "types";
-
-const LEADERBOARD = "Leaderboard";
-
-export default async function leaderboardByGameModeRoutes(
- fastify: FastifyInstance
-) {
- fastify.get<{
- Params: { gameMode: string };
- Querystring: { page?: string; pageSize?: string };
- }>("/", async (request, reply) => {
- const { gameMode } = request.params;
-
- const page = Math.max(
- parseInt(request.query.page || String(DEFAULT_PAGE), 10),
- PAGINATION_CONFIG.MIN_PAGE
- );
- const pageSize = Math.min(
- Math.max(
- parseInt(
- request.query.pageSize ||
- String(PAGINATION_CONFIG.DEFAULT_LIMIT_LEADERBOARD),
- 10
- ),
- PAGINATION_CONFIG.MIN_LIMIT
- ),
- PAGINATION_CONFIG.MAX_LIMIT
- );
-
- if (!isGameMode(gameMode)) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: `Game mode ${genericReturnMessages[httpResponseCodes.CLIENT_ERROR.BAD_REQUEST].IS_INVALID}.`
- });
- }
-
- try {
- const result = await leaderboardService.getLeaderboard(
- gameMode,
- page,
- pageSize
- );
-
- const response: LeaderboardAPI.GetLeaderboardResponse = {
- gameMode: gameMode,
- entries: result.entries,
- page,
- pageSize,
- totalEntries: result.total,
- totalPages: Math.ceil(result.total / pageSize),
- lastUpdated: result.lastUpdated.toISOString()
- };
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response);
- } catch (error) {
- request.log.error(
- { err: error },
- `${ERROR_MESSAGES.FETCH.FAILED_TO_FETCH} leaderboard`
- );
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- error: `${LEADERBOARD} ${genericReturnMessages[httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR].WENT_WRONG}`
- });
- }
- });
-}
diff --git a/libs/backend/src/routes/leaderboard/recalculate/index.ts b/libs/backend/src/routes/leaderboard/recalculate/index.ts
deleted file mode 100644
index 336a6b84..00000000
--- a/libs/backend/src/routes/leaderboard/recalculate/index.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { ERROR_MESSAGES, httpResponseCodes } from "types";
-import { FastifyInstance } from "fastify";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import moderatorOnly from "@/plugins/middleware/moderator-only.js";
-import { leaderboardService } from "@/services/leaderboard.service.js";
-
-export default async function recalculateLeaderboardRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: [authenticated, moderatorOnly]
- },
- async (request, reply) => {
- try {
- const result = await leaderboardService.recalculateAllLeaderboards();
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- message: `Successfully recalculated leaderboards`,
- processed: result
- });
- } catch (error) {
- fastify.log.error(error, "Failed to recalculate leaderboards");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR,
- message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG
- });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/leaderboard/user/[id]/index.ts b/libs/backend/src/routes/leaderboard/user/[id]/index.ts
deleted file mode 100644
index 35325aef..00000000
--- a/libs/backend/src/routes/leaderboard/user/[id]/index.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { genericReturnMessages } from "@/config/generic-return-messages.js";
-import User from "@/models/user/user.js";
-import { leaderboardService } from "@/services/leaderboard.service.js";
-import { FastifyInstance } from "fastify";
-import { ERROR_MESSAGES, httpResponseCodes, LeaderboardAPI } from "types";
-
-const USER = "User";
-
-export default async function leaderboardUserByIdRoutes(
- fastify: FastifyInstance
-) {
- fastify.get<{ Params: { id: string } }>("/", async (request, reply) => {
- const { id } = request.params;
-
- try {
- const user = await User.findById(id);
- if (!user) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- error: `${USER} ${genericReturnMessages[httpResponseCodes.CLIENT_ERROR.NOT_FOUND].COULD_NOT_BE_FOUND}`
- });
- }
-
- const rankings = await leaderboardService.getUserRankings(id);
-
- const response: LeaderboardAPI.GetUserLeaderboardStatsResponse = {
- userId: id,
- username: user.username,
- rankings
- };
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response);
- } catch (error) {
- request.log.error(
- { err: error },
- `${ERROR_MESSAGES.FETCH.FAILED_TO_FETCH} user rankings`
- );
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- error: `User rankings ${genericReturnMessages[httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR]}`
- });
- }
- });
-}
diff --git a/libs/backend/src/routes/login/index.ts b/libs/backend/src/routes/login/index.ts
deleted file mode 100644
index 475916ce..00000000
--- a/libs/backend/src/routes/login/index.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { FastifyInstance } from "fastify";
-import bcrypt from "bcryptjs";
-import User from "../../models/user/user.js";
-import { generateToken } from "../../utils/functions/generate-token.js";
-import {
- AuthenticatedInfo,
- cookieKeys,
- environment,
- ERROR_MESSAGES,
- getCookieOptions,
- httpResponseCodes,
- isEmail,
- loginSchema
-} from "types";
-
-export default async function loginRoutes(fastify: FastifyInstance) {
- fastify.post(
- "/",
- {
- config: {
- rateLimit: {
- max: 5,
- timeWindow: "1 minute"
- }
- }
- },
- async (request, reply) => {
- const parseResult = loginSchema.safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: ERROR_MESSAGES.FORM.VALIDATION_ERRORS });
- }
-
- const { identifier, password } = parseResult.data;
-
- try {
- const user = isEmail(identifier)
- ? await User.findOne({ email: identifier }).select("+password")
- : await User.findOne({ username: identifier })
- .select("+password")
- .exec();
-
- if (!user) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({
- message: ERROR_MESSAGES.AUTHENTICATION.INVALID_CREDENTIALS
- });
- }
- const isMatch = await bcrypt.compare(password, user.password);
-
- if (!isMatch) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({
- message: ERROR_MESSAGES.AUTHENTICATION.INVALID_CREDENTIALS
- });
- }
-
- const authenticatedUserInfo: AuthenticatedInfo = {
- userId: String(user._id),
- username: user.username,
- role: user.role,
- isAuthenticated: true
- };
- const token = generateToken(fastify, authenticatedUserInfo);
- const maxAge = 7 * 24 * 60 * 60;
- const isProduction = process.env.NODE_ENV === environment.PRODUCTION;
-
- const cookieOptions = getCookieOptions({
- isProduction,
- ...(process.env.FRONTEND_HOST && {
- frontendHost: process.env.FRONTEND_HOST
- }),
- maxAge
- });
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.OK)
- .setCookie(cookieKeys.TOKEN, token, cookieOptions)
- .send({ message: "Login successful" });
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ message: error });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/logout/index.ts b/libs/backend/src/routes/logout/index.ts
deleted file mode 100644
index 650ae7f9..00000000
--- a/libs/backend/src/routes/logout/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { cookieKeys, environment, getCookieOptions } from "types";
-
-export default async function logoutRoutes(fastify: FastifyInstance) {
- fastify.post("/", async (request, reply) => {
- try {
- const isProduction = process.env.NODE_ENV === environment.PRODUCTION;
-
- const cookieOptions = getCookieOptions({
- isProduction,
- ...(process.env.FRONTEND_HOST && {
- frontendHost: process.env.FRONTEND_HOST
- })
- });
-
- // Clear the cookie using Fastify's clearCookie method
- reply.clearCookie(cookieKeys.TOKEN, cookieOptions);
-
- return reply.status(200).send({ message: "Logout successful" });
- } catch (error) {
- console.error("Logout error:", error);
- return reply.status(500).send({ message: "Logout failed" });
- }
- });
-}
diff --git a/libs/backend/src/routes/moderation/puzzle/[id]/approve/index.ts b/libs/backend/src/routes/moderation/puzzle/[id]/approve/index.ts
deleted file mode 100644
index dab952b6..00000000
--- a/libs/backend/src/routes/moderation/puzzle/[id]/approve/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- puzzleVisibilityEnum,
- approvePuzzleSchema
-} from "types";
-import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js";
-import Puzzle from "../../../../../models/puzzle/puzzle.js";
-import { ParamsId } from "../../../../../types/types.js";
-
-export default async function moderationPuzzleByIdApproveRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const parseResult = approvePuzzleSchema.safeParse(request.body);
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- try {
- const puzzle = await Puzzle.findById(id);
-
- if (!puzzle) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Puzzle not found" });
- }
-
- // Update puzzle to approved status
- puzzle.visibility = puzzleVisibilityEnum.APPROVED;
- puzzle.updatedAt = new Date();
- await puzzle.save();
-
- return reply.send({
- message: "Puzzle approved successfully",
- puzzle
- });
- } catch (error) {
- fastify.log.error(error, "Failed to approve puzzle");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to approve puzzle" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/puzzle/[id]/revise/index.ts b/libs/backend/src/routes/moderation/puzzle/[id]/revise/index.ts
deleted file mode 100644
index 7dbef2e3..00000000
--- a/libs/backend/src/routes/moderation/puzzle/[id]/revise/index.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- puzzleVisibilityEnum,
- revisePuzzleSchema
-} from "types";
-import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js";
-import Puzzle from "../../../../../models/puzzle/puzzle.js";
-import { ParamsId } from "../../../../../types/types.js";
-
-export default async function moderationPuzzleByIdReviseRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const parseResult = revisePuzzleSchema.safeParse(request.body);
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- const { reason } = parseResult.data;
-
- try {
- const puzzle = await Puzzle.findById(id);
-
- if (!puzzle) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Puzzle not found" });
- }
-
- puzzle.visibility = puzzleVisibilityEnum.REVISE;
- puzzle.moderationFeedback = reason;
- puzzle.updatedAt = new Date();
- await puzzle.save();
-
- return reply.send({
- message: "Puzzle sent back for revisions",
- puzzle
- });
- } catch (error) {
- fastify.log.error(error, "Failed to request puzzle revisions");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to request puzzle revisions" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/report/[id]/resolve/index.ts b/libs/backend/src/routes/moderation/report/[id]/resolve/index.ts
deleted file mode 100644
index dcaeb1f4..00000000
--- a/libs/backend/src/routes/moderation/report/[id]/resolve/index.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- isAuthenticatedInfo,
- resolveReportSchema,
- reviewStatusEnum,
- ProblemTypeEnum
-} from "types";
-import mongoose from "mongoose";
-import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js";
-import Report from "../../../../../models/report/report.js";
-import { ParamsId } from "../../../../../types/types.js";
-import {
- incrementReportCount,
- applyAutomaticEscalation
-} from "../../../../../utils/moderation/escalation.js";
-import Comment from "../../../../../models/comment/comment.js";
-import Puzzle from "../../../../../models/puzzle/puzzle.js";
-import ChatMessage from "../../../../../models/chat/chat-message.js";
-
-export default async function moderationReportByIdResolveRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const parseResult = resolveReportSchema.safeParse(request.body);
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
-
- try {
- const report = await Report.findById(id);
-
- if (!report) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Report not found" });
- }
-
- // Update report status
- report.status = parseResult.data.status;
- report.resolvedBy = new mongoose.Types.ObjectId(userId);
- report.updatedAt = new Date();
- await report.save();
-
- // If report is resolved, increment user's report count and check for escalation
- if (parseResult.data.status === reviewStatusEnum.RESOLVED) {
- try {
- // Find the reported entity to get the user ID
- let reportedUserId = null;
-
- if (report.problemType === ProblemTypeEnum.USER) {
- reportedUserId = report.problematicIdentifier;
- } else {
- // For other types, we need to find the author/owner directly
- // This could be a puzzle author, comment author, or chat message sender
- // We'll query the specific model based on problem type
- if (report.problemType === ProblemTypeEnum.PUZZLE) {
- const puzzle = await Puzzle.findById(
- report.problematicIdentifier
- );
- reportedUserId = puzzle?.author;
- } else if (report.problemType === ProblemTypeEnum.COMMENT) {
- const comment = await Comment.findById(
- report.problematicIdentifier
- );
- reportedUserId = comment?.author;
- } else if (report.problemType === ProblemTypeEnum.GAME_CHAT) {
- const message = await ChatMessage.findById(
- report.problematicIdentifier
- );
- reportedUserId = message?.userId;
- }
- }
-
- if (reportedUserId) {
- // Increment report count
- const reportCount = await incrementReportCount(
- reportedUserId.toString()
- );
-
- // Apply automatic escalation if needed
- const ban = await applyAutomaticEscalation(
- reportedUserId.toString(),
- userId,
- report.explanation
- );
-
- if (ban) {
- fastify.log.info(
- {
- userId: reportedUserId,
- reportCount,
- banType: ban.banType
- },
- "Automatic ban applied"
- );
- }
- }
- } catch (escalationError) {
- fastify.log.error(
- escalationError,
- "Failed to apply escalation, but report was resolved"
- );
- }
- }
-
- return reply.send({
- message: "Report resolved successfully",
- report
- });
- } catch (error) {
- fastify.log.error(error, "Failed to resolve report");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to resolve report" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/review/index.ts b/libs/backend/src/routes/moderation/review/index.ts
deleted file mode 100644
index fc9a8fb5..00000000
--- a/libs/backend/src/routes/moderation/review/index.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- reviewItemTypeEnum,
- reviewStatusEnum,
- puzzleVisibilityEnum,
- ReviewItem,
- DEFAULT_PAGE,
- DEFAULT_PAGE_SIZE,
- BAN_CONFIG,
- ProblemTypeEnum
-} from "types";
-import moderatorOnly from "../../../plugins/middleware/moderator-only.js";
-import Puzzle from "../../../models/puzzle/puzzle.js";
-import Report from "../../../models/report/report.js";
-import ChatMessage from "../../../models/chat/chat-message.js";
-
-export default async function moderationReviewRoutes(fastify: FastifyInstance) {
- // Get review items (pending puzzles or reports)
- fastify.get(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const query = request.query as {
- type?: string;
- page?: string;
- limit?: string;
- };
-
- const type = query.type || reviewItemTypeEnum.PENDING_PUZZLE;
- const page = Number.parseInt(query.page || String(DEFAULT_PAGE), 10);
- const limit = Number.parseInt(
- query.limit || String(DEFAULT_PAGE_SIZE),
- 10
- );
- const skip = (page - 1) * limit;
-
- try {
- let items: ReviewItem[] = [];
- let total = 0;
-
- if (type === reviewItemTypeEnum.PENDING_PUZZLE) {
- // Get puzzles that are ready for review
- const puzzles = await Puzzle.find({
- visibility: puzzleVisibilityEnum.READY
- })
- .populate("author", "username")
- .sort({ createdAt: -1 })
- .skip(skip)
- .limit(limit);
-
- total = await Puzzle.countDocuments({
- visibility: puzzleVisibilityEnum.READY
- });
-
- items = puzzles.map((puzzle: any) => ({
- id: puzzle._id.toString(),
- type: reviewItemTypeEnum.PENDING_PUZZLE,
- title: puzzle.title,
- description: puzzle.statement,
- createdAt: puzzle.createdAt || new Date(),
- authorName:
- typeof puzzle.author === "object" &&
- puzzle.author &&
- "username" in puzzle.author
- ? String(puzzle.author.username)
- : undefined
- }));
- } else {
- // Get reports filtered by type
- const filter: any = { status: reviewStatusEnum.PENDING };
-
- if (type === reviewItemTypeEnum.REPORTED_PUZZLE) {
- filter.problemType = ProblemTypeEnum.PUZZLE;
- } else if (type === reviewItemTypeEnum.REPORTED_USER) {
- filter.problemType = ProblemTypeEnum.USER;
- } else if (type === reviewItemTypeEnum.REPORTED_COMMENT) {
- filter.problemType = ProblemTypeEnum.COMMENT;
- } else if (type === reviewItemTypeEnum.REPORTED_GAME_CHAT) {
- filter.problemType = ProblemTypeEnum.GAME_CHAT;
- }
-
- const reports = await Report.find(filter)
- .populate("reportedBy", "username")
- .populate("problematicIdentifier")
- .sort({ createdAt: -1 })
- .skip(skip)
- .limit(limit);
-
- total = await Report.countDocuments(filter);
-
- items = await Promise.all(
- reports.map(async (report: any) => {
- let title = "Unknown";
- let description = "";
- let gameId;
- let contextMessages;
-
- // Get title based on problem type
- if (report.problemType === ProblemTypeEnum.PUZZLE) {
- const puzzle = report.problematicIdentifier;
- title = puzzle?.title || "Deleted Puzzle";
- description = puzzle?.statement || "";
- } else if (report.problemType === ProblemTypeEnum.USER) {
- const user = report.problematicIdentifier;
- title = user?.username || "Deleted User";
- } else if (report.problemType === ProblemTypeEnum.COMMENT) {
- const comment = report.problematicIdentifier;
- title = `Comment: ${comment?.text?.substring(0, 50) || "Deleted Comment"}`;
- description = comment?.text || "";
- } else if (report.problemType === ProblemTypeEnum.GAME_CHAT) {
- const chatMessage = report.problematicIdentifier;
- title = `Chat from ${chatMessage?.username || "Unknown"}`;
- description = chatMessage?.message || "Deleted Message";
- gameId = chatMessage?.gameId;
-
- // Get context messages (5 before and 5 after)
- if (chatMessage && chatMessage.gameId) {
- const allMessages = await ChatMessage.find({
- gameId: chatMessage.gameId
- })
- .sort({ createdAt: 1 })
- .exec();
-
- const reportedIndex = allMessages.findIndex(
- (msg) =>
- String(msg._id) ===
- report.problematicIdentifier.toString()
- );
-
- if (reportedIndex !== -1) {
- const startIndex = Math.max(
- 0,
- reportedIndex -
- BAN_CONFIG.chatRetention.CONTEXT_MESSAGES_BEFORE
- );
- const endIndex = Math.min(
- allMessages.length,
- reportedIndex +
- BAN_CONFIG.chatRetention.CONTEXT_MESSAGES_AFTER +
- 1
- );
-
- contextMessages = allMessages
- .slice(startIndex, endIndex)
- .map((msg) => ({
- _id: msg._id,
- username: msg.username,
- message: msg.message,
- createdAt: msg.createdAt,
- isReported:
- String(msg._id) ===
- report.problematicIdentifier.toString()
- }));
- }
- }
- }
-
- return {
- id: report._id.toString(),
- type: type as (typeof reviewItemTypeEnum)[keyof typeof reviewItemTypeEnum],
- title,
- description,
- createdAt: report.createdAt || new Date(),
- reportExplanation: report.explanation,
- reportedBy:
- typeof report.reportedBy === "object" &&
- report.reportedBy &&
- "username" in report.reportedBy
- ? String(report.reportedBy.username)
- : undefined,
- gameId,
- contextMessages
- };
- })
- );
- }
-
- const response = {
- data: items,
- pagination: {
- page,
- limit,
- total,
- totalPages: Math.ceil(total / limit)
- }
- };
-
- return reply.send(response);
- } catch (error) {
- fastify.log.error(error, "Failed to fetch review items");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch review items" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/user/[id]/ban/history/index.ts b/libs/backend/src/routes/moderation/user/[id]/ban/history/index.ts
deleted file mode 100644
index 67b3ed0b..00000000
--- a/libs/backend/src/routes/moderation/user/[id]/ban/history/index.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { httpResponseCodes, isAuthenticatedInfo } from "types";
-import moderatorOnly from "../../../../../../plugins/middleware/moderator-only.js";
-import { ParamsId } from "../../../../../../types/types.js";
-import UserBan from "../../../../../../models/moderation/user-ban.js";
-
-export default async function moderationUserByIdBanHistoryRoutes(
- fastify: FastifyInstance
-) {
- fastify.get(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- try {
- const bans = await UserBan.find({ userId: id })
- .populate("bannedBy", "username")
- .sort({ createdAt: -1 })
- .exec();
-
- return reply.send({
- bans
- });
- } catch (error) {
- fastify.log.error(error, "Failed to fetch ban history");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch ban history" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/user/[id]/ban/permanent/index.ts b/libs/backend/src/routes/moderation/user/[id]/ban/permanent/index.ts
deleted file mode 100644
index f7427288..00000000
--- a/libs/backend/src/routes/moderation/user/[id]/ban/permanent/index.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- isAuthenticatedInfo,
- createPermanentBanSchema
-} from "types";
-import moderatorOnly from "../../../../../../plugins/middleware/moderator-only.js";
-import { ParamsId } from "../../../../../../types/types.js";
-import { createPermanentBan } from "../../../../../../utils/moderation/escalation.js";
-
-export default async function moderationUserByIdBanPermanentRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const parseResult = createPermanentBanSchema.safeParse(request.body);
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- try {
- const ban = await createPermanentBan(
- id,
- request.user.userId,
- parseResult.data.reason
- );
-
- return reply.send({
- message: "User permanently banned",
- ban
- });
- } catch (error) {
- fastify.log.error(error, "Failed to ban user");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to ban user" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/user/[id]/ban/temporary/index.ts b/libs/backend/src/routes/moderation/user/[id]/ban/temporary/index.ts
deleted file mode 100644
index edf3d94e..00000000
--- a/libs/backend/src/routes/moderation/user/[id]/ban/temporary/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- isAuthenticatedInfo,
- createTemporaryBanSchema
-} from "types";
-import moderatorOnly from "../../../../../../plugins/middleware/moderator-only.js";
-import { ParamsId } from "../../../../../../types/types.js";
-import { createTemporaryBan } from "../../../../../../utils/moderation/escalation.js";
-
-export default async function moderationUserByIdBanTemporaryRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const parseResult = createTemporaryBanSchema.safeParse(request.body);
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- try {
- const ban = await createTemporaryBan(
- id,
- request.user.userId,
- parseResult.data.reason,
- parseResult.data.durationMs
- );
-
- return reply.send({
- message: "User temporarily banned",
- ban
- });
- } catch (error) {
- fastify.log.error(error, "Failed to ban user");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to ban user" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/moderation/user/[id]/unban/index.ts b/libs/backend/src/routes/moderation/user/[id]/unban/index.ts
deleted file mode 100644
index 98508b1a..00000000
--- a/libs/backend/src/routes/moderation/user/[id]/unban/index.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { httpResponseCodes, isAuthenticatedInfo, unbanUserSchema } from "types";
-import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js";
-import { ParamsId } from "../../../../../types/types.js";
-import { unbanUser } from "../../../../../utils/moderation/escalation.js";
-
-export default async function moderationUserByIdBanUnbanRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: moderatorOnly
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const parseResult = unbanUserSchema.safeParse(request.body);
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- try {
- await unbanUser(id, request.user.userId, parseResult.data.reason);
-
- return reply.send({
- message: "User unbanned successfully"
- });
- } catch (error) {
- fastify.log.error(error, "Failed to unban user");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to unban user" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/programming-language/[id]/index.ts b/libs/backend/src/routes/programming-language/[id]/index.ts
deleted file mode 100644
index 4fa0bb6e..00000000
--- a/libs/backend/src/routes/programming-language/[id]/index.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- ProgrammingLanguageDto,
- programmingLanguageDtoSchema
-} from "types";
-import ProgrammingLanguage from "../../../models/programming-language/language.js";
-
-export default async function programmingLanguageByIdRoutes(
- fastify: FastifyInstance
-) {
- // GET programming language by ID
- fastify.get("/", async (request, reply) => {
- try {
- const { id } = request.params as { id: string };
-
- const language = await ProgrammingLanguage.findById(id)
- .select("-createdAt -updatedAt -__v")
- .lean();
-
- if (!language) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- error: "Programming language not found"
- });
- }
-
- const dto: ProgrammingLanguageDto = programmingLanguageDtoSchema.parse({
- _id: language._id.toString(),
- language: language.language,
- version: language.version,
- aliases: language.aliases,
- runtime: language.runtime
- });
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(dto);
- } catch (error) {
- fastify.log.error(error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch programming language" });
- }
- });
-}
diff --git a/libs/backend/src/routes/programming-language/index.ts b/libs/backend/src/routes/programming-language/index.ts
deleted file mode 100644
index 710a2487..00000000
--- a/libs/backend/src/routes/programming-language/index.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- httpResponseCodes,
- ProgrammingLanguageDto,
- programmingLanguageDtoSchema,
- arePistonRuntimes
-} from "types";
-import ProgrammingLanguage from "../../models/programming-language/language.js";
-
-export default async function programmingLanguageRoutes(
- fastify: FastifyInstance
-) {
- fastify.get("/", async (_, reply) => {
- try {
- // Get available runtimes from Piston
- const runtimes = await fastify.runtimes();
-
- if (!arePistonRuntimes(runtimes)) {
- fastify.log.error("Failed to fetch Piston runtimes");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE)
- .send({ error: "Code execution service is unavailable" });
- }
-
- // Create a set of available language+version combinations
- const availableLanguages = new Set(
- runtimes.map((runtime) => `${runtime.language}:${runtime.version}`)
- );
-
- // Fetch all programming languages from database
- const allLanguages = await ProgrammingLanguage.find()
- .select("-createdAt -updatedAt -__v")
- .sort({ language: 1, version: -1 })
- .lean();
-
- // Filter to only include languages that are available in Piston
- const installedLanguages = allLanguages.filter((lang) =>
- availableLanguages.has(`${lang.language}:${lang.version}`)
- );
-
- const dtos: ProgrammingLanguageDto[] = installedLanguages.map((lang) =>
- programmingLanguageDtoSchema.parse({
- _id: lang._id.toString(),
- language: lang.language,
- version: lang.version,
- aliases: lang.aliases,
- runtime: lang.runtime
- })
- );
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- languages: dtos
- });
- } catch (error) {
- fastify.log.error(error);
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch programming languages" });
- }
- });
-}
diff --git a/libs/backend/src/routes/puzzle/[id]/comment/index.ts b/libs/backend/src/routes/puzzle/[id]/comment/index.ts
deleted file mode 100644
index c9935285..00000000
--- a/libs/backend/src/routes/puzzle/[id]/comment/index.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import Comment from "@/models/comment/comment.js";
-import Puzzle from "@/models/puzzle/puzzle.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { ParamsId } from "@/types/types.js";
-import { FastifyInstance } from "fastify";
-import {
- CommentEntity,
- commentTypeEnum,
- createCommentSchema,
- httpResponseCodes,
- isAuthenticatedInfo
-} from "types";
-
-export default async function puzzleByIdCommentRoutes(
- fastify: FastifyInstance
-) {
- fastify.post(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const parseResult = createCommentSchema.safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const id = request.params.id;
- const user = request.user;
- const userId = user.userId;
-
- const commentData: CommentEntity = {
- ...parseResult.data,
- author: userId,
- upvote: 0,
- downvote: 0,
- comments: [],
- commentType: commentTypeEnum.PUZZLE,
- parentId: id
- };
- try {
- const newComment = new Comment(commentData);
- await newComment.save();
-
- await Puzzle.findByIdAndUpdate(
- id,
- { $push: { comments: newComment._id } },
- { new: true }
- );
-
- const comment = await Comment.findById(newComment.id).populate(
- "author"
- );
-
- return reply.status(httpResponseCodes.SUCCESSFUL.CREATED).send(comment);
- } catch (error) {
- request.log.error({ err: error }, "Failed to create comment");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to create comment" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/puzzle/[id]/index.ts b/libs/backend/src/routes/puzzle/[id]/index.ts
deleted file mode 100644
index 7803e9bd..00000000
--- a/libs/backend/src/routes/puzzle/[id]/index.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- AuthenticatedInfo,
- DeletePuzzle,
- ERROR_MESSAGES,
- ErrorResponse,
- httpResponseCodes,
- isAuthor,
- isAuthenticatedInfo,
- isModerator,
- isPuzzleDto,
- PUZZLE_CONFIG,
- PuzzleAPI,
- puzzleVisibilityEnum,
- type PuzzleVisibility
-} from "types";
-import Puzzle from "@/models/puzzle/puzzle.js";
-import User from "@/models/user/user.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { ParamsId } from "@/types/types.js";
-import { checkAllValidators } from "@/utils/functions/check-all-validators.js";
-
-export default async function puzzleByIdRoutes(fastify: FastifyInstance) {
- fastify.get("/", async (request, reply) => {
- const { id } = request.params;
-
- try {
- const puzzle = await Puzzle.findById(id)
- .populate("author")
- .populate("comments")
- .populate({
- path: "comments",
- populate: {
- path: "author"
- }
- });
-
- if (!puzzle) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Puzzle not found" });
- }
-
- return reply.send(puzzle);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch puzzle" });
- }
- });
-
- fastify.put(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const { id } = request.params;
- const parseResult = PuzzleAPI.updatePuzzleRequestSchema
- .omit({ id: true })
- .safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- const user = request.user;
-
- if (!isAuthenticatedInfo(user)) {
- const errorResponse: ErrorResponse = {
- error: "Missing credentials",
- message: "You need to be logged in."
- };
-
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send(errorResponse);
- }
-
- const userId = user.userId;
-
- try {
- const puzzle = await Puzzle.findById(id).lean();
-
- if (!puzzle) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: ERROR_MESSAGES.PUZZLE.NOT_FOUND });
- }
-
- const user = await User.findById(userId);
-
- const authorIdString = puzzle.author ? String(puzzle.author) : null;
- const isAuthorCheck =
- authorIdString !== null && isAuthor(authorIdString, userId);
-
- if (!isAuthorCheck && !isModerator(user?.role)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN)
- .send({ error: "Not authorized to edit this puzzle" });
- }
- if (
- parseResult.data.visibility === puzzleVisibilityEnum.APPROVED &&
- !isModerator(user?.role)
- ) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN).send({
- error: "Only moderators can approve puzzles",
- message:
- "You cannot set your own puzzle to approved status. Submit it for review instead."
- });
- }
-
- const updatedPuzzle = await Puzzle.findByIdAndUpdate(
- id,
- parseResult.data,
- { new: true }
- );
-
- const checkWhenEdited: PuzzleVisibility[] = [
- puzzleVisibilityEnum.DRAFT,
- puzzleVisibilityEnum.READY,
- puzzleVisibilityEnum.REVIEW
- ];
-
- if (
- updatedPuzzle &&
- checkWhenEdited.includes(updatedPuzzle.visibility) &&
- updatedPuzzle.validators &&
- updatedPuzzle.validators.length >=
- PUZZLE_CONFIG.requiredNumberOfValidators &&
- isPuzzleDto(updatedPuzzle)
- ) {
- try {
- const allPassed = await checkAllValidators(updatedPuzzle, fastify);
-
- if (allPassed) {
- updatedPuzzle.visibility = puzzleVisibilityEnum.READY;
- } else {
- updatedPuzzle.visibility = puzzleVisibilityEnum.DRAFT;
- }
-
- await updatedPuzzle.save();
- } catch (error) {
- request.log.error(error, "Failed to check validators");
- }
- }
-
- return reply.send(updatedPuzzle);
- } catch (error) {
- const errorResponse: ErrorResponse = {
- error: ERROR_MESSAGES.PUZZLE.FAILED_TO_UPDATE,
- message: ""
- };
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send(errorResponse);
- }
- }
- );
-
- fastify.delete<{ Params: DeletePuzzle }>(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const user: AuthenticatedInfo = request.user as AuthenticatedInfo;
- const userId = user.userId;
-
- try {
- const puzzle = await Puzzle.findById(id);
-
- if (!puzzle) {
- const error: ErrorResponse = {
- error: "Puzzle not found",
- message: `Couldn't find puzzle with id (${id})`
- };
-
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send(error);
- }
-
- const isAuthorOfPuzzle = isAuthor(puzzle.author.toString(), userId);
- const isNotAuthorOfPuzzle = !isAuthorOfPuzzle;
-
- if (isNotAuthorOfPuzzle) {
- return reply
- .status(403)
- .send({ error: "Not authorized to delete this puzzle" });
- }
-
- const allowedToRemoveState: PuzzleVisibility[] = [
- puzzleVisibilityEnum.DRAFT,
- puzzleVisibilityEnum.READY
- ];
-
- const isDraft = allowedToRemoveState.includes(puzzle.visibility);
- const isNotDraft = !isDraft;
- if (isNotDraft) {
- // TODO: figure out: this is a questionable choice at the moment, but might not want to delete an interesting puzzle completely which users already have solved, so maybe archive instead of a full delete??
- return reply.status(403).send({
- error: "This puzzle was public, contact support to get it deleted."
- });
- }
-
- await puzzle.deleteOne();
-
- return reply.status(204).send();
- } catch (error) {
- return reply.status(500).send({ error: "Failed to delete puzzle" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/puzzle/[id]/solution/index.ts b/libs/backend/src/routes/puzzle/[id]/solution/index.ts
deleted file mode 100644
index 53e4d2d9..00000000
--- a/libs/backend/src/routes/puzzle/[id]/solution/index.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- ErrorResponse,
- getUserIdFromUser,
- httpResponseCodes,
- isAuthenticatedInfo,
- isAuthor,
- isModerator
-} from "types";
-import { ParamsId } from "@/types/types.js";
-import Puzzle from "@/models/puzzle/puzzle.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import User from "@/models/user/user.js";
-
-export default async function puzzleByIdSolutionRoutes(
- fastify: FastifyInstance
-) {
- fastify.get(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const { id } = request.params;
-
- const user = request.user;
-
- if (!isAuthenticatedInfo(user)) {
- const errorResponse: ErrorResponse = {
- error: "Missing credentials",
- message: "You need to be logged in."
- };
-
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send(errorResponse);
- }
-
- const userId = user.userId;
-
- try {
- const puzzle = await Puzzle.findById(id)
- .select("+solution")
- .populate("author")
- .populate("solution.programmingLanguage")
- .lean();
-
- if (!puzzle) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Puzzle not found" });
- }
-
- const user = await User.findById(userId);
-
- const authorIdString = getUserIdFromUser(puzzle.author);
- const isAuthorCheck =
- authorIdString !== null && isAuthor(authorIdString, userId);
-
- const lacksRequiredPermissions =
- !isAuthorCheck && !isModerator(user?.role);
-
- if (lacksRequiredPermissions) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN)
- .send({ error: "Not authorized" });
- }
-
- return reply.send(puzzle);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch puzzle" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/puzzle/index.ts b/libs/backend/src/routes/puzzle/index.ts
deleted file mode 100644
index cb573e83..00000000
--- a/libs/backend/src/routes/puzzle/index.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- DEFAULT_PAGE,
- httpResponseCodes,
- isAuthenticatedInfo,
- PuzzleAPI,
- type CreatePuzzleBackend
-} from "types";
-import Puzzle from "../../models/puzzle/puzzle.js";
-import authenticated from "../../plugins/middleware/authenticated.js";
-import checkUserBan from "../../plugins/middleware/check-user-ban.js";
-
-export default async function puzzleRoutes(fastify: FastifyInstance) {
- fastify.post(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const parseResult = PuzzleAPI.createPuzzleRequestSchema.safeParse(
- request.body
- );
-
- if (!parseResult.success) {
- return reply.status(400).send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply.status(401).send({ error: "Invalid credentials" });
- }
-
- const user = request.user;
- const userId = user.userId;
-
- const puzzleData: CreatePuzzleBackend = {
- ...parseResult.data,
- author: userId
- };
-
- try {
- const puzzle = new Puzzle(puzzleData);
- await puzzle.save();
-
- return reply.status(201).send(puzzle);
- } catch (error) {
- return reply.status(500).send({ error: "Failed to create puzzle" });
- }
- }
- );
-
- fastify.get("/", async (request, reply) => {
- const parseResult = PuzzleAPI.getPuzzlesRequestSchema.safeParse(
- request.query
- );
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- const query = parseResult.data;
- const { page, pageSize } = query;
-
- try {
- const offsetSkip = (page - DEFAULT_PAGE) * pageSize;
-
- const [puzzles, total] = await Promise.all([
- Puzzle.find()
- .populate("author")
- .skip(offsetSkip)
- .limit(pageSize)
- .lean()
- .exec(),
- Puzzle.countDocuments()
- ]);
-
- const totalPages = Math.ceil(total / pageSize);
-
- const paginatedResponse = {
- items: puzzles,
- page,
- pageSize,
- totalItems: total,
- totalPages
- };
-
- return reply.send(paginatedResponse);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch puzzles" });
- }
- });
-}
diff --git a/libs/backend/src/routes/register/index.ts b/libs/backend/src/routes/register/index.ts
deleted file mode 100644
index 937886eb..00000000
--- a/libs/backend/src/routes/register/index.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { z } from "zod";
-import { Error } from "mongoose";
-import { MongoError } from "mongodb";
-import User from "../../models/user/user.js";
-import {
- cookieKeys,
- environment,
- ERROR_MESSAGES,
- getCookieOptions,
- httpResponseCodes,
- registerSchema
-} from "types";
-import { generateToken } from "../../utils/functions/generate-token.js";
-
-export default async function registerRoutes(fastify: FastifyInstance) {
- fastify.post(
- "/",
- {
- config: {
- rateLimit: {
- max: 3,
- timeWindow: "15 minutes"
- }
- }
- },
- async (request, reply) => {
- let parsedBody;
- try {
- parsedBody = registerSchema.parse(request.body);
- } catch (error) {
- if (error instanceof z.ZodError) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: error.issues });
- }
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ message: ERROR_MESSAGES.SERVER.INTERNAL_ERROR });
- }
-
- const { email, password, username } = parsedBody;
-
- try {
- const existingUserByUsername = await User.findOne({ username });
- if (existingUserByUsername) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: "Username already exists" });
- }
-
- const existingUserByEmail = await User.findOne({ email });
- if (existingUserByEmail) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: "Email already exists" });
- }
-
- const user = new User({ email, password, username });
- await user.save();
-
- const authenticatedUserInfo = {
- userId: `${user._id}`,
- username: user.username,
- role: user.role,
- isAuthenticated: true
- };
- const token = generateToken(fastify, authenticatedUserInfo);
- const isProduction = process.env.NODE_ENV === environment.PRODUCTION;
-
- const cookieOptions = getCookieOptions({
- isProduction,
- ...(process.env.FRONTEND_HOST && {
- frontendHost: process.env.FRONTEND_HOST
- }),
- maxAge: 7 * 24 * 60 * 60
- });
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.OK)
- .setCookie(cookieKeys.TOKEN, token, cookieOptions)
- .send({ message: "User registered successfully" });
- } catch (error) {
- if (error instanceof Error.ValidationError) {
- const messages = Object.values(error.errors).map(
- (err) => err.message
- );
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- message: ERROR_MESSAGES.FORM.VALIDATION_ERRORS,
- error: messages
- });
- } else if (error instanceof MongoError && error.code === 11000) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ message: `Duplicate key, unique value already exists` });
- }
-
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ message: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, error });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/report/index.ts b/libs/backend/src/routes/report/index.ts
deleted file mode 100644
index 9625661e..00000000
--- a/libs/backend/src/routes/report/index.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- createReportSchema,
- httpResponseCodes,
- isAuthenticatedInfo,
- ReportEntity,
- reviewStatusEnum,
- ProblemTypeEnum
-} from "types";
-import Report from "../../models/report/report.js";
-import authenticated from "../../plugins/middleware/authenticated.js";
-import checkUserBan from "../../plugins/middleware/check-user-ban.js";
-import ChatMessage from "../../models/chat/chat-message.js";
-
-export default async function reportRoutes(fastify: FastifyInstance) {
- fastify.post(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const parseResult = createReportSchema.safeParse(request.body);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- if (!isAuthenticatedInfo(request.user)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED)
- .send({ error: "Invalid credentials" });
- }
-
- const userId = request.user.userId;
-
- if (parseResult.data.problemType === ProblemTypeEnum.GAME_CHAT) {
- const chatMessage = await ChatMessage.findById(
- parseResult.data.problematicIdentifier
- );
-
- if (!chatMessage) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: "Chat message not found" });
- }
- }
-
- const newReportData: Omit = {
- ...parseResult.data,
- reportedBy: userId,
- status: reviewStatusEnum.PENDING
- };
-
- try {
- const newReport = new Report(newReportData);
- await newReport.save();
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.CREATED)
- .send(newReport);
- } catch (error) {
- fastify.log.error(error, "Failed to create report");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to create report" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/submission/[id]/index.ts b/libs/backend/src/routes/submission/[id]/index.ts
deleted file mode 100644
index e7cb3948..00000000
--- a/libs/backend/src/routes/submission/[id]/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import Submission from "@/models/submission/submission.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { ParamsId } from "@/types/types.js";
-import { FastifyInstance } from "fastify";
-import { httpResponseCodes } from "types";
-
-export default async function submissionByIdRoutes(fastify: FastifyInstance) {
- fastify.get(
- "/",
- {
- onRequest: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const { id } = request.params;
-
- try {
- const submission = await Submission.findById(id)
- .select("+code")
- .populate("programmingLanguage");
-
- if (!submission) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: "Submission not found" });
- }
-
- return reply.send(submission);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to fetch submission" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/submission/game/index.ts b/libs/backend/src/routes/submission/game/index.ts
deleted file mode 100644
index ad4709db..00000000
--- a/libs/backend/src/routes/submission/game/index.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- getUserIdFromUser,
- httpResponseCodes,
- isAuthor,
- isString,
- SUBMISSION_BUFFER_IN_MILLISECONDS,
- gameModeEnum,
- SubmissionAPI
-} from "types";
-import { isValidationError } from "../../../utils/functions/is-validation-error.js";
-import Submission from "@/models/submission/submission.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { gameService } from "@/services/game.service.js";
-import { gameModeService } from "@/services/game-mode.service.js";
-import { validateBody } from "@/plugins/middleware/validate-body.js";
-
-export default async function submissionGameRoutes(fastify: FastifyInstance) {
- /**
- * POST /submission/game - Submit code to a multiplayer game
- * Uses specific SubmissionAPI types
- */
- fastify.post<{ Body: SubmissionAPI.SubmitToGameRequest }>(
- "/",
- {
- onRequest: [authenticated, checkUserBan],
- preHandler: validateBody(SubmissionAPI.submitToGameRequestSchema)
- },
- async (request, reply) => {
- const { gameId, submissionId, userId } = request.body;
-
- try {
- const matchingSubmission = await Submission.findById(submissionId)
- .populate("programmingLanguage")
- .exec();
-
- if (
- !matchingSubmission ||
- getUserIdFromUser(matchingSubmission.user) !== userId
- ) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- error: `couldn't find a submission with id (${submissionId}) belonging to user with id (${userId})`
- });
- }
-
- const matchingGame = await gameService.findByIdPopulated(gameId);
-
- if (!matchingGame) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ error: `couldn't find a game with id (${gameId})` });
- }
-
- const gameMode = matchingGame.options?.mode ?? gameModeEnum.FASTEST;
- const validation = gameModeService.validateSubmissionForMode(gameMode, {
- result: matchingSubmission.result,
- attempts: 1
- });
-
- if (!validation.valid) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: `Submission invalid for ${gameMode} mode`,
- reason: validation.reason
- });
- }
-
- const latestSubmissionTime =
- new Date(matchingSubmission.createdAt).getTime() +
- SUBMISSION_BUFFER_IN_MILLISECONDS;
- const currentTime = Date.now();
- const tooFarInThePast = latestSubmissionTime < currentTime;
-
- if (tooFarInThePast) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: `Submission too old for game with id (${gameId})`
- });
- }
-
- const gameHasExistingUserSubmission =
- matchingGame.playerSubmissions.find((submission) => {
- if (isString(submission)) {
- return false;
- }
-
- return isAuthor(getUserIdFromUser(submission.user), userId);
- });
-
- if (gameHasExistingUserSubmission) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: `User ${userId} has already submitted to game ${gameId}`
- });
- }
-
- // Add submission to game
- const uniquePlayerSubmissions = new Set([
- ...(matchingGame.playerSubmissions ?? []),
- submissionId
- ]);
-
- matchingGame.playerSubmissions = Array.from(uniquePlayerSubmissions);
-
- const updatedGame = await matchingGame.save();
-
- // Calculate leaderboard position
- const leaderboard = gameModeService.getGameLeaderboard(
- updatedGame,
- await Submission.find({
- _id: { $in: updatedGame.playerSubmissions }
- })
- .populate("user")
- .exec()
- );
-
- const userPosition = leaderboard.findIndex(
- (entry) => entry.userId === userId
- );
-
- // Build response using specific type
- const response: SubmissionAPI.SubmitToGameResponse = {
- success: true,
- message: "Submission successfully added to game",
- game: {
- id: (
- updatedGame._id as import("mongoose").Types.ObjectId
- ).toString(),
- status: "in_progress", // Could be calculated based on game state
- playerCount: updatedGame.players?.length ?? 0
- },
- leaderboardPosition:
- userPosition !== -1 ? userPosition + 1 : undefined
- };
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.CREATED)
- .send(response);
- } catch (error) {
- request.log.error({ err: error }, "Error submitting to game");
-
- if (isValidationError(error)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: "Validation failed", details: error.errors });
- }
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to submit to game" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/submission/index.ts b/libs/backend/src/routes/submission/index.ts
deleted file mode 100644
index ddabb14c..00000000
--- a/libs/backend/src/routes/submission/index.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- ERROR_MESSAGES,
- httpResponseCodes,
- PistonExecutionResponse,
- PistonExecutionRequest,
- ErrorResponse,
- arePistonRuntimes,
- SubmissionAPI
-} from "types";
-import mongoose from "mongoose";
-import Submission from "../../models/submission/submission.js";
-import Puzzle, { PuzzleDocument } from "../../models/puzzle/puzzle.js";
-import { isValidationError } from "../../utils/functions/is-validation-error.js";
-import { findRuntime } from "@/utils/functions/findRuntimeInfo.js";
-import authenticated from "@/plugins/middleware/authenticated.js";
-import checkUserBan from "@/plugins/middleware/check-user-ban.js";
-import { validateBody } from "@/plugins/middleware/validate-body.js";
-import { calculateResults } from "@/utils/functions/calculate-result.js";
-import ProgrammingLanguage from "../../models/programming-language/language.js";
-
-export default async function submissionRoutes(fastify: FastifyInstance) {
- /**
- * POST /submission - Create a new code submission
- * Uses specific SubmissionAPI types instead of generic DTOs
- */
- fastify.post<{ Body: SubmissionAPI.SubmitCodeRequest }>(
- "/",
- {
- onRequest: [authenticated, checkUserBan],
- preHandler: validateBody(SubmissionAPI.submitCodeRequestSchema),
- config: {
- rateLimit: {
- max: 10,
- timeWindow: "1 minute"
- }
- }
- },
- async (request, reply) => {
- const { programmingLanguageId, puzzleId, code, userId } = request.body;
-
- // Retrieve puzzle and test cases
- const puzzle: PuzzleDocument | null = await Puzzle.findById(puzzleId);
-
- if (!puzzle) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- error: ERROR_MESSAGES.PUZZLE.NOT_FOUND
- });
- }
-
- if (!puzzle.validators || puzzle.validators.length === 0) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: ERROR_MESSAGES.PUZZLE.FAILED_TO_UPDATE
- });
- }
-
- // Get piston runtimes
- const runtimes = await fastify.runtimes();
-
- if (!arePistonRuntimes(runtimes)) {
- const error: ErrorResponse = runtimes;
- return reply
- .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE)
- .send(error);
- }
-
- // Find programming language
- const language = await ProgrammingLanguage.findById(
- programmingLanguageId
- );
-
- if (!language) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: "Invalid programming language"
- });
- }
-
- const runtimeInfo = findRuntime(runtimes, language.language);
-
- if (!runtimeInfo) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- error: `Unsupported language: ${language.language}`
- });
- }
-
- // Execute code against all test cases
- const pistonExecutionResults: PistonExecutionResponse[] = [];
- const expectedOutputs: string[] = [];
-
- const promises = puzzle.validators.map(async (validator) => {
- const pistonRequest: PistonExecutionRequest = {
- language: runtimeInfo.language,
- version: runtimeInfo.version,
- files: [{ content: code }],
- stdin: validator.input
- };
- const executionResponse = await fastify.piston(pistonRequest);
- return { executionResponse, output: validator.output };
- });
-
- const results = await Promise.all(promises);
-
- results.forEach(({ executionResponse, output }) => {
- pistonExecutionResults.push(executionResponse);
- expectedOutputs.push(output);
- });
-
- try {
- const result = calculateResults(
- expectedOutputs,
- pistonExecutionResults
- );
-
- const submission = new Submission({
- code: code,
- puzzle: puzzleId,
- user: userId,
- createdAt: new Date(),
- programmingLanguage: programmingLanguageId,
- result: {
- result: result.result,
- successRate: result.successRate
- }
- });
-
- await submission.save();
-
- // Return response with specific type - cast to satisfy type checking
- const response = {
- submissionId: (submission._id as mongoose.Types.ObjectId).toString(),
- code: submission.code ?? code,
- puzzleId: submission.puzzle.toString(),
- programmingLanguageId: submission.programmingLanguage.toString(),
- userId: submission.user.toString(),
- result: {
- successRate: result.successRate,
- passed: result.passed,
- failed: result.failed,
- total: result.total
- },
- createdAt: new Date(submission.createdAt).toISOString(),
- codeLength: code.length
- } as SubmissionAPI.SubmitCodeResponse;
-
- return reply
- .status(httpResponseCodes.SUCCESSFUL.CREATED)
- .send(response);
- } catch (error) {
- fastify.log.error(error, "Error saving submission");
-
- if (isValidationError(error)) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: "Validation failed" });
- }
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: "Failed to create submission" });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/user/[username]/activity/index.ts b/libs/backend/src/routes/user/[username]/activity/index.ts
deleted file mode 100644
index d24707e0..00000000
--- a/libs/backend/src/routes/user/[username]/activity/index.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import Puzzle from "@/models/puzzle/puzzle.js";
-import Submission from "@/models/submission/submission.js";
-import User from "@/models/user/user.js";
-import { FastifyInstance } from "fastify";
-import { httpResponseCodes, isUsername, puzzleVisibilityEnum } from "types";
-import { ParamsUsername } from "../types.js";
-import {
- genericReturnMessages,
- userProperties
-} from "@/config/generic-return-messages.js";
-
-export default async function userByUsernameActivityRoutes(
- fastify: FastifyInstance
-) {
- fastify.get("/", async (request, reply) => {
- const { username } = request.params;
-
- if (!isUsername(username)) {
- const { BAD_REQUEST } = httpResponseCodes.CLIENT_ERROR;
- const { IS_INVALID } = genericReturnMessages[BAD_REQUEST];
- const { USERNAME } = userProperties;
-
- return reply.status(BAD_REQUEST).send({
- message: `${USERNAME} ${IS_INVALID}`
- });
- }
-
- try {
- const user = await User.findOne({ username });
-
- if (!user) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ message: "User not found" });
- }
-
- const userId = user._id;
-
- const [puzzlesByUser, submissionsByUser] = await Promise.all([
- // TODO: add other puzzle visibility states as well?
- Puzzle.find({
- author: userId,
- visibility: puzzleVisibilityEnum.APPROVED
- }),
- Submission.find({ user: userId }).populate("programmingLanguage")
- ]);
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- user,
- message: "User activity found",
- activity: { puzzles: puzzlesByUser, submissions: submissionsByUser }
- });
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ message: "Internal Server Error" });
- }
- });
-}
diff --git a/libs/backend/src/routes/user/[username]/index.ts b/libs/backend/src/routes/user/[username]/index.ts
deleted file mode 100644
index 6175e32f..00000000
--- a/libs/backend/src/routes/user/[username]/index.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import User from "@/models/user/user.js";
-import { FastifyInstance } from "fastify";
-import { ERROR_MESSAGES, httpResponseCodes, UserAPI } from "types";
-import { ParamsUsername } from "./types.js";
-
-export default async function userByUsernameRoutes(fastify: FastifyInstance) {
- fastify.get("/", async (request, reply) => {
- const { username } = request.params;
-
- const parseResult = UserAPI.getUserByUsernameRequestSchema.safeParse(
- request.params
- );
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- try {
- const user = await User.findOne({ username }).lean();
-
- if (!user) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- error: "User not found",
- message: `User with username '${username}' could not be found`
- });
- }
-
- const response = {
- message: "User found",
- user: {
- ...user,
- _id: user._id.toString()
- }
- };
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR,
- message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG
- });
- }
- });
-}
diff --git a/libs/backend/src/routes/user/[username]/isAvailable/index.ts b/libs/backend/src/routes/user/[username]/isAvailable/index.ts
deleted file mode 100644
index 0459e9b4..00000000
--- a/libs/backend/src/routes/user/[username]/isAvailable/index.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import User from "@/models/user/user.js";
-import { FastifyInstance } from "fastify";
-import { ERROR_MESSAGES, httpResponseCodes, UserAPI } from "types";
-import { ParamsUsername } from "../types.js";
-
-export default async function isUsernameAvailableRoutes(
- fastify: FastifyInstance
-) {
- fastify.get("/", async (request, reply) => {
- const { username } = request.params;
-
- const parseResult =
- UserAPI.checkUsernameAvailabilityRequestSchema.safeParse(request.params);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- try {
- const user = await User.findOne({ username });
- const response: UserAPI.CheckUsernameAvailabilityResponse = {
- available: !user
- };
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR,
- message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG
- });
- }
- });
-}
diff --git a/libs/backend/src/routes/user/[username]/puzzle/index.ts b/libs/backend/src/routes/user/[username]/puzzle/index.ts
deleted file mode 100644
index 3cbb3784..00000000
--- a/libs/backend/src/routes/user/[username]/puzzle/index.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import Puzzle from "@/models/puzzle/puzzle.js";
-import User from "@/models/user/user.js";
-import { FastifyInstance } from "fastify";
-import {
- DEFAULT_PAGE,
- httpResponseCodes,
- isAuthenticatedInfo,
- isUsername,
- PaginatedQueryResponse,
- paginatedQuerySchema,
- puzzleVisibilityEnum
-} from "types";
-import { ParamsUsername } from "../types.js";
-import {
- genericReturnMessages,
- userProperties
-} from "@/config/generic-return-messages.js";
-import decodeToken from "@/plugins/middleware/decode-token.js";
-
-export default async function userByUsernamePuzzleRoutes(
- fastify: FastifyInstance
-) {
- fastify.get(
- "/",
- {
- onRequest: decodeToken
- },
- async (request, reply) => {
- const { username } = request.params;
-
- if (!isUsername(username)) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({
- message: `${userProperties.USERNAME} ${
- genericReturnMessages[httpResponseCodes.CLIENT_ERROR.BAD_REQUEST]
- .IS_INVALID
- }`
- });
- }
-
- const parseResult = paginatedQuerySchema.safeParse(request.query);
-
- if (!parseResult.success) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST)
- .send({ error: parseResult.error.issues });
- }
-
- try {
- const user = await User.findOne({ username });
-
- if (!user) {
- return reply
- .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND)
- .send({ message: "User not found" });
- }
-
- const userId = user._id;
-
- const query = parseResult.data;
- const { page, pageSize } = query;
-
- // Calculate pagination offsets
- const offsetSkip = (page - DEFAULT_PAGE) * pageSize;
-
- const queryCondition: Record = { author: userId };
-
- // If the user is authenticated and is the owner or contributor, fetch all puzzles
- if (
- isAuthenticatedInfo(request.user) &&
- request.user.username === user.username
- ) {
- // No additional condition needed for visibility
- } else {
- // Otherwise, only fetch approved puzzles
- queryCondition.visibility = puzzleVisibilityEnum.APPROVED;
- }
-
- const [puzzles, total] = await Promise.all([
- Puzzle.find(queryCondition)
- .populate("author")
- .skip(offsetSkip)
- .limit(pageSize)
- .exec(),
- Puzzle.countDocuments(queryCondition)
- ]);
-
- // Calculate total pages
- const totalPages = Math.ceil(total / pageSize);
-
- const paginatedResponse: PaginatedQueryResponse = {
- page,
- pageSize,
- totalPages,
- totalItems: total,
- items: puzzles
- };
-
- return reply.send(paginatedResponse);
- } catch (error) {
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({ error: `Failed to fetch puzzles of user (${username})` });
- }
- }
- );
-}
diff --git a/libs/backend/src/routes/user/[username]/types.d.ts b/libs/backend/src/routes/user/[username]/types.d.ts
deleted file mode 100644
index 35783a35..00000000
--- a/libs/backend/src/routes/user/[username]/types.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type ParamsUsername = { Params: { username: string } };
diff --git a/libs/backend/src/routes/user/index.ts b/libs/backend/src/routes/user/index.ts
deleted file mode 100644
index d8dba9dd..00000000
--- a/libs/backend/src/routes/user/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { FastifyInstance } from "fastify";
-
-export default async function userRoutes(fastify: FastifyInstance) {
- // No routes defined yet
-}
diff --git a/libs/backend/src/routes/user/me/index.ts b/libs/backend/src/routes/user/me/index.ts
deleted file mode 100644
index 304f51c3..00000000
--- a/libs/backend/src/routes/user/me/index.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { FastifyInstance } from "fastify";
-import authenticated from "../../../plugins/middleware/authenticated.js";
-import checkUserBan from "../../../plugins/middleware/check-user-ban.js";
-import { AuthenticatedInfo, DEFAULT_USER_ROLE, httpResponseCodes } from "types";
-import User from "../../../models/user/user.js";
-import { validateBody } from "@/plugins/middleware/validate-body.js";
-import { z } from "zod";
-
-const updateProfileSchema = z.object({
- bio: z.string().max(500).optional(),
- location: z.string().max(100).optional(),
- picture: z.string().url().optional().or(z.literal("")),
- socials: z.array(z.string().url()).max(5).optional()
-});
-
-export default async function userMeRoutes(fastify: FastifyInstance) {
- fastify.get(
- "/",
- {
- preHandler: [authenticated, checkUserBan]
- },
- async (request, reply) => {
- const user = request.user as AuthenticatedInfo | undefined;
-
- if (!user) {
- return reply.status(401).send({
- isAuthenticated: false,
- message: "Not authenticated"
- });
- }
-
- try {
- // Fetch the user from database to get the role
- const dbUser = await User.findById(user.userId);
-
- return reply.status(200).send({
- isAuthenticated: true,
- userId: user.userId,
- username: user.username,
- role: dbUser?.role || DEFAULT_USER_ROLE
- });
- } catch (error) {
- fastify.log.error(error, "Failed to fetch user data");
- return reply.status(500).send({
- isAuthenticated: false,
- message: "Failed to fetch user data"
- });
- }
- }
- );
-
- fastify.patch(
- "/profile",
- {
- preHandler: [
- authenticated,
- checkUserBan,
- validateBody(updateProfileSchema)
- ]
- },
- async (request, reply) => {
- const user = request.user as AuthenticatedInfo | undefined;
-
- if (!user) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED).send({
- message: "Not authenticated"
- });
- }
-
- try {
- const updates = request.body as z.infer;
-
- const dbUser = await User.findById(user.userId);
-
- if (!dbUser) {
- return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({
- message: "User not found"
- });
- }
-
- // Update profile fields
- if (!dbUser.profile) {
- dbUser.profile = {};
- }
-
- if (updates.bio !== undefined) dbUser.profile.bio = updates.bio;
- if (updates.location !== undefined)
- dbUser.profile.location = updates.location;
- if (updates.picture !== undefined)
- dbUser.profile.picture = updates.picture;
- if (updates.socials !== undefined)
- dbUser.profile.socials = updates.socials;
-
- await dbUser.save();
-
- return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({
- message: "Profile updated successfully",
- profile: dbUser.profile
- });
- } catch (error) {
- fastify.log.error(error, "Failed to update profile");
- return reply
- .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR)
- .send({
- message: "Failed to update profile"
- });
- }
- }
- );
-}
diff --git a/libs/backend/src/seeds/clear.ts b/libs/backend/src/seeds/clear.ts
deleted file mode 100644
index 56466e90..00000000
--- a/libs/backend/src/seeds/clear.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/usr/bin/env node
-
-import { config } from "dotenv";
-import {
- connectToDatabase,
- disconnectFromDatabase
-} from "./utils/db-connection.js";
-import { clearDatabase, getCollectionCounts } from "./utils/clear-database.js";
-
-config();
-
-/**
- * Script to clear the database
- */
-async function clear() {
- console.log("🗑️ Database Clear Utility\n");
- console.log("=".repeat(50));
-
- try {
- await connectToDatabase();
-
- // Show current counts
- console.log("\n📊 Current Database Counts:\n");
- const beforeCounts = await getCollectionCounts();
- Object.entries(beforeCounts).forEach(([collection, count]) => {
- console.log(` ${collection.padEnd(15)}: ${count}`);
- });
-
- // Clear database (requires confirmation unless --force)
- await clearDatabase(process.argv.includes("--force"));
-
- // Show final counts
- console.log("\n📊 Final Database Counts:\n");
- const afterCounts = await getCollectionCounts();
- Object.entries(afterCounts).forEach(([collection, count]) => {
- console.log(` ${collection.padEnd(15)}: ${count}`);
- });
-
- console.log("\n" + "=".repeat(50));
- console.log("✅ Database clear completed\n");
- } catch (error) {
- console.error("\n❌ Clear operation failed:", error);
- process.exit(1);
- } finally {
- await disconnectFromDatabase();
- }
-}
-
-clear();
diff --git a/libs/backend/src/seeds/config/seed-presets.ts b/libs/backend/src/seeds/config/seed-presets.ts
deleted file mode 100644
index 2ead75cf..00000000
--- a/libs/backend/src/seeds/config/seed-presets.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-export interface SeedPreset {
- name: string;
- counts: {
- users: number;
- puzzles: number;
- submissionsPerPuzzle: number;
- commentsPerPuzzle: number;
- reports: number;
- games: number;
- };
-}
-
-export const SEED_PRESETS: Record = {
- minimal: {
- name: "Minimal",
- counts: {
- users: 5,
- puzzles: 5,
- submissionsPerPuzzle: 2,
- commentsPerPuzzle: 2,
- reports: 2,
- games: 2
- }
- },
-
- standard: {
- name: "Standard",
- counts: {
- users: 20,
- puzzles: 30,
- submissionsPerPuzzle: 5,
- commentsPerPuzzle: 8,
- reports: 12,
- games: 15
- }
- },
-
- comprehensive: {
- name: "Comprehensive",
- counts: {
- users: 100,
- puzzles: 150,
- submissionsPerPuzzle: 15,
- commentsPerPuzzle: 20,
- reports: 50,
- games: 75
- }
- },
-
- demo: {
- name: "Demo",
- counts: {
- users: 25,
- puzzles: 40,
- submissionsPerPuzzle: 8,
- commentsPerPuzzle: 12,
- reports: 15,
- games: 20
- }
- }
-};
-
-export function getSeedPreset(presetName?: string): SeedPreset {
- const name = presetName?.toLowerCase() || "standard";
- return SEED_PRESETS[name] || SEED_PRESETS.standard;
-}
-
-export function getSeedCounts(
- getEnvNumber: (key: string, defaultValue: number) => number
-) {
- const presetName = process.env.SEED_PRESET;
- const preset = getSeedPreset(presetName);
-
- return {
- users: getEnvNumber("SEED_USERS", preset.counts.users),
- puzzles: getEnvNumber("SEED_PUZZLES", preset.counts.puzzles),
- submissionsPerPuzzle: getEnvNumber(
- "SEED_SUBMISSIONS_PER_PUZZLE",
- preset.counts.submissionsPerPuzzle
- ),
- commentsPerPuzzle: getEnvNumber(
- "SEED_COMMENTS_PER_PUZZLE",
- preset.counts.commentsPerPuzzle
- ),
- reports: getEnvNumber("SEED_REPORTS", preset.counts.reports),
- games: getEnvNumber("SEED_GAMES", preset.counts.games)
- };
-}
diff --git a/libs/backend/src/seeds/factories/chat-message.factory.ts b/libs/backend/src/seeds/factories/chat-message.factory.ts
deleted file mode 100644
index 0209fa33..00000000
--- a/libs/backend/src/seeds/factories/chat-message.factory.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { faker } from "@faker-js/faker";
-import ChatMessage from "../../models/chat/chat-message.js";
-import User from "../../models/user/user.js";
-import { ChatMessageEntity } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-
-export interface ChatMessageFactoryOptions {
- gameId: Types.ObjectId;
- userId: Types.ObjectId;
- username?: string;
-}
-
-/**
- * Generate realistic game chat messages
- */
-function generateChatMessage(): string {
- const messageTemplates = [
- () => "Good luck everyone!",
- () => "GL HF!",
- () => `Nice approach!`,
- () =>
- `${faker.helpers.arrayElement(["Interesting", "Cool", "Smart"])} solution`,
- () =>
- `Anyone using ${randomFromArray(["Python", "JavaScript", "Java", "C++"])}?`,
- () => "GG",
- () => "Well played!",
- () =>
- `This puzzle is ${faker.helpers.arrayElement(["tough", "tricky", "interesting", "fun"])}!`,
- () => `${faker.number.int({ min: 1, max: 100 })}% done`,
- () => "Almost there!",
- () => "First time playing this mode",
- () => `Time's running out!`,
- () => `Let's do this!`,
- () => faker.helpers.arrayElement(["😊", "👍", "🎉", "🔥", "💪"]),
- () => `${faker.helpers.arrayElement(["Found", "Got", "Solved"])} it!`,
- () => "Quick question about the constraints",
- () => `Testing edge case ${faker.number.int({ min: 1, max: 10 })}`,
- () => "Thanks for the game!",
- () => "Rematch?",
- () => `Love this ${randomFromArray(["puzzle", "challenge", "problem"])}`
- ];
-
- return randomFromArray(messageTemplates)();
-}
-
-/**
- * Create a single chat message
- */
-export async function createChatMessage(
- options: ChatMessageFactoryOptions
-): Promise {
- let username = options.username;
-
- // If username not provided, fetch it from the user
- if (!username) {
- const user = await User.findById(options.userId).select("username");
- if (!user) throw new Error("User not found for chat message");
- username = user.username;
- }
-
- const chatMessageData: Partial = {
- gameId: options.gameId.toString(),
- userId: options.userId.toString(),
- username,
- message: generateChatMessage(),
- isDeleted: faker.datatype.boolean({ probability: 0.05 }), // 5% deleted messages
- createdAt: faker.date.recent({ days: 30 }),
- updatedAt: faker.date.recent({ days: 30 })
- };
-
- const chatMessage = new ChatMessage(chatMessageData);
- await chatMessage.save();
-
- return chatMessage._id as Types.ObjectId;
-}
-
-/**
- * Create chat messages for a game
- */
-export async function createChatMessagesForGame(
- gameId: Types.ObjectId,
- playerIds: Types.ObjectId[],
- messageCount: number = 10
-): Promise {
- const chatMessageIds: Types.ObjectId[] = [];
-
- // Fetch usernames once for all players
- const users = await User.find({ _id: { $in: playerIds } })
- .select("_id username")
- .lean();
- const userMap = new Map(
- users.map((u) => [(u._id as Types.ObjectId).toString(), u.username])
- );
-
- for (let i = 0; i < messageCount; i++) {
- const userId = randomFromArray(playerIds);
- const username = userMap.get(userId.toString());
-
- if (!username) continue;
-
- chatMessageIds.push(
- await createChatMessage({
- gameId,
- userId,
- username
- })
- );
- }
-
- return chatMessageIds;
-}
-
-/**
- * Create chat messages for multiple games
- */
-export async function createChatMessages(
- gameIds: Types.ObjectId[],
- gamePlayerMap: Map
-): Promise {
- const chatMessageIds: Types.ObjectId[] = [];
-
- for (const gameId of gameIds) {
- const playerIds = gamePlayerMap.get(gameId.toString());
- if (!playerIds || playerIds.length === 0) continue;
-
- // Each game gets 5-20 messages
- const messageCount = faker.number.int({ min: 5, max: 20 });
- const messages = await createChatMessagesForGame(
- gameId,
- playerIds,
- messageCount
- );
- chatMessageIds.push(...messages);
- }
-
- return chatMessageIds;
-}
diff --git a/libs/backend/src/seeds/factories/comment.factory.ts b/libs/backend/src/seeds/factories/comment.factory.ts
deleted file mode 100644
index d43314cd..00000000
--- a/libs/backend/src/seeds/factories/comment.factory.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { faker } from "@faker-js/faker";
-import Comment from "../../models/comment/comment.js";
-import Puzzle from "../../models/puzzle/puzzle.js";
-import { commentTypeEnum, CommentEntity } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-
-type CommentTypeValue = (typeof commentTypeEnum)[keyof typeof commentTypeEnum];
-
-export interface CommentFactoryOptions {
- authorId: Types.ObjectId;
- parentId: Types.ObjectId;
- commentType: CommentTypeValue;
-}
-
-/**
- * Generate realistic comment text
- */
-function generateCommentText(): string {
- const commentTemplates = [
- () => `Great puzzle! ${faker.lorem.sentence()}`,
- () => `This was challenging. ${faker.lorem.sentences(2)}`,
- () =>
- `I think there's a bug in test case ${faker.number.int({ min: 1, max: 10 })}.`,
- () => `Here's my approach: ${faker.lorem.paragraph()}`,
- () => `Can someone explain ${faker.lorem.words(3)}?`,
- () => `Nice solution! ${faker.lorem.sentence()}`,
- () => faker.lorem.sentences(faker.number.int({ min: 1, max: 3 })),
- () =>
- `I solved this using ${randomFromArray(["recursion", "dynamic programming", "greedy algorithm", "BFS", "DFS"])}`,
- () =>
- `The time complexity of my solution is O(${randomFromArray(["n", "n log n", "n²", "1"])})`
- ];
-
- return randomFromArray(commentTemplates)();
-}
-
-/**
- * Create a single comment
- */
-export async function createComment(
- options: CommentFactoryOptions
-): Promise {
- const commentData: Partial = {
- author: options.authorId.toString(),
- text: generateCommentText(),
- upvote: faker.number.int({ min: 0, max: 50 }),
- downvote: faker.number.int({ min: 0, max: 10 }),
- commentType: options.commentType,
- parentId: options.parentId.toString(),
- comments: [] // Nested comments added separately
- };
-
- const comment = new Comment(commentData);
- await comment.save();
-
- if (options.commentType === commentTypeEnum.PUZZLE) {
- await Puzzle.findByIdAndUpdate(options.parentId, {
- $push: { comments: comment._id }
- });
- } else if (options.commentType === commentTypeEnum.COMMENT) {
- await Comment.findByIdAndUpdate(options.parentId, {
- $push: { comments: comment._id }
- });
- }
-
- return comment._id as unknown as Types.ObjectId;
-}
-
-/**
- * Create nested comment replies
- */
-export async function createNestedComments(
- parentCommentId: Types.ObjectId,
- authorIds: Types.ObjectId[],
- maxDepth = 4,
- currentDepth = 0
-): Promise {
- if (currentDepth >= maxDepth) return;
-
- if (!faker.datatype.boolean(0.5)) return;
-
- const replyCount = faker.number.int({ min: 1, max: 3 });
-
- for (let i = 0; i < replyCount; i++) {
- const authorId = randomFromArray(authorIds);
- const replyId = await createComment({
- authorId,
- parentId: parentCommentId,
- commentType: commentTypeEnum.COMMENT
- });
-
- // Recursively create nested replies
- await createNestedComments(replyId, authorIds, maxDepth, currentDepth + 1);
- }
-}
-
-/**
- * Create multiple puzzle comments with nested replies
- */
-export async function createPuzzleComments(
- count: number,
- userIds: Types.ObjectId[],
- puzzleIds: Types.ObjectId[]
-): Promise {
- const commentIds: Types.ObjectId[] = [];
-
- for (let i = 0; i < count; i++) {
- const authorId = randomFromArray(userIds);
- const puzzleId = randomFromArray(puzzleIds);
-
- const commentId = await createComment({
- authorId,
- parentId: puzzleId,
- commentType: commentTypeEnum.PUZZLE
- });
-
- commentIds.push(commentId);
-
- if (faker.datatype.boolean(0.5)) {
- await createNestedComments(commentId, userIds, 2);
- }
- }
-
- return commentIds;
-}
diff --git a/libs/backend/src/seeds/factories/game.factory.ts b/libs/backend/src/seeds/factories/game.factory.ts
deleted file mode 100644
index 3fc791dc..00000000
--- a/libs/backend/src/seeds/factories/game.factory.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { faker } from "@faker-js/faker";
-import Game, { GameDocument } from "../../models/game/game.js";
-import {
- gameModeEnum,
- gameVisibilityEnum,
- GameMode,
- GameVisibility
-} from "types";
-import {
- randomFromArray,
- randomMultipleFromArray
-} from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-import ProgrammingLanguage from "../../models/programming-language/language.js";
-
-export interface GameFactoryOptions {
- ownerId: Types.ObjectId;
- puzzleId: Types.ObjectId;
- playerIds: Types.ObjectId[];
- mode?: GameMode;
- visibility?: GameVisibility;
- rated?: boolean;
-}
-
-/**
- * Generate game allowed languages from database
- */
-async function generateAllowedLanguages(): Promise {
- // Get all available programming languages from database
- const allLanguages = await ProgrammingLanguage.find().lean();
-
- if (allLanguages.length === 0) {
- throw new Error(
- "No programming languages found in database. Run migrations first!"
- );
- }
-
- // Select 1-4 languages randomly
- const count = faker.number.int({
- min: 1,
- max: Math.min(4, allLanguages.length)
- });
- const selectedLanguages = randomMultipleFromArray(allLanguages, count, count);
-
- return selectedLanguages.map((lang) => lang._id.toString());
-}
-
-/**
- * Create a single game with realistic data
- */
-export async function createGame(
- options: GameFactoryOptions
-): Promise {
- const mode = options.mode || randomFromArray(Object.values(gameModeEnum));
- const visibility =
- options.visibility || randomFromArray(Object.values(gameVisibilityEnum));
-
- // Game duration varies: 5min to 60min
- const durationInSeconds = faker.number.int({ min: 300, max: 3600 });
-
- // Start time can be in the past (completed) or future (scheduled)
- const isPast = faker.datatype.boolean({ probability: 0.7 }); // 70% past games
- const startTime = isPast
- ? faker.date.recent({ days: 30 })
- : faker.date.soon({ days: 7 });
-
- const endTime = new Date(startTime.getTime() + durationInSeconds * 1000);
-
- // Select 2-4 players from provided player IDs
- const playerCount = Math.min(
- faker.number.int({ min: 2, max: 4 }),
- options.playerIds.length
- );
- const players = randomMultipleFromArray(
- options.playerIds,
- playerCount,
- playerCount
- );
-
- // Ensure owner is in the players list
- if (!players.includes(options.ownerId)) {
- players[0] = options.ownerId;
- }
-
- const gameData: Partial = {
- owner: options.ownerId.toString(),
- puzzle: options.puzzleId.toString(),
- players: players.map((id) => id.toString()),
- startTime,
- endTime,
- options: {
- mode,
- visibility,
- maxGameDurationInSeconds: durationInSeconds,
- allowedLanguages: await generateAllowedLanguages(),
- rated: faker.datatype.boolean({ probability: 0.6 })
- },
- playerSubmissions: []
- };
-
- const game = new Game(gameData);
- await game.save();
-
- return game._id as Types.ObjectId;
-}
-
-/**
- * Create multiple games with variety
- */
-export async function createGames(
- count: number,
- userIds: Types.ObjectId[],
- puzzleIds: Types.ObjectId[]
-): Promise {
- const gameIds: Types.ObjectId[] = [];
-
- for (let i = 0; i < count; i++) {
- const ownerId = randomFromArray(userIds);
- const puzzleId = randomFromArray(puzzleIds);
-
- const mode = randomFromArray(Object.values(gameModeEnum));
-
- // Visibility distribution: 70% PUBLIC, 30% PRIVATE
- const visibility = faker.datatype.boolean({ probability: 0.7 })
- ? gameVisibilityEnum.PUBLIC
- : gameVisibilityEnum.PRIVATE;
-
- // Get random players (ensure we have enough users)
- const playerCount = Math.min(
- faker.number.int({ min: 2, max: 4 }),
- userIds.length
- );
- const playerIds = randomMultipleFromArray(
- userIds,
- playerCount,
- playerCount
- );
-
- gameIds.push(
- await createGame({
- ownerId,
- puzzleId,
- playerIds,
- mode,
- visibility,
- rated: faker.datatype.boolean()
- })
- );
- }
-
- return gameIds;
-}
diff --git a/libs/backend/src/seeds/factories/preferences.factory.ts b/libs/backend/src/seeds/factories/preferences.factory.ts
deleted file mode 100644
index 217c1974..00000000
--- a/libs/backend/src/seeds/factories/preferences.factory.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { faker } from "@faker-js/faker";
-import Preferences, {
- PreferencesDocument
-} from "../../models/preferences/preferences.js";
-import { themeOption } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-import { ObjectId } from "mongoose";
-
-export interface PreferencesFactoryOptions {
- ownerId: Types.ObjectId;
-}
-
-/**
- * Create user preferences
- */
-export async function createPreferences(
- options: PreferencesFactoryOptions
-): Promise {
- const themes = Object.values(themeOption);
-
- const preferencesData: Partial = {
- owner: options.ownerId as unknown as ObjectId,
- ...(faker.helpers.maybe(() => ({ theme: randomFromArray(themes) }), {
- probability: 0.7
- }) || {}),
- ...(faker.helpers.maybe(
- () => ({
- preferredLanguage: randomFromArray([
- "python",
- "javascript",
- "java",
- "cpp"
- ])
- }),
- { probability: 0.6 }
- ) || {}),
- blockedUsers: [] // Could add some blocked users if needed
- // Editor preferences will use schema defaults
- };
-
- const preferences = new Preferences(preferencesData);
- await preferences.save();
-
- return preferences._id as Types.ObjectId;
-}
-
-/**
- * Create preferences for multiple users
- */
-export async function createMultiplePreferences(
- userIds: Types.ObjectId[]
-): Promise {
- const preferencesIds: Types.ObjectId[] = [];
-
- const userCount = Math.ceil(userIds.length * 0.2);
-
- for (let i = 0; i < userCount; i++) {
- preferencesIds.push(
- await createPreferences({
- ownerId: userIds[i]
- })
- );
- }
-
- return preferencesIds;
-}
diff --git a/libs/backend/src/seeds/factories/puzzle.factory.ts b/libs/backend/src/seeds/factories/puzzle.factory.ts
deleted file mode 100644
index d13d0308..00000000
--- a/libs/backend/src/seeds/factories/puzzle.factory.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { faker } from "@faker-js/faker";
-import Puzzle from "../../models/puzzle/puzzle.js";
-import {
- DifficultyEnum,
- puzzleVisibilityEnum,
- TagEnum,
- PuzzleEntity
-} from "types";
-import {
- randomFromArray,
- randomMultipleFromArray
-} from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-import ProgrammingLanguage from "../../models/programming-language/language.js";
-
-type DifficultyValue = (typeof DifficultyEnum)[keyof typeof DifficultyEnum];
-type VisibilityValue =
- (typeof puzzleVisibilityEnum)[keyof typeof puzzleVisibilityEnum];
-
-export interface PuzzleFactoryOptions {
- authorId: Types.ObjectId;
- visibility?: VisibilityValue;
- difficulty?: DifficultyValue;
-}
-
-/**
- * Generate realistic test cases for a puzzle
- */
-function generateValidators(count: number) {
- const validators = [];
-
- for (let i = 0; i < count; i++) {
- validators.push({
- input: faker.helpers.arrayElement([
- faker.number.int({ min: 1, max: 100 }).toString(),
- `${faker.number.int({ min: 1, max: 10 })} ${faker.number.int({ min: 1, max: 10 })}`,
- faker.lorem.word(),
- JSON.stringify([faker.number.int(), faker.number.int()])
- ]),
- output: faker.helpers.arrayElement([
- faker.number.int({ min: 1, max: 100 }).toString(),
- faker.datatype.boolean().toString(),
- faker.lorem.word()
- ]),
- createdAt: new Date(),
- updatedAt: new Date()
- });
- }
-
- return validators;
-}
-
-/**
- * Generate puzzle title based on difficulty and tags
- */
-function generatePuzzleTitle(
- difficulty: DifficultyValue,
- tags: string[]
-): string {
- const titleTemplates = [
- `${faker.hacker.verb()} the ${faker.hacker.noun()}`,
- `${faker.lorem.words(2)} Challenge`,
- `Find the ${faker.hacker.adjective()} ${faker.hacker.noun()}`,
- `${faker.lorem.word()} Algorithm`,
- `Reverse ${faker.lorem.word()}`,
- `Calculate ${faker.lorem.words(2)}`,
- `Optimize ${faker.hacker.noun()} Processing`
- ];
-
- return faker.helpers.arrayElement(titleTemplates);
-}
-
-/**
- * Create a single puzzle with realistic data
- */
-export async function createPuzzle(
- options: PuzzleFactoryOptions
-): Promise {
- const difficulty =
- options.difficulty || randomFromArray(Object.values(DifficultyEnum));
-
- const visibility =
- options.visibility || randomFromArray(Object.values(puzzleVisibilityEnum));
-
- // Select 1-4 tags
- const tags = randomMultipleFromArray(Object.values(TagEnum), 1, 4);
- const title = generatePuzzleTitle(difficulty, tags);
-
- // Number of test cases varies by difficulty
- const testCaseCount: Record = {
- [DifficultyEnum.BEGINNER]: faker.number.int({ min: 3, max: 5 }),
- [DifficultyEnum.INTERMEDIATE]: faker.number.int({ min: 5, max: 8 }),
- [DifficultyEnum.ADVANCED]: faker.number.int({ min: 8, max: 12 }),
- [DifficultyEnum.EXPERT]: faker.number.int({ min: 10, max: 15 })
- };
-
- const puzzleData: Partial = {
- title,
- statement: faker.lorem.paragraphs(faker.number.int({ min: 2, max: 4 })),
- constraints: faker.helpers.maybe(
- () =>
- `- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}`,
- { probability: 0.7 }
- ),
- author: options.authorId.toString(),
- validators: generateValidators(testCaseCount[difficulty]),
- difficulty,
- visibility,
- tags,
- ...(await (async () => {
- if (
- faker.helpers.maybe(() => true, {
- probability: visibility === puzzleVisibilityEnum.APPROVED ? 0.9 : 0.3
- })
- ) {
- // Get a random programming language from database for the solution
- const allLanguages = await ProgrammingLanguage.find().lean();
- if (allLanguages.length > 0) {
- const selectedLanguage = randomFromArray(allLanguages);
- return {
- solution: {
- code: faker.helpers.arrayElement([
- "def solution(n):\n return n * 2",
- "function solution(arr) {\n return arr.sort();\n}",
- "public int solution(int x) {\n return x + 1;\n}"
- ]),
- programmingLanguage: selectedLanguage._id.toString(),
- explanation: faker.lorem.paragraph()
- }
- };
- }
- }
- return {};
- })()),
- ...(faker.helpers.maybe(
- () => ({ moderationFeedback: faker.lorem.sentence() }),
- { probability: visibility === puzzleVisibilityEnum.DRAFT ? 0.4 : 0.1 }
- ) || {})
- };
-
- const puzzle = new Puzzle(puzzleData);
- await puzzle.save();
-
- return puzzle._id as Types.ObjectId;
-}
-
-/**
- * Create multiple puzzles with variety
- */
-export async function createPuzzles(
- count: number,
- authorIds: Types.ObjectId[]
-): Promise {
- const puzzleIds: Types.ObjectId[] = [];
-
- for (let i = 0; i < count; i++) {
- const authorId = randomFromArray(authorIds);
-
- // Distribution: 60% APPROVED, 15% DRAFT, 10% ARCHIVED, 10% REVIEW, 5% others
- const visibilityValues = Object.values(puzzleVisibilityEnum);
- const visibility = faker.helpers.weightedArrayElement([
- { value: visibilityValues[4], weight: 60 }, // APPROVED
- { value: visibilityValues[0], weight: 15 }, // DRAFT
- { value: visibilityValues[6], weight: 10 }, // ARCHIVED
- { value: visibilityValues[2], weight: 10 }, // REVIEW
- { value: visibilityValues[1], weight: 3 }, // READY
- { value: visibilityValues[3], weight: 1 }, // REVISE
- { value: visibilityValues[5], weight: 1 } // INACTIVE
- ]) as VisibilityValue;
-
- // Difficulty distribution
- const difficultyValues = Object.values(DifficultyEnum);
- const difficulty = faker.helpers.weightedArrayElement([
- { value: difficultyValues[0], weight: 30 }, // BEGINNER
- { value: difficultyValues[1], weight: 40 }, // INTERMEDIATE
- { value: difficultyValues[2], weight: 20 }, // ADVANCED
- { value: difficultyValues[3], weight: 10 } // EXPERT
- ]) as DifficultyValue;
-
- puzzleIds.push(
- await createPuzzle({
- authorId,
- visibility,
- difficulty
- })
- );
- }
-
- return puzzleIds;
-}
diff --git a/libs/backend/src/seeds/factories/report.factory.ts b/libs/backend/src/seeds/factories/report.factory.ts
deleted file mode 100644
index 97c7917d..00000000
--- a/libs/backend/src/seeds/factories/report.factory.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { faker } from "@faker-js/faker";
-import Report from "../../models/report/report.js";
-import User from "../../models/user/user.js";
-import { ProblemTypeEnum, ReportEntity, reviewStatusEnum } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-
-type ProblemTypeValue = (typeof ProblemTypeEnum)[keyof typeof ProblemTypeEnum];
-type ReviewStatusValue =
- (typeof reviewStatusEnum)[keyof typeof reviewStatusEnum];
-
-export interface ReportFactoryOptions {
- reportedById: Types.ObjectId;
- problematicIdentifier: Types.ObjectId;
- problemType: ProblemTypeValue;
- resolvedById?: Types.ObjectId;
-}
-
-/**
- * Generate realistic report explanation
- */
-function generateReportExplanation(problemType: ProblemTypeValue): string {
- const explanations: Record = {
- [ProblemTypeEnum.PUZZLE]: [
- "This puzzle contains inappropriate content",
- "The test cases are incorrect",
- "Puzzle statement is unclear and confusing",
- "Contains offensive language"
- ],
- [ProblemTypeEnum.USER]: [
- "User is harassing other members",
- "Spamming comments across multiple puzzles",
- "Using inappropriate username",
- "Cheating in multiplayer games"
- ],
- [ProblemTypeEnum.COMMENT]: [
- "Comment contains spam",
- "Offensive or inappropriate language",
- "Personal attack on another user",
- "Off-topic or irrelevant content"
- ],
- [ProblemTypeEnum.GAME_CHAT]: [
- "Inappropriate messages in game chat",
- "Harassment of other players",
- "Spam in chat",
- "Offensive language"
- ]
- };
-
- return randomFromArray(
- explanations[problemType] || explanations[ProblemTypeEnum.COMMENT]
- );
-}
-
-/**
- * Create a report
- */
-export async function createReport(
- options: ReportFactoryOptions
-): Promise {
- // Status distribution: 60% PENDING, 30% RESOLVED, 10% REJECTED
- const statusValues = Object.values(reviewStatusEnum);
- const status = faker.helpers.weightedArrayElement([
- { value: statusValues[0], weight: 60 }, // PENDING
- { value: statusValues[1], weight: 30 }, // RESOLVED
- { value: statusValues[2], weight: 10 } // REJECTED
- ]) as ReviewStatusValue;
-
- const reportData: Partial = {
- problematicIdentifier: options.problematicIdentifier.toString(),
- problemType: options.problemType,
- reportedBy: options.reportedById.toString(),
- explanation: generateReportExplanation(options.problemType),
- status,
- ...(status !== reviewStatusEnum.PENDING && options.resolvedById
- ? { resolvedBy: options.resolvedById.toString() }
- : {}),
- createdAt: faker.date.recent({ days: 30 }),
- ...(status !== reviewStatusEnum.PENDING
- ? { updatedAt: faker.date.recent({ days: 15 }) }
- : { updatedAt: faker.date.recent({ days: 30 }) })
- };
-
- const report = new Report(reportData);
- await report.save();
-
- // Update user reportCount if resolved
- if (
- status === reviewStatusEnum.RESOLVED &&
- options.problemType === ProblemTypeEnum.USER
- ) {
- await User.findByIdAndUpdate(options.problematicIdentifier, {
- $inc: { reportCount: 1 }
- });
- }
-
- return report._id as Types.ObjectId;
-}
-
-/**
- * Create multiple reports
- */
-export async function createReports(
- count: number,
- userIds: Types.ObjectId[],
- puzzleIds: Types.ObjectId[],
- moderatorIds: Types.ObjectId[]
-): Promise {
- const reportIds: Types.ObjectId[] = [];
-
- for (let i = 0; i < count; i++) {
- const reportedById = randomFromArray(userIds);
- const resolvedById = randomFromArray(moderatorIds);
-
- // Problem type distribution
- const problemTypeValues = Object.values(ProblemTypeEnum);
- const problemType = randomFromArray([
- problemTypeValues[0], // PUZZLE
- problemTypeValues[1], // USER
- problemTypeValues[2] // COMMENT
- ]) as ProblemTypeValue;
-
- let problematicIdentifier: Types.ObjectId;
- if (problemType === problemTypeValues[0]) {
- // PUZZLE
- problematicIdentifier = randomFromArray(puzzleIds);
- } else {
- problematicIdentifier = randomFromArray(userIds);
- }
-
- reportIds.push(
- await createReport({
- reportedById,
- problematicIdentifier,
- problemType,
- resolvedById
- })
- );
- }
-
- return reportIds;
-}
diff --git a/libs/backend/src/seeds/factories/submission.factory.ts b/libs/backend/src/seeds/factories/submission.factory.ts
deleted file mode 100644
index b643750d..00000000
--- a/libs/backend/src/seeds/factories/submission.factory.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { faker } from "@faker-js/faker";
-import Submission from "../../models/submission/submission.js";
-import { PuzzleResultEnum } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types, ObjectId } from "mongoose";
-import ProgrammingLanguage from "../../models/programming-language/language.js";
-
-type PuzzleResultValue =
- (typeof PuzzleResultEnum)[keyof typeof PuzzleResultEnum];
-
-export interface SubmissionFactoryOptions {
- userId: Types.ObjectId;
- puzzleId: Types.ObjectId;
- result?: PuzzleResultValue;
-}
-
-/**
- * Generate code submission based on language
- */
-function generateCode(language: string): string {
- const codeTemplates: Record = {
- python: [
- "def solution(n):\n # TODO: implement solution\n return n",
- "def solve(arr):\n result = []\n for item in arr:\n result.append(item * 2)\n return result",
- "def calculate(x, y):\n return x + y"
- ],
- javascript: [
- "function solution(n) {\n // TODO: implement solution\n return n;\n}",
- "const solve = (arr) => {\n return arr.map(x => x * 2);\n}",
- "function calculate(x, y) {\n return x + y;\n}"
- ],
- java: [
- "public int solution(int n) {\n // TODO: implement solution\n return n;\n}",
- "public int[] solve(int[] arr) {\n return Arrays.stream(arr).map(x -> x * 2).toArray();\n}"
- ],
- cpp: [
- "int solution(int n) {\n // TODO: implement solution\n return n;\n}",
- "vector solve(vector arr) {\n for (auto& x : arr) x *= 2;\n return arr;\n}"
- ]
- };
-
- return randomFromArray(codeTemplates[language] || codeTemplates.python);
-}
-
-/**
- * Generate result info based on puzzle validators
- */
-async function generateResultInfo(
- puzzleId: Types.ObjectId,
- result: PuzzleResultValue
-) {
- let successRate: number;
- if (result === PuzzleResultEnum.SUCCESS) {
- successRate = 1.0;
- } else if (result === PuzzleResultEnum.ERROR) {
- successRate = faker.number.float({ min: 0, max: 0.5 });
- } else {
- successRate = faker.number.float({ min: 0, max: 1 });
- }
-
- return {
- result,
- successRate
- };
-}
-
-/**
- * Create a single submission
- */
-export async function createSubmission(
- options: SubmissionFactoryOptions
-): Promise {
- // Get a random programming language from database
- const allLanguages = await ProgrammingLanguage.find().lean();
- if (allLanguages.length === 0) {
- throw new Error(
- "No programming languages found in database. Run migrations first!"
- );
- }
-
- const selectedLanguage = randomFromArray(allLanguages);
- const languageName = selectedLanguage.language;
-
- // Result distribution: 60% SUCCESS, 30% ERROR, 10% UNKNOWN
- const resultValues = Object.values(PuzzleResultEnum);
- const result =
- options.result ||
- (faker.helpers.weightedArrayElement([
- { value: resultValues[1], weight: 60 }, // SUCCESS
- { value: resultValues[0], weight: 30 }, // ERROR
- { value: resultValues[2], weight: 10 } // UNKNOWN
- ]) as PuzzleResultValue);
-
- const code = generateCode(languageName);
- const submission = new Submission({
- code,
- codeLength: code.length,
- puzzle: options.puzzleId as unknown as ObjectId,
- user: options.userId as unknown as ObjectId,
- programmingLanguage: selectedLanguage._id as unknown as ObjectId,
- result: await generateResultInfo(options.puzzleId, result)
- });
- await submission.save();
-
- return submission._id as Types.ObjectId;
-}
-
-/**
- * Create multiple submissions for puzzles
- */
-export async function createSubmissions(
- count: number,
- userIds: Types.ObjectId[],
- puzzleIds: Types.ObjectId[]
-): Promise {
- const submissionIds: Types.ObjectId[] = [];
-
- for (let i = 0; i < count; i++) {
- const userId = randomFromArray(userIds);
- const puzzleId = randomFromArray(puzzleIds);
-
- submissionIds.push(
- await createSubmission({
- userId,
- puzzleId
- })
- );
- }
-
- return submissionIds;
-}
diff --git a/libs/backend/src/seeds/factories/user-ban.factory.ts b/libs/backend/src/seeds/factories/user-ban.factory.ts
deleted file mode 100644
index 6e1ad420..00000000
--- a/libs/backend/src/seeds/factories/user-ban.factory.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { faker } from "@faker-js/faker";
-import UserBan from "../../models/moderation/user-ban.js";
-import User from "../../models/user/user.js";
-import { banTypeEnum, UserBanEntity } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-
-type BanTypeValue = (typeof banTypeEnum)[keyof typeof banTypeEnum];
-
-export interface UserBanFactoryOptions {
- userId: Types.ObjectId;
- bannedById: Types.ObjectId;
- banType?: BanTypeValue;
- isActive?: boolean;
-}
-
-/**
- * Create a user ban record
- */
-export async function createUserBan(
- options: UserBanFactoryOptions
-): Promise {
- const banType =
- options.banType || randomFromArray(Object.values(banTypeEnum));
-
- const isActive =
- options.isActive ?? faker.datatype.boolean({ probability: 0.7 });
-
- const startDate = faker.date.recent({ days: 60 });
- const endDate =
- banType === banTypeEnum.TEMPORARY
- ? faker.date.future({ years: 0.5, refDate: startDate })
- : undefined;
-
- const banReasons = [
- "Spamming comments",
- "Inappropriate content",
- "Harassment of other users",
- "Cheating in games",
- "Multiple rule violations",
- "Posting offensive material",
- "Abuse of reporting system"
- ];
-
- const banData: Partial = {
- userId: options.userId.toString(),
- bannedBy: options.bannedById.toString(),
- banType,
- reason: randomFromArray(banReasons),
- startDate,
- endDate,
- isActive,
- createdAt: startDate,
- updatedAt: isActive ? startDate : faker.date.recent({ days: 30 })
- };
-
- const userBan = new UserBan(banData);
- await userBan.save();
-
- // Update user's currentBan if active
- if (isActive) {
- await User.findByIdAndUpdate(options.userId, {
- currentBan: userBan._id,
- $inc: { banCount: 1 }
- });
- }
-
- return userBan._id as Types.ObjectId;
-}
-
-/**
- * Create user bans for some users
- */
-export async function createUserBans(
- userIds: Types.ObjectId[],
- moderatorIds: Types.ObjectId[]
-): Promise {
- const banIds: Types.ObjectId[] = [];
-
- // Ban 10-15% of users
- const banCount = Math.ceil(userIds.length * 0.12);
-
- for (let i = 0; i < banCount; i++) {
- const userId = randomFromArray(userIds);
- const bannedById = randomFromArray(moderatorIds);
-
- // 70% temporary, 30% permanent
- const banTypeValues = Object.values(banTypeEnum);
- const banType = faker.datatype.boolean({ probability: 0.7 })
- ? banTypeValues[0] // TEMPORARY
- : banTypeValues[1]; // PERMANENT
-
- banIds.push(
- await createUserBan({
- userId,
- bannedById,
- banType
- })
- );
- }
-
- return banIds;
-}
diff --git a/libs/backend/src/seeds/factories/user-vote.factory.ts b/libs/backend/src/seeds/factories/user-vote.factory.ts
deleted file mode 100644
index 652bada4..00000000
--- a/libs/backend/src/seeds/factories/user-vote.factory.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { faker } from "@faker-js/faker";
-import UserVote from "../../models/user/user-vote.js";
-import Comment from "../../models/comment/comment.js";
-import { UserVoteEntity, voteTypeEnum } from "types";
-import { randomFromArray } from "../utils/seed-helpers.js";
-import { Types } from "mongoose";
-
-type VoteTypeValue = (typeof voteTypeEnum)[keyof typeof voteTypeEnum];
-
-export interface UserVoteFactoryOptions {
- authorId: Types.ObjectId;
- votedOnId: Types.ObjectId;
- voteType?: VoteTypeValue;
-}
-
-/**
- * Create a single user vote
- */
-export async function createUserVote(
- options: UserVoteFactoryOptions
-): Promise {
- const voteType =
- options.voteType || randomFromArray(Object.values(voteTypeEnum));
-
- const voteData: Partial = {
- author: options.authorId.toString(),
- votedOn: options.votedOnId.toString(),
- type: voteType,
- createdAt: faker.date.recent({ days: 30 })
- };
-
- const vote = new UserVote(voteData);
- await vote.save();
-
- // Update the voted-on entity's vote count
- // This could be a Comment or other votable entity
- const incrementField =
- voteType === voteTypeEnum.UPVOTE ? "upvote" : "downvote";
-
- // Try to update as comment first
- await Comment.findByIdAndUpdate(options.votedOnId, {
- $inc: { [incrementField]: 1 }
- });
-
- // Could also update other votable entities like puzzles if they have vote fields
- // For now, just handling comments
-
- return vote._id as Types.ObjectId;
-}
-
-/**
- * Create votes for comments
- */
-export async function createVotesForComments(
- commentIds: Types.ObjectId[],
- userIds: Types.ObjectId[]
-): Promise {
- const voteIds: Types.ObjectId[] = [];
-
- // Each comment gets 0-10 votes
- for (const commentId of commentIds) {
- const voteCount = faker.number.int({ min: 0, max: 10 });
-
- // Select random voters (no duplicates per comment)
- const voters = randomFromArray(userIds);
- const uniqueVoters = new Set();
-
- for (let i = 0; i < voteCount && uniqueVoters.size < userIds.length; i++) {
- const voterId = voters;
-
- if (!uniqueVoters.has(voterId)) {
- uniqueVoters.add(voterId);
-
- // 70% upvotes, 30% downvotes
- const voteType = faker.datatype.boolean({ probability: 0.7 })
- ? voteTypeEnum.UPVOTE
- : voteTypeEnum.DOWNVOTE;
-
- voteIds.push(
- await createUserVote({
- authorId: voterId,
- votedOnId: commentId,
- voteType
- })
- );
- }
- }
- }
-
- return voteIds;
-}
-
-/**
- * Create votes for various entities
- */
-export async function createUserVotes(
- commentIds: Types.ObjectId[],
- userIds: Types.ObjectId[]
-): Promise {
- const voteIds: Types.ObjectId[] = [];
-
- // Create votes for comments
- const commentVotes = await createVotesForComments(commentIds, userIds);
- voteIds.push(...commentVotes);
-
- return voteIds;
-}
diff --git a/libs/backend/src/seeds/factories/user.factory.ts b/libs/backend/src/seeds/factories/user.factory.ts
deleted file mode 100644
index 75dd80b2..00000000
--- a/libs/backend/src/seeds/factories/user.factory.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { faker } from "@faker-js/faker";
-import User, { UserDocument } from "../../models/user/user.js";
-import { userRole, UserRole } from "types";
-import { Types } from "mongoose";
-
-export interface UserFactoryOptions {
- role?: UserRole;
- username?: string;
- email?: string;
- reportCount?: number;
- banCount?: number;
-}
-
-/**
- * Create a single user with realistic data
- */
-export async function createUser(
- options: UserFactoryOptions = {}
-): Promise {
- const username = options.username || faker.internet.username().toLowerCase();
- const email =
- options.email ||
- faker.internet.email({ firstName: username }).toLowerCase();
-
- const userData: Partial = {
- username,
- email,
- password: "TestPassword123!", // Will be hashed by pre-save hook
- role: options.role || userRole.USER,
- reportCount: options.reportCount ?? faker.number.int({ min: 0, max: 5 }),
- banCount: options.banCount ?? faker.number.int({ min: 0, max: 2 }),
- profile: {
- bio: faker.helpers.maybe(() => faker.lorem.sentence(), {
- probability: 0.6
- }),
- picture: faker.helpers.maybe(() => faker.image.avatar(), {
- probability: 0.4
- }),
- location: faker.helpers.maybe(() => faker.location.city(), {
- probability: 0.5
- }),
- socials: faker.helpers.maybe(() => [faker.internet.url()], {
- probability: 0.3
- })
- }
- };
-
- const user = new User(userData);
- await user.save();
-
- return user._id as Types.ObjectId;
-}
-
-/**
- * Create multiple users with various roles
- *
- * Always creates:
- * - 1 test user (username: "testuser", email: "test@codincod.com", password: "TestPassword123!")
- * - 2-3 moderators (username: "moderator1", etc.)
- * - Remaining users with random data
- */
-export async function createUsers(count: number): Promise {
- const userIds: Types.ObjectId[] = [];
-
- // Create test user with known credentials
- userIds.push(
- await createUser({
- username: "codincoder",
- email: "codincoder@codincod.com",
- role: userRole.USER,
- reportCount: 0,
- banCount: 0
- })
- );
-
- // Create 2-3 moderators
- const moderatorCount = faker.number.int({ min: 2, max: 3 });
- for (let i = 0; i < moderatorCount; i++) {
- userIds.push(
- await createUser({
- username: `moderator${i + 1}`,
- email: `moderator${i + 1}@codincod.com`,
- role: userRole.MODERATOR,
- reportCount: 0,
- banCount: 0
- })
- );
- }
-
- // Create regular users
- const regularUserCount = count - userIds.length;
- for (let i = 0; i < regularUserCount; i++) {
- userIds.push(await createUser());
- }
-
- return userIds;
-}
diff --git a/libs/backend/src/seeds/index.ts b/libs/backend/src/seeds/index.ts
deleted file mode 100644
index ba9eef7e..00000000
--- a/libs/backend/src/seeds/index.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { config } from "dotenv";
-config();
-
-import {
- connectToDatabase,
- disconnectFromDatabase
-} from "./utils/db-connection.js";
-import { clearDatabase, getCollectionCounts } from "./utils/clear-database.js";
-import { SeedLogger, getEnvNumber } from "./utils/seed-helpers.js";
-import { getSeedCounts } from "./config/seed-presets.js";
-import { createUsers } from "./factories/user.factory.js";
-import { createPuzzles } from "./factories/puzzle.factory.js";
-import { createSubmissions } from "./factories/submission.factory.js";
-import { createPuzzleComments } from "./factories/comment.factory.js";
-import { createUserBans } from "./factories/user-ban.factory.js";
-import { createMultiplePreferences } from "./factories/preferences.factory.js";
-import { createReports } from "./factories/report.factory.js";
-import { createGames } from "./factories/game.factory.js";
-import { createChatMessages } from "./factories/chat-message.factory.js";
-import { createUserVotes } from "./factories/user-vote.factory.js";
-import { userRole } from "types";
-import User from "../models/user/user.js";
-import Game from "../models/game/game.js";
-import { Types } from "mongoose";
-import { seedProgrammingLanguages } from "./programming-language.seed.js";
-
-async function seed() {
- console.log("🌱 Starting database seeding...\n");
- console.log("=".repeat(50));
-
- try {
- // Connect to database
- await connectToDatabase();
-
- // Clear existing data
- await clearDatabase(process.argv.includes("--force"));
-
- // Seed programming languages from Piston (must be first!)
- const langLogger = new SeedLogger(
- "Seeding programming languages from Piston"
- );
- const programmingLanguages = await seedProgrammingLanguages();
- langLogger.success(programmingLanguages.length, "programming languages");
-
- // Get seed counts from environment or preset
- const seedCounts = getSeedCounts(getEnvNumber);
- const presetName = process.env.SEED_PRESET || "standard";
-
- console.log("\n📊 Seed Configuration:");
- console.log(` Preset: ${presetName.toUpperCase()}`);
- console.log(` Users: ${seedCounts.users}`);
- console.log(` Puzzles: ${seedCounts.puzzles}`);
- console.log(
- ` Submissions: ~${seedCounts.puzzles * seedCounts.submissionsPerPuzzle}`
- );
- console.log(
- ` Comments: ~${seedCounts.puzzles * seedCounts.commentsPerPuzzle}`
- );
- console.log(` Reports: ${seedCounts.reports}`);
- console.log(` Games: ${seedCounts.games}\n`);
-
- // 1. Create Users (admin, moderators, regular users)
- const userLogger = new SeedLogger("Creating users");
- const userIds = await createUsers(seedCounts.users);
- userLogger.success(userIds.length, "users");
-
- // 2. Create Preferences for users
- const prefLogger = new SeedLogger("Creating user preferences");
- const preferencesIds = await createMultiplePreferences(userIds);
- prefLogger.success(preferencesIds.length, "preferences");
-
- // Get moderator IDs for later use
- const moderators = await User.find({ role: userRole.MODERATOR }).lean();
- const moderatorIds = moderators.map(
- (mod) => mod._id as unknown as Types.ObjectId
- );
-
- // 3. Create User Bans
- const banLogger = new SeedLogger("Creating user bans");
- const banIds = await createUserBans(userIds, moderatorIds);
- banLogger.success(banIds.length, "user bans");
-
- // 4. Create Puzzles
- const puzzleLogger = new SeedLogger("Creating puzzles");
- const puzzleIds = await createPuzzles(seedCounts.puzzles, userIds);
- puzzleLogger.success(puzzleIds.length, "puzzles");
-
- // 5. Create Submissions
- const submissionLogger = new SeedLogger("Creating submissions");
- const submissionCount =
- seedCounts.puzzles * seedCounts.submissionsPerPuzzle;
- const submissionIds = await createSubmissions(
- submissionCount,
- userIds,
- puzzleIds
- );
- submissionLogger.success(submissionIds.length, "submissions");
-
- // 6. Create Comments (with nested replies)
- const commentLogger = new SeedLogger("Creating comments");
- const commentCount = seedCounts.puzzles * seedCounts.commentsPerPuzzle;
- const commentIds = await createPuzzleComments(
- commentCount,
- userIds,
- puzzleIds
- );
- commentLogger.success(commentIds.length, "comments (+ nested replies)");
-
- // 7. Create Reports
- const reportLogger = new SeedLogger("Creating reports");
- const reportIds = await createReports(
- seedCounts.reports,
- userIds,
- puzzleIds,
- moderatorIds
- );
- reportLogger.success(reportIds.length, "reports");
-
- // 8. Create Games
- const gameLogger = new SeedLogger("Creating games");
- const gameIds = await createGames(seedCounts.games, userIds, puzzleIds);
- gameLogger.success(gameIds.length, "games");
-
- // 9. Create Chat Messages for Games
- const chatLogger = new SeedLogger("Creating chat messages");
- // Build a map of game IDs to player IDs
- const games = await Game.find({ _id: { $in: gameIds } }).lean();
- const gamePlayerMap = new Map();
- games.forEach((game) => {
- gamePlayerMap.set(
- game._id.toString(),
- game.players as unknown as Types.ObjectId[]
- );
- });
- const chatMessageIds = await createChatMessages(gameIds, gamePlayerMap);
- chatLogger.success(chatMessageIds.length, "chat messages");
-
- // 10. Create User Votes
- const voteLogger = new SeedLogger("Creating user votes");
- const voteIds = await createUserVotes(commentIds, userIds);
- voteLogger.success(voteIds.length, "user votes");
-
- // Display final counts
- console.log("\n" + "=".repeat(50));
- console.log("📈 Final Database Counts:\n");
-
- const counts = await getCollectionCounts();
- Object.entries(counts).forEach(([collection, count]) => {
- console.log(` ${collection.padEnd(15)}: ${count}`);
- });
-
- console.log("\n" + "=".repeat(50));
- console.log("✨ Seeding completed successfully!\n");
- } catch (error) {
- console.error("\n❌ Seeding failed:", error);
- process.exit(1);
- } finally {
- await disconnectFromDatabase();
- }
-}
-
-seed();
diff --git a/libs/backend/src/seeds/programming-language.seed.ts b/libs/backend/src/seeds/programming-language.seed.ts
deleted file mode 100644
index 66bdb79c..00000000
--- a/libs/backend/src/seeds/programming-language.seed.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { arePistonRuntimes, PistonRuntime, pistonUrls } from "types";
-import ProgrammingLanguage from "../models/programming-language/language.js";
-import { buildPistonUri } from "@/utils/functions/build-piston-uri.js";
-
-/**
- * Seed programming languages from Piston runtimes
- * This function fetches available runtimes from Piston and populates the ProgrammingLanguage collection
- */
-export async function seedProgrammingLanguages() {
- try {
- console.log("Fetching available runtimes from Piston...");
- const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES));
- const runtimes = await response.json();
-
- if (!arePistonRuntimes(runtimes)) {
- throw new Error("Failed to fetch Piston runtimes");
- }
-
- console.log(`Found ${runtimes.length} runtimes from Piston`);
-
- // Clear existing programming languages
- await ProgrammingLanguage.deleteMany({});
- console.log("Cleared existing programming languages");
-
- // Insert all runtimes as programming languages
- const languageDocs = runtimes.map((runtime: PistonRuntime) => ({
- language: runtime.language,
- version: runtime.version,
- aliases: runtime.aliases || [],
- runtime: runtime.runtime
- }));
-
- const insertedLanguages =
- await ProgrammingLanguage.insertMany(languageDocs);
- console.log(`✓ Seeded ${insertedLanguages.length} programming languages`);
-
- return insertedLanguages;
- } catch (error) {
- console.error("Error seeding programming languages:", error);
- throw error;
- }
-}
diff --git a/libs/backend/src/seeds/utils/clear-database.ts b/libs/backend/src/seeds/utils/clear-database.ts
deleted file mode 100644
index fccec895..00000000
--- a/libs/backend/src/seeds/utils/clear-database.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import User from "../../models/user/user.js";
-import Puzzle from "../../models/puzzle/puzzle.js";
-import Submission from "../../models/submission/submission.js";
-import Comment from "../../models/comment/comment.js";
-import Game from "../../models/game/game.js";
-import Report from "../../models/report/report.js";
-import Preferences from "../../models/preferences/preferences.js";
-import UserBan from "../../models/moderation/user-ban.js";
-import UserVote from "../../models/user/user-vote.js";
-import ChatMessage from "../../models/chat/chat-message.js";
-import readline from "readline/promises";
-
-export async function clearDatabase(force = false): Promise {
- if (!force) {
- const counts = await getCollectionCounts();
- console.log("Current collection counts:", counts);
-
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- });
-
- const answer = await rl.question(
- "⚠️ This will DELETE ALL DATA from the database. Are you sure? (yes/no): "
- );
- rl.close();
-
- if (answer.toLowerCase() !== "yes") {
- console.log("❌ Database clear cancelled");
- return;
- }
- }
-
- console.log("🗑️ Clearing database...");
-
- try {
- // Delete in reverse dependency order
- await Promise.all([
- ChatMessage.deleteMany({}),
- UserVote.deleteMany({}),
- Report.deleteMany({}),
- Game.deleteMany({})
- ]);
-
- await Promise.all([Comment.deleteMany({}), Submission.deleteMany({})]);
-
- await Puzzle.deleteMany({});
-
- await Promise.all([UserBan.deleteMany({}), Preferences.deleteMany({})]);
-
- await User.deleteMany({});
-
- console.log("✅ Database cleared successfully");
- } catch (error) {
- console.error("❌ Error clearing database:", error);
- throw error;
- }
-}
-
-export async function getCollectionCounts(): Promise> {
- const counts = {
- users: await User.countDocuments(),
- puzzles: await Puzzle.countDocuments(),
- submissions: await Submission.countDocuments(),
- comments: await Comment.countDocuments(),
- games: await Game.countDocuments(),
- reports: await Report.countDocuments(),
- preferences: await Preferences.countDocuments(),
- userBans: await UserBan.countDocuments(),
- userVotes: await UserVote.countDocuments(),
- chatMessages: await ChatMessage.countDocuments()
- };
-
- return counts;
-}
diff --git a/libs/backend/src/seeds/utils/db-connection.ts b/libs/backend/src/seeds/utils/db-connection.ts
deleted file mode 100644
index 0473a432..00000000
--- a/libs/backend/src/seeds/utils/db-connection.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import mongoose from "mongoose";
-import { config } from "dotenv";
-config();
-
-export async function connectToDatabase(): Promise {
- const mongoUri = process.env.MONGO_URI || "mongodb://localhost:27017";
- const dbName = process.env.MONGO_DB_NAME || "codincod";
-
- try {
- await mongoose.connect(mongoUri, {
- dbName,
- serverSelectionTimeoutMS: 5000,
- socketTimeoutMS: 45000
- });
-
- console.log(`✅ Connected to MongoDB: ${dbName}`);
- } catch (error) {
- console.error("❌ MongoDB connection error:", error);
- throw error;
- }
-}
-export async function disconnectFromDatabase(): Promise {
- await mongoose.disconnect();
- console.log("✅ Disconnected from MongoDB");
-}
diff --git a/libs/backend/src/seeds/utils/seed-helpers.ts b/libs/backend/src/seeds/utils/seed-helpers.ts
deleted file mode 100644
index 371c9b4c..00000000
--- a/libs/backend/src/seeds/utils/seed-helpers.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { faker } from "@faker-js/faker";
-
-/**
- * Randomly select an item from an array
- */
-export function randomFromArray(array: T[]): T {
- return array[Math.floor(Math.random() * array.length)];
-}
-
-/**
- * Randomly select multiple items from an array
- */
-export function randomMultipleFromArray(
- array: T[],
- min: number,
- max: number
-): T[] {
- const count = faker.number.int({ min, max });
- const shuffled = [...array].sort(() => 0.5 - Math.random());
- return shuffled.slice(0, Math.min(count, array.length));
-}
-
-/**
- * Generate a random subset of an enum's values
- */
-export function randomEnumValues>(
- enumObj: T,
- min = 1,
- max = 3
-): string[] {
- const values = Object.values(enumObj);
- return randomMultipleFromArray(values, min, max);
-}
-
-/**
- * Weighted random boolean (default 50/50)
- */
-export function randomBoolean(probability = 0.5): boolean {
- return Math.random() < probability;
-}
-
-/**
- * Progress logger for seeding operations
- */
-export class SeedLogger {
- private startTime: number;
-
- constructor(private operation: string) {
- this.startTime = Date.now();
- console.log(`\n🌱 ${operation}...`);
- }
-
- success(count: number, itemType: string): void {
- const duration = Date.now() - this.startTime;
- console.log(`✅ Created ${count} ${itemType} (${duration}ms)`);
- }
-
- error(error: unknown): void {
- console.error(`❌ ${this.operation} failed:`, error);
- }
-}
-
-/**
- * Get environment variable as number with fallback
- */
-export function getEnvNumber(key: string, defaultValue: number): number {
- const value = process.env[key];
- return value ? parseInt(value, 10) : defaultValue;
-}
diff --git a/libs/backend/src/services/game-mode.service.ts b/libs/backend/src/services/game-mode.service.ts
deleted file mode 100644
index b04e16ca..00000000
--- a/libs/backend/src/services/game-mode.service.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { GameMode, gameModeEnum } from "types";
-import {
- getGameModeConfig,
- sortSubmissionsByGameMode,
- type SubmissionData
-} from "../utils/game-mode/game-mode-strategy.js";
-import { GameDocument } from "../models/game/game.js";
-import { SubmissionDocument } from "../models/submission/submission.js";
-import type { ObjectId } from "mongoose";
-
-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;
-}
-
-/**
- * Service for game mode logic
- * Handles scoring, ranking, and game mode-specific business logic
- */
-export class GameModeService {
- /**
- * Calculate score for a submission based on game mode
- */
- calculateSubmissionScore(
- mode: GameMode,
- submission: {
- result: { successRate: number };
- createdAt: Date | string;
- code: string;
- gameStartTime: Date | string;
- attempts?: number;
- }
- ): number {
- const config = getGameModeConfig(mode);
- const startTime = new Date(submission.gameStartTime).getTime();
- const submissionTime = new Date(submission.createdAt).getTime();
- const timeSpent = (submissionTime - startTime) / 1000; // Convert to seconds
-
- const submissionData: SubmissionData = {
- successRate: submission.result.successRate,
- timeSpent,
- codeLength: submission.code.length,
- attempts: submission.attempts ?? undefined
- };
-
- return config.calculateScore(submissionData);
- }
-
- /**
- * Get leaderboard for a game based on its mode
- */
- getGameLeaderboard(
- game: GameDocument,
- submissions: Array
- ): Array<{
- userId: string;
- username: string;
- score: number;
- timeSpent: number;
- codeLength?: number;
- successRate: number;
- rank: number;
- }> {
- const mode = game.options?.mode ?? gameModeEnum.FASTEST;
-
- const sortedSubmissions = sortSubmissionsByGameMode(
- submissions,
- mode,
- game.createdAt
- );
-
- return sortedSubmissions.map((submission, index) => {
- const userId = isPopulatedUser(submission.user)
- ? submission.user._id.toString()
- : submission.user.toString();
-
- const username = isPopulatedUser(submission.user)
- ? submission.user.username
- : "";
-
- const startTime = new Date(game.createdAt).getTime();
- const submissionTime = new Date(submission.createdAt).getTime();
- const timeSpent = (submissionTime - startTime) / 1000;
-
- const submissionData: SubmissionData = {
- successRate: submission.result.successRate,
- timeSpent,
- codeLength: submission.code?.length ?? 0,
- attempts: submission.attempts
- };
-
- const config = getGameModeConfig(mode);
- const score = config.calculateScore(submissionData);
-
- return {
- userId,
- username,
- score,
- timeSpent,
- codeLength: submission.code?.length ?? 0,
- successRate: submission.result.successRate,
- rank: index + 1
- };
- });
- }
-
- /**
- * Get display metrics for a game mode
- */
- getDisplayMetricsForMode(mode: GameMode): string[] {
- const config = getGameModeConfig(mode);
- return config.displayMetrics;
- }
-
- /**
- * Validate submission based on game mode rules
- */
- validateSubmissionForMode(
- mode: GameMode,
- submission: {
- result: { successRate: number };
- attempts?: number;
- }
- ): { valid: boolean; reason?: string } {
- switch (mode) {
- case gameModeEnum.HARDCORE:
- // Hardcore mode: only one attempt allowed
- if ((submission.attempts ?? 1) > 1) {
- return {
- valid: false,
- reason:
- "Hardcore mode allows only one attempt. This submission has multiple attempts."
- };
- }
- break;
-
- case gameModeEnum.BACKWARDS:
- case gameModeEnum.DEBUG:
- break;
-
- case gameModeEnum.INCREMENTAL:
- // Incremental mode accepts partial success
- if (submission.result.successRate === 0) {
- return {
- valid: false,
- reason: "Incremental mode requires at least partial success."
- };
- }
- break;
-
- case gameModeEnum.FASTEST:
- case gameModeEnum.SHORTEST:
- case gameModeEnum.EFFICIENCY:
- case gameModeEnum.TYPERACER:
- case gameModeEnum.RANDOM:
- default:
- // Most modes require full success
- if (submission.result.successRate < 1) {
- return {
- valid: false,
- reason: "Submission must pass all test cases for this game mode."
- };
- }
- }
-
- return { valid: true };
- }
-
- /**
- * Get game mode description for UI
- */
- getGameModeDescription(mode: GameMode): string {
- switch (mode) {
- case gameModeEnum.FASTEST:
- return "Complete the puzzle in the shortest time";
- case gameModeEnum.SHORTEST:
- return "Write the solution with the fewest characters";
- case gameModeEnum.BACKWARDS:
- return "Work from output to input - logical deduction challenge";
- case gameModeEnum.HARDCORE:
- return "One attempt only - no test runs allowed";
- case gameModeEnum.DEBUG:
- return "Fix broken code with minimal changes";
- case gameModeEnum.EFFICIENCY:
- return "Write the most computationally efficient solution";
- case gameModeEnum.TYPERACER:
- return "Copy code perfectly at maximum speed";
- case gameModeEnum.INCREMENTAL:
- return "Progressive requirements - solve step by step";
- case gameModeEnum.RANDOM:
- return "Random mode - a surprise challenge";
- default:
- return "Complete the challenge";
- }
- }
-
- /**
- * Resolve random mode to actual mode
- */
- resolveGameMode(mode: GameMode): GameMode {
- if (mode !== gameModeEnum.RANDOM) {
- return mode;
- }
-
- // Select a random mode (excluding RANDOM itself)
- const modes = Object.values(gameModeEnum).filter(
- (m) => m !== gameModeEnum.RANDOM
- );
- const randomIndex = Math.floor(Math.random() * modes.length);
- return modes[randomIndex] as GameMode;
- }
-}
-
-// Export singleton instance
-export const gameModeService = new GameModeService();
diff --git a/libs/backend/src/services/game.service.ts b/libs/backend/src/services/game.service.ts
deleted file mode 100644
index 082c82e8..00000000
--- a/libs/backend/src/services/game.service.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import Game, { GameDocument } from "../models/game/game.js";
-import { GameEntity, ObjectId } from "types";
-
-/**
- * Service for Game database operations
- * Centralizes all MongoDB queries for games
- */
-export class GameService {
- /**
- * Find a game by ID with all related data populated
- */
- async findByIdPopulated(id: string | ObjectId): Promise {
- return await Game.findById(id)
- .populate("owner")
- .populate("players")
- .populate({
- path: "playerSubmissions",
- populate: [{ path: "user" }, { path: "programmingLanguage" }]
- })
- .exec();
- }
-
- /**
- * Find a game by ID without population
- */
- async findById(id: string | ObjectId): Promise {
- return await Game.findById(id).exec();
- }
-
- /**
- * Create a new game
- */
- async create(gameEntity: GameEntity): Promise {
- const game = new Game(gameEntity);
- return await game.save();
- }
-
- /**
- * Update a game's player submissions
- */
- async addPlayerSubmission(
- gameId: string | ObjectId,
- submissionId: string | ObjectId
- ): Promise {
- const game = await Game.findById(gameId);
- if (!game) return null;
-
- const uniqueSubmissions = new Set([
- ...(game.playerSubmissions ?? []),
- submissionId.toString()
- ]);
- game.playerSubmissions = Array.from(uniqueSubmissions);
-
- return await game.save();
- }
-
- /**
- * Add a player to a game with optimistic locking (prevents race conditions)
- * @throws Error if version mismatch occurs (game was modified by another request)
- */
- async addPlayer(
- gameId: string | ObjectId,
- playerId: string | ObjectId,
- expectedVersion: number
- ): Promise {
- const result = await Game.findOneAndUpdate(
- {
- _id: gameId,
- version: expectedVersion,
- players: { $ne: playerId }
- },
- {
- $push: { players: playerId },
- $inc: { version: 1 }
- },
- { new: true }
- ).exec();
-
- return result;
- }
-
- /**
- * Find games by player ID
- */
- async findByPlayerId(
- playerId: string | ObjectId,
- options?: {
- limit?: number;
- skip?: number;
- sort?: Record;
- }
- ): Promise {
- let query = Game.find({ players: playerId });
-
- if (options?.sort) {
- query = query.sort(options.sort);
- }
- if (options?.skip) {
- query = query.skip(options.skip);
- }
- if (options?.limit) {
- query = query.limit(options.limit);
- }
-
- return await query.exec();
- }
-
- /**
- * Find games by owner ID
- */
- async findByOwnerId(
- ownerId: string | ObjectId,
- options?: {
- limit?: number;
- skip?: number;
- sort?: Record;
- }
- ): Promise {
- let query = Game.find({ owner: ownerId });
-
- if (options?.sort) {
- query = query.sort(options.sort);
- }
- if (options?.skip) {
- query = query.skip(options.skip);
- }
- if (options?.limit) {
- query = query.limit(options.limit);
- }
-
- return await query.exec();
- }
-
- /**
- * Find all games with optional filters
- */
- async findAll(options?: {
- filter?: Record;
- limit?: number;
- skip?: number;
- sort?: Record;
- }): Promise {
- let query = Game.find(options?.filter ?? {});
-
- if (options?.sort) {
- query = query.sort(options.sort);
- }
- if (options?.skip) {
- query = query.skip(options.skip);
- }
- if (options?.limit) {
- query = query.limit(options.limit);
- }
-
- return await query.exec();
- }
-
- /**
- * Count games matching a filter
- */
- async count(filter?: Record): Promise {
- return await Game.countDocuments(filter ?? {});
- }
-
- /**
- * Delete a game by ID
- */
- async deleteById(id: string | ObjectId): Promise {
- return await Game.findByIdAndDelete(id).exec();
- }
-}
-
-// Export a singleton instance
-export const gameService = new GameService();
diff --git a/libs/backend/src/services/leaderboard.service.ts b/libs/backend/src/services/leaderboard.service.ts
deleted file mode 100644
index 74b555d3..00000000
--- a/libs/backend/src/services/leaderboard.service.ts
+++ /dev/null
@@ -1,359 +0,0 @@
-import { GameMode, gameModeEnum } from "types";
-import UserMetrics, {
- UserMetricsDocument
-} from "../models/user-metrics/user-metrics.js";
-import Game, { GameDocument } from "../models/game/game.js";
-import Submission from "../models/submission/submission.js";
-import User from "../models/user/user.js";
-import { gameModeService } from "./game-mode.service.js";
-import {
- calculateNewRating,
- getDefaultRating,
- type GlickoRating
-} from "../utils/rating/glicko.js";
-
-/**
- * Helper to ensure glicko rating has Date objects instead of strings
- */
-function normalizeGlickoRating(rating: any): GlickoRating {
- return {
- ...rating,
- lastUpdated:
- rating.lastUpdated instanceof Date
- ? rating.lastUpdated
- : new Date(rating.lastUpdated)
- };
-}
-
-/**
- * Service for calculating and managing leaderboards
- * Processes games incrementally to update player ratings and rankings
- */
-export class LeaderboardService {
- /**
- * Get or create user metrics document
- */
- async getUserMetrics(userId: string): Promise {
- let metrics = await UserMetrics.findOne({ userId });
-
- if (!metrics) {
- metrics = new UserMetrics({
- userId,
- totalGamesPlayed: 0,
- totalGamesWon: 0,
- lastProcessedGameDate: new Date(0),
- lastCalculationDate: new Date()
- });
- await metrics.save();
- }
-
- return metrics;
- }
-
- /**
- * Process all completed games since last update for a specific game mode
- */
- async processGamesForMode(mode: GameMode): Promise {
- // Get all user metrics to find the earliest lastProcessedGameDate
- const allMetrics = await UserMetrics.find({});
- const earliestProcessedDate = allMetrics.reduce((earliest, metric) => {
- const modeMetrics = metric[mode as keyof UserMetricsDocument];
- if (!modeMetrics || typeof modeMetrics !== "object") return earliest;
-
- const lastGameDate = modeMetrics.lastGameDate;
- if (!lastGameDate) return earliest;
-
- return lastGameDate < earliest ? lastGameDate : earliest;
- }, new Date(0));
-
- // Find completed games since last processed date
- const completedGames = await Game.find({
- "options.mode": mode,
- status: "completed",
- createdAt: { $gt: earliestProcessedDate }
- })
- .populate("players")
- .sort({ createdAt: 1 })
- .exec();
-
- let processedCount = 0;
-
- for (const game of completedGames) {
- await this.processGameResults(game, mode);
- processedCount++;
- }
-
- if (processedCount > 0) {
- await this.updateRankingsForMode(mode);
- }
-
- return processedCount;
- }
-
- async processGameResults(game: GameDocument, mode: GameMode): Promise {
- if (!game.playerSubmissions || game.playerSubmissions.length === 0) {
- return;
- }
-
- const submissions = await Submission.find({
- _id: { $in: game.playerSubmissions }
- })
- .populate("user")
- .select("+code")
- .exec();
-
- if (submissions.length < 2) {
- return;
- }
-
- const leaderboard = gameModeService.getGameLeaderboard(game, submissions);
-
- if (leaderboard.length === 0) return;
-
- const winner = leaderboard[0];
-
- for (let i = 0; i < leaderboard.length; i++) {
- const entry = leaderboard[i];
- const userId = entry.userId;
-
- if (!userId) continue;
-
- const metrics = await this.getUserMetrics(userId);
-
- if (!metrics[mode]) {
- metrics[mode] = {
- averageScore: 0,
- bestScore: 0,
- gamesPlayed: 0,
- gamesWon: 0,
- glickoRating: getDefaultRating(),
- totalScore: 0
- };
- }
-
- const modeMetrics = metrics[mode];
- const isWinner = entry.userId === winner.userId;
-
- modeMetrics.gamesPlayed += 1;
- if (isWinner) {
- modeMetrics.gamesWon += 1;
- }
-
- modeMetrics.totalScore += entry.score;
- modeMetrics.averageScore =
- modeMetrics.totalScore / modeMetrics.gamesPlayed;
-
- if (entry.score > modeMetrics.bestScore) {
- modeMetrics.bestScore = entry.score;
- }
-
- const currentRating: GlickoRating = normalizeGlickoRating(
- modeMetrics.glickoRating
- );
- const games = leaderboard
- .filter((opp) => opp.userId !== userId)
- .map((opponent) => {
- const opponentMetrics = metrics[mode];
- const opponentRating: GlickoRating = opponentMetrics?.glickoRating
- ? normalizeGlickoRating(opponentMetrics.glickoRating)
- : getDefaultRating();
-
- const playerWon = entry.rank < opponent.rank;
-
- return { opponentRating, playerWon };
- });
-
- if (games.length > 0) {
- const newRating = calculateNewRating(
- currentRating,
- games.map((g) => ({
- opponentRating: g.opponentRating.rating,
- opponentRd: g.opponentRating.rd,
- score: g.playerWon ? 1 : 0
- }))
- );
-
- modeMetrics.glickoRating = newRating;
- }
-
- modeMetrics.lastGameDate = game.createdAt;
-
- metrics.totalGamesPlayed += 1;
- if (isWinner) {
- metrics.totalGamesWon += 1;
- }
-
- metrics.lastProcessedGameDate = game.createdAt;
- metrics.lastCalculationDate = new Date();
-
- await metrics.save();
- }
- }
-
- /**
- * Update rankings for all players in a game mode
- */
- async updateRankingsForMode(mode: GameMode): Promise {
- const modeField = `${mode}.glickoRating.rating`;
-
- // Get all users sorted by rating for this mode
- const sortedMetrics = await UserMetrics.find({
- [mode]: { $exists: true }
- })
- .sort({ [modeField]: -1 })
- .exec();
-
- // Update ranks
- for (let i = 0; i < sortedMetrics.length; i++) {
- const metrics = sortedMetrics[i];
- const modeMetrics = metrics[mode];
-
- if (modeMetrics) {
- modeMetrics.rank = i + 1;
- await metrics.save();
- }
- }
- }
-
- /**
- * Recalculate all leaderboards (called by cron job)
- */
- async recalculateAllLeaderboards(): Promise<{
- processedGames: Record;
- totalProcessed: number;
- }> {
- const modes = Object.values(gameModeEnum);
- const results: Record = {} as Record;
- let totalProcessed = 0;
-
- for (const mode of modes) {
- const count = await this.processGamesForMode(mode);
- results[mode] = count;
- totalProcessed += count;
- }
-
- return { processedGames: results, totalProcessed };
- }
-
- /**
- * Get leaderboard entries for a specific game mode
- */
- async getLeaderboard(
- mode: GameMode,
- page: number = 1,
- pageSize: number = 50
- ): Promise<{
- entries: Array<{
- rank: number;
- userId: string;
- username: string;
- rating: number;
- glicko: GlickoRating;
- gamesPlayed: number;
- gamesWon: number;
- winRate: number;
- bestScore: number;
- averageScore: number;
- }>;
- total: number;
- lastUpdated: Date;
- }> {
- const skip = (page - 1) * pageSize;
- const modeField = `${mode}.glickoRating.rating`;
-
- const metrics = await UserMetrics.find({
- [mode]: { $exists: true }
- })
- .sort({ [modeField]: -1 })
- .skip(skip)
- .limit(pageSize)
- .populate("userId", "username")
- .exec();
-
- const total = await UserMetrics.countDocuments({
- [mode]: { $exists: true }
- });
-
- const entries = await Promise.all(
- metrics.map(async (metric) => {
- const modeMetrics = metric[mode];
- const user = await User.findById(metric.userId);
-
- if (!modeMetrics) {
- throw new Error("Mode metrics not found for user");
- }
-
- return {
- rank: modeMetrics.rank || 0,
- userId: metric.userId.toString(),
- username: user?.username || "Unknown",
- rating: modeMetrics.glickoRating.rating,
- glicko: normalizeGlickoRating(modeMetrics.glickoRating),
- gamesPlayed: modeMetrics.gamesPlayed,
- gamesWon: modeMetrics.gamesWon,
- winRate:
- modeMetrics.gamesPlayed > 0
- ? modeMetrics.gamesWon / modeMetrics.gamesPlayed
- : 0,
- bestScore: modeMetrics.bestScore,
- averageScore: modeMetrics.averageScore
- };
- })
- );
-
- // Find most recent calculation date
- const mostRecent = metrics.reduce((latest: Date, m) => {
- const calcDate =
- m.lastCalculationDate instanceof Date
- ? m.lastCalculationDate
- : new Date(m.lastCalculationDate);
- return calcDate > latest ? calcDate : latest;
- }, new Date(0));
-
- return {
- entries,
- total,
- lastUpdated: mostRecent
- };
- }
-
- /**
- * Get user's rankings across all game modes
- */
- async getUserRankings(userId: string): Promise<
- Record<
- GameMode,
- {
- rank?: number;
- rating: number;
- gamesPlayed: number;
- winRate: number;
- }
- >
- > {
- const metrics = await this.getUserMetrics(userId);
- const modes = Object.values(gameModeEnum);
- const rankings: any = {};
-
- for (const mode of modes) {
- const modeMetrics = metrics[mode];
-
- if (modeMetrics) {
- rankings[mode] = {
- rank: modeMetrics.rank,
- rating: modeMetrics.glickoRating.rating,
- gamesPlayed: modeMetrics.gamesPlayed,
- winRate:
- modeMetrics.gamesPlayed > 0
- ? modeMetrics.gamesWon / modeMetrics.gamesPlayed
- : 0
- };
- }
- }
-
- return rankings;
- }
-}
-
-// Export singleton instance
-export const leaderboardService = new LeaderboardService();
diff --git a/libs/backend/src/services/programming-language.service.ts b/libs/backend/src/services/programming-language.service.ts
deleted file mode 100644
index 0dffb8ad..00000000
--- a/libs/backend/src/services/programming-language.service.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import ProgrammingLanguage, {
- ProgrammingLanguageDocument
-} from "../models/programming-language/language.js";
-import {
- ObjectId,
- ProgrammingLanguageDto,
- programmingLanguageDtoSchema
-} from "types";
-
-/**
- * Service for ProgrammingLanguage database operations
- * Centralizes all MongoDB queries for programming languages
- */
-export class ProgrammingLanguageService {
- /**
- * Find a programming language by ID
- */
- async findById(
- id: string | ObjectId
- ): Promise {
- return (await ProgrammingLanguage.findById(
- id
- ).lean()) as ProgrammingLanguageDocument | null;
- }
-
- /**
- * Find all programming languages
- */
- async findAll(): Promise {
- return await ProgrammingLanguage.find()
- .select("-createdAt -updatedAt -__v")
- .sort({ language: 1, version: -1 });
- }
-
- /**
- * Find programming language by language name and version
- */
- async findByLanguageAndVersion(
- language: string,
- version: string
- ): Promise {
- return await ProgrammingLanguage.findOne({ language, version });
- }
-
- /**
- * Convert a ProgrammingLanguageDocument to DTO
- */
- toDto(doc: ProgrammingLanguageDocument): ProgrammingLanguageDto {
- return programmingLanguageDtoSchema.parse({
- _id: doc._id.toString(),
- language: doc.language,
- version: doc.version,
- aliases: doc.aliases,
- runtime: doc.runtime
- });
- }
-
- /**
- * Get all programming languages as DTOs
- */
- async findAllAsDto(): Promise {
- const languages = await this.findAll();
- return languages.map((lang) => this.toDto(lang));
- }
-
- /**
- * Count all programming languages
- */
- async count(): Promise {
- return await ProgrammingLanguage.countDocuments({});
- }
-
- /**
- * Create a new programming language
- */
- async create(data: {
- language: string;
- version: string;
- aliases?: string[];
- runtime?: string;
- }): Promise {
- const programmingLanguage = new ProgrammingLanguage(data);
- return await programmingLanguage.save();
- }
-
- /**
- * Create multiple programming languages
- */
- async createMany(
- data: Array<{
- language: string;
- version: string;
- aliases?: string[];
- runtime?: string;
- }>
- ): Promise {
- return (await ProgrammingLanguage.insertMany(
- data
- )) as ProgrammingLanguageDocument[];
- }
-
- /**
- * Delete all programming languages
- */
- async deleteAll(): Promise {
- await ProgrammingLanguage.deleteMany({});
- }
-}
-
-// Export a singleton instance
-export const programmingLanguageService = new ProgrammingLanguageService();
diff --git a/libs/backend/src/services/puzzle.service.ts b/libs/backend/src/services/puzzle.service.ts
deleted file mode 100644
index c9d87226..00000000
--- a/libs/backend/src/services/puzzle.service.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import Puzzle, { PuzzleDocument } from "../models/puzzle/puzzle.js";
-import { ObjectId, PuzzleDto, PuzzleEntity, puzzleVisibilityEnum } from "types";
-import { PipelineStage } from "mongoose";
-
-/**
- * Service for Puzzle database operations
- * Centralizes all MongoDB queries for puzzles
- */
-export class PuzzleService {
- /**
- * Find a puzzle by ID
- */
- async findById(id: string | ObjectId): Promise {
- return await Puzzle.findById(id).exec();
- }
-
- /**
- * Find a puzzle by ID with author and comments populated
- */
- async findByIdPopulated(
- id: string | ObjectId
- ): Promise {
- return await Puzzle.findById(id)
- .populate("author")
- .populate({
- path: "comments",
- populate: { path: "author" }
- })
- .exec();
- }
-
- /**
- * Find random approved puzzles
- */
- async findRandomApproved(count: number = 1): Promise {
- const pipeline: PipelineStage[] = [
- { $match: { visibility: puzzleVisibilityEnum.APPROVED } },
- { $sample: { size: count } }
- ];
-
- return await Puzzle.aggregate(pipeline).exec();
- }
-
- /**
- * Create a new puzzle
- */
- async create(puzzleEntity: PuzzleEntity): Promise {
- const puzzle = new Puzzle(puzzleEntity);
- return await puzzle.save();
- }
-
- /**
- * Update a puzzle by ID
- */
- async updateById(
- id: string | ObjectId,
- update: Partial
- ): Promise {
- return await Puzzle.findByIdAndUpdate(id, update, {
- new: true,
- runValidators: true
- }).exec();
- }
-
- /**
- * Find puzzles by author ID
- */
- async findByAuthorId(
- authorId: string | ObjectId,
- options?: {
- visibility?: string;
- limit?: number;
- skip?: number;
- sort?: Record;
- }
- ): Promise {
- const filter: Record = { author: authorId };
- if (options?.visibility) {
- filter.visibility = options.visibility;
- }
-
- let query = Puzzle.find(filter);
-
- if (options?.sort) {
- query = query.sort(options.sort);
- }
- if (options?.skip) {
- query = query.skip(options.skip);
- }
- if (options?.limit) {
- query = query.limit(options.limit);
- }
-
- return await query.exec();
- }
-
- /**
- * Find all puzzles with optional filters
- */
- async findAll(options?: {
- filter?: Record;
- limit?: number;
- skip?: number;
- sort?: Record;
- populate?: string | string[];
- }): Promise {
- let query = Puzzle.find(options?.filter ?? {});
-
- if (options?.sort) {
- query = query.sort(options.sort);
- }
- if (options?.skip) {
- query = query.skip(options.skip);
- }
- if (options?.limit) {
- query = query.limit(options.limit);
- }
- if (options?.populate) {
- query = query.populate(options.populate);
- }
-
- return await query.exec();
- }
-
- /**
- * Count puzzles matching a filter
- */
- async count(filter?: Record): Promise {
- return await Puzzle.countDocuments(filter ?? {});
- }
-
- /**
- * Delete a puzzle by ID
- */
- async deleteById(id: string | ObjectId): Promise {
- return await Puzzle.findByIdAndDelete(id).exec();
- }
-
- /**
- * Find puzzles with pagination
- */
- async findWithPagination(
- page: number,
- pageSize: number,
- filter?: Record,
- sort?: Record
- ): Promise<{
- puzzles: PuzzleDocument[];
- total: number;
- page: number;
- pageSize: number;
- totalPages: number;
- }> {
- const skip = (page - 1) * pageSize;
- const [puzzles, total] = await Promise.all([
- this.findAll({
- ...(filter && { filter }),
- skip,
- limit: pageSize,
- ...(sort && { sort })
- }),
- this.count(filter)
- ]);
-
- return {
- puzzles,
- total,
- page,
- pageSize,
- totalPages: Math.ceil(total / pageSize)
- };
- }
-}
-
-// Export a singleton instance
-export const puzzleService = new PuzzleService();
diff --git a/libs/backend/src/services/submission.service.ts b/libs/backend/src/services/submission.service.ts
deleted file mode 100644
index 9e791358..00000000
--- a/libs/backend/src/services/submission.service.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import Submission, {
- SubmissionDocument
-} from "../models/submission/submission.js";
-import { ObjectId, SubmissionEntity } from "types";
-
-export class SubmissionService {
- async findById(id: string | ObjectId): Promise {
- return await Submission.findById(id);
- }
-
- async findByIdWithCode(
- id: string | ObjectId
- ): Promise {
- return await Submission.findById(id).select("+code");
- }
-
- async findByIdPopulated(
- id: string | ObjectId
- ): Promise {
- return await Submission.findById(id)
- .populate("user")
- .populate("programmingLanguage")
- .populate("puzzle");
- }
-
- async findByIdWithCodePopulated(
- id: string | ObjectId
- ): Promise {
- return await Submission.findById(id)
- .select("+code")
- .populate("user")
- .populate("programmingLanguage")
- .populate("puzzle");
- }
-
- async findByUser(
- userId: string | ObjectId,
- limit?: number
- ): Promise {
- const query = Submission.find({ user: userId })
- .sort({ createdAt: -1 })
- .populate("puzzle")
- .populate("programmingLanguage");
-
- if (limit) {
- query.limit(limit);
- }
-
- return await query.exec();
- }
-
- async findByPuzzle(
- puzzleId: string | ObjectId
- ): Promise {
- return await Submission.find({ puzzle: puzzleId })
- .populate("user")
- .populate("programmingLanguage")
- .sort({ createdAt: -1 });
- }
-
- async create(data: SubmissionEntity): Promise {
- const submission = new Submission(data);
- return await submission.save();
- }
-
- async countByUser(userId: string | ObjectId): Promise {
- return await Submission.countDocuments({ user: userId });
- }
-
- async countByPuzzle(puzzleId: string | ObjectId): Promise {
- return await Submission.countDocuments({ puzzle: puzzleId });
- }
-
- async findSuccessfulByUser(
- userId: string | ObjectId
- ): Promise {
- return await Submission.find({
- user: userId,
- "result.successRate": 1
- })
- .populate("puzzle")
- .populate("programmingLanguage")
- .sort({ createdAt: -1 });
- }
-
- async deleteMany(ids: (string | ObjectId)[]): Promise {
- await Submission.deleteMany({ _id: { $in: ids } });
- }
-
- async deleteByPuzzle(puzzleId: string | ObjectId): Promise {
- await Submission.deleteMany({ puzzle: puzzleId });
- }
-
- async deleteByUser(userId: string | ObjectId): Promise {
- await Submission.deleteMany({ user: userId });
- }
-}
-
-export const submissionService = new SubmissionService();
diff --git a/libs/backend/src/services/user.service.ts b/libs/backend/src/services/user.service.ts
deleted file mode 100644
index 26f68a1c..00000000
--- a/libs/backend/src/services/user.service.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import User, { UserDocument } from "../models/user/user.js";
-import { ObjectId, UserDto, UserEntity } from "types";
-
-export class UserService {
- async findById(id: string | ObjectId): Promise {
- return await User.findById(id);
- }
-
- async findByIdWithBan(id: string | ObjectId): Promise {
- return await User.findById(id).populate("currentBan");
- }
-
- async findByUsername(username: string): Promise {
- return await User.findOne({ username });
- }
-
- async findByEmail(email: string): Promise {
- return await User.findOne({ email }).select("+email");
- }
-
- async findByUsernameWithPassword(
- username: string
- ): Promise {
- return await User.findOne({ username }).select("+password");
- }
-
- async create(
- data: Omit
- ): Promise {
- const user = new User(data);
- return await user.save();
- }
-
- async updateProfile(
- id: string | ObjectId,
- profile: Partial
- ): Promise {
- return await User.findByIdAndUpdate(
- id,
- { $set: { profile } },
- { new: true }
- );
- }
-
- async usernameExists(username: string): Promise {
- const count = await User.countDocuments({ username });
- return count > 0;
- }
-
- async emailExists(email: string): Promise {
- const count = await User.countDocuments({ email });
- return count > 0;
- }
-
- async updateBan(
- userId: string | ObjectId,
- banId: ObjectId | null
- ): Promise {
- await User.findByIdAndUpdate(userId, { currentBan: banId });
- }
-
- async incrementReportCount(userId: string | ObjectId): Promise {
- await User.findByIdAndUpdate(userId, { $inc: { reportCount: 1 } });
- }
-
- async findMany(ids: (string | ObjectId)[]): Promise {
- return await User.find({ _id: { $in: ids } });
- }
-
- toDto(user: UserDocument): UserDto {
- return {
- _id: (user._id as ObjectId).toString(),
- username: user.username,
- profile: user.profile,
- createdAt: user.createdAt
- };
- }
-}
-
-export const userService = new UserService();
diff --git a/libs/backend/src/static/index.html b/libs/backend/src/static/index.html
deleted file mode 100644
index 2a847134..00000000
--- a/libs/backend/src/static/index.html
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
- Fastify + Typescript App
-
-
-
-
- Welcome to Fastify + Typescript App 🔥
- API Documentation
-
-
-
-
-
diff --git a/libs/backend/src/tests/execute.test.ts b/libs/backend/src/tests/execute.test.ts
deleted file mode 100644
index 47844943..00000000
--- a/libs/backend/src/tests/execute.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, beforeAll, afterAll, it, expect, vi } from "vitest";
-import fastify, { FastifyInstance } from "fastify";
-import router from "@/router.js";
-import { backendUrls, httpRequestMethod, httpResponseCodes } from "types";
-import { executionResponseErrors } from "@/routes/execute/index.js";
-
-vi.mock("@/plugins/middleware/authenticated.js", () => ({
- default: vi.fn((request, _reply, done) => {
- request.user = {
- userId: "test-user-id",
- username: "test-user"
- };
- done();
- })
-}));
-
-describe("Execute Endpoint", () => {
- let app: FastifyInstance;
-
- beforeAll(async () => {
- app = fastify();
- await app.register(router);
- await app.ready();
- });
-
- afterAll(async () => {
- await app.close();
- });
-
- const tests = [
- {
- name: 'execute with "unknown" language',
- payload: {
- code: "print('hi')",
- language: "unknown",
- testInput: "",
- testOutput: "hi"
- },
- expectedStatus: httpResponseCodes.CLIENT_ERROR.BAD_REQUEST,
- expectedResponse: executionResponseErrors.UNSUPPORTED_LANGUAGE
- }
- ];
-
- it.each(tests)(
- "Test: $name",
- async ({ payload, expectedStatus, expectedResponse }) => {
- const response = await app.inject({
- method: httpRequestMethod.POST,
- url: backendUrls.EXECUTE,
- payload
- });
-
- expect(response.statusCode).toBe(expectedStatus);
- expect(response.json()).toEqual(expectedResponse);
- }
- );
-});
diff --git a/libs/backend/src/tests/game-mode-strategy.test.ts b/libs/backend/src/tests/game-mode-strategy.test.ts
deleted file mode 100644
index 0a7fb6ea..00000000
--- a/libs/backend/src/tests/game-mode-strategy.test.ts
+++ /dev/null
@@ -1,367 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
- calculateScore,
- getGameModeConfig,
- sortSubmissionsByGameMode,
- type SubmissionData
-} from "../utils/game-mode/game-mode-strategy.js";
-import { gameModeEnum } from "types";
-
-describe("Game Mode Strategy", () => {
- describe("FASTEST mode", () => {
- it("should give higher score to faster completions", () => {
- const fast: SubmissionData = {
- successRate: 1,
- timeSpent: 10
- };
- const slow: SubmissionData = {
- successRate: 1,
- timeSpent: 20
- };
-
- const fastScore = calculateScore(gameModeEnum.FASTEST, fast);
- const slowScore = calculateScore(gameModeEnum.FASTEST, slow);
-
- expect(fastScore).toBeGreaterThan(slowScore);
- });
-
- it("should return 0 score for failed submissions", () => {
- const failed: SubmissionData = {
- successRate: 0.5,
- timeSpent: 10
- };
-
- expect(calculateScore(gameModeEnum.FASTEST, failed)).toBe(0);
- });
- });
-
- describe("SHORTEST mode", () => {
- it("should give higher score to shorter code", () => {
- const short: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 50
- };
- const long: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 100
- };
-
- const shortScore = calculateScore(gameModeEnum.SHORTEST, short);
- const longScore = calculateScore(gameModeEnum.SHORTEST, long);
-
- expect(shortScore).toBeGreaterThan(longScore);
- });
-
- it("should return 0 for missing code length", () => {
- const noLength: SubmissionData = {
- successRate: 1,
- timeSpent: 10
- };
-
- expect(calculateScore(gameModeEnum.SHORTEST, noLength)).toBe(0);
- });
- });
-
- describe("BACKWARDS mode", () => {
- it("should penalize multiple attempts", () => {
- const oneAttempt: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- attempts: 1
- };
- const multipleAttempts: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- attempts: 5
- };
-
- const oneScore = calculateScore(gameModeEnum.BACKWARDS, oneAttempt);
- const multiScore = calculateScore(
- gameModeEnum.BACKWARDS,
- multipleAttempts
- );
-
- expect(oneScore).toBeGreaterThan(multiScore);
- });
- });
-
- describe("HARDCORE mode", () => {
- it("should give score only to first-attempt successes", () => {
- const firstTry: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- attempts: 1
- };
- const secondTry: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- attempts: 2
- };
-
- const firstScore = calculateScore(gameModeEnum.HARDCORE, firstTry);
- const secondScore = calculateScore(gameModeEnum.HARDCORE, secondTry);
-
- expect(firstScore).toBeGreaterThan(0);
- expect(secondScore).toBe(0);
- });
- });
-
- describe("DEBUG mode", () => {
- it("should reward fewer code changes", () => {
- const smallChange: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 10
- };
- const largeChange: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 100
- };
-
- const smallScore = calculateScore(gameModeEnum.DEBUG, smallChange);
- const largeScore = calculateScore(gameModeEnum.DEBUG, largeChange);
-
- expect(smallScore).toBeGreaterThan(largeScore);
- });
- });
-
- describe("EFFICIENCY mode", () => {
- it("should balance time and code length", () => {
- const efficient: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 50
- };
- const inefficient: SubmissionData = {
- successRate: 1,
- timeSpent: 50,
- codeLength: 200
- };
-
- const efficientScore = calculateScore(gameModeEnum.EFFICIENCY, efficient);
- const inefficientScore = calculateScore(
- gameModeEnum.EFFICIENCY,
- inefficient
- );
-
- expect(efficientScore).toBeGreaterThan(inefficientScore);
- });
-
- it("should weight code length more heavily (60%) than time (40%)", () => {
- const shortSlow: SubmissionData = {
- successRate: 1,
- timeSpent: 100,
- codeLength: 50
- };
- const longFast: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 500
- };
-
- const shortScore = calculateScore(gameModeEnum.EFFICIENCY, shortSlow);
- const longScore = calculateScore(gameModeEnum.EFFICIENCY, longFast);
-
- // With 60% weight on length, shorter code with slower time should still win
- // shortScore = 1000000/100 * 0.4 + 500000/50 * 0.6 = 4000 + 6000 = 10000
- // longScore = 1000000/10 * 0.4 + 500000/500 * 0.6 = 40000 + 600 = 40600
- // Actually longScore wins because time difference is too large
- // This test was incorrect - efficiency balances both factors
- expect(longScore).toBeGreaterThan(shortScore);
- });
- });
-
- describe("TYPERACER mode", () => {
- it("should calculate typing speed (chars per second)", () => {
- const fast: SubmissionData = {
- successRate: 1,
- timeSpent: 10,
- codeLength: 200 // 20 chars/sec
- };
- const slow: SubmissionData = {
- successRate: 1,
- timeSpent: 20,
- codeLength: 200 // 10 chars/sec
- };
-
- const fastScore = calculateScore(gameModeEnum.TYPERACER, fast);
- const slowScore = calculateScore(gameModeEnum.TYPERACER, slow);
-
- expect(fastScore).toBeGreaterThan(slowScore);
- expect(fastScore).toBeCloseTo(20 * 1000, 0);
- expect(slowScore).toBeCloseTo(10 * 1000, 0);
- });
-
- it("should return 0 for missing code length", () => {
- const noLength: SubmissionData = {
- successRate: 1,
- timeSpent: 10
- };
-
- expect(calculateScore(gameModeEnum.TYPERACER, noLength)).toBe(0);
- });
- });
-
- describe("INCREMENTAL mode", () => {
- it("should reward earlier completion with time decay", () => {
- const early: SubmissionData = {
- successRate: 1,
- timeSpent: 60 // 1 minute
- };
- const late: SubmissionData = {
- successRate: 1,
- timeSpent: 1800 // 30 minutes
- };
-
- const earlyScore = calculateScore(gameModeEnum.INCREMENTAL, early);
- const lateScore = calculateScore(gameModeEnum.INCREMENTAL, late);
-
- expect(earlyScore).toBeGreaterThan(lateScore);
- });
-
- it("should have minimum decay factor of 0.1", () => {
- const veryLate: SubmissionData = {
- successRate: 1,
- timeSpent: 7200 // 2 hours (way past 1 hour window)
- };
-
- const score = calculateScore(gameModeEnum.INCREMENTAL, veryLate);
- expect(score).toBeGreaterThan(0); // Should still have 0.1 factor
- expect(score).toBeCloseTo(1000000 * 0.1, -4);
- });
-
- it("should allow partial success", () => {
- const partial: SubmissionData = {
- successRate: 0.6,
- timeSpent: 100
- };
-
- const score = calculateScore(gameModeEnum.INCREMENTAL, partial);
- // Incremental mode rewards partial success proportionally
- expect(score).toBeGreaterThan(0);
- expect(score).toBeLessThan(
- calculateScore(gameModeEnum.INCREMENTAL, {
- successRate: 1,
- timeSpent: 100
- })
- );
- });
- });
-
- describe("sortSubmissionsByGameMode", () => {
- it("should sort submissions correctly for FASTEST mode", () => {
- const gameStart = new Date("2024-01-01T00:00:00Z");
- const submissions = [
- {
- result: { successRate: 1 },
- createdAt: new Date("2024-01-01T00:00:20Z"),
- id: "slow"
- },
- {
- result: { successRate: 1 },
- createdAt: new Date("2024-01-01T00:00:10Z"),
- id: "fast"
- },
- {
- result: { successRate: 0.5 },
- createdAt: new Date("2024-01-01T00:00:05Z"),
- id: "failed"
- }
- ];
-
- const sorted = sortSubmissionsByGameMode(
- submissions,
- gameModeEnum.FASTEST,
- gameStart
- );
-
- expect(sorted[0].id).toBe("fast");
- expect(sorted[1].id).toBe("slow");
- expect(sorted[2].id).toBe("failed");
- });
-
- it("should sort submissions correctly for SHORTEST mode", () => {
- const gameStart = new Date("2024-01-01T00:00:00Z");
- const submissions = [
- {
- result: { successRate: 1 },
- createdAt: new Date("2024-01-01T00:00:10Z"),
- codeLength: 100,
- id: "long"
- },
- {
- result: { successRate: 1 },
- createdAt: new Date("2024-01-01T00:00:10Z"),
- codeLength: 50,
- id: "short"
- }
- ];
-
- const sorted = sortSubmissionsByGameMode(
- submissions,
- gameModeEnum.SHORTEST,
- gameStart
- );
-
- expect(sorted[0].id).toBe("short");
- expect(sorted[1].id).toBe("long");
- });
-
- it("should prioritize success rate above all other factors", () => {
- const gameStart = new Date("2024-01-01T00:00:00Z");
- const submissions = [
- {
- result: { successRate: 0.8 },
- createdAt: new Date("2024-01-01T00:00:05Z"),
- id: "partial"
- },
- {
- result: { successRate: 1 },
- createdAt: new Date("2024-01-01T00:01:00Z"),
- id: "complete"
- }
- ];
-
- const sorted = sortSubmissionsByGameMode(
- submissions,
- gameModeEnum.FASTEST,
- gameStart
- );
-
- expect(sorted[0].id).toBe("complete");
- expect(sorted[1].id).toBe("partial");
- });
- });
-
- describe("getGameModeConfig", () => {
- it("should return config for all defined game modes", () => {
- const modes = Object.values(gameModeEnum);
-
- modes.forEach((mode) => {
- const config = getGameModeConfig(mode);
- expect(config).toBeDefined();
- expect(config.calculateScore).toBeTypeOf("function");
- expect(config.compareSubmissions).toBeTypeOf("function");
- expect(Array.isArray(config.displayMetrics)).toBe(true);
- });
- });
-
- it("should have correct display metrics for each mode", () => {
- expect(getGameModeConfig(gameModeEnum.FASTEST).displayMetrics).toContain(
- "time"
- );
- expect(getGameModeConfig(gameModeEnum.SHORTEST).displayMetrics).toContain(
- "length"
- );
- expect(
- getGameModeConfig(gameModeEnum.TYPERACER).displayMetrics
- ).toContain("speed");
- expect(getGameModeConfig(gameModeEnum.DEBUG).displayMetrics).toContain(
- "changes"
- );
- });
- });
-});
diff --git a/libs/backend/src/tests/health.test.ts b/libs/backend/src/tests/health.test.ts
deleted file mode 100644
index eb54fa4e..00000000
--- a/libs/backend/src/tests/health.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, beforeEach, afterEach, it, expect } from "vitest";
-import fastify, { FastifyInstance } from "fastify";
-import { backendUrls, httpRequestMethod, httpResponseCodes } from "types";
-import router from "@/router.js";
-import { healthResponse } from "@/routes/health/index.js";
-
-describe("Health Check Endpoint", () => {
- let app: FastifyInstance;
-
- beforeEach(async () => {
- app = fastify();
- await app.register(router);
- await app.ready();
- });
-
- afterEach(async () => {
- await app.close();
- });
-
- it(`should return ${httpResponseCodes.SUCCESSFUL.OK} and status ${healthResponse}`, async () => {
- const response = await app.inject({
- method: httpRequestMethod.GET,
- url: backendUrls.HEALTH
- });
-
- expect(response.statusCode).toBe(httpResponseCodes.SUCCESSFUL.OK);
- expect(response.json()).toEqual({ status: healthResponse });
- });
-});
diff --git a/libs/backend/src/types/fastify.d.ts b/libs/backend/src/types/fastify.d.ts
deleted file mode 100644
index 9a85e4e7..00000000
--- a/libs/backend/src/types/fastify.d.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import "fastify";
-import { ErrorResponse, PistonRuntime } from "types";
-
-declare module "fastify" {
- interface FastifyInstance {
- authenticate(request: FastifyRequest, reply: FastifyReply): Promise;
- piston(
- pistonExecutionRequestObject: PistonExecutionRequest
- ): Promise;
- runtimes(): Promise;
- }
- interface FastifyRequest {
- user?: { userId: string; username: string }; // Extend the request type to include user
- }
-}
diff --git a/libs/backend/src/types/jwt.d.ts b/libs/backend/src/types/jwt.d.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/libs/backend/src/types/types.d.ts b/libs/backend/src/types/types.d.ts
deleted file mode 100644
index 58a783b1..00000000
--- a/libs/backend/src/types/types.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type ParamsId = { Params: { id: string } };
diff --git a/libs/backend/src/utils/constants/model.ts b/libs/backend/src/utils/constants/model.ts
deleted file mode 100644
index 8d891322..00000000
--- a/libs/backend/src/utils/constants/model.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const USER = "User";
-export const PUZZLE = "Puzzle";
-export const SUBMISSION = "Submission";
-export const METRICS = "Metrics";
-export const USER_METRICS = "UserMetrics";
-export const GAME = "Game";
-export const PREFERENCES = "Preferences";
-export const COMMENT = "Comment";
-export const USER_VOTE = "UserVote";
-export const REPORT = "Report";
-export const CHAT_MESSAGE = "ChatMessage";
-export const USER_BAN = "UserBan";
-export const PROGRAMMING_LANGUAGE = "ProgrammingLanguage";
diff --git a/libs/backend/src/utils/functions/build-piston-uri.ts b/libs/backend/src/utils/functions/build-piston-uri.ts
deleted file mode 100644
index 591a9f8b..00000000
--- a/libs/backend/src/utils/functions/build-piston-uri.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { ERROR_MESSAGES } from "types";
-
-export function buildPistonUri(url: string) {
- const pistonUrl = process.env.PISTON_URI;
-
- if (!pistonUrl) {
- throw new Error(
- `${ERROR_MESSAGES.SERVER.INTERNAL_ERROR}: PISTON_URI environment variable is not set`
- );
- }
-
- return `${pistonUrl}${url}`;
-}
diff --git a/libs/backend/src/utils/functions/calculate-result.ts b/libs/backend/src/utils/functions/calculate-result.ts
deleted file mode 100644
index 4a263ed5..00000000
--- a/libs/backend/src/utils/functions/calculate-result.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import {
- isPistonExecutionResponseSuccess,
- PistonExecutionResponse,
- PuzzleResultEnum,
- PuzzleResultInformation
-} from "types";
-
-function compareOutputWithExpectedOutput(
- expectedOutput: string,
- output: string
-) {
- return expectedOutput.trimEnd() === output.trimEnd();
-}
-
-export function calculateResults(
- expectedOutput: string[],
- executionResponses: PistonExecutionResponse[]
-): PuzzleResultInformation & { passed: number; failed: number; total: number } {
- const successfulTests = executionResponses.reduce(
- (previous, executionResponse, index) => {
- if (isPistonExecutionResponseSuccess(executionResponse)) {
- const currentExpectedOutput = expectedOutput[index];
-
- return (
- previous +
- Number(
- compareOutputWithExpectedOutput(
- currentExpectedOutput,
- executionResponse.run.output
- ) ||
- compareOutputWithExpectedOutput(
- currentExpectedOutput,
- executionResponse.run.stdout
- )
- )
- );
- }
-
- return previous;
- },
- 0
- );
-
- const totalTests = executionResponses.length;
- const successRate = successfulTests / totalTests;
- const failedTests = totalTests - successfulTests;
-
- return {
- result:
- successfulTests === totalTests
- ? PuzzleResultEnum.SUCCESS
- : PuzzleResultEnum.ERROR,
- successRate,
- passed: successfulTests,
- failed: failedTests,
- total: totalTests
- };
-}
diff --git a/libs/backend/src/utils/functions/check-all-validators.ts b/libs/backend/src/utils/functions/check-all-validators.ts
deleted file mode 100644
index 09dbb880..00000000
--- a/libs/backend/src/utils/functions/check-all-validators.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { FastifyInstance } from "fastify";
-import {
- arePistonRuntimes,
- isPistonExecutionResponseSuccess,
- PistonExecutionRequest,
- PuzzleDto,
- isProgrammingLanguageDto
-} from "types";
-import { findRuntime } from "./findRuntimeInfo.js";
-import { calculateResults } from "./calculate-result.js";
-
-export async function checkAllValidators(
- puzzle: PuzzleDto,
- fastify: FastifyInstance
-): Promise {
- const runtimes = await fastify.runtimes();
-
- if (!arePistonRuntimes(runtimes)) {
- fastify.log.error("Piston runtimes unavailable");
- return false;
- }
-
- if (!puzzle.solution) {
- return false;
- }
-
- // Get the language name from the programming language (could be string ObjectId or populated object)
- const languageName = isProgrammingLanguageDto(
- puzzle.solution.programmingLanguage
- )
- ? puzzle.solution.programmingLanguage.language
- : undefined;
-
- if (!languageName) {
- return false;
- }
-
- const runtimeInfo = findRuntime(runtimes, languageName);
-
- if (!runtimeInfo) {
- return false;
- }
-
- if (!puzzle.validators || puzzle.validators.length === 0) {
- return false;
- }
-
- for (const validator of puzzle.validators) {
- const requestObject: PistonExecutionRequest = {
- language: runtimeInfo.language,
- version: runtimeInfo.version,
- files: [{ content: puzzle.solution.code }],
- stdin: validator.input
- };
-
- try {
- const executionRes = await fastify.piston(requestObject);
-
- if (!isPistonExecutionResponseSuccess(executionRes)) {
- return false;
- }
-
- const item = calculateResults([validator.output], [executionRes]);
-
- if (item.successRate !== 1) {
- return false;
- }
- } catch (error) {
- fastify.log.error(error, `Validator ${validator} execution failed`);
- return false;
- }
- }
-
- return true;
-}
diff --git a/libs/backend/src/utils/functions/findRuntimeInfo.ts b/libs/backend/src/utils/functions/findRuntimeInfo.ts
deleted file mode 100644
index 4c476307..00000000
--- a/libs/backend/src/utils/functions/findRuntimeInfo.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { PistonRuntime } from "types";
-
-export function findRuntime(runtimes: PistonRuntime[], language: string) {
- return runtimes.find((runtime) => runtime.language === language);
-}
diff --git a/libs/backend/src/utils/functions/generate-token.ts b/libs/backend/src/utils/functions/generate-token.ts
deleted file mode 100644
index bb315336..00000000
--- a/libs/backend/src/utils/functions/generate-token.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { FastifyInstance } from "fastify";
-import { AuthenticatedInfo } from "types";
-
-export function generateToken(
- fastify: FastifyInstance,
- payload: AuthenticatedInfo
-): string {
- try {
- return fastify.jwt.sign(payload, { expiresIn: "24h" });
- } catch (error) {
- console.error("Error generating token:", error);
- throw new Error("Token generation failed");
- }
-}
diff --git a/libs/backend/src/utils/functions/is-validation-error.ts b/libs/backend/src/utils/functions/is-validation-error.ts
deleted file mode 100644
index 9c7fee81..00000000
--- a/libs/backend/src/utils/functions/is-validation-error.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Error } from "mongoose";
-
-export function isValidationError(
- error: unknown
-): error is Error.ValidationError {
- return error instanceof Error.ValidationError;
-}
diff --git a/libs/backend/src/utils/functions/parse-raw-data-message.ts b/libs/backend/src/utils/functions/parse-raw-data-message.ts
deleted file mode 100644
index a8896340..00000000
--- a/libs/backend/src/utils/functions/parse-raw-data-message.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import {
- GameRequest,
- isGameRequest,
- isWaitingRoomRequest,
- WaitingRoomRequest
-} from "types";
-import { RawData } from "ws";
-
-function convertRawDataToString(message: RawData): string {
- if (Buffer.isBuffer(message)) {
- return message.toString("utf-8");
- } else if (Array.isArray(message)) {
- return message.map((buf) => buf.toString("utf-8")).join("");
- } else if (message instanceof ArrayBuffer) {
- return Buffer.from(message).toString("utf-8");
- } else {
- throw new Error("unable to convert, unsupported raw message type");
- }
-}
-
-export function parseRawDataWaitingRoomRequest(
- message: RawData
-): WaitingRoomRequest {
- const messageString = convertRawDataToString(message);
-
- const receivedMessageData = JSON.parse(messageString);
-
- if (!isWaitingRoomRequest(receivedMessageData)) {
- throw new Error("parsing message failed");
- }
-
- return receivedMessageData;
-}
-
-export function parseRawDataGameRequest(message: RawData): GameRequest {
- const messageString = convertRawDataToString(message);
-
- const receivedMessageData = JSON.parse(messageString);
-
- if (!isGameRequest(receivedMessageData)) {
- throw new Error("parsing message failed");
- }
-
- return receivedMessageData;
-}
diff --git a/libs/backend/src/utils/game-mode/README.md b/libs/backend/src/utils/game-mode/README.md
deleted file mode 100644
index d4eb22bd..00000000
--- a/libs/backend/src/utils/game-mode/README.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# Game Mode Architecture
-
-## Overview
-
-CodinCod uses a **strategy pattern** to handle different game modes. This makes it easy to add new game modes without modifying existing code.
-
-## Current Game Modes
-
-- **FASTEST**: Solve the puzzle as quickly as possible (default for competitive play)
-- **SHORTEST**: Solve the puzzle with the least amount of characters (code golf)
-- **RATED**: Competitive mode with ELO-style ranking (affects player ratings)
-- **CASUAL**: Non-competitive mode (doesn't affect ratings)
-
-## How to Add a New Game Mode
-
-### 1. Add the Mode to the Enum
-
-Update `libs/types/src/core/game/enum/game-mode-enum.ts`:
-
-```typescript
-export const gameModeEnum = {
- FASTEST: "fastest",
- SHORTEST: "shortest",
- RATED: "rated",
- CASUAL: "casual",
- YOUR_NEW_MODE: "your_new_mode" // Add your mode here
-} as const;
-```
-
-### 2. Create a Strategy Class
-
-In `libs/backend/src/utils/game-mode/game-mode-strategy.ts`, create a new strategy:
-
-```typescript
-class YourNewModeStrategy implements GameModeStrategy {
- calculateScore(submission: {
- successRate: number;
- timeSpent: number;
- codeLength?: number;
- // Add any custom metrics you need
- }): number {
- // Return a numeric score for the submission
- // Higher is better
- return 0;
- }
-
- compareSubmissions(
- a: { successRate: number; timeSpent: number; codeLength?: number },
- b: { successRate: number; timeSpent: number; codeLength?: number }
- ): number {
- // Return negative if a is better, positive if b is better, 0 if equal
- // This determines leaderboard order
- return 0;
- }
-
- getDisplayMetrics(): string[] {
- // Return which metrics should be shown in the UI
- return ["score", "yourMetric"];
- }
-}
-```
-
-### 3. Register the Strategy
-
-Add your strategy to the `strategies` object:
-
-```typescript
-const strategies: Record = {
- // ... existing strategies
- [gameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy()
-};
-```
-
-### 4. Add Required Data Fields
-
-If your mode needs new submission data (like `codeLength` for SHORTEST mode):
-
-1. Update `libs/types/src/core/submission/schema/submission-entity.schema.ts`
-2. Update `libs/backend/src/models/submission/submission.ts`
-3. Update submission routes to calculate/store the data
-
-### 5. Update the Frontend
-
-Update `libs/frontend/src/lib/features/game/standings/components/standings-table.svelte`:
-
-```svelte
-{#if game.options.mode === gameModeEnum.YOUR_NEW_MODE}
- Your Metric
-{/if}
-```
-
-## Architecture Benefits
-
-✅ **Extensible**: Add new modes without changing existing code
-✅ **Type-safe**: TypeScript ensures all modes are handled
-✅ **Testable**: Each strategy can be unit tested independently
-✅ **Maintainable**: Mode-specific logic is isolated
-
-## Example: Adding a "Memory Efficient" Mode
-
-1. Add `MEMORY_EFFICIENT: "memory_efficient"` to gameModeEnum
-2. Create `MemoryEfficientModeStrategy` that:
- - Tracks peak memory usage during execution
- - Scores based on lowest memory usage + success rate
- - Breaks ties by execution time
-3. Add `peakMemoryUsage` field to SubmissionEntity
-4. Update Piston execution to capture memory metrics
-5. Update standings table to show memory usage column
-
-That's it! The game mode system handles the rest automatically.
diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts
deleted file mode 100644
index 6b9a975b..00000000
--- a/libs/backend/src/utils/game-mode/game-mode-strategy.ts
+++ /dev/null
@@ -1,408 +0,0 @@
-import { gameModeEnum, type GameMode } from "types";
-
-/**
- * Submission data for scoring and comparison
- */
-export interface SubmissionData {
- successRate: number;
- timeSpent: number;
- codeLength?: number;
- attempts?: number | undefined;
-}
-
-/**
- * Game mode configuration
- */
-export interface GameModeConfig {
- displayMetrics: string[];
- calculateScore: (submission: SubmissionData) => number;
- compareSubmissions: (a: SubmissionData, b: SubmissionData) => number;
-}
-
-/**
- * Calculate score for FASTEST mode
- * Score is inversely proportional to time spent
- */
-function calculateFastestScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- return 1000000 / submission.timeSpent;
-}
-
-/**
- * Compare submissions for FASTEST mode
- * Priority: success rate > time
- */
-function compareFastestSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Calculate score for SHORTEST mode
- * Score is inversely proportional to code length
- */
-function calculateShortestScore(submission: SubmissionData): number {
- if (submission.successRate < 1 || !submission.codeLength) return 0;
- return 1000000 / submission.codeLength;
-}
-
-/**
- * Compare submissions for SHORTEST mode
- * Priority: success rate > code length > time
- */
-function compareShortestSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- const aLength = a.codeLength ?? Number.MAX_SAFE_INTEGER;
- const bLength = b.codeLength ?? Number.MAX_SAFE_INTEGER;
- if (aLength !== bLength) {
- return aLength - bLength;
- }
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Calculate score for BACKWARDS mode
- * Solve from output to input - scoring based on logical steps
- * Same as FASTEST but with bonus for fewer attempts
- */
-function calculateBackwardsScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- const baseScore = 1000000 / submission.timeSpent;
- const attemptPenalty = (submission.attempts ?? 1) * 0.1;
- return baseScore * (1 - attemptPenalty);
-}
-
-/**
- * Compare submissions for BACKWARDS mode
- * Priority: success rate > attempts > time
- */
-function compareBackwardsSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- const aAttempts = a.attempts ?? 1;
- const bAttempts = b.attempts ?? 1;
- if (aAttempts !== bAttempts) {
- return aAttempts - bAttempts;
- }
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Calculate score for HARDCORE mode
- * One attempt only - binary score
- */
-function calculateHardcoreScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- if ((submission.attempts ?? 1) > 1) return 0; // Failed - multiple attempts
- return 1000000 / submission.timeSpent; // Succeeded on first try
-}
-
-/**
- * Compare submissions for HARDCORE mode
- * Priority: success on first attempt > time
- */
-function compareHardcoreSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- const aSuccess = a.successRate === 1 && (a.attempts ?? 1) === 1 ? 1 : 0;
- const bSuccess = b.successRate === 1 && (b.attempts ?? 1) === 1 ? 1 : 0;
-
- if (aSuccess !== bSuccess) {
- return bSuccess - aSuccess;
- }
- if (aSuccess === 0) return 0; // Both failed
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Calculate score for DEBUG mode
- * Fix broken code - bonus for fewer changes
- */
-function calculateDebugScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- const baseScore = 1000000 / submission.timeSpent;
- // Smaller code changes = higher score (assume original code is baseline)
- const changeFactor = submission.codeLength
- ? Math.max(0.5, 1 - submission.codeLength / 10000)
- : 1;
- return baseScore * changeFactor;
-}
-
-/**
- * Compare submissions for DEBUG mode
- * Priority: success rate > fewer changes > time
- */
-function compareDebugSubmissions(a: SubmissionData, b: SubmissionData): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- const aLength = a.codeLength ?? Number.MAX_SAFE_INTEGER;
- const bLength = b.codeLength ?? Number.MAX_SAFE_INTEGER;
- if (aLength !== bLength) {
- return aLength - bLength; // Fewer changes is better
- }
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Calculate score for EFFICIENCY mode
- * Focus on computational efficiency (simulated by code quality metrics)
- */
-function calculateEfficiencyScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- // Efficiency approximated by code length and time
- // Shorter, faster code = more efficient
- const timeComponent = 1000000 / submission.timeSpent;
- const lengthComponent = submission.codeLength
- ? 500000 / submission.codeLength
- : 0;
- return timeComponent * 0.4 + lengthComponent * 0.6;
-}
-
-/**
- * Compare submissions for EFFICIENCY mode
- * Priority: success rate > efficiency score
- */
-function compareEfficiencySubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- const aScore = calculateEfficiencyScore(a);
- const bScore = calculateEfficiencyScore(b);
- return bScore - aScore;
-}
-
-/**
- * Calculate score for TYPERACER mode
- * Copy code perfectly, fastest wins
- */
-function calculateTyperacerScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- // Pure speed - character per second rate
- const charsPerSecond = (submission.codeLength ?? 0) / submission.timeSpent;
- return charsPerSecond * 1000;
-}
-
-/**
- * Compare submissions for TYPERACER mode
- * Priority: success rate > typing speed (chars/sec)
- */
-function compareTyperacerSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- const aSpeed = (a.codeLength ?? 0) / a.timeSpent;
- const bSpeed = (b.codeLength ?? 0) / b.timeSpent;
- return bSpeed - aSpeed;
-}
-
-/**
- * Calculate score for INCREMENTAL mode
- * Requirements added each minute - handle complexity over time
- */
-function calculateIncrementalScore(submission: SubmissionData): number {
- // Allow partial success in incremental mode (requirements build over time)
- if (submission.successRate === 0) return 0;
- // Score decreases with time (earlier completion = higher score)
- const timeDecayFactor = Math.max(0.1, 1 - submission.timeSpent / 3600);
- return 1000000 * submission.successRate * timeDecayFactor;
-}
-
-/**
- * Compare submissions for INCREMENTAL mode
- * Priority: success rate > earlier completion time
- */
-function compareIncrementalSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- // Earlier completion wins
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Calculate score for LEGACY_MODE
- * Must maintain backwards compatibility - fewer changes to working code
- * NOTE: LEGACY_MODE has been removed from gameModeEnum but kept for backwards compatibility
- */
-export function calculateLegacyScore(submission: SubmissionData): number {
- if (submission.successRate < 1) return 0;
- // Reward minimal changes and fast completion
- const baseScore = 1000000 / submission.timeSpent;
- const changeBonus = submission.codeLength
- ? Math.max(0.5, 1 - submission.codeLength / 5000)
- : 0.5;
- return baseScore * (0.5 + changeBonus * 0.5);
-}
-
-/**
- * Default scoring and comparison for unsupported modes
- */
-function calculateDefaultScore(submission: SubmissionData): number {
- return submission.successRate === 1 ? 1000000 / submission.timeSpent : 0;
-}
-
-function compareDefaultSubmissions(
- a: SubmissionData,
- b: SubmissionData
-): number {
- if (a.successRate !== b.successRate) {
- return b.successRate - a.successRate;
- }
- return a.timeSpent - b.timeSpent;
-}
-
-/**
- * Game mode configurations using functional composition
- * Each mode defines its scoring logic, comparison function, and display metrics
- */
-const gameModeConfigs: Record = {
- [gameModeEnum.FASTEST]: {
- displayMetrics: ["score", "time"],
- calculateScore: calculateFastestScore,
- compareSubmissions: compareFastestSubmissions
- },
- [gameModeEnum.SHORTEST]: {
- displayMetrics: ["score", "length", "time"],
- calculateScore: calculateShortestScore,
- compareSubmissions: compareShortestSubmissions
- },
- [gameModeEnum.BACKWARDS]: {
- displayMetrics: ["score", "attempts", "time"],
- calculateScore: calculateBackwardsScore,
- compareSubmissions: compareBackwardsSubmissions
- },
- [gameModeEnum.HARDCORE]: {
- displayMetrics: ["score", "time", "attempts"],
- calculateScore: calculateHardcoreScore,
- compareSubmissions: compareHardcoreSubmissions
- },
- [gameModeEnum.DEBUG]: {
- displayMetrics: ["score", "changes", "time"],
- calculateScore: calculateDebugScore,
- compareSubmissions: compareDebugSubmissions
- },
- [gameModeEnum.EFFICIENCY]: {
- displayMetrics: ["score", "efficiency", "time", "length"],
- calculateScore: calculateEfficiencyScore,
- compareSubmissions: compareEfficiencySubmissions
- },
- [gameModeEnum.TYPERACER]: {
- displayMetrics: ["score", "speed", "time"],
- calculateScore: calculateTyperacerScore,
- compareSubmissions: compareTyperacerSubmissions
- },
- [gameModeEnum.INCREMENTAL]: {
- displayMetrics: ["score", "time", "completion"],
- calculateScore: calculateIncrementalScore,
- compareSubmissions: compareIncrementalSubmissions
- },
- [gameModeEnum.RANDOM]: {
- displayMetrics: ["score", "time"],
- calculateScore: calculateDefaultScore,
- compareSubmissions: compareDefaultSubmissions
- }
-};
-
-/**
- * Get game mode configuration for a specific mode
- * Uses functional approach with switch statement for clarity
- */
-export function getGameModeConfig(mode: GameMode): GameModeConfig {
- switch (mode) {
- case gameModeEnum.FASTEST:
- case gameModeEnum.SHORTEST:
- case gameModeEnum.BACKWARDS:
- case gameModeEnum.HARDCORE:
- case gameModeEnum.DEBUG:
- case gameModeEnum.EFFICIENCY:
- case gameModeEnum.TYPERACER:
- case gameModeEnum.INCREMENTAL:
- case gameModeEnum.RANDOM:
- return gameModeConfigs[mode];
- default:
- // Exhaustiveness check
- const exhaustiveCheck: never = mode;
- console.warn(`Unknown game mode: ${exhaustiveCheck}, using default`);
- return gameModeConfigs[gameModeEnum.FASTEST];
- }
-}
-
-/**
- * Calculate score for a submission based on game mode
- */
-export function calculateScore(
- mode: GameMode,
- submission: SubmissionData
-): number {
- const config = getGameModeConfig(mode);
- return config.calculateScore(submission);
-}
-
-/**
- * Get display metrics for a game mode
- */
-export function getDisplayMetrics(mode: GameMode): string[] {
- const config = getGameModeConfig(mode);
- return config.displayMetrics;
-}
-
-/**
- * Sort submissions by game mode using functional composition
- */
-export function sortSubmissionsByGameMode<
- T extends {
- result: { successRate: number };
- createdAt: Date | string;
- codeLength?: number;
- attempts?: number;
- }
->(submissions: T[], mode: GameMode, gameStartTime: Date | string): T[] {
- const config = getGameModeConfig(mode);
- const startTime = new Date(gameStartTime).getTime();
-
- return [...submissions].sort((a, b) => {
- const aTime = (new Date(a.createdAt).getTime() - startTime) / 1000;
- const bTime = (new Date(b.createdAt).getTime() - startTime) / 1000;
-
- const aData: SubmissionData = {
- successRate: a.result.successRate,
- timeSpent: aTime,
- ...(a.codeLength !== undefined && { codeLength: a.codeLength }),
- ...(a.attempts !== undefined && { attempts: a.attempts })
- };
-
- const bData: SubmissionData = {
- successRate: b.result.successRate,
- timeSpent: bTime,
- ...(b.codeLength !== undefined && { codeLength: b.codeLength }),
- ...(b.attempts !== undefined && { attempts: b.attempts })
- };
-
- return config.compareSubmissions(aData, bData);
- });
-}
diff --git a/libs/backend/src/utils/moderation/escalation.ts b/libs/backend/src/utils/moderation/escalation.ts
deleted file mode 100644
index b29d195b..00000000
--- a/libs/backend/src/utils/moderation/escalation.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import {
- getBanDuration,
- shouldAutoBan,
- shouldBePermanent,
- banTypeEnum,
- ObjectId
-} from "types";
-import UserBan from "../../models/moderation/user-ban.js";
-import User from "../../models/user/user.js";
-import mongoose from "mongoose";
-
-export async function applyAutomaticEscalation(
- userId: ObjectId,
- moderatorId: ObjectId,
- reason: string
-): Promise {
- const user = await User.findById(userId);
-
- if (!user) {
- throw new Error("User not found");
- }
-
- const reportCount = user.reportCount || 0;
-
- if (!shouldAutoBan(reportCount)) {
- return null;
- }
-
- const isPermanent = shouldBePermanent(reportCount);
- const durationMs = getBanDuration(reportCount);
-
- const banData = {
- userId: new mongoose.Types.ObjectId(userId),
- bannedBy: new mongoose.Types.ObjectId(moderatorId),
- banType: isPermanent ? banTypeEnum.PERMANENT : banTypeEnum.TEMPORARY,
- reason: `Automatic escalation: ${reason}`,
- startDate: new Date(),
- endDate: durationMs ? new Date(Date.now() + durationMs) : undefined,
- isActive: true
- };
-
- const ban = new UserBan(banData);
- const savedBan = await ban.save();
-
- user.banCount = (user.banCount || 0) + 1;
- user.currentBan = savedBan._id as mongoose.Types.ObjectId;
- await user.save();
-
- return savedBan;
-}
-
-export async function checkUserBanStatus(
- userId: ObjectId
-): Promise<{ isBanned: boolean; ban?: typeof UserBan.prototype }> {
- const user = await User.findById(userId).populate("currentBan");
-
- if (!user || !user.currentBan) {
- return { isBanned: false };
- }
-
- const ban = await UserBan.findById(user.currentBan);
-
- if (!ban || !ban.isActive) {
- user.currentBan = null;
- await user.save();
- return { isBanned: false };
- }
-
- if (ban.banType === banTypeEnum.TEMPORARY && ban.endDate) {
- if (new Date() > ban.endDate) {
- ban.isActive = false;
- await ban.save();
- user.currentBan = null;
- await user.save();
- return { isBanned: false };
- }
- }
-
- return { isBanned: true, ban };
-}
-
-/**
- * Manually unban a user
- */
-export async function unbanUser(
- userId: ObjectId,
- moderatorId: ObjectId,
- reason: string
-): Promise {
- const user = await User.findById(userId);
-
- if (!user || !user.currentBan) {
- throw new Error("User is not currently banned");
- }
-
- const ban = await UserBan.findById(user.currentBan);
-
- if (ban) {
- ban.isActive = false;
- ban.reason = `${ban.reason} | Unbanned by moderator: ${reason}`;
- await ban.save();
- }
-
- user.currentBan = null;
- await user.save();
-}
-
-export async function createTemporaryBan(
- userId: ObjectId,
- moderatorId: ObjectId,
- reason: string,
- durationMs: number
-): Promise {
- const user = await User.findById(userId);
-
- if (!user) {
- throw new Error("User not found");
- }
-
- // Deactivate any existing ban
- if (user.currentBan) {
- const existingBan = await UserBan.findById(user.currentBan);
- if (existingBan) {
- existingBan.isActive = false;
- await existingBan.save();
- }
- }
-
- const ban = new UserBan({
- userId: new mongoose.Types.ObjectId(userId),
- bannedBy: new mongoose.Types.ObjectId(moderatorId),
- banType: banTypeEnum.TEMPORARY,
- reason,
- startDate: new Date(),
- endDate: new Date(Date.now() + durationMs),
- isActive: true
- });
-
- const savedBan = await ban.save();
-
- user.banCount = (user.banCount || 0) + 1;
- user.currentBan = savedBan._id as mongoose.Types.ObjectId;
- await user.save();
-
- return savedBan;
-}
-
-export async function createPermanentBan(
- userId: ObjectId,
- moderatorId: ObjectId,
- reason: string
-): Promise {
- const user = await User.findById(userId);
-
- if (!user) {
- throw new Error("User not found");
- }
-
- // Deactivate any existing ban
- if (user.currentBan) {
- const existingBan = await UserBan.findById(user.currentBan);
- if (existingBan) {
- existingBan.isActive = false;
- await existingBan.save();
- }
- }
-
- const ban = new UserBan({
- userId: new mongoose.Types.ObjectId(userId),
- bannedBy: new mongoose.Types.ObjectId(moderatorId),
- banType: banTypeEnum.PERMANENT,
- reason,
- startDate: new Date(),
- isActive: true
- });
-
- const savedBan = await ban.save();
-
- user.banCount = (user.banCount || 0) + 1;
- user.currentBan = savedBan._id as mongoose.Types.ObjectId;
- await user.save();
-
- return savedBan;
-}
-
-export async function incrementReportCount(userId: ObjectId): Promise {
- const user = await User.findById(userId);
-
- if (!user) {
- throw new Error("User not found");
- }
-
- user.reportCount = (user.reportCount || 0) + 1;
- await user.save();
-
- return user.reportCount;
-}
diff --git a/libs/backend/src/utils/rating/glicko.ts b/libs/backend/src/utils/rating/glicko.ts
deleted file mode 100644
index 11d0a6fe..00000000
--- a/libs/backend/src/utils/rating/glicko.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-/**
- * Glicko-2 Rating System Implementation
- *
- * A simplified, rudimentary implementation of the Glicko-2 rating system
- * for competitive multiplayer games. This can be expanded later with more
- * sophisticated calculations.
- *
- * Reference: http://www.glicko.net/glicko/glicko2.pdf
- */
-
-export interface GlickoRating {
- rating: number; // Player's rating (μ)
- rd: number; // Rating deviation (φ) - uncertainty in rating
- volatility: number; // Volatility (σ) - degree of expected fluctuation
- lastUpdated: Date;
-}
-
-export interface GameOutcome {
- opponentRating: number;
- opponentRd: number;
- score: number; // 1 = win, 0.5 = draw, 0 = loss
-}
-
-// Glicko-2 system constants
-const TAU = 0.5; // System constant (volatility constraint)
-const EPSILON = 0.000001; // Convergence tolerance
-const GLICKO_SCALE = 173.7178; // Conversion factor (q in original Glicko)
-
-/**
- * Convert Glicko-2 rating to Glicko-2 scale
- */
-function toGlicko2Scale(rating: number): number {
- return (rating - 1500) / GLICKO_SCALE;
-}
-
-/**
- * Convert Glicko-2 scale back to rating
- */
-function fromGlicko2Scale(mu: number): number {
- return mu * GLICKO_SCALE + 1500;
-}
-
-/**
- * g function - measures impact of opponent's RD
- */
-function g(rd: number): number {
- const phi = rd / GLICKO_SCALE;
- return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI));
-}
-
-/**
- * E function - expected score against opponent
- */
-function E(playerMu: number, opponentMu: number, opponentPhi: number): number {
- const gValue = g(opponentPhi * GLICKO_SCALE);
- return 1 / (1 + Math.exp(-gValue * (playerMu - opponentMu)));
-}
-
-/**
- * Calculate variance (v) based on opponents
- */
-function calculateVariance(playerMu: number, outcomes: GameOutcome[]): number {
- let sum = 0;
- for (const outcome of outcomes) {
- const opponentMu = toGlicko2Scale(outcome.opponentRating);
- const opponentPhi = outcome.opponentRd / GLICKO_SCALE;
- const gValue = g(outcome.opponentRd);
- const eValue = E(playerMu, opponentMu, opponentPhi);
- sum += gValue * gValue * eValue * (1 - eValue);
- }
- return sum > 0 ? 1 / sum : Infinity;
-}
-
-/**
- * Calculate delta (improvement in rating)
- */
-function calculateDelta(playerMu: number, outcomes: GameOutcome[]): number {
- let sum = 0;
- for (const outcome of outcomes) {
- const opponentMu = toGlicko2Scale(outcome.opponentRating);
- const opponentPhi = outcome.opponentRd / GLICKO_SCALE;
- const gValue = g(outcome.opponentRd);
- const eValue = E(playerMu, opponentMu, opponentPhi);
- sum += gValue * (outcome.score - eValue);
- }
- return sum;
-}
-
-/**
- * Simplified volatility update (using Newton-Raphson approximation)
- * This is a rudimentary implementation - can be refined later
- */
-function updateVolatility(
- sigma: number,
- phi: number,
- v: number,
- delta: number
-): number {
- const a = Math.log(sigma * sigma);
- const delta2 = delta * delta;
- const phi2 = phi * phi;
-
- // Simplified calculation - good enough for initial implementation
- const f = (x: number): number => {
- const ex = Math.exp(x);
- return (
- (ex * (delta2 - phi2 - v - ex)) /
- (2 * (phi2 + v + ex) * (phi2 + v + ex)) -
- (x - a) / (TAU * TAU)
- );
- };
-
- // Find approximate solution
- let A = a;
- let B: number;
- if (delta2 > phi2 + v) {
- B = Math.log(delta2 - phi2 - v);
- } else {
- let k = 1;
- while (f(a - k * TAU) < 0) {
- k++;
- }
- B = a - k * TAU;
- }
-
- // Newton-Raphson iterations (simplified)
- let fA = f(A);
- let fB = f(B);
-
- while (Math.abs(B - A) > EPSILON) {
- const C = A + ((A - B) * fA) / (fB - fA);
- const fC = f(C);
-
- if (fC * fB < 0) {
- A = B;
- fA = fB;
- } else {
- fA = fA / 2;
- }
-
- B = C;
- fB = fC;
- }
-
- return Math.exp(A / 2);
-}
-
-/**
- * Calculate new rating after a series of games
- * This is the main function to use for rating updates
- */
-export function calculateNewRating(
- currentRating: GlickoRating,
- outcomes: GameOutcome[]
-): GlickoRating {
- // If no games, increase RD (rating becomes more uncertain over time)
- if (outcomes.length === 0) {
- const daysSinceLastGame =
- (Date.now() - currentRating.lastUpdated.getTime()) /
- (1000 * 60 * 60 * 24);
- const rdIncrease = Math.min(
- Math.sqrt(currentRating.rd * currentRating.rd + daysSinceLastGame * 2),
- 350
- );
-
- return {
- ...currentRating,
- rd: rdIncrease,
- lastUpdated: new Date()
- };
- }
-
- // Convert to Glicko-2 scale
- const mu = toGlicko2Scale(currentRating.rating);
- const phi = currentRating.rd / GLICKO_SCALE;
- const sigma = currentRating.volatility;
-
- // Step 3: Calculate v (variance)
- const v = calculateVariance(mu, outcomes);
-
- // Step 4: Calculate delta (improvement)
- const delta = v * calculateDelta(mu, outcomes);
-
- // Step 5: Update volatility
- const newSigma = updateVolatility(sigma, phi, v, delta);
-
- // Step 6: Update phi (rating deviation)
- const phiStar = Math.sqrt(phi * phi + newSigma * newSigma);
-
- // Step 7: Update phi and mu
- const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v);
- const newMu = mu + newPhi * newPhi * calculateDelta(mu, outcomes);
-
- // Convert back to Glicko scale
- return {
- rating: fromGlicko2Scale(newMu),
- rd: newPhi * GLICKO_SCALE,
- volatility: newSigma,
- lastUpdated: new Date()
- };
-}
-
-/**
- * Initialize default rating for new player
- */
-export function getDefaultRating(): GlickoRating {
- return {
- rating: 1500,
- rd: 350,
- volatility: 0.06,
- lastUpdated: new Date()
- };
-}
-
-/**
- * Calculate expected win probability against opponent
- */
-export function expectedWinProbability(
- playerRating: GlickoRating,
- opponentRating: GlickoRating
-): number {
- const playerMu = toGlicko2Scale(playerRating.rating);
- const opponentMu = toGlicko2Scale(opponentRating.rating);
- const opponentPhi = opponentRating.rd / GLICKO_SCALE;
-
- return E(playerMu, opponentMu, opponentPhi);
-}
-
-/**
- * Simplified rating update for head-to-head games
- * Easier to use for simple win/loss scenarios
- */
-export function updateRatingAfterGame(
- playerRating: GlickoRating,
- opponentRating: GlickoRating,
- playerWon: boolean
-): GlickoRating {
- const outcome: GameOutcome = {
- opponentRating: opponentRating.rating,
- opponentRd: opponentRating.rd,
- score: playerWon ? 1 : 0
- };
-
- return calculateNewRating(playerRating, [outcome]);
-}
-
-/**
- * Batch update for multiple games in a rating period
- */
-export function updateRatingAfterMultipleGames(
- playerRating: GlickoRating,
- games: Array<{ opponentRating: GlickoRating; playerWon: boolean }>
-): GlickoRating {
- const outcomes: GameOutcome[] = games.map((game) => ({
- opponentRating: game.opponentRating.rating,
- opponentRd: game.opponentRating.rd,
- score: game.playerWon ? 1 : 0
- }));
-
- return calculateNewRating(playerRating, outcomes);
-}
diff --git a/libs/backend/src/websocket/connection-manager.ts b/libs/backend/src/websocket/connection-manager.ts
deleted file mode 100644
index 955497de..00000000
--- a/libs/backend/src/websocket/connection-manager.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import websocket from "@fastify/websocket";
-import { AuthenticatedInfo, websocketCloseCodes } from "types";
-
-const websocketState = {
- CONNECTING: 0,
- OPEN: 1,
- CLOSING: 2,
- CLOSED: 3
-} as const;
-
-type Username = string;
-type ConnectionId = string;
-
-interface Connection {
- socket: websocket.WebSocket;
- connectionId: ConnectionId;
- userId: string;
- heartbeatInterval?: NodeJS.Timeout;
- lastPong: number;
- pongHandler: () => void;
-}
-
-interface ConnectionCallbacks {
- onConnectionLost?: (username: Username) => void;
- onConnectionRestored?: (username: Username) => void;
-}
-
-export class ConnectionManager {
- private connections = new Map();
- private readonly HEARTBEAT_INTERVAL = 30 * 1000;
- private readonly HEARTBEAT_TIMEOUT = 35 * 1000;
- private globalHeartbeatTimer?: NodeJS.Timeout;
- private callbacks: ConnectionCallbacks;
-
- constructor(callbacks: ConnectionCallbacks = {}) {
- this.callbacks = callbacks;
- this.startGlobalHeartbeat();
- }
-
- private startGlobalHeartbeat() {
- this.globalHeartbeatTimer = setInterval(() => {
- this.heartbeatAll();
- }, this.HEARTBEAT_INTERVAL);
- }
-
- private heartbeatAll() {
- const now = Date.now();
- const toRemove: Username[] = [];
-
- for (const [username, connection] of this.connections.entries()) {
- const timeSinceLastPong = now - connection.lastPong;
-
- if (timeSinceLastPong > this.HEARTBEAT_TIMEOUT) {
- console.warn(`Heartbeat timeout for ${username}`);
- toRemove.push(username);
- continue;
- }
-
- if (connection.socket.readyState === websocketState.OPEN) {
- try {
- connection.socket.ping();
- } catch (error) {
- console.error(`Failed to ping ${username}:`, error);
- toRemove.push(username);
- }
- } else {
- toRemove.push(username);
- }
- }
-
- toRemove.forEach((username) => {
- this.callbacks.onConnectionLost?.(username);
- this.remove(username);
- });
-
- if (toRemove.length > 0) {
- console.info(`Removed ${toRemove.length} dead connections`);
- }
- }
-
- add(user: AuthenticatedInfo, socket: websocket.WebSocket): ConnectionId {
- const connectionId = crypto.randomUUID();
-
- const existing = this.connections.get(user.username);
- if (existing) {
- socket.removeListener("pong", existing.pongHandler);
- if (existing.socket.readyState === websocketState.OPEN) {
- existing.socket.close();
- }
- }
-
- const pongHandler = () => {
- const conn = this.connections.get(user.username);
- if (conn) {
- conn.lastPong = Date.now();
- }
- };
-
- socket.on("pong", pongHandler);
-
- const connection: Connection = {
- socket,
- connectionId,
- userId: user.userId,
- lastPong: Date.now(),
- pongHandler
- };
-
- this.connections.set(user.username, connection);
- console.info(
- `Connection established for ${user.username} (${connectionId})`
- );
-
- return connectionId;
- }
-
- remove(username: Username): void {
- const connection = this.connections.get(username);
- if (!connection) return;
-
- connection.socket.removeListener("pong", connection.pongHandler);
-
- if (connection.socket.readyState === websocketState.OPEN) {
- try {
- connection.socket.close();
- } catch (error) {
- console.error(`Error closing socket for ${username}:`, error);
- }
- }
-
- this.connections.delete(username);
- console.info(`Connection removed for ${username}`);
- }
-
- get(username: Username): Connection | undefined {
- return this.connections.get(username);
- }
-
- send(username: Username, data: any): boolean {
- const connection = this.connections.get(username);
- if (!connection || connection.socket.readyState !== websocketState.OPEN) {
- return false;
- }
-
- try {
- connection.socket.send(JSON.stringify(data));
- return true;
- } catch (error) {
- console.error(`Failed to send message to ${username}:`, error);
- this.remove(username);
- return false;
- }
- }
-
- broadcast(data: any, excludeUsers: Username[] = []): void {
- const message = JSON.stringify(data);
- this.connections.forEach((connection, username) => {
- if (excludeUsers.includes(username)) return;
-
- if (connection.socket.readyState === websocketState.OPEN) {
- try {
- connection.socket.send(message);
- } catch (error) {
- console.error(`Failed to broadcast to ${username}:`, error);
- this.remove(username);
- }
- }
- });
- }
-
- isConnected(username: Username): boolean {
- const connection = this.connections.get(username);
- return connection?.socket.readyState === websocketState.OPEN;
- }
-
- getConnectionCount(): number {
- return this.connections.size;
- }
-
- getAllUsernames(): Username[] {
- return Array.from(this.connections.keys());
- }
-
- destroy(): void {
- if (this.globalHeartbeatTimer) {
- clearInterval(this.globalHeartbeatTimer);
- }
-
- for (const [_username, connection] of this.connections.entries()) {
- connection.socket.removeListener("pong", connection.pongHandler);
-
- if (connection.socket.readyState === websocketState.OPEN) {
- connection.socket.close(
- websocketCloseCodes.GOING_AWAY,
- "Server shutting down"
- );
- }
- }
-
- this.connections.clear();
- console.info("ConnectionManager destroyed");
- }
-}
diff --git a/libs/backend/src/websocket/game/game-setup.ts b/libs/backend/src/websocket/game/game-setup.ts
deleted file mode 100644
index a90e4bba..00000000
--- a/libs/backend/src/websocket/game/game-setup.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-import { WebSocket } from "@fastify/websocket";
-import { FastifyInstance, FastifyRequest } from "fastify";
-import { onConnection } from "./on-connection.js";
-import {
- ChatMessage,
- gameEventEnum,
- getUserIdFromUser,
- isAuthenticatedInfo,
- isGameDto,
- isPuzzleDto,
- ObjectId,
- banTypeEnum,
- websocketCloseCodes,
- ERROR_MESSAGES
-} from "types";
-import { isValidObjectId } from "mongoose";
-import { parseRawDataGameRequest } from "@/utils/functions/parse-raw-data-message.js";
-import Game, { GameDocument } from "@/models/game/game.js";
-import { UserWebSockets } from "./user-web-sockets.js";
-import { ParamsId } from "@/types/types.js";
-import Puzzle from "@/models/puzzle/puzzle.js";
-import ChatMessageModel from "@/models/chat/chat-message.js";
-import { checkUserBanStatus } from "@/utils/moderation/escalation.js";
-
-const userWebSockets = new UserWebSockets();
-
-function isPlayerInGame(game: GameDocument, userId: ObjectId): boolean {
- return game.players.some((player) => getUserIdFromUser(player) === userId);
-}
-
-function sendErrorAndClose(socket: WebSocket, message: string): void {
- socket.send(
- JSON.stringify({
- event: gameEventEnum.ERROR,
- message
- })
- );
- socket.close(websocketCloseCodes.POLICY_VIOLATION, message);
-}
-
-export async function gameSetup(
- socket: WebSocket,
- req: FastifyRequest,
- fastify: FastifyInstance
-) {
- const { id } = req.params;
-
- if (!isAuthenticatedInfo(req.user)) {
- sendErrorAndClose(
- socket,
- ERROR_MESSAGES.AUTHENTICATION.AUTHENTICATION_REQUIRED
- );
- return;
- }
-
- // Check if user is banned
- const banStatus = await checkUserBanStatus(req.user.userId);
- if (banStatus.isBanned && banStatus.ban) {
- sendErrorAndClose(
- socket,
- `You are banned: ${banStatus.ban.reason}. ${banStatus.ban.banType === banTypeEnum.PERMANENT ? "This ban is permanent." : `Ban expires: ${banStatus.ban.endDate}`}`
- );
- return;
- }
-
- if (!isValidObjectId(id)) {
- sendErrorAndClose(socket, ERROR_MESSAGES.GAME.NOT_FOUND);
- return;
- }
-
- onConnection(userWebSockets, req.user, id, socket);
-
- // Handle ping from client
- socket.on("ping", () => {
- socket.pong();
- });
-
- socket.on("message", async (message) => {
- if (!isAuthenticatedInfo(req.user)) {
- return;
- }
-
- let parsedMessage;
-
- try {
- parsedMessage = parseRawDataGameRequest(message);
- } catch (e) {
- const error = e as Error;
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.ERROR,
- message: error.message
- });
- return;
- }
-
- const { event } = parsedMessage;
-
- switch (event) {
- case gameEventEnum.JOIN_GAME: {
- try {
- const gameToUpdate = await Game.findById(id);
-
- if (!isGameDto(gameToUpdate)) {
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.NONEXISTENT_GAME,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- });
- return;
- }
-
- if (!isPlayerInGame(gameToUpdate, req.user.userId)) {
- gameToUpdate.players.push(req.user.userId);
- await gameToUpdate.save();
- }
-
- const game = await Game.findById(id)
- .populate("owner")
- .populate("players")
- .populate({
- path: "playerSubmissions",
- populate: { path: "user" }
- })
- .exec();
-
- if (!isGameDto(game)) {
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.NONEXISTENT_GAME,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- });
- return;
- }
-
- console.log({ game });
-
- const puzzle = await Puzzle.findById(game.puzzle).populate("author");
-
- if (!isPuzzleDto(puzzle)) {
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.ERROR,
- message: ERROR_MESSAGES.PUZZLE.NOT_FOUND
- });
- return;
- }
-
- userWebSockets.updateAllUsers({
- event: gameEventEnum.OVERVIEW_GAME,
- game,
- puzzle
- });
- } catch (error) {
- fastify.log.error({ err: error }, "Error in JOIN_GAME");
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.ERROR,
- message: "Failed to join game"
- });
- }
- break;
- }
-
- case gameEventEnum.SUBMITTED_PLAYER: {
- try {
- const game = await Game.findById(id)
- .populate("owner")
- .populate("players")
- .populate({
- path: "playerSubmissions",
- populate: { path: "user" }
- })
- .exec();
-
- if (!isGameDto(game)) {
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.NONEXISTENT_GAME,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- });
- return;
- }
-
- userWebSockets.updateAllUsers({
- event: gameEventEnum.OVERVIEW_GAME,
- game
- });
- } catch (error) {
- fastify.log.error({ err: error }, "Error in SUBMITTED_PLAYER");
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.ERROR,
- message: "Failed to update submission"
- });
- }
- break;
- }
-
- case gameEventEnum.SEND_MESSAGE: {
- // Check if user is banned before allowing chat
- const banStatus = await checkUserBanStatus(req.user.userId);
- if (banStatus.isBanned && banStatus.ban) {
- userWebSockets.updateUser(req.user.username, {
- event: gameEventEnum.ERROR,
- message: `Cannot send message: You are banned. ${banStatus.ban.reason}`
- });
- break;
- }
-
- // Persist chat message to database
- let chatMessageId;
- try {
- const chatMessage = new ChatMessageModel({
- gameId: id,
- userId: req.user.userId,
- username: req.user.username,
- message: parsedMessage.chatMessage.message
- });
- const savedMessage = await chatMessage.save();
- chatMessageId = String(savedMessage._id);
- } catch (error) {
- fastify.log.error({ err: error }, "Failed to save chat message");
- }
-
- const updatedChatMessage: ChatMessage = {
- ...parsedMessage.chatMessage,
- _id: chatMessageId,
- createdAt: new Date().toISOString()
- };
-
- userWebSockets.updateAllUsers({
- event: gameEventEnum.SEND_MESSAGE,
- chatMessage: updatedChatMessage
- });
- break;
- }
-
- case gameEventEnum.CHANGE_LANGUAGE: {
- const language = parsedMessage.language;
-
- if (!language) {
- return;
- }
-
- userWebSockets.updateAllUsers({
- event: gameEventEnum.CHANGE_LANGUAGE,
- language,
- username: req.user.username
- });
- break;
- }
-
- default:
- parsedMessage satisfies never;
- break;
- }
- });
-
- socket.on("close", (code, reason) => {
- if (!isAuthenticatedInfo(req.user)) {
- return;
- }
- fastify.log.info(
- { username: req.user.username, code, reason: reason.toString() },
- "Game socket closed"
- );
- userWebSockets.remove(req.user.username);
- });
-
- socket.on("error", (error) => {
- if (!isAuthenticatedInfo(req.user)) {
- return;
- }
- fastify.log.error(
- { err: error },
- `Game socket error for ${req.user.username}`
- );
- userWebSockets.remove(req.user.username);
- });
-}
diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts
deleted file mode 100644
index 146c2792..00000000
--- a/libs/backend/src/websocket/game/on-connection.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import {
- AuthenticatedInfo,
- ERROR_MESSAGES,
- gameEventEnum,
- getUserIdFromUser,
- isGameDto,
- isPuzzleDto,
- isString,
- ObjectId,
- websocketCloseCodes
-} from "types";
-import { UserWebSockets } from "./user-web-sockets.js";
-import { WebSocket } from "@fastify/websocket";
-import { gameService } from "@/services/game.service.js";
-import { puzzleService } from "@/services/puzzle.service.js";
-
-export async function onConnection(
- userWebSockets: UserWebSockets,
- user: AuthenticatedInfo,
- gameId: ObjectId,
- socket: WebSocket
-): Promise {
- try {
- const game = await gameService.findByIdPopulated(gameId);
-
- if (!isGameDto(game)) {
- socket.send(
- JSON.stringify({
- event: gameEventEnum.NONEXISTENT_GAME,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- })
- );
- socket.close(
- websocketCloseCodes.POLICY_VIOLATION,
- ERROR_MESSAGES.GAME.NOT_FOUND
- );
- return;
- }
-
- const isPlayerInGame = game.players.some(
- (player) => getUserIdFromUser(player) === user.userId
- );
-
- if (!isPlayerInGame) {
- socket.send(
- JSON.stringify({
- event: gameEventEnum.OVERVIEW_GAME,
- game
- })
- );
- socket.send(
- JSON.stringify({
- event: gameEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME
- })
- );
- socket.close(
- websocketCloseCodes.POLICY_VIOLATION,
- ERROR_MESSAGES.GAME.USER_NOT_IN_GAME
- );
- return;
- }
-
- userWebSockets.add(user.username, socket, user);
-
- const isGameFinished = game.endTime < new Date();
- if (isGameFinished) {
- userWebSockets.updateUser(user.username, {
- event: gameEventEnum.FINISHED_GAME,
- game
- });
- return;
- }
-
- const puzzleId = isString(game.puzzle)
- ? game.puzzle
- : game.puzzle._id.toString();
- const puzzle = await puzzleService.findByIdPopulated(puzzleId);
-
- if (!isPuzzleDto(puzzle)) {
- userWebSockets.updateUser(user.username, {
- event: gameEventEnum.ERROR,
- message: ERROR_MESSAGES.PUZZLE.NOT_FOUND
- });
- return;
- }
-
- userWebSockets.updateUser(user.username, {
- event: gameEventEnum.OVERVIEW_GAME,
- game,
- puzzle
- });
- } catch (error) {
- console.error("Error in game websocket connection:", error);
- socket.close(
- websocketCloseCodes.INTERNAL_ERROR,
- ERROR_MESSAGES.SERVER.INTERNAL_ERROR
- );
- }
-}
diff --git a/libs/backend/src/websocket/game/user-web-sockets.ts b/libs/backend/src/websocket/game/user-web-sockets.ts
deleted file mode 100644
index 13edc503..00000000
--- a/libs/backend/src/websocket/game/user-web-sockets.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { WebSocket } from "@fastify/websocket";
-import { AuthenticatedInfo, GameResponse } from "types";
-import { ConnectionManager } from "../connection-manager.js";
-
-type Username = string;
-
-export class UserWebSockets {
- private connectionManager: ConnectionManager;
-
- constructor() {
- this.connectionManager = new ConnectionManager({
- onConnectionLost: (username: string) => {
- console.info(`Game connection lost for user: ${username}`);
- }
- });
- }
-
- add(username: Username, socket: WebSocket, user: AuthenticatedInfo): void {
- this.connectionManager.add(user, socket);
- }
-
- remove(username: Username): void {
- this.connectionManager.remove(username);
- }
-
- updateAllUsers(response: GameResponse): void {
- const usernames = this.connectionManager.getAllUsernames();
- usernames.forEach((username: string) => {
- this.updateUser(username, response);
- });
- }
-
- updateUser(username: string, response: GameResponse): boolean {
- return this.connectionManager.send(username, response);
- }
-
- isConnected(username: Username): boolean {
- return this.connectionManager.isConnected(username);
- }
-
- getConnectionCount(): number {
- return this.connectionManager.getConnectionCount();
- }
-
- destroy(): void {
- this.connectionManager.destroy();
- }
-}
diff --git a/libs/backend/src/websocket/waiting-room/on-connection.ts b/libs/backend/src/websocket/waiting-room/on-connection.ts
deleted file mode 100644
index 0d7a5dc4..00000000
--- a/libs/backend/src/websocket/waiting-room/on-connection.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { WebSocket } from "@fastify/websocket";
-import { AuthenticatedInfo, waitingRoomEventEnum } from "types";
-import { WaitingRoom } from "./waiting-room.js";
-
-export function onConnection(
- waitingRoom: WaitingRoom,
- socket: WebSocket,
- user: AuthenticatedInfo
-): void {
- waitingRoom.addUserToUsers(user.username, socket, user);
-
- const openRooms = waitingRoom.getRooms();
- waitingRoom.updateUser(user.username, {
- event: waitingRoomEventEnum.OVERVIEW_OF_ROOMS,
- rooms: openRooms
- });
-}
diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts
deleted file mode 100644
index 1e9a0254..00000000
--- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import { WebSocket } from "@fastify/websocket";
-import { FastifyInstance, FastifyRequest } from "fastify";
-import {
- DEFAULT_GAME_LENGTH_IN_MILLISECONDS,
- ERROR_MESSAGES,
- frontendUrls,
- GameEntity,
- gameModeEnum,
- gameVisibilityEnum,
- isAuthenticatedInfo,
- waitingRoomEventEnum
-} from "types";
-import { WaitingRoom } from "./waiting-room.js";
-import { onConnection as onWaitingRoomConnection } from "./on-connection.js";
-import { parseRawDataWaitingRoomRequest } from "@/utils/functions/parse-raw-data-message.js";
-import { puzzleService } from "@/services/puzzle.service.js";
-import { gameService } from "@/services/game.service.js";
-
-const waitingRoom = new WaitingRoom();
-
-export function waitingRoomSetup(
- socket: WebSocket,
- req: FastifyRequest,
- fastify: FastifyInstance
-) {
- if (!isAuthenticatedInfo(req.user)) {
- socket.close(1008, ERROR_MESSAGES.AUTHENTICATION.AUTHENTICATION_REQUIRED);
- return;
- }
-
- onWaitingRoomConnection(waitingRoom, socket, req.user);
-
- // Handle ping from client
- socket.on("ping", () => {
- socket.pong();
- });
-
- socket.on("message", async (message) => {
- if (!isAuthenticatedInfo(req.user)) {
- return;
- }
-
- let parsedMessage;
-
- try {
- parsedMessage = parseRawDataWaitingRoomRequest(message);
- } catch (e) {
- const error = e as Error;
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: error.message
- });
- return;
- }
-
- const { event } = parsedMessage;
-
- switch (event) {
- case waitingRoomEventEnum.HOST_ROOM: {
- const roomId = waitingRoom.hostRoom(req.user, parsedMessage.options);
- fastify.log.info(
- { username: req.user.username, roomId },
- "User hosted room"
- );
- break;
- }
-
- case waitingRoomEventEnum.JOIN_ROOM: {
- const success = waitingRoom.joinRoom(req.user, parsedMessage.roomId);
- if (!success) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- });
- }
- break;
- }
-
- case waitingRoomEventEnum.JOIN_BY_INVITE_CODE: {
- const roomId = waitingRoom.getRoomByInviteCode(
- parsedMessage.inviteCode
- );
- if (!roomId) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: `Invalid invite code: ${parsedMessage.inviteCode}`
- });
- break;
- }
-
- const success = waitingRoom.joinRoom(req.user, roomId);
- if (!success) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.FAILED_TO_START
- });
- }
- break;
- }
-
- case waitingRoomEventEnum.LEAVE_ROOM: {
- waitingRoom.leaveRoom(req.user.username, parsedMessage.roomId);
- break;
- }
-
- case waitingRoomEventEnum.CHAT_MESSAGE: {
- const room = waitingRoom.getRoom(parsedMessage.roomId);
-
- if (!room) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- });
- break;
- }
-
- const userInRoom = req.user.username in room;
-
- if (!userInRoom) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME
- });
- break;
- }
-
- waitingRoom.updateUsersInRoom(parsedMessage.roomId, {
- event: waitingRoomEventEnum.CHAT_MESSAGE,
- username: req.user.username,
- message: parsedMessage.message,
- createdAt: new Date()
- });
- break;
- }
-
- case waitingRoomEventEnum.START_GAME: {
- try {
- const randomPuzzles = await puzzleService.findRandomApproved(1);
-
- if (randomPuzzles.length < 1) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.NOT_ENOUGH_PUZZLES,
- message: "Create a puzzle and get it approved to play multiplayer"
- });
- return;
- }
-
- const randomPuzzle = randomPuzzles[0];
- const room = waitingRoom.getRoom(parsedMessage.roomId);
-
- if (!room) {
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.NOT_FOUND
- });
- return;
- }
-
- const players = Object.values(room).map((player) => player.userId);
-
- if (players.length <= 0) {
- waitingRoom.removeEmptyRooms();
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME
- });
- return;
- }
-
- const now = new Date();
- const roomOptions = waitingRoom.getRoomOptions(parsedMessage.roomId);
- const gameDuration =
- roomOptions?.maxGameDurationInSeconds ??
- DEFAULT_GAME_LENGTH_IN_MILLISECONDS / 1000;
- const gameDurationMs = gameDuration * 1000;
-
- const countdownSeconds = 15;
- const startTime = new Date(now.getTime() + countdownSeconds * 1000);
- const endTime = new Date(startTime.getTime() + gameDurationMs);
-
- const createGameEntity: GameEntity = {
- players,
- owner: waitingRoom.findRoomOwner(room).userId,
- puzzle: randomPuzzle._id.toString(),
- createdAt: now,
- startTime,
- endTime,
- options: {
- allowedLanguages: [],
- maxGameDurationInSeconds: gameDuration,
- mode: gameModeEnum.FASTEST,
- visibility: gameVisibilityEnum.PUBLIC,
- rated: true,
- ...roomOptions
- },
- playerSubmissions: []
- };
- const newlyCreatedGame = await gameService.create(createGameEntity);
- const gameUrl = frontendUrls.multiplayerById(newlyCreatedGame.id);
-
- // Store the pending game start state in the room
- waitingRoom.setPendingGameStart(
- parsedMessage.roomId,
- gameUrl,
- startTime
- );
-
- waitingRoom.updateUsersInRoom(parsedMessage.roomId, {
- event: waitingRoomEventEnum.START_GAME,
- gameUrl,
- startTime
- });
-
- fastify.log.info(
- {
- gameId: newlyCreatedGame.id,
- playerCount: players.length,
- startTime,
- countdownSeconds
- },
- "Game created with countdown"
- );
- return;
- } catch (error) {
- fastify.log.error({ err: error }, "Error starting game");
- waitingRoom.updateUser(req.user.username, {
- event: waitingRoomEventEnum.ERROR,
- message: ERROR_MESSAGES.GAME.FAILED_TO_START
- });
- }
- break;
- }
-
- default:
- event satisfies never;
- break;
- }
-
- const joinableRooms = waitingRoom.getRooms();
- waitingRoom.updateAllUsers({
- event: waitingRoomEventEnum.OVERVIEW_OF_ROOMS,
- rooms: joinableRooms
- });
- });
-
- socket.on("close", (code, reason) => {
- if (!isAuthenticatedInfo(req.user)) {
- return;
- }
- fastify.log.info(
- { username: req.user.username, code, reason: reason.toString() },
- "Waiting room socket closed"
- );
- waitingRoom.removeUserFromUsers(req.user.username);
- waitingRoom.removeEmptyRooms();
- });
-
- socket.on("error", (error) => {
- if (!isAuthenticatedInfo(req.user)) {
- return;
- }
- fastify.log.error(
- { err: error },
- `Waiting room socket error for ${req.user.username}`
- );
- waitingRoom.removeUserFromUsers(req.user.username);
- waitingRoom.removeEmptyRooms();
- });
-}
diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts
deleted file mode 100644
index 7386e413..00000000
--- a/libs/backend/src/websocket/waiting-room/waiting-room.ts
+++ /dev/null
@@ -1,290 +0,0 @@
-import { WebSocket } from "@fastify/websocket";
-import mongoose from "mongoose";
-import {
- AuthenticatedInfo,
- GameOptions,
- GameUserInfo,
- ObjectId,
- waitingRoomEventEnum,
- WaitingRoomResponse
-} from "types";
-import { ConnectionManager } from "../connection-manager.js";
-
-type Username = string;
-type RoomId = ObjectId;
-type Room = Record;
-
-interface RoomConfig {
- users: Room;
- options?: GameOptions | undefined;
- inviteCode?: string | undefined;
- pendingGameStart?: { gameUrl: string; startTime: Date } | undefined;
-}
-
-export class WaitingRoom {
- private roomsByRoomId: Record;
- private roomsByUsername: Record;
- private connectionManager: ConnectionManager;
-
- constructor() {
- this.roomsByRoomId = {};
- this.roomsByUsername = {};
- this.connectionManager = new ConnectionManager({
- onConnectionLost: (username) => {
- this.handleDisconnectedUser(username);
- }
- });
- }
-
- private handleDisconnectedUser(username: Username): void {
- console.info(`Waiting room connection lost for user: ${username}`);
- const roomId = this.roomsByUsername[username];
- if (roomId) {
- this.leaveRoom(username, roomId);
- }
- this.removeEmptyRooms();
- }
-
- addUserToUsers(
- username: Username,
- socket: WebSocket,
- user: AuthenticatedInfo
- ): void {
- this.connectionManager.add(user, socket);
- }
-
- removeUserFromUsers(username: Username): void {
- const roomId = this.roomsByUsername[username];
- if (roomId) {
- this.leaveRoom(username, roomId);
- }
- this.connectionManager.remove(username);
- }
-
- hostRoom(user: AuthenticatedInfo, options?: GameOptions): RoomId {
- const randomId = new mongoose.Types.ObjectId().toString();
-
- // Generate a 6-character invite code for private rooms
- let inviteCode: string | undefined;
- if (options?.visibility === "private") {
- inviteCode = this.generateInviteCode();
- }
-
- this.roomsByRoomId[randomId] = {
- users: {
- [user.username]: {
- joinedAt: new Date(),
- userId: user.userId,
- username: user.username
- }
- },
- options,
- inviteCode
- };
-
- this.joinRoom(user, randomId);
- return randomId;
- }
-
- private generateInviteCode(): string {
- // Generate a random 6-character code using uppercase letters and numbers
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
- let code = "";
- for (let i = 0; i < 6; i++) {
- code += chars.charAt(Math.floor(Math.random() * chars.length));
- }
- return code;
- }
-
- joinRoom(user: AuthenticatedInfo, roomId: RoomId): boolean {
- const roomConfig = this.roomsByRoomId[roomId];
- if (!roomConfig) {
- console.warn(
- `Room ${roomId} not found when user ${user.username} tried to join`
- );
- return false;
- }
-
- roomConfig.users[user.username] = {
- joinedAt: new Date(),
- userId: user.userId,
- username: user.username
- };
-
- this.roomsByUsername[user.username] = roomId;
- console.info(`User ${user.username} joined room ${roomId}`);
- this.updateUsersOnRoomState(roomId);
-
- // If there's a pending game start, notify the newly joined user
- if (roomConfig.pendingGameStart) {
- this.updateUser(user.username, {
- event: waitingRoomEventEnum.START_GAME,
- gameUrl: roomConfig.pendingGameStart.gameUrl,
- startTime: roomConfig.pendingGameStart.startTime
- });
- }
-
- return true;
- }
-
- leaveRoom(username: Username, roomId: RoomId): void {
- const roomConfig = this.roomsByRoomId[roomId];
- if (!roomConfig) {
- console.warn(
- `Room ${roomId} not found when user ${username} tried to leave`
- );
- return;
- }
-
- delete roomConfig.users[username];
- delete this.roomsByUsername[username];
- console.info(
- `User ${username} left room ${roomId}. Remaining players: ${Object.keys(roomConfig.users).length}`
- );
-
- if (Object.keys(roomConfig.users).length <= 0) {
- delete this.roomsByRoomId[roomId];
- console.info(`Room ${roomId} is now empty and removed`);
- } else {
- this.updateUsersOnRoomState(roomId);
- }
- }
-
- getRoom(roomId: RoomId): Room | undefined {
- const roomConfig = this.roomsByRoomId[roomId];
- return roomConfig?.users;
- }
-
- getRoomOptions(roomId: RoomId): GameOptions | undefined {
- return this.roomsByRoomId[roomId]?.options;
- }
-
- getRooms(): Array<{ roomId: RoomId; amountOfPlayersJoined: number }> {
- // Only return public rooms
- return Object.entries(this.roomsByRoomId)
- .filter(([_roomId, roomConfig]) => {
- return roomConfig.options?.visibility !== "private";
- })
- .map(([roomId, roomConfig]) => {
- return {
- roomId,
- amountOfPlayersJoined: Object.keys(roomConfig.users).length
- };
- });
- }
-
- getRoomByInviteCode(inviteCode: string): RoomId | undefined {
- const entry = Object.entries(this.roomsByRoomId).find(
- ([_roomId, roomConfig]) => roomConfig.inviteCode === inviteCode
- );
- return entry?.[0];
- }
-
- getInviteCode(roomId: RoomId): string | undefined {
- return this.roomsByRoomId[roomId]?.inviteCode;
- }
-
- getAllRoomIds(): RoomId[] {
- return Object.keys(this.roomsByRoomId);
- }
-
- setPendingGameStart(roomId: RoomId, gameUrl: string, startTime: Date): void {
- const roomConfig = this.roomsByRoomId[roomId];
- if (roomConfig) {
- roomConfig.pendingGameStart = { gameUrl, startTime };
- }
- }
-
- updateUsersOnRoomState(roomId: RoomId): void {
- const room = this.getRoom(roomId);
- const inviteCode = this.getInviteCode(roomId);
- if (!room) {
- return;
- }
-
- const usersInRoom = Object.values(room);
- this.updateUsersInRoom(roomId, {
- event: waitingRoomEventEnum.OVERVIEW_ROOM,
- room: {
- users: usersInRoom,
- owner: this.findRoomOwner(room),
- roomId,
- ...(inviteCode && { inviteCode })
- }
- });
- }
-
- findRoomOwner(room: Room): GameUserInfo {
- const usersInRoom = Object.values(room);
- return usersInRoom.sort((userA, userB) => {
- const userAJoinDate = new Date(userA.joinedAt).getTime();
- const userBJoinDate = new Date(userB.joinedAt).getTime();
- return userAJoinDate - userBJoinDate;
- })[0];
- }
-
- updateUsersInRoom(roomId: RoomId, response: WaitingRoomResponse): void {
- const room = this.getRoom(roomId);
- if (!room) {
- return;
- }
-
- Object.keys(room).forEach((username) => {
- this.updateUser(username, response);
- });
- }
-
- updateAllUsers(response: WaitingRoomResponse): void {
- const usernames = this.connectionManager.getAllUsernames();
- usernames.forEach((username) => {
- this.updateUser(username, response);
- });
- }
-
- updateUser(username: string, response: WaitingRoomResponse): boolean {
- return this.connectionManager.send(username, response);
- }
-
- removeEmptyRooms(): void {
- const emptyRoomIds = Object.entries(this.roomsByRoomId)
- .filter(
- ([_roomId, roomConfig]) => Object.keys(roomConfig.users).length === 0
- )
- .map(([roomId]) => roomId);
-
- emptyRoomIds.forEach((roomId) => {
- console.info(`Removing empty room: ${roomId}`);
- delete this.roomsByRoomId[roomId];
- });
-
- if (emptyRoomIds.length > 0) {
- console.info(`Removed ${emptyRoomIds.length} empty rooms`);
- }
- }
-
- dissolveRoom(roomId: RoomId): void {
- const room = this.getRoom(roomId);
- if (!room) return;
-
- const usernames = Object.keys(room);
-
- usernames.forEach((username) => {
- delete this.roomsByUsername[username];
- });
- delete this.roomsByRoomId[roomId];
-
- usernames.forEach((username) => {
- this.connectionManager.remove(username);
- });
-
- console.info(`Dissolved room ${roomId} with ${usernames.length} users`);
- }
-
- isUserConnected(username: Username): boolean {
- return this.connectionManager.isConnected(username);
- }
-
- destroy(): void {
- this.connectionManager.destroy();
- }
-}
diff --git a/libs/backend/tsconfig.json b/libs/backend/tsconfig.json
deleted file mode 100644
index 99931b1c..00000000
--- a/libs/backend/tsconfig.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "extends": "@tsconfig/recommended/tsconfig.json",
- "$schema": "https://json.schemastore.org/tsconfig",
- "compilerOptions": {
- "target": "ESNext",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "outDir": "dist",
- "rootDir": "src",
- "esModuleInterop": true,
- "strict": true,
- "resolveJsonModule": true,
- "removeComments": true,
- "newLine": "lf",
- "noUnusedLocals": true,
- "noFallthroughCasesInSwitch": true,
- "isolatedModules": true,
- "forceConsistentCasingInFileNames": true,
- "exactOptionalPropertyTypes": true,
- "skipLibCheck": true,
- "lib": ["ESNext"],
-
- "paths": {
- "#/*": ["./src/lib/components"],
- "@/*": ["./src/*"]
- }
- }
-}
diff --git a/libs/backend/validate_migration.exs b/libs/backend/validate_migration.exs
new file mode 100644
index 00000000..aacda368
--- /dev/null
+++ b/libs/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/backend/vitest.config.js b/libs/backend/vitest.config.js
deleted file mode 100644
index 6c3bf813..00000000
--- a/libs/backend/vitest.config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { defineConfig } from "vitest/config";
-import tsconfigPaths from "vite-tsconfig-paths";
-
-export default defineConfig({
- plugins: [tsconfigPaths()],
- test: {
- environment: "node",
- globals: true
- // setupFiles: ["./src/tests/setup.ts"] // optional setup file
- }
-});
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..2e945373
--- /dev/null
+++ b/libs/elixir-backend/codincod_api/priv/repo/seeds.exs
@@ -0,0 +1,433 @@
+# Script for populating the database with test data
+#
+# Run with: mix run priv/repo/seeds.exs
+#
+# This will create test users, puzzles, and other data for development
+
+alias CodincodApi.Repo
+alias CodincodApi.Accounts
+alias CodincodApi.Accounts.User
+alias CodincodApi.Puzzles
+alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator}
+alias CodincodApi.Languages
+alias CodincodApi.Languages.ProgrammingLanguage
+
+require Logger
+
+# Helper to safely insert or find existing record
+defmodule SeedHelpers do
+ def insert_or_find(module, attrs, unique_field) do
+ case Repo.get_by(module, [{unique_field, Map.get(attrs, unique_field)}]) do
+ nil ->
+ %{module.__struct__() | id: Ecto.UUID.generate()}
+ |> module.changeset(attrs)
+ |> Repo.insert!()
+
+ existing ->
+ Logger.info("#{module} with #{unique_field}=#{Map.get(attrs, unique_field)} already exists")
+ existing
+ end
+ end
+end
+
+Logger.info("🌱 Starting seed process...")
+
+# ============================================================================
+# PROGRAMMING LANGUAGES
+# ============================================================================
+Logger.info("Creating programming languages...")
+
+languages = [
+ %{
+ name: "python",
+ version: "3.12.0",
+ runtime: "python",
+ piston_name: "python"
+ },
+ %{
+ name: "javascript",
+ version: "18.15.0",
+ runtime: "node",
+ piston_name: "javascript"
+ },
+ %{
+ name: "ruby",
+ version: "3.2.0",
+ runtime: "ruby",
+ piston_name: "ruby"
+ },
+ %{
+ name: "rust",
+ version: "1.68.2",
+ runtime: "rust",
+ piston_name: "rust"
+ },
+ %{
+ name: "elixir",
+ version: "1.14.0",
+ runtime: "elixir",
+ piston_name: "elixir"
+ },
+ %{
+ name: "go",
+ version: "1.21.0",
+ runtime: "go",
+ piston_name: "go"
+ }
+]
+
+_created_languages =
+ Enum.map(languages, fn lang_attrs ->
+ SeedHelpers.insert_or_find(ProgrammingLanguage, lang_attrs, :name)
+ end)
+
+# ============================================================================
+# TEST USERS
+# ============================================================================
+Logger.info("Creating test users...")
+
+# Main test user (matches mongo_testdata.py)
+codincoder =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "codincoder",
+ email: "codincoder@example.com",
+ password: "strongpassword123!",
+ password_confirmation: "strongpassword123!",
+ profile: %{
+ bio: "I love coding challenges!",
+ location: "Code City",
+ picture: nil,
+ socials: %{
+ github: "codincoder",
+ twitter: "codincoder"
+ }
+ },
+ role: "user"
+ },
+ :username
+ )
+
+# Additional test users for variety
+alice =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "alice",
+ email: "alice@example.com",
+ password: "alicepassword123!",
+ password_confirmation: "alicepassword123!",
+ profile: %{
+ bio: "Algorithm enthusiast",
+ location: "Wonderland"
+ },
+ role: "user"
+ },
+ :username
+ )
+
+bob =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "bob",
+ email: "bob@example.com",
+ password: "bobpassword123!",
+ password_confirmation: "bobpassword123!",
+ profile: %{
+ bio: "Puzzle solver extraordinaire"
+ },
+ role: "user"
+ },
+ :username
+ )
+
+moderator =
+ SeedHelpers.insert_or_find(
+ User,
+ %{
+ username: "moderator",
+ email: "moderator@example.com",
+ password: "modpassword123!",
+ password_confirmation: "modpassword123!",
+ profile: %{
+ bio: "Keeping the platform safe"
+ },
+ role: "moderator"
+ },
+ :username
+ )
+
+# ============================================================================
+# PUZZLES
+# ============================================================================
+Logger.info("Creating test puzzles...")
+
+# Easy puzzle - Print 42
+easy_puzzle =
+ case Repo.get_by(Puzzle, title: "Print 42") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Print 42",
+ statement: "Print the number 42.",
+ constraints: "No input required",
+ difficulty: "BEGINNER",
+ visibility: "APPROVED",
+ tags: ["beginner", "output"],
+ solution: %{
+ code: "print(42)",
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: codincoder.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ # Add validators
+ validators = [
+ %{input: "", output: "42"},
+ %{input: "", output: "42"},
+ %{input: "", output: "42"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Print 42' already exists")
+ existing
+ end
+
+# FizzBuzz puzzle (from mongo_testdata.py)
+fizzbuzz_puzzle =
+ case Repo.get_by(Puzzle, title: "FizzBuzz") do
+ nil ->
+ puzzle_attrs = %{
+ title: "FizzBuzz",
+ statement: """
+ Print numbers from N to M except for:
+ - Every number divisible by 3: print "Fizz"
+ - Every number divisible by 5: print "Buzz"
+ - Numbers divisible by both 3 and 5: print "FizzBuzz"
+
+ ## Input Format
+ Two space-separated integers: N and M
+
+ ## Output Format
+ Print each result on a new line.
+ """,
+ constraints: "0 <= N < M <= 1000",
+ difficulty: "INTERMEDIATE",
+ visibility: "DRAFT",
+ tags: ["loops", "conditionals", "classic"],
+ solution: %{
+ code: """
+ n, m = [int(x) for x in input().split()]
+ for i in range(n, m+1):
+ fizz = i % 3 == 0
+ buzz = i % 5 == 0
+ print("Fizz" * fizz + "Buzz" * buzz + str(i) * (not fizz and not buzz))
+ """,
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: codincoder.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ # Add validators
+ validators = [
+ %{
+ input: "1 3",
+ output: "1\n2\nFizz"
+ },
+ %{
+ input: "3 5",
+ output: "Fizz\n4\nBuzz"
+ },
+ %{
+ input: "1 16",
+ output: "1\n2\nFizz\n4\nBuzz\nFizz\n7\n8\nFizz\nBuzz\n11\nFizz\n13\n14\nFizzBuzz\n16"
+ }
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'FizzBuzz' already exists")
+ existing
+ end
+
+# Reverse String puzzle
+_reverse_puzzle =
+ case Repo.get_by(Puzzle, title: "Reverse String") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Reverse String",
+ statement: """
+ Given a string, output it reversed.
+
+ ## Input Format
+ A single line containing the string to reverse.
+
+ ## Output Format
+ The reversed string.
+ """,
+ constraints: "1 <= string length <= 1000",
+ difficulty: "EASY",
+ visibility: "APPROVED",
+ tags: ["strings", "beginner"],
+ solution: %{
+ code: "print(input()[::-1])",
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: alice.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ validators = [
+ %{input: "hello", output: "olleh"},
+ %{input: "world", output: "dlrow"},
+ %{input: "racecar", output: "racecar"},
+ %{input: "a", output: "a"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Reverse String' already exists")
+ existing
+ end
+
+# Sum of Numbers puzzle
+_sum_puzzle =
+ case Repo.get_by(Puzzle, title: "Sum of Numbers") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Sum of Numbers",
+ statement: """
+ Calculate the sum of all integers from 1 to N (inclusive).
+
+ ## Input Format
+ A single integer N.
+
+ ## Output Format
+ The sum of integers from 1 to N.
+ """,
+ constraints: "1 <= N <= 10000",
+ difficulty: "BEGINNER",
+ visibility: "APPROVED",
+ tags: ["math", "beginner", "loops"],
+ solution: %{
+ code: """
+ n = int(input())
+ print(sum(range(1, n + 1)))
+ """,
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: bob.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ validators = [
+ %{input: "1", output: "1"},
+ %{input: "5", output: "15"},
+ %{input: "10", output: "55"},
+ %{input: "100", output: "5050"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Sum of Numbers' already exists")
+ existing
+ end
+
+# Palindrome Check puzzle
+_palindrome_puzzle =
+ case Repo.get_by(Puzzle, title: "Palindrome Check") do
+ nil ->
+ puzzle_attrs = %{
+ title: "Palindrome Check",
+ statement: """
+ Determine if a given string is a palindrome.
+
+ A palindrome reads the same forwards and backwards (ignoring case).
+
+ ## Input Format
+ A single string.
+
+ ## Output Format
+ Print "YES" if it's a palindrome, "NO" otherwise.
+ """,
+ constraints: "1 <= string length <= 1000",
+ difficulty: "EASY",
+ visibility: "APPROVED",
+ tags: ["strings", "palindrome"],
+ solution: %{
+ code: """
+ s = input().strip().lower()
+ print("YES" if s == s[::-1] else "NO")
+ """,
+ language: "python",
+ languageVersion: "3.12.0"
+ },
+ author_id: alice.id
+ }
+
+ {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs)
+
+ validators = [
+ %{input: "racecar", output: "YES"},
+ %{input: "hello", output: "NO"},
+ %{input: "A man a plan a canal Panama", output: "NO"},
+ %{input: "aabbaa", output: "YES"}
+ ]
+
+ Enum.each(validators, fn validator_attrs ->
+ %PuzzleValidator{}
+ |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id))
+ |> Repo.insert!()
+ end)
+
+ puzzle
+
+ existing ->
+ Logger.info("Puzzle 'Palindrome Check' already exists")
+ existing
+ end
+
+Logger.info("✅ Seed data created successfully!")
+Logger.info(" Users: codincoder, alice, bob, moderator")
+Logger.info(" Puzzles: 5 puzzles with validators")
+Logger.info(" Programming Languages: 6 languages")
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..863f7b75
--- /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: "../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..5a403af4 100644
--- a/libs/frontend/package.json
+++ b/libs/frontend/package.json
@@ -3,8 +3,8 @@
"version": "0.0.1",
"private": true,
"scripts": {
- "dev": "vite dev",
- "build": "vite build && node scripts/copy-maintenance.mjs",
+ "dev": "concurrently \"pnpm run generate:api:watch\" \"vite dev\"",
+ "build": "pnpm run generate:api && vite build && node scripts/copy-maintenance.mjs",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -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",
@@ -29,6 +31,7 @@
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"bits-ui": "^2.14.1",
+ "concurrently": "^9.2.1",
"eslint": "^9.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-sort-destructure-keys": "^2.0.0",
@@ -37,9 +40,12 @@
"formsnap": "2.0.0-next.1",
"globals": "^16.2.0",
"mdsvex": "^0.12.3",
+ "openapi-typescript": "^7.10.1",
+ "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