diff --git a/config/config.exs b/config/config.exs index 29621f6..5bfa3d8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -45,6 +45,12 @@ config :logger, :console, format: "$time $metadata[$level] $message\n", metadata: [:request_id] +# Configures Waffle +config :waffle, + storage: Waffle.Storage.Local, + storage_dir_prefix: "priv", + asset_host: {:system, "ASSET_HOST"} + # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason diff --git a/config/prod.exs b/config/prod.exs index 1d0d4e6..451543f 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -9,5 +9,25 @@ config :swoosh, local: false # Do not print debug messages in production config :logger, level: :info +# Configures Waffle +config :waffle, + storage: Waffle.Storage.S3, + bucket: {:system, "AWS_S3_BUCKET"}, + asset_host: {:system, "ASSET_HOST"} + +# Configure ExAws +config :ex_aws, + json_codec: Jason, + access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, + secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"}, + region: {:system, "AWS_REGION"}, + s3: [ + scheme: "https://", + host: {:system, "ASSET_HOST"}, + region: {:system, "AWS_REGION"}, + access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, + secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"} + ] + # Runtime production configuration, including reading # of environment variables, is done on config/runtime.exs. diff --git a/lib/atlas/accounts.ex b/lib/atlas/accounts.ex index af7d966..d6196c7 100644 --- a/lib/atlas/accounts.ex +++ b/lib/atlas/accounts.ex @@ -7,6 +7,7 @@ defmodule Atlas.Accounts do alias Atlas.Accounts.{User, UserNotifier, UserPreference, UserSession, UserToken} alias Atlas.University.Student + alias Atlas.Uploaders.UserAvatar ## Database getters @@ -599,4 +600,39 @@ defmodule Atlas.Accounts do conflict_target: :user_id ) end + + @doc """ + Updates an user's avatar. + """ + + def update_user_avatar(%User{} = user, attrs) do + user + |> User.avatar_changeset(attrs) + |> Repo.update() + end + + @doc """ + Gets the avatar url. + + ## Examples + ... (not tested yet) + """ + + def get_user_avatar_url(%User{} = user) do + UserAvatar.url({user.avatar, user}) + end + + @doc """ + Deletes a user's avatar. + """ + + def remove_user_avatar(%User{} = user) do + if user.avatar do + UserAvatar.delete({user.avatar, user}) + end + + user + |> User.avatar_changeset(%{avatar: nil}) + |> Repo.update() + end end diff --git a/lib/atlas/accounts/user.ex b/lib/atlas/accounts/user.ex index 7826468..b9ae89d 100644 --- a/lib/atlas/accounts/user.ex +++ b/lib/atlas/accounts/user.ex @@ -3,6 +3,7 @@ defmodule Atlas.Accounts.User do Application user schema and changesets. """ use Atlas.Schema + use Waffle.Ecto.Schema alias Atlas.University @@ -14,6 +15,7 @@ defmodule Atlas.Accounts.User do field :current_password, :string, virtual: true, redact: true field :confirmed_at, :utc_datetime field :type, Ecto.Enum, values: [:student, :admin, :professor] + field :avatar, Atlas.Uploaders.UserAvatar.Type has_one :student, University.Student, on_delete: :delete_all @@ -167,4 +169,12 @@ defmodule Atlas.Accounts.User do add_error(changeset, :current_password, "is not valid") end end + + @doc """ + A user changeset for updating avatar + """ + def avatar_changeset(user, attrs) do + user + |> cast_attachments(attrs, [:avatar]) + end end diff --git a/lib/atlas/uploader.ex b/lib/atlas/uploader.ex new file mode 100644 index 0000000..65f9faf --- /dev/null +++ b/lib/atlas/uploader.ex @@ -0,0 +1,15 @@ +defmodule Atlas.Uploader do + @moduledoc """ + Base uploader module. + """ + defmacro __using__(_) do + quote do + use Waffle.Definition + use Waffle.Ecto.Definition + + def s3_object_headers(_version, {file, _scope}) do + [content_type: MIME.from_path(file.file_name)] + end + end + end +end diff --git a/lib/atlas/uploaders/user_avatar.ex b/lib/atlas/uploaders/user_avatar.ex new file mode 100644 index 0000000..b067f37 --- /dev/null +++ b/lib/atlas/uploaders/user_avatar.ex @@ -0,0 +1,35 @@ +defmodule Atlas.Uploaders.UserAvatar do + @moduledoc """ + User Avatar image uploader. + """ + use Atlas.Uploader + + @versions [:original] + @extension_whitelist ~w(.jpg .jpeg .png) + + def validate({file, _}) do + file_extension = file.file_name |> Path.extname() |> String.downcase() + + case Enum.member?(extension_whitelist(), file_extension) do + true -> :ok + false -> {:error, "Invalid file type"} + end + end + + def storage_dir(_version, {_file, %{id: user_id}}) do + "uploads/user/avatars/#{user_id}" + end + + def filename(version, _) do + version + end + + def extension_whitelist do + @extension_whitelist + end + + # Provide a default URL if there hasn't been a file uploaded + # def default_url(version, scope) do + # "/images/avatars/default_#{version}.png" + # end +end diff --git a/lib/atlas_web/controllers/user_controller.ex b/lib/atlas_web/controllers/user_controller.ex new file mode 100644 index 0000000..22ad2be --- /dev/null +++ b/lib/atlas_web/controllers/user_controller.ex @@ -0,0 +1,194 @@ +defmodule AtlasWeb.UserController do + use AtlasWeb, :controller + use PhoenixSwagger + + alias Atlas.Accounts + + def upload_avatar(conn, %{"id" => user_id, "avatar" => upload}) do + user_id + |> get_user() + |> update_user_avatar(upload) + |> send_upload_response(conn) + end + + def upload_avatar(conn, %{"id" => _user_id}) do + conn + |> put_status(:unprocessable_entity) + |> json(%{status: "error", message: "No avatar file provided"}) + end + + defp send_upload_response({:ok, user}, conn) do + conn + |> put_status(:ok) + |> json(%{ + status: "success", + message: "Avatar uploaded successfully", + data: %{avatar_url: Accounts.get_user_avatar_url(user), user_id: user.id} + }) + end + + defp send_upload_response({:error, reason}, conn) do + {status, message} = + case reason do + :not_found -> {:not_found, "User not found"} + %Ecto.Changeset{} -> {:unprocessable_entity, "Avatar validation failed"} + _ -> {:unprocessable_entity, "Upload failed"} + end + + conn |> put_status(status) |> json(%{status: "error", message: message}) + end + + swagger_path :upload_avatar do + post("/v1/users/{id}/avatar") + summary("Upload user avatar") + description("Upload an avatar image for a specific user") + produces("application/json") + tag("Uploaders") + consumes("multipart/form-data") + security([%{Bearer: []}]) + + parameters do + id(:path, :string, "User ID", required: true) + avatar(:formData, :file, "Avatar image file", required: true) + end + + response(200, "Success", Schema.ref(:AvatarUploadSuccess)) + response(422, "Validation error", Schema.ref(:ErrorResponse)) + end + + defp get_user(user_id) do + case Accounts.get_user(user_id) do + %Atlas.Accounts.User{} = user -> {:ok, user} + nil -> {:error, :not_found} + end + end + + defp update_user_avatar({:ok, user}, upload) do + Accounts.update_user_avatar(user, %{avatar: upload}) + end + + defp update_user_avatar(error, _upload), do: error + + def delete_avatar(conn, %{"id" => user_id}) do + user_id + |> get_user() + |> remove_user_avatar() + |> send_delete_response(conn) + end + + defp remove_user_avatar({:ok, user}) do + Accounts.remove_user_avatar(user) + end + + defp remove_user_avatar(error), do: error + + defp send_delete_response({:ok, user}, conn) do + conn + |> put_status(:ok) + |> json(%{ + status: "success", + message: "Avatar deleted successfully", + data: %{user_id: user.id} + }) + end + + defp send_delete_response({:error, reason}, conn) do + {status, message} = + case reason do + %Ecto.Changeset{} -> {:unprocessable_entity, "Avatar deletion failed"} + _ -> {:unprocessable_entity, "Deletion failed"} + end + + conn |> put_status(status) |> json(%{status: "error", message: message}) + end + + swagger_path :delete_avatar do + delete("/v1/users/{id}/avatar") + summary("Delete user avatar") + description("Delete the avatar image for a specific user") + produces("application/json") + tag("Uploaders") + security([%{Bearer: []}]) + + parameters do + id(:path, :string, "User ID", required: true) + end + + response(200, "Success", Schema.ref(:AvatarDeleteSuccess)) + response(422, "Deletion failed", Schema.ref(:ErrorResponse)) + end + + def swagger_definitions do + %{ + AvatarUploadSuccess: + swagger_schema do + title("Avatar Upload Success Response") + description("Successful avatar upload response") + type(:object) + + properties do + status(:string, "Response status", example: "success") + message(:string, "Success message", example: "Avatar uploaded successfully") + data(Schema.ref(:AvatarData)) + end + + required([:status, :message, :data]) + end, + AvatarData: + swagger_schema do + title("Avatar Data") + description("Avatar upload data") + type(:object) + + properties do + avatar_url(:string, "URL of the uploaded avatar", + example: "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg" + ) + + user_id(:string, "User UUID", example: "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + end + + required([:avatar_url, :user_id]) + end, + ErrorResponse: + swagger_schema do + title("Error Response") + description("Error response format") + type(:object) + + properties do + status(:string, "Response status", example: "error") + message(:string, "Error message", example: "User not found") + end + + required([:status, :message]) + end, + AvatarDeleteSuccess: + swagger_schema do + title("Successful Avatar Deletion") + description("Successful avatar deletion response") + type(:object) + + properties do + status(:string, "Response status", example: "success") + message(:string, "Success message", example: "Avatar deleted successfully") + data(Schema.ref(:DeleteData)) + end + + required([:status, :message, :data]) + end, + DeleteData: + swagger_schema do + title("Delete Data") + description("Avatar deletion data") + type(:object) + + properties do + user_id(:string, "User UUID", example: "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + end + + required([:user_id]) + end + } + end +end diff --git a/lib/atlas_web/router.ex b/lib/atlas_web/router.ex index 2faad83..3831b7a 100644 --- a/lib/atlas_web/router.ex +++ b/lib/atlas_web/router.ex @@ -43,6 +43,9 @@ defmodule AtlasWeb.Router do pipe_through :auth + post "/users/:id/avatar", UserController, :upload_avatar + delete "/users/:id/avatar", UserController, :delete_avatar + scope "/auth" do post "/sign_out", AuthController, :sign_out get "/me", AuthController, :me diff --git a/mix.exs b/mix.exs index 7c41397..93d1a5a 100644 --- a/mix.exs +++ b/mix.exs @@ -59,6 +59,14 @@ defmodule Atlas.MixProject do {:xlsx_reader, "~> 0.8.8"}, {:igniter, "~> 0.5", only: [:dev]}, + # uploads + {:waffle, "~> 1.1"}, + {:waffle_ecto, "~> 0.0.12"}, + {:ex_aws, "~> 2.1.2"}, + {:ex_aws_s3, "~> 2.0"}, + {:hackney, "~> 1.9"}, + {:sweet_xml, "~> 0.6"}, + # monitoring {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 37d9291..5f0b9f4 100644 --- a/mix.lock +++ b/mix.lock @@ -3,9 +3,10 @@ "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"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, - "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [: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", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -13,21 +14,28 @@ "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [: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", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, "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"}, + "ex_aws": {:hex, :ex_aws, "2.1.9", "dc4865ecc20a05190a34a0ac5213e3e5e2b0a75a0c2835e923ae7bfeac5e3c31", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "3e6c776703c9076001fbe1f7c049535f042cb2afa0d2cbd3b47cbc4e92ac0d10"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.7", "e571424d2f345299753382f3a01b005c422b1a460a8bc3ed47659b3d3ef91e9e", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "858e51241e50181e29aa2bc128fef548873a3a9cd580471f57eda5b64dec937f"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [: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", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, "guardian_db": {:hex, :guardian_db, "3.0.0", "c42902e3f1af1ba1e2d0c10913b926a1421f3a7e38eb4fc382b715c17489abdb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "9c2ec4278efa34f9f1cc6ba795e552d41fdc7ffba5319d67eeb533b89392d183"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "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"}, "igniter": {:hex, :igniter, "0.6.25", "e2774a4605c2bc9fc38f689232604aea0fc925c7966ae8e928fd9ea2fa9d300c", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "b1916e1e45796d5c371c7671305e81277231617eb58b1c120915aba237fbce6a"}, "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"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "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"}, "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.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [: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.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {: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", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "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.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, @@ -38,8 +46,11 @@ "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"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [: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", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "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"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"}, @@ -52,6 +63,9 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "ua_parser": {:hex, :ua_parser, "1.9.3", "1c3191ac62a6f3663b9c213ae5e1faef5dc03e29b6edbe34731a8f2f07802467", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "17e1b46cee8c2b49a4f9edec7ecb822846d4974cbd84ce02cbc169cdf1f58dfb"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "waffle": {:hex, :waffle, "1.1.9", "8ce5ca9e59fa5491da67a2df57b8711d93223df3c3e5c21ad2acdedc41a0f51a", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.1", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "307c63cfdfb4624e7c423868a128ccfcb0e5291ae73a9deecb3a10b7a3eb277c"}, + "waffle_ecto": {:hex, :waffle_ecto, "0.0.12", "e5c17c49b071b903df71861c642093281123142dc4e9908c930db3e06795b040", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:waffle, "~> 1.0", [hex: :waffle, repo: "hexpm", optional: false]}], "hexpm", "585fe6371057066d2e8e3383ddd7a2437ff0668caf3f4cbf5a041e0de9837168"}, "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"}, "xlsx_reader": {:hex, :xlsx_reader, "0.8.8", "fbb29049548ff687f03a2873f2eb0d9057e47eb69cafb07f44988f030fb620b7", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:saxy, "~> 1.5", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "642d979a3a156b150bb76a89998a130483e1c399fa32e8d3a66abc1d9799dbd7"}, diff --git a/priv/repo/migrations/20250810133546_add_avatar_to_users.exs b/priv/repo/migrations/20250810133546_add_avatar_to_users.exs new file mode 100644 index 0000000..92d58d4 --- /dev/null +++ b/priv/repo/migrations/20250810133546_add_avatar_to_users.exs @@ -0,0 +1,9 @@ +defmodule Atlas.Repo.Migrations.AddAvatarToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :avatar, :string + end + end +end diff --git a/priv/static/swagger.json b/priv/static/swagger.json index 292adfd..8f0546d 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -5,6 +5,55 @@ }, "host": "localhost:4000", "definitions": { + "User": { + "description": "User schema", + "example": { + "user": { + "email": "a114437@alunos.uminho.pt", + "id": "d18472e7-5251-4027-884f-58b8a3a6abe5", + "inserted_at": "2025-07-15T18:10:27Z", + "name": "Leonardo Carvalho", + "updated_at": "2025-07-15T18:10:27Z" + } + }, + "properties": { + "email": { + "description": "User email", + "type": "string" + }, + "id": { + "description": "User ID", + "type": "integer" + }, + "inserted_at": { + "description": "Creation timestamp", + "format": "date-time", + "type": "string" + }, + "name": { + "description": "User name", + "type": "string" + }, + "type": { + "description": "User type", + "type": "string" + }, + "updated_at": { + "description": "Last update timestamp", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "updated_at", + "email", + "inserted_at", + "id" + ], + "title": "User", + "type": "object" + }, "UserSession": { "description": "User session schema", "properties": { @@ -45,50 +94,56 @@ "title": "User Session", "type": "object" }, - "ErrorResponse": { - "description": "Error response schema", + "SignOutResponse": { + "description": "Response schema for successful sign out", + "example": { + "message": "Signed out successfully" + }, "properties": { - "error": { - "description": "Error message", + "message": { + "description": "Message indicating successful sign out", "type": "string" } }, "required": [ - "error" + "message" ], - "title": "ErrorResponse", + "title": "SignOutResponse", "type": "object" }, - "NoContentResponse": { - "description": "Response schema for no content", - "example": {}, + "UnauthorizedResponse": { + "description": "Unauthorized response schema", "properties": { - "message": { - "description": "Message indicating no content", + "error": { + "description": "Unauthorized error message", "type": "string" } }, "required": [ - "message" + "error" ], - "title": "NoContentResponse", + "title": "UnauthorizedResponse", "type": "object" }, - "ResetPasswordResponse": { - "description": "Response schema for successful password reset", - "example": { - "message": "Password reset successfully" - }, + "ErrorResponse": { + "description": "Error response format", "properties": { "message": { - "description": "Message indicating successful password reset", + "description": "Error message", + "example": "User not found", + "type": "string" + }, + "status": { + "description": "Response status", + "example": "error", "type": "string" } }, "required": [ + "status", "message" ], - "title": "ResetPasswordResponse", + "title": "Error Response", "type": "object" }, "SignInResponse": { @@ -114,21 +169,50 @@ "title": "SignInResponse", "type": "object" }, - "SignOutResponse": { - "description": "Response schema for successful sign out", + "UserSessionsResponse": { + "description": "Response schema for a list of user sessions", "example": { - "message": "Signed out successfully" + "sessions": [ + { + "first_seen": "2025-07-15T20:13:41Z", + "id": "8fd2bef3-f1eb-4bf2-aade-f3ae80e0563d", + "ip": "127.0.0.1", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0", + "user_browser": "Firefox", + "user_os": "Linux" + } + ] + }, + "properties": { + "sessions": { + "description": "List of user sessions", + "items": { + "$ref": "#/definitions/UserSession" + }, + "type": "array" + } + }, + "required": [ + "sessions" + ], + "title": "UserSessionsResponse", + "type": "object" + }, + "ResetPasswordResponse": { + "description": "Response schema for successful password reset", + "example": { + "message": "Password reset successfully" }, "properties": { "message": { - "description": "Message indicating successful sign out", + "description": "Message indicating successful password reset", "type": "string" } }, "required": [ "message" ], - "title": "SignOutResponse", + "title": "ResetPasswordResponse", "type": "object" }, "SuccessfulRefreshResponse": { @@ -148,96 +232,105 @@ "title": "SuccessfulRefreshResponse", "type": "object" }, - "UnauthorizedResponse": { - "description": "Unauthorized response schema", + "NoContentResponse": { + "description": "Response schema for no content", + "example": {}, "properties": { - "error": { - "description": "Unauthorized error message", + "message": { + "description": "Message indicating no content", "type": "string" } }, "required": [ - "error" + "message" ], - "title": "UnauthorizedResponse", + "title": "NoContentResponse", "type": "object" }, - "User": { - "description": "User schema", - "example": { - "user": { - "email": "a114437@alunos.uminho.pt", - "id": "d18472e7-5251-4027-884f-58b8a3a6abe5", - "inserted_at": "2025-07-15T18:10:27Z", - "name": "Leonardo Carvalho", - "updated_at": "2025-07-15T18:10:27Z" - } - }, + "AvatarUploadSuccess": { + "description": "Successful avatar upload response", "properties": { - "email": { - "description": "User email", - "type": "string" - }, - "id": { - "description": "User ID", - "type": "integer" + "data": { + "$ref": "#/definitions/AvatarData" }, - "inserted_at": { - "description": "Creation timestamp", - "format": "date-time", + "message": { + "description": "Success message", + "example": "Avatar uploaded successfully", "type": "string" }, - "name": { - "description": "User name", + "status": { + "description": "Response status", + "example": "success", "type": "string" + } + }, + "required": [ + "status", + "message", + "data" + ], + "title": "Avatar Upload Success Response", + "type": "object" + }, + "AvatarDeleteSuccess": { + "description": "Successful avatar deletion response", + "properties": { + "data": { + "$ref": "#/definitions/DeleteData" }, - "type": { - "description": "User type", + "message": { + "description": "Success message", + "example": "Avatar deleted successfully", "type": "string" }, - "updated_at": { - "description": "Last update timestamp", - "format": "date-time", + "status": { + "description": "Response status", + "example": "success", "type": "string" } }, "required": [ - "type", - "updated_at", - "email", - "inserted_at", - "id" + "status", + "message", + "data" ], - "title": "User", + "title": "Successful Avatar Deletion", "type": "object" }, - "UserSessionsResponse": { - "description": "Response schema for a list of user sessions", - "example": { - "sessions": [ - { - "first_seen": "2025-07-15T20:13:41Z", - "id": "8fd2bef3-f1eb-4bf2-aade-f3ae80e0563d", - "ip": "127.0.0.1", - "user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0", - "user_browser": "Firefox", - "user_os": "Linux" - } - ] + "AvatarData": { + "description": "Avatar upload data", + "properties": { + "avatar_url": { + "description": "URL of the uploaded avatar", + "example": "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg", + "type": "string" + }, + "user_id": { + "description": "User UUID", + "example": "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "string" + } }, + "required": [ + "avatar_url", + "user_id" + ], + "title": "Avatar Data", + "type": "object" + }, + "DeleteData": { + "description": "Avatar deletion data", "properties": { - "sessions": { - "description": "List of user sessions", - "items": { - "$ref": "#/definitions/UserSession" - }, - "type": "array" + "user_id": { + "description": "User UUID", + "example": "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "string" } }, "required": [ - "sessions" + "user_id" ], - "title": "UserSessionsResponse", + "title": "Delete Data", "type": "object" }, "Job": { @@ -697,6 +790,96 @@ "Job" ] } + }, + "/v1/users/{id}/avatar": { + "delete": { + "description": "Delete the avatar image for a specific user", + "operationId": "AtlasWeb.UserController.delete_avatar", + "parameters": [ + { + "description": "User ID", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/AvatarDeleteSuccess" + } + }, + "422": { + "description": "Deletion failed", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "Bearer": [] + } + ], + "summary": "Delete user avatar", + "tags": [ + "Uploaders" + ] + }, + "post": { + "consumes": [ + "multipart/form-data" + ], + "description": "Upload an avatar image for a specific user", + "operationId": "AtlasWeb.UserController.upload_avatar", + "parameters": [ + { + "description": "User ID", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "Avatar image file", + "in": "formData", + "name": "avatar", + "required": true, + "type": "file" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/AvatarUploadSuccess" + } + }, + "422": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "Bearer": [] + } + ], + "summary": "Upload user avatar", + "tags": [ + "Uploaders" + ] + } } }, "swagger": "2.0", diff --git a/test/atlas/uploaders/avatar_test.exs b/test/atlas/uploaders/avatar_test.exs new file mode 100644 index 0000000..d9a6fff --- /dev/null +++ b/test/atlas/uploaders/avatar_test.exs @@ -0,0 +1,48 @@ +defmodule Atlas.AvatarTest do + use AtlasWeb.ConnCase + + alias Atlas.AccountsFixtures + alias AtlasWeb.UserController + + setup do + user = AccountsFixtures.user_fixture(%{type: :student}) + conn = authenticated_conn(%{type: :student}) + + %{ + user: user, + conn: conn + } + end + + describe "valid avatar upload" do + test "uploads a valid avatar", %{user: user, conn: conn} do + upload = %Plug.Upload{ + content_type: "image/png", + filename: "avatar.png", + path: "test/support/fixtures/images/avatar.png" + } + + conn = UserController.upload_avatar(conn, %{"id" => user.id, "avatar" => upload}) + assert conn.status == 200 + + assert %{"status" => "success", "message" => "Avatar uploaded successfully"} = + Jason.decode!(conn.resp_body) + end + end + + describe "invalid avatar upload" do + test "uploads an invalid avatar", %{user: user, conn: conn} do + upload = %Plug.Upload{ + content_type: "image/gif", + filename: "avatar.gif", + path: "test/support/fixtures/images/avatar.gif" + } + + conn = UserController.upload_avatar(conn, %{"id" => user.id, "avatar" => upload}) + assert conn.status == 422 + + assert %{"status" => "error", "message" => "Avatar validation failed"} = + Jason.decode!(conn.resp_body) + end + end +end diff --git a/test/support/fixtures/images/avatar.png b/test/support/fixtures/images/avatar.png new file mode 100644 index 0000000..c8964cb Binary files /dev/null and b/test/support/fixtures/images/avatar.png differ