summaryrefslogtreecommitdiff
path: root/lib/silmataivas
diff options
context:
space:
mode:
Diffstat (limited to 'lib/silmataivas')
-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
11 files changed, 654 insertions, 0 deletions
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