diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-03-23 17:11:39 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-04-05 21:16:51 +0200 |
| commit | 0ab2e5ba2b0631b28b5b1405559237b3913c878f (patch) | |
| tree | 791cea788b0a62bc483d0041fbd0c655d2ad49e8 /lib | |
feat: initialize Phoenix application for weather alerts
This commit sets up the initial Silmataivas project structure, including: Phoenix web framework configuration, database models for users and locations, weather polling service, notification system, Docker and deployment configurations, CI/CD pipeline setup
Diffstat (limited to 'lib')
26 files changed, 1166 insertions, 0 deletions
diff --git a/lib/mix/tasks/silmataivas.user.new.ex b/lib/mix/tasks/silmataivas.user.new.ex new file mode 100644 index 0000000..fe10c7f --- /dev/null +++ b/lib/mix/tasks/silmataivas.user.new.ex @@ -0,0 +1,48 @@ +defmodule Mix.Tasks.Silmataivas.User.New do + use Mix.Task + + @shortdoc "Creates a new user and prints its API token." + + @moduledoc """ + Creates a new user. + + mix silmataivas.user.new + mix silmataivas.user.new <user_id> + mix silmataivas.user.new <user_id> <role> + + This task starts the application and creates a user using the Silmataivas.Users context. + + ## Options + * `<user_id>` - An optional user ID to use. If not provided, a UUID will be generated. + * `<role>` - An optional role, must be either "user" or "admin". Defaults to "user". + """ + + def run(args) do + Mix.Task.run("app.start", []) + + {user_id, role} = + case args do + [provided_id, provided_role | _] -> {provided_id, provided_role} + [provided_id | _] -> {provided_id, "user"} + [] -> {Ecto.UUID.generate(), "user"} + end + + # Validate role + unless role in ["user", "admin"] do + Mix.raise("Invalid role: #{role}. Role must be either \"user\" or \"admin\".") + end + + user_params = %{user_id: user_id, role: role} + + case Silmataivas.Users.create_user(user_params) do + {:ok, user} -> + IO.puts("\n✅ User created successfully!") + IO.puts(" User ID (API token): #{user.user_id}") + IO.puts(" Role: #{user.role}") + + {:error, changeset} -> + IO.puts("\n❌ Failed to create user:") + IO.inspect(changeset.errors) + end + end +end diff --git a/lib/silmataivas.ex b/lib/silmataivas.ex new file mode 100644 index 0000000..ba6dfcf --- /dev/null +++ b/lib/silmataivas.ex @@ -0,0 +1,9 @@ +defmodule Silmataivas do + @moduledoc """ + Silmataivas 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/lib/silmataivas/application.ex b/lib/silmataivas/application.ex new file mode 100644 index 0000000..269f48f --- /dev/null +++ b/lib/silmataivas/application.ex @@ -0,0 +1,37 @@ +defmodule Silmataivas.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 = [ + SilmataivasWeb.Telemetry, + Silmataivas.Repo, + {DNSCluster, query: Application.get_env(:silmataivas, :dns_cluster_query, :ignore)}, + {Phoenix.PubSub, name: Silmataivas.PubSub}, + # Start the Finch HTTP client for sending emails + {Finch, name: Silmataivas.Finch}, + # Start a worker by calling: Silmataivas.Worker.start_link(arg) + # {Silmataivas.Worker, arg}, + # Start to serve requests, typically the last entry + SilmataivasWeb.Endpoint, + Silmataivas.Scheduler + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Silmataivas.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 + SilmataivasWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/silmataivas/locations.ex b/lib/silmataivas/locations.ex new file mode 100644 index 0000000..2fc33dc --- /dev/null +++ b/lib/silmataivas/locations.ex @@ -0,0 +1,104 @@ +defmodule Silmataivas.Locations do + @moduledoc """ + The Locations context. + """ + + import Ecto.Query, warn: false + alias Silmataivas.Repo + + alias Silmataivas.Locations.Location + + @doc """ + Returns the list of locations. + + ## Examples + + iex> list_locations() + [%Location{}, ...] + + """ + def list_locations do + Repo.all(Location) + end + + @doc """ + Gets a single location. + + Raises `Ecto.NoResultsError` if the Location does not exist. + + ## Examples + + iex> get_location!(123) + %Location{} + + iex> get_location!(456) + ** (Ecto.NoResultsError) + + """ + def get_location!(id), do: Repo.get!(Location, id) + + @doc """ + Creates a location. + + ## Examples + + iex> create_location(%{field: value}) + {:ok, %Location{}} + + iex> create_location(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_location(attrs \\ %{}) do + %Location{} + |> Location.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a location. + + ## Examples + + iex> update_location(location, %{field: new_value}) + {:ok, %Location{}} + + iex> update_location(location, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_location(%Location{} = location, attrs) do + location + |> Location.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a location. + + ## Examples + + iex> delete_location(location) + {:ok, %Location{}} + + iex> delete_location(location) + {:error, %Ecto.Changeset{}} + + """ + def delete_location(%Location{} = location) do + Repo.delete(location) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking location changes. + + ## Examples + + iex> change_location(location) + %Ecto.Changeset{data: %Location{}} + + """ + def change_location(%Location{} = location, attrs \\ %{}) do + Location.changeset(location, attrs) + end +end diff --git a/lib/silmataivas/locations/location.ex b/lib/silmataivas/locations/location.ex new file mode 100644 index 0000000..7da7290 --- /dev/null +++ b/lib/silmataivas/locations/location.ex @@ -0,0 +1,19 @@ +defmodule Silmataivas.Locations.Location do + use Ecto.Schema + import Ecto.Changeset + + schema "locations" do + field :latitude, :float + field :longitude, :float + field :user_id, :id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(location, attrs) do + location + |> cast(attrs, [:latitude, :longitude, :user_id]) + |> validate_required([:latitude, :longitude, :user_id]) + end +end diff --git a/lib/silmataivas/mailer.ex b/lib/silmataivas/mailer.ex new file mode 100644 index 0000000..3c11436 --- /dev/null +++ b/lib/silmataivas/mailer.ex @@ -0,0 +1,44 @@ +defmodule Silmataivas.Mailer do + use Swoosh.Mailer, otp_app: :silmataivas + require Logger + + def send_alert( + email, + %{ + "main" => %{"temp" => temp}, + "wind" => %{"speed" => speed}, + "dt_txt" => time_str + } = entry + ) do + rain_mm = get_in(entry, ["rain", "3h"]) || 0.0 + wind_kmh = speed * 3.6 + + import Swoosh.Email + + body = """ + 🚨 Weather alert for your location (#{time_str}): + + 🌬️ Wind: #{Float.round(wind_kmh, 1)} km/h + 🌧️ Rain: #{rain_mm} mm + 🌡️ Temperature: #{temp} °C + + Stay safe, + — Silmätaivas + """ + + email_struct = + new() + |> to(email) + |> from({"Silmätaivas Alerts", "silmataivas@rycerz.cloud"}) + |> subject("⚠️ Weather Alert for Your Location") + |> text_body(body) + + case deliver(email_struct) do + {:ok, response} -> + Logger.info("📨 Email sent via SES: #{inspect(response)}") + + {:error, reason} -> + Logger.error("❌ Failed to send email: #{inspect(reason)}") + end + end +end diff --git a/lib/silmataivas/ntfy_notifier.ex b/lib/silmataivas/ntfy_notifier.ex new file mode 100644 index 0000000..26815db --- /dev/null +++ b/lib/silmataivas/ntfy_notifier.ex @@ -0,0 +1,35 @@ +defmodule Silmataivas.Notifications.NtfyNotifier do + @moduledoc """ + Sends push notifications using ntfy.sh. + """ + + @ntfy_url System.get_env("NTFY_URL") || "https://ntfy.sh" + + def send_alert( + topic, + %{ + "main" => %{"temp" => temp}, + "wind" => %{"speed" => speed}, + "dt_txt" => time_str + } = entry + ) do + rain_mm = get_in(entry, ["rain", "3h"]) || 0.0 + wind_kmh = speed * 3.6 + + message = """ + 🚨 Weather alert for your location (#{time_str}): + + 🌬️ Wind: #{Float.round(wind_kmh, 1)} km/h + 🌧️ Rain: #{rain_mm} mm + 🌡️ Temperature: #{temp} °C + + Stay safe, + — Silmätaivas + """ + + Req.post("#{@ntfy_url}/#{topic}", + headers: [{"Priority", "5"}], + body: message + ) + end +end diff --git a/lib/silmataivas/release.ex b/lib/silmataivas/release.ex new file mode 100644 index 0000000..4fc9e93 --- /dev/null +++ b/lib/silmataivas/release.ex @@ -0,0 +1,136 @@ +defmodule Silmataivas.Release do + @moduledoc """ + Release tasks for Silmataivas application. + + This module provides functions to run Ecto migrations in a + compiled release, supporting both SQLite and PostgreSQL backends. + """ + + @app :silmataivas + + @doc """ + Creates a new user with optional user ID and role. + + ## Parameters + * `user_id` - An optional user ID to use. If not provided, a UUID will be generated. + * `role` - An optional role, must be either "user" or "admin". Defaults to "user". + + ## Examples + Silmataivas.Release.new_user() + Silmataivas.Release.new_user("custom_user_id") + Silmataivas.Release.new_user("custom_user_id", "admin") + """ + def new_user(user_id \\ nil, role \\ "user") do + # Create the new user + load_app() + start_repos() + + # Validate role + unless role in ["user", "admin"] do + IO.puts("\n❌ Invalid role: #{role}. Role must be either \"user\" or \"admin\".") + exit({:shutdown, 1}) + end + + user_id = user_id || Ecto.UUID.generate() + user_params = %{user_id: user_id, role: role} + + case Silmataivas.Users.create_user(user_params) do + {:ok, user} -> + IO.puts("\n✅ User created successfully!") + IO.puts(" User ID (API token): #{user.user_id}") + IO.puts(" Role: #{user.role}") + + {:error, changeset} -> + IO.puts("\n❌ Failed to create user:") + IO.inspect(changeset.errors) + end + end + + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + load_app() + + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + def create_db do + load_app() + + for repo <- repos() do + # Create the database if it doesn't exist + adapter = get_repo_adapter(repo) + + case adapter.storage_up(repo.config()) do + :ok -> + IO.puts("Database for #{inspect(repo)} created successfully") + + {:error, :already_up} -> + IO.puts("Database for #{inspect(repo)} already exists") + + {:error, reason} -> + IO.warn("Database for #{inspect(repo)} failed to create: #{inspect(reason)}") + end + end + end + + def setup do + # Create the database and then run migrations + create_db() + migrate() + end + + def db_info do + load_app() + + for repo <- repos() do + adapter = get_repo_adapter(repo) + config = repo.config() + + IO.puts("Repository: #{inspect(repo)}") + IO.puts("Adapter: #{inspect(adapter)}") + + case adapter do + Ecto.Adapters.SQLite3 -> + db_path = config[:database] || "default.db" + IO.puts("Database path: #{db_path}") + + Ecto.Adapters.Postgres -> + hostname = config[:hostname] || "localhost" + database = config[:database] || "default" + IO.puts("Host: #{hostname}, Database: #{database}") + + _ -> + IO.puts("Config: #{inspect(config)}") + end + + IO.puts("---") + end + end + + defp get_repo_adapter(repo) do + repo.config()[:adapter] + end + + defp start_repos do + {:ok, _} = Application.ensure_all_started(:ecto_sql) + + for repo <- repos() do + {:ok, _} = repo.start_link(pool_size: 2) + end + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.load(@app) + end +end diff --git a/lib/silmataivas/repo.ex b/lib/silmataivas/repo.ex new file mode 100644 index 0000000..d1bbcca --- /dev/null +++ b/lib/silmataivas/repo.ex @@ -0,0 +1,20 @@ +defmodule Silmataivas.Repo do + use Ecto.Repo, + otp_app: :silmataivas, + adapter: Ecto.Adapters.SQLite3 + + @doc """ + Dynamic adapter configuration based on application environment. + + This will be automatically called by Ecto during startup. + """ + def init(_type, config) do + # Check for adapter in config, fall back to Ecto.Adapters.SQLite3 + adapter = + config[:adapter] || + Application.get_env(:silmataivas, Silmataivas.Repo, [])[:adapter] || + Ecto.Adapters.SQLite3 + + {:ok, Keyword.put(config, :adapter, adapter)} + end +end diff --git a/lib/silmataivas/scheduler.ex b/lib/silmataivas/scheduler.ex new file mode 100644 index 0000000..3e04f7e --- /dev/null +++ b/lib/silmataivas/scheduler.ex @@ -0,0 +1,4 @@ +# lib/silmataivas/scheduler.ex +defmodule Silmataivas.Scheduler do + use Quantum, otp_app: :silmataivas +end diff --git a/lib/silmataivas/users.ex b/lib/silmataivas/users.ex new file mode 100644 index 0000000..1fcefd4 --- /dev/null +++ b/lib/silmataivas/users.ex @@ -0,0 +1,124 @@ +defmodule Silmataivas.Users do + @moduledoc """ + The Users context. + """ + + import Ecto.Query, warn: false + alias Silmataivas.Repo + + alias Silmataivas.Users.User + + @doc """ + Returns the list of users. + + ## Examples + + iex> list_users() + [%User{}, ...] + + """ + def list_users do + Repo.all(User) + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + @doc """ + Gets a user by user_id. + + ## Examples + + iex> get_user_by_user_id("some_user_id") + %User{} + + iex> get_user_by_user_id("non_existent_user_id") + nil + + """ + def get_user_by_user_id(user_id) do + Repo.get_by(User, user_id: user_id) + end + + @doc """ + Creates a user. + + ## Examples + + iex> create_user(%{field: value}) + {:ok, %User{}} + + iex> create_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_user(attrs \\ %{}) do + %User{} + |> User.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a user. + + ## Examples + + iex> update_user(user, %{field: new_value}) + {:ok, %User{}} + + iex> update_user(user, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_user(%User{} = user, attrs) do + user + |> User.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a user. + + ## Examples + + iex> delete_user(user) + {:ok, %User{}} + + iex> delete_user(user) + {:error, %Ecto.Changeset{}} + + """ + def delete_user(%User{} = user) do + Repo.delete(user) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user(%User{} = user, attrs \\ %{}) do + User.changeset(user, attrs) + end + + def list_users_with_locations do + Repo.all(from u in User, preload: [:location]) + end +end diff --git a/lib/silmataivas/users/user.ex b/lib/silmataivas/users/user.ex new file mode 100644 index 0000000..b0746cd --- /dev/null +++ b/lib/silmataivas/users/user.ex @@ -0,0 +1,29 @@ +defmodule Silmataivas.Users.User do + use Ecto.Schema + import Ecto.Changeset + alias Silmataivas.Repo + + @roles ["user", "admin"] + + schema "users" do + field :user_id, :string + field :role, :string, default: "user" + has_one :location, Silmataivas.Locations.Location + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(user, attrs) do + user + |> cast(attrs, [:user_id, :role]) + |> validate_required([:user_id]) + |> validate_inclusion(:role, @roles) + |> unique_constraint(:user_id) + end + + def create_user(attrs \\ %{}) do + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert() + end +end diff --git a/lib/silmataivas/weather_poller.ex b/lib/silmataivas/weather_poller.ex new file mode 100644 index 0000000..b42b184 --- /dev/null +++ b/lib/silmataivas/weather_poller.ex @@ -0,0 +1,102 @@ +defmodule Silmataivas.WeatherPoller do + require Logger + alias Silmataivas.{Users, Notifications.NtfyNotifier} + + @api_url "https://api.openweathermap.org/data/2.5/forecast" + # Check forecasts within the next 24 hours + @alert_window_hours 24 + + def check_all do + Logger.info("🔄 Checking weather forecast for all users...") + + Users.list_users_with_locations() + |> Enum.each(&check_user_weather/1) + end + + def check_user_weather(%{user_id: user_id, location: %{latitude: lat, longitude: lon}} = _user) do + case fetch_forecast(lat, lon) do + {:ok, forecasts} -> + case find_first_alert_entry(forecasts) do + nil -> :ok + entry -> NtfyNotifier.send_alert(user_id, entry) + end + + {:error, reason} -> + Logger.error("❌ Error fetching forecast for user #{user_id}: #{inspect(reason)}") + end + end + + # Add this clause to handle users with missing location data + def check_user_weather(%{user_id: user_id} = user) do + Logger.warning( + "⚠️ User #{user_id} has missing or incomplete location data: #{inspect(user)}", + [] + ) + + :ok + end + + # Add a catch-all clause to handle unexpected data formats + def check_user_weather(invalid_user) do + Logger.error("❌ Invalid user data structure: #{inspect(invalid_user)}") + :ok + end + + defp fetch_forecast(lat, lon) do + api_key = Application.fetch_env!(:silmataivas, :openweathermap_api_key) + + Req.get( + url: @api_url, + params: [ + lat: lat, + lon: lon, + units: "metric", + appid: api_key + ] + ) + |> case do + {:ok, %{status: 200, body: %{"list" => forecast_list}}} -> + {:ok, forecast_list} + + {:ok, %{status: code, body: body}} -> + {:error, {code, body}} + + error -> + error + end + end + + defp dangerous_conditions?( + %{ + "main" => %{"temp" => temp}, + "wind" => %{"speed" => speed}, + "dt_txt" => time_str + } = entry + ) do + rain_mm = get_in(entry, ["rain", "3h"]) || 0.0 + wind_kmh = speed * 3.6 + + cond do + wind_kmh > 80 -> log_reason("Wind", wind_kmh, time_str) + rain_mm > 40 -> log_reason("Rain", rain_mm, time_str) + temp < 0 -> log_reason("Temperature", temp, time_str) + true -> false + end + end + + defp find_first_alert_entry(forecast_list) do + now = DateTime.utc_now() + + forecast_list + |> Enum.take_while(fn %{"dt" => ts} -> + forecast_time = DateTime.from_unix!(ts) + DateTime.diff(forecast_time, now, :hour) <= @alert_window_hours + end) + |> Enum.find(&dangerous_conditions?/1) + end + + defp log_reason(type, value, time_str) do + Logger.info("🚨 #{type} threshold exceeded: #{value} at #{time_str}") + true + end +end diff --git a/lib/silmataivas_web.ex b/lib/silmataivas_web.ex new file mode 100644 index 0000000..ef60499 --- /dev/null +++ b/lib/silmataivas_web.ex @@ -0,0 +1,67 @@ +defmodule SilmataivasWeb 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 SilmataivasWeb, :controller + use SilmataivasWeb, :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], + layouts: [html: SilmataivasWeb.Layouts] + + use Gettext, backend: SilmataivasWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: SilmataivasWeb.Endpoint, + router: SilmataivasWeb.Router, + statics: SilmataivasWeb.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/lib/silmataivas_web/controllers/changeset_json.ex b/lib/silmataivas_web/controllers/changeset_json.ex new file mode 100644 index 0000000..ac0226d --- /dev/null +++ b/lib/silmataivas_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule SilmataivasWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} + end + + defp translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(SilmataivasWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(SilmataivasWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/silmataivas_web/controllers/error_json.ex b/lib/silmataivas_web/controllers/error_json.ex new file mode 100644 index 0000000..a2ca902 --- /dev/null +++ b/lib/silmataivas_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule SilmataivasWeb.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/lib/silmataivas_web/controllers/fallback_controller.ex b/lib/silmataivas_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..f315110 --- /dev/null +++ b/lib/silmataivas_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule SilmataivasWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use SilmataivasWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: SilmataivasWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: SilmataivasWeb.ErrorHTML, json: SilmataivasWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/silmataivas_web/controllers/health_controller.ex b/lib/silmataivas_web/controllers/health_controller.ex new file mode 100644 index 0000000..959b84b --- /dev/null +++ b/lib/silmataivas_web/controllers/health_controller.ex @@ -0,0 +1,9 @@ +defmodule SilmataivasWeb.HealthController do + use SilmataivasWeb, :controller + + def index(conn, _params) do + conn + |> put_status(:ok) + |> json(%{status: "ok"}) + end +end diff --git a/lib/silmataivas_web/controllers/location_controller.ex b/lib/silmataivas_web/controllers/location_controller.ex new file mode 100644 index 0000000..d494d59 --- /dev/null +++ b/lib/silmataivas_web/controllers/location_controller.ex @@ -0,0 +1,46 @@ +defmodule SilmataivasWeb.LocationController do + use SilmataivasWeb, :controller + + alias Silmataivas.Locations + alias Silmataivas.Locations.Location + + action_fallback SilmataivasWeb.FallbackController + + def index(conn, _params) do + locations = Locations.list_locations() + render(conn, :index, locations: locations) + end + + def create(conn, params) do + user = conn.assigns.current_user + params = Map.put(params, "user_id", user.id) + + with {:ok, %Location{} = location} <- Locations.create_location(params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/locations/#{location}") + |> render(:show, location: location) + end + end + + def show(conn, %{"id" => id}) do + location = Locations.get_location!(id) + render(conn, :show, location: location) + end + + def update(conn, %{"id" => id, "location" => location_params}) do + location = Locations.get_location!(id) + + with {:ok, %Location{} = location} <- Locations.update_location(location, location_params) do + render(conn, :show, location: location) + end + end + + def delete(conn, %{"id" => id}) do + location = Locations.get_location!(id) + + with {:ok, %Location{}} <- Locations.delete_location(location) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/silmataivas_web/controllers/location_json.ex b/lib/silmataivas_web/controllers/location_json.ex new file mode 100644 index 0000000..db7e469 --- /dev/null +++ b/lib/silmataivas_web/controllers/location_json.ex @@ -0,0 +1,25 @@ +defmodule SilmataivasWeb.LocationJSON do + alias Silmataivas.Locations.Location + + @doc """ + Renders a list of locations. + """ + def index(%{locations: locations}) do + %{data: for(location <- locations, do: data(location))} + end + + @doc """ + Renders a single location. + """ + def show(%{location: location}) do + %{data: data(location)} + end + + defp data(%Location{} = location) do + %{ + id: location.id, + latitude: location.latitude, + longitude: location.longitude + } + end +end diff --git a/lib/silmataivas_web/endpoint.ex b/lib/silmataivas_web/endpoint.ex new file mode 100644 index 0000000..086b1f9 --- /dev/null +++ b/lib/silmataivas_web/endpoint.ex @@ -0,0 +1,51 @@ +defmodule SilmataivasWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :silmataivas + + # 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: "_silmataivas_key", + signing_salt: "Fvhz8Cqb", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :silmataivas, + gzip: false, + only: SilmataivasWeb.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: :silmataivas + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + 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 SilmataivasWeb.Router +end diff --git a/lib/silmataivas_web/gettext.ex b/lib/silmataivas_web/gettext.ex new file mode 100644 index 0000000..a494c80 --- /dev/null +++ b/lib/silmataivas_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule SilmataivasWeb.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: SilmataivasWeb.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: :silmataivas +end diff --git a/lib/silmataivas_web/plugs/admin_only.ex b/lib/silmataivas_web/plugs/admin_only.ex new file mode 100644 index 0000000..b3f21dc --- /dev/null +++ b/lib/silmataivas_web/plugs/admin_only.ex @@ -0,0 +1,8 @@ +defmodule SilmataivasWeb.Plugs.AdminOnly do + import Plug.Conn + + def init(opts), do: opts + + def call(%{assigns: %{current_user: %{role: "admin"}}} = conn, _opts), do: conn + def call(conn, _opts), do: send_resp(conn, 403, "Forbidden") |> halt() +end diff --git a/lib/silmataivas_web/plugs/auth.ex b/lib/silmataivas_web/plugs/auth.ex new file mode 100644 index 0000000..ff5d25b --- /dev/null +++ b/lib/silmataivas_web/plugs/auth.ex @@ -0,0 +1,20 @@ +defmodule SilmataivasWeb.Plugs.Auth do + import Plug.Conn + alias Silmataivas.Users + alias Silmataivas.Repo + + def init(opts), do: opts + + def call(conn, _opts) do + with ["Bearer " <> user_id] <- get_req_header(conn, "authorization"), + %Users.User{} = user <- Users.get_user_by_user_id(user_id), + loaded_user <- Repo.preload(user, :location) do + assign(conn, :current_user, loaded_user) + else + _ -> + conn + |> send_resp(:unauthorized, "Unauthorized") + |> halt() + end + end +end diff --git a/lib/silmataivas_web/router.ex b/lib/silmataivas_web/router.ex new file mode 100644 index 0000000..d790ef9 --- /dev/null +++ b/lib/silmataivas_web/router.ex @@ -0,0 +1,41 @@ +defmodule SilmataivasWeb.Router do + use SilmataivasWeb, :router + + pipeline :api do + plug :accepts, ["json"] + plug SilmataivasWeb.Plugs.Auth + end + + pipeline :api_public do + plug :accepts, ["json"] + end + + scope "/api", SilmataivasWeb do + pipe_through :api + + resources "/locations", LocationController, only: [:index, :create, :show, :update] + end + + scope "/", SilmataivasWeb do + pipe_through :api_public + + get "/health", HealthController, :index + end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:silmataivas, :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: SilmataivasWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/lib/silmataivas_web/telemetry.ex b/lib/silmataivas_web/telemetry.ex new file mode 100644 index 0000000..f893b0e --- /dev/null +++ b/lib/silmataivas_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule SilmataivasWeb.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("silmataivas.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("silmataivas.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("silmataivas.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("silmataivas.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("silmataivas.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. + # {SilmataivasWeb, :count_users, []} + ] + end +end |
