summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/mix/tasks/silmataivas.user.new.ex48
-rw-r--r--lib/silmataivas.ex9
-rw-r--r--lib/silmataivas/application.ex37
-rw-r--r--lib/silmataivas/locations.ex104
-rw-r--r--lib/silmataivas/locations/location.ex19
-rw-r--r--lib/silmataivas/mailer.ex44
-rw-r--r--lib/silmataivas/ntfy_notifier.ex35
-rw-r--r--lib/silmataivas/release.ex136
-rw-r--r--lib/silmataivas/repo.ex20
-rw-r--r--lib/silmataivas/scheduler.ex4
-rw-r--r--lib/silmataivas/users.ex124
-rw-r--r--lib/silmataivas/users/user.ex29
-rw-r--r--lib/silmataivas/weather_poller.ex102
-rw-r--r--lib/silmataivas_web.ex67
-rw-r--r--lib/silmataivas_web/controllers/changeset_json.ex25
-rw-r--r--lib/silmataivas_web/controllers/error_json.ex21
-rw-r--r--lib/silmataivas_web/controllers/fallback_controller.ex24
-rw-r--r--lib/silmataivas_web/controllers/health_controller.ex9
-rw-r--r--lib/silmataivas_web/controllers/location_controller.ex46
-rw-r--r--lib/silmataivas_web/controllers/location_json.ex25
-rw-r--r--lib/silmataivas_web/endpoint.ex51
-rw-r--r--lib/silmataivas_web/gettext.ex25
-rw-r--r--lib/silmataivas_web/plugs/admin_only.ex8
-rw-r--r--lib/silmataivas_web/plugs/auth.ex20
-rw-r--r--lib/silmataivas_web/router.ex41
-rw-r--r--lib/silmataivas_web/telemetry.ex93
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