fix(devices): make upsert_devices/1 work with Repo.insert_all

Three related bugs prevented upsert_devices/1 from ever succeeding in
production:

- The JSON map keys ("identifier", "vendor", etc.) were passed through
  to Repo.insert_all, which only accepts atom field names.
- updated_at was a %DateTime{} but the Devices schema uses :naive_datetime.
- inserted_at was never set, violating the NOT NULL constraint.

Fix by whitelisting known string keys and converting them to atoms via
String.to_existing_atom/1 (safe against atom exhaustion), using
NaiveDateTime.utc_now/1, and setting both timestamps explicitly.
This commit is contained in:
Graham McIntire 2026-04-23 13:31:49 -05:00
parent 5ef97adaf7
commit 1ad1bef59f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -135,6 +135,10 @@ defmodule Aprsme.DeviceIdentification do
end
end
# The Devices schema uses :naive_datetime for its timestamps, so insert_all
# needs a NaiveDateTime, not a DateTime.
defp now_for_insert, do: NaiveDateTime.utc_now(:second)
def fetch_and_upsert_devices do
case CircuitBreaker.call(:aprs_foundation_api, &fetch_devices_from_url/0, 15_000) do
{:ok, result} -> result
@ -159,7 +163,7 @@ defmodule Aprsme.DeviceIdentification do
tocalls = Map.get(json, "tocalls", %{})
mice = Map.get(json, "mice", %{})
micelegacy = Map.get(json, "micelegacy", %{})
now = DateTime.utc_now()
now = now_for_insert()
all_devices =
Enum.flat_map([tocalls, mice, micelegacy], fn group ->
@ -177,15 +181,24 @@ defmodule Aprsme.DeviceIdentification do
:ok
end
# Whitelist of JSON keys that map directly to Devices schema fields.
# Using an explicit list avoids String.to_atom on untrusted input.
@device_fields ~w(identifier class model vendor os contact features)
defp process_device_attrs(attrs, identifier, now) do
attrs
|> Map.put("identifier", identifier)
|> Map.update("features", nil, fn f ->
if is_list(f), do: f, else: [f]
end)
|> Map.put("updated_at", now)
|> Map.update("features", nil, &normalize_features/1)
|> Map.take(@device_fields)
|> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end)
|> Map.put(:inserted_at, now)
|> Map.put(:updated_at, now)
end
defp normalize_features(features) when is_list(features), do: features
defp normalize_features(nil), do: nil
defp normalize_features(other), do: [other]
@doc """
Looks up a device by identifier, using ? as a single-character wildcard.
Returns the device struct if found, or nil.