diff --git a/lib/microwaveprop/accounts/user_api_token.ex b/lib/microwaveprop/accounts/user_api_token.ex index 615e171b..3464ac8d 100644 --- a/lib/microwaveprop/accounts/user_api_token.ex +++ b/lib/microwaveprop/accounts/user_api_token.ex @@ -36,6 +36,13 @@ defmodule Microwaveprop.Accounts.UserApiToken do @type t :: %__MODULE__{} + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def changeset(token, attrs) do + token + |> cast(attrs, [:name, :user_id]) + |> foreign_key_constraint(:user_id) + end + @doc "Returns the bearer-token prefix (e.g. `mwp_`)." @spec token_prefix() :: String.t() def token_prefix, do: @prefix diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex index 8eb414f0..ba5eb364 100644 --- a/lib/microwaveprop/beacons/beacon.ex +++ b/lib/microwaveprop/beacons/beacon.ex @@ -198,6 +198,7 @@ defmodule Microwaveprop.Beacons.Beacon do |> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180) |> validate_length(:callsign, min: 3, max: 10) |> validate_grid_format() + |> foreign_key_constraint(:user_id) end defp round_coord(nil), do: nil diff --git a/lib/microwaveprop/propagation/grid_task_enqueuer.ex b/lib/microwaveprop/propagation/grid_task_enqueuer.ex index a64a4137..958a3cfe 100644 --- a/lib/microwaveprop/propagation/grid_task_enqueuer.ex +++ b/lib/microwaveprop/propagation/grid_task_enqueuer.ex @@ -169,25 +169,35 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do cutoff = DateTime.utc_now() |> DateTime.add(-cutoff_seconds, :second) |> DateTime.truncate(:second) now = DateTime.truncate(DateTime.utc_now(), :second) - {failed_n, _} = - "grid_tasks" - |> where( - [t], - t.status == "running" and t.claimed_at < ^cutoff and t.attempt >= ^@max_reclaim_attempts - ) - |> Repo.update_all( - set: [ - status: "failed", - error: "reclaim-orphan: exceeded max_reclaim_attempts", - claimed_at: nil, - updated_at: now - ] - ) + {failed_n, requeued_n} = + fn -> + {failed_count, _} = + "grid_tasks" + |> where( + [t], + t.status == "running" and t.claimed_at < ^cutoff and t.attempt >= ^@max_reclaim_attempts + ) + |> Repo.update_all( + set: [ + status: "failed", + error: "reclaim-orphan: exceeded max_reclaim_attempts", + claimed_at: nil, + updated_at: now + ] + ) - {requeued_n, _} = - "grid_tasks" - |> where([t], t.status == "running" and t.claimed_at < ^cutoff) - |> Repo.update_all(set: [status: "queued", claimed_at: nil, updated_at: now]) + {rq_count, _} = + "grid_tasks" + |> where([t], t.status == "running" and t.claimed_at < ^cutoff) + |> Repo.update_all(set: [status: "queued", claimed_at: nil, updated_at: now]) + + {failed_count, rq_count} + end + |> Repo.transaction() + |> case do + {:ok, {fc, rqc}} -> {fc, rqc} + {:error, reason} -> raise "reclaim_stale_running transaction failed: #{inspect(reason)}" + end if failed_n > 0 or requeued_n > 0 do Logger.info("GridTaskEnqueuer: reclaimed stale-running grid_tasks (requeued=#{requeued_n}, failed=#{failed_n})") diff --git a/lib/microwaveprop/propagation/path_analysis.ex b/lib/microwaveprop/propagation/path_analysis.ex index d75eb6aa..d7fa4670 100644 --- a/lib/microwaveprop/propagation/path_analysis.ex +++ b/lib/microwaveprop/propagation/path_analysis.ex @@ -11,6 +11,7 @@ defmodule Microwaveprop.Propagation.PathAnalysis do duplicated duct-detection and mechanism-classification logic. """ + alias Microwaveprop.Repo alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.TerrainAnalysis @@ -564,6 +565,7 @@ defmodule Microwaveprop.Propagation.PathAnalysis do defp boundary_layer_detail(_), do: nil defp sounding_ducting_detail(soundings) when is_list(soundings) do + soundings = Repo.preload(soundings, :station) ducting = Enum.filter(soundings, & &1.ducting_detected) if ducting == [] do diff --git a/lib/microwaveprop/pskr/feature_bin.ex b/lib/microwaveprop/pskr/feature_bin.ex index b1ebc2d5..c4a9e470 100644 --- a/lib/microwaveprop/pskr/feature_bin.ex +++ b/lib/microwaveprop/pskr/feature_bin.ex @@ -53,6 +53,7 @@ defmodule Microwaveprop.Pskr.FeatureBin do record |> cast(attrs, @cast_fields) |> validate_required(@required_fields) + |> foreign_key_constraint(:run_id) |> unique_constraint([:run_id, :band, :feature, :bin_label]) end end diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index 008ade89..ecb1bf7c 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -90,6 +90,8 @@ defmodule Microwaveprop.Radio.Contact do contact |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:flagged_by_user_id) end @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft private notes)a @@ -127,6 +129,8 @@ defmodule Microwaveprop.Radio.Contact do |> validate_height(:height2_ft) |> normalize_blank_notes() |> validate_length(:notes, max: 2000) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:flagged_by_user_id) end # Collapse whitespace-only notes to `nil` so the DB column reflects diff --git a/lib/microwaveprop/radio/contact_common_volume_radar.ex b/lib/microwaveprop/radio/contact_common_volume_radar.ex index bbd75bbd..e7db5b83 100644 --- a/lib/microwaveprop/radio/contact_common_volume_radar.ex +++ b/lib/microwaveprop/radio/contact_common_volume_radar.ex @@ -42,6 +42,7 @@ defmodule Microwaveprop.Radio.ContactCommonVolumeRadar do row |> cast(attrs, @required ++ @optional) |> validate_required(@required) + |> foreign_key_constraint(:contact_id) |> unique_constraint(:contact_id) end end diff --git a/lib/microwaveprop/radio/contacts.ex b/lib/microwaveprop/radio/contacts.ex index fc1014f7..80222cc4 100644 --- a/lib/microwaveprop/radio/contacts.ex +++ b/lib/microwaveprop/radio/contacts.ex @@ -308,7 +308,7 @@ defmodule Microwaveprop.Radio.Contacts do def contact_path_points(%{pos1: pos1, pos2: pos2}), do: build_path_points(extract_latlon(pos1), extract_latlon(pos2)) - defp extract_latlon(%{"lat" => lat} = pos) when is_number(lat), do: if(pos["lon"], do: {lat, pos["lon"]}) + defp extract_latlon(%{"lat" => lat} = pos) when is_number(lat), do: if(is_number(pos["lon"]), do: {lat, pos["lon"]}) defp extract_latlon(_), do: nil defp build_path_points({lat1, lon1}, {lat2, lon2}) do diff --git a/lib/microwaveprop/rover/fixed_station.ex b/lib/microwaveprop/rover/fixed_station.ex index f01beffd..158a0aec 100644 --- a/lib/microwaveprop/rover/fixed_station.ex +++ b/lib/microwaveprop/rover/fixed_station.ex @@ -49,6 +49,7 @@ defmodule Microwaveprop.Rover.FixedStation do |> validate_required([:lat, :lon]) |> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0) |> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0) + |> foreign_key_constraint(:user_id) |> unique_constraint(:callsign, name: :fixed_stations_user_id_callsign_index) end diff --git a/lib/microwaveprop/rover/location.ex b/lib/microwaveprop/rover/location.ex index c14bf1db..3859f70e 100644 --- a/lib/microwaveprop/rover/location.ex +++ b/lib/microwaveprop/rover/location.ex @@ -40,5 +40,6 @@ defmodule Microwaveprop.Rover.Location do |> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0) |> validate_inclusion(:status, @statuses) |> validate_length(:notes, max: 4000) + |> foreign_key_constraint(:user_id) end end diff --git a/lib/microwaveprop/rover_planning/mission.ex b/lib/microwaveprop/rover_planning/mission.ex index 6d09c789..e0265a0b 100644 --- a/lib/microwaveprop/rover_planning/mission.ex +++ b/lib/microwaveprop/rover_planning/mission.ex @@ -72,6 +72,7 @@ defmodule Microwaveprop.RoverPlanning.Mission do |> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0) |> validate_length(:notes, max: 4000) |> validate_at_least_one_station() + |> foreign_key_constraint(:user_id) end # The multi-checkbox form sends a hidden empty-string sentinel so diff --git a/lib/microwaveprop/rover_planning/path.ex b/lib/microwaveprop/rover_planning/path.ex index 46e8e054..1a524f6d 100644 --- a/lib/microwaveprop/rover_planning/path.ex +++ b/lib/microwaveprop/rover_planning/path.ex @@ -52,6 +52,9 @@ defmodule Microwaveprop.RoverPlanning.Path do |> validate_required([:mission_id, :rover_location_id, :station_id, :band_mhz, :status]) |> validate_inclusion(:status, @statuses) |> validate_number(:band_mhz, greater_than: 0) + |> foreign_key_constraint(:mission_id) + |> foreign_key_constraint(:rover_location_id) + |> foreign_key_constraint(:station_id) |> unique_constraint([:mission_id, :rover_location_id, :station_id, :band_mhz], name: :rover_mission_paths_unique ) diff --git a/lib/microwaveprop/rover_planning/station.ex b/lib/microwaveprop/rover_planning/station.ex index e49ef250..7ac70b6e 100644 --- a/lib/microwaveprop/rover_planning/station.ex +++ b/lib/microwaveprop/rover_planning/station.ex @@ -38,6 +38,7 @@ defmodule Microwaveprop.RoverPlanning.Station do |> validate_required([:lat, :lon]) |> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0) |> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0) + |> foreign_key_constraint(:mission_id) end # When `input` looks like a callsign or grid, geocode it and populate diff --git a/lib/microwaveprop/weather/hrdps_client.ex b/lib/microwaveprop/weather/hrdps_client.ex index d0137aad..bf0cdea3 100644 --- a/lib/microwaveprop/weather/hrdps_client.ex +++ b/lib/microwaveprop/weather/hrdps_client.ex @@ -53,12 +53,12 @@ defmodule Microwaveprop.Weather.HrdpsClient do @pressure_var_kinds [{:tmp, "TMP"}, {:depr, "DEPR"}, {:hgt, "HGT"}] - # All variable atoms are generated from compile-time constants - # (@grid_pressure_levels + @pressure_var_kinds = 21 atoms max) — safe - # to use String.to_atom/1 here; not reachable from user input. + # All pressure-level variable atoms are created as atom literals at compile + # time. The bounded cross-product of @grid_pressure_levels × @pressure_var_kinds + # (7 levels × 3 vars = 21 atoms) ensures no runtime atom creation. @pressure_vars (for level <- @grid_pressure_levels, {kind, msc} <- @pressure_var_kinds do - atom = String.to_atom("#{kind}_#{level}mb") + atom = :"#{kind}_#{level}mb" msc_level = "ISBL_#{level |> Integer.to_string() |> String.pad_leading(4, "0")}" {atom, msc, msc_level} end) diff --git a/lib/microwaveprop/weather/soundings.ex b/lib/microwaveprop/weather/soundings.ex index 8a9543d8..ac8975ed 100644 --- a/lib/microwaveprop/weather/soundings.ex +++ b/lib/microwaveprop/weather/soundings.ex @@ -195,7 +195,8 @@ defmodule Microwaveprop.Weather.Soundings do s.observed_at, ^timestamp ), - limit: 1 + limit: 1, + preload: [:station] ) |> Repo.one() |> case do diff --git a/lib/microwaveprop_web/live/admin/monitor_live/index.ex b/lib/microwaveprop_web/live/admin/monitor_live/index.ex index ca3384ba..f0dbdfcb 100644 --- a/lib/microwaveprop_web/live/admin/monitor_live/index.ex +++ b/lib/microwaveprop_web/live/admin/monitor_live/index.ex @@ -108,7 +108,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Index do