# Code Review: DRY & Idiomatic Elixir Findings This document captures concrete duplication and non-idiomatic patterns found across the codebase. Each finding includes file paths and line numbers where applicable. --- ## 1. ✅ DONE - Duplicated `credential_source_atom` / `safe_source_to_atom` **Files:** `lib/towerops/devices.ex` lines 408–412 and 887–893 Two private functions in the same module do the exact same thing — convert a credential source string to an atom — with slightly different names and one extra `nil` clause: ```elixir # line 408 defp safe_source_to_atom("device"), do: :device defp safe_source_to_atom("site"), do: :site defp safe_source_to_atom("organization"), do: :organization defp safe_source_to_atom(nil), do: :organization defp safe_source_to_atom(_unknown), do: :organization # line 887 defp credential_source_atom(value) do case value do "device" -> :device "site" -> :site "organization" -> :organization _ -> :organization end end ``` These should be merged into a single private function using pattern-matching clauses (the idiomatic form): ```elixir defp credential_source_atom("device"), do: :device defp credential_source_atom("site"), do: :site defp credential_source_atom(_), do: :organization ``` --- ## 2. ✅ DONE - Six Near-Identical `propagate_*` Functions in `devices.ex` **File:** `lib/towerops/devices.ex` The following six public functions share an identical structure — build a query, fetch device IDs, compute `now`, call `Repo.update_all`, then broadcast per-device: - `propagate_site_mikrotik_change/2` (~line 459) - `propagate_organization_mikrotik_change/2` (~line 492) - `propagate_site_snmpv3_change/2` (~line 563) - `propagate_organization_snmpv3_change/2` (~line 591) - `propagate_site_community_change/2` (~line 903) - `propagate_organization_community_change/2` (~line 930) The only differences are the query filter field (`site_id` vs `organization_id`), the credential source string (`"site"` vs `"organization"`), the fields being updated, and the broadcast event atom. Extract a private helper: ```elixir defp propagate_credential_change(device_query, attrs, broadcast_event) do device_ids = Repo.all(from(d in device_query, select: d.id)) now = DateTime.truncate(DateTime.utc_now(), :second) updates = attrs |> Map.new() |> Map.put(:updated_at, now) |> Map.to_list() Repo.update_all(device_query, set: updates) Enum.each(device_ids, fn device_id -> Phoenix.PubSub.broadcast( Towerops.PubSub, "device:#{device_id}:assignments", {:assignments_changed, broadcast_event} ) end) :ok end ``` The six public functions then become thin wrappers that build the query and delegate. --- ## 3. ⏭️ DEFERRED - Repeated Form LiveView Pattern (handle_params + validate + save) **Reason**: Complex refactoring requiring behavior module or macro abstraction across 4 LiveView files. Would benefit from separate PR with comprehensive testing. **Files:** - `lib/towerops_web/live/schedule_live/form.ex` - `lib/towerops_web/live/escalation_policy_live/form.ex` - `lib/towerops_web/live/maintenance_live/form.ex` - `lib/towerops_web/live/site_live/form.ex` All four follow the exact same skeleton: ```elixir def handle_params(params, _url, socket) do case socket.assigns.live_action do :new -> # assign empty struct + changeset :edit -> # load resource + changeset end end def handle_event("validate", %{"resource" => params}, socket) do changeset = socket.assigns.resource |> Schema.changeset(params) |> Map.put(:action, :validate) {:noreply, assign(socket, :form, to_form(changeset))} end def handle_event("save", %{"resource" => params}, socket) do case socket.assigns.live_action do :new -> save_resource(socket, :create, params) :edit -> save_resource(socket, :update, params) end end defp save_resource(socket, :create, params) do case Context.create_resource(params) do {:ok, resource} -> {:noreply, socket |> put_flash(:info, "...") |> push_navigate(...)} {:error, changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end defp save_resource(socket, :update, params) do case Context.update_resource(socket.assigns.resource, params) do {:ok, resource} -> {:noreply, socket |> put_flash(:info, "...") |> push_navigate(...)} {:error, changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end ``` The `save_resource/3` `:create` and `:update` clauses are also structurally identical — only the context function and flash message differ. The `case socket.assigns.live_action` dispatch in `handle_event("save")` is also repeated verbatim in all four modules. --- ## 4. ⏭️ DEFERRED - Repeated Access Control Error Handling in LiveViews **Reason**: Would require AccessControl helper to return socket-ready responses. Impacts 6+ LiveView files. Better as separate focused PR. **Files:** - `lib/towerops_web/live/alert_live/index.ex` (~lines 53–80) - `lib/towerops_web/live/device_live/form.ex` (~lines 105–115) - `lib/towerops_web/live/device_live/show.ex` (~lines 61–75, 817–825, 1018–1025) - `lib/towerops_web/live/config_timeline_live.ex` (~line 24) - `lib/towerops_web/live/dashboard_live.ex` (~line 66) - `lib/towerops_web/live/device_live/index.ex` (~lines 146–166) Every call to `AccessControl.verify_*_access/2` is followed by the same two error clauses: ```elixir {:error, :not_found} -> {:noreply, put_flash(socket, :error, t_equipment("... not found"))} {:error, :unauthorized} -> {:noreply, put_flash(socket, :error, t_equipment("You don't have access to this ..."))} ``` `AccessControl` already centralises the lookup logic. A companion helper in the same module (or a small addition to `AccessControl`) could handle the flash + navigate response, reducing each call site to a single `with` or `case` without the repeated error branches. --- ## 5. ✅ DONE - `determine_effective_agent_id` Duplicates `Agents.get_effective_agent_token/1` **File:** `lib/towerops_web/live/device_live/form.ex` (~lines 390–415) `determine_effective_agent_id/2` walks device → site → org to find an agent token. This is exactly what `Agents.get_effective_agent_token/1` and `Agents.get_effective_agent_token_with_source/1` already do in `lib/towerops/agents.ex`. The LiveView version operates on form params (maps) rather than structs, but the logic is the same. Similarly, `using_cloud_poller?/2` (~line 418) repeats the same three-level walk. These should be consolidated — either by passing a lightweight struct to the existing `Agents` functions, or by extracting a shared helper that accepts either form params or a struct. --- ## 6. ✅ DONE - `get_fallback_agent_token/1` and `get_fallback_agent_token_with_source/1` Are Near-Identical **File:** `lib/towerops/agents.ex` Both private functions walk the same site → org → global chain. The only difference is that one returns just the token ID and the other returns `{token_id, source}`. This is a classic case for a single function that always returns `{id, source}`, with the caller discarding the source when not needed: ```elixir defp resolve_fallback_agent(%{site: %{agent_token_id: id}} = _device) when not is_nil(id), do: {id, :site} defp resolve_fallback_agent(%{site: %{organization: %{default_agent_token_id: id}}} = _device) when not is_nil(id), do: {id, :organization} defp resolve_fallback_agent(%{organization: %{default_agent_token_id: id}} = _device) when not is_nil(id), do: {id, :organization} defp resolve_fallback_agent(_device) do case Towerops.Settings.get_global_default_cloud_poller() do nil -> {nil, :none} id -> {id, :global} end end ``` `get_effective_agent_token/1` then becomes `elem(get_effective_agent_token_with_source(device), 0)`. --- ## 7. ✅ DONE - `cond` Instead of Pattern-Matching Function Clauses **File:** `lib/towerops_web/live/device_live/form.ex` (~lines 72–82) ```elixir equipment_attrs = cond do socket.assigns.preselected_site_id -> Map.put(equipment_attrs, :site_id, socket.assigns.preselected_site_id) length(socket.assigns.available_sites) == 1 -> site = List.first(socket.assigns.available_sites) Map.put(equipment_attrs, :site_id, site.id) true -> equipment_attrs end ``` This is better expressed as a private function with pattern-matching clauses: ```elixir defp maybe_preselect_site(attrs, preselected_id, _sites) when not is_nil(preselected_id), do: Map.put(attrs, :site_id, preselected_id) defp maybe_preselect_site(attrs, nil, [site]), do: Map.put(attrs, :site_id, site.id) defp maybe_preselect_site(attrs, nil, _sites), do: attrs ``` **File:** `lib/towerops/agents.ex` — `get_fallback_agent_token/1` and `get_fallback_agent_token_with_source/1` both use `cond` with `match?/2` guards. These should be pattern-matching function clauses (see finding #6 above). **File:** `lib/towerops/devices.ex` — `should_trigger_discovery?/4` (~line 1090) uses `cond` with boolean conditions that are better expressed as pattern-matching clauses or a series of guard-based function heads. --- ## 8. ✅ DONE - `if` for Optional Value Lookup (Use `case` or Pattern Matching) **File:** `lib/towerops_web/live/site_live/form.ex` (~lines 30–36 and 52–58) ```elixir org_agent = if socket.assigns.organization.default_agent_token_id do Enum.find(socket.assigns.available_agents, fn a -> a.id == socket.assigns.organization.default_agent_token_id end) end ``` This duplicates the same block in both `apply_action/3` clauses. Extract to a private function and use `case` or pattern matching: ```elixir defp find_org_agent(nil, _agents), do: nil defp find_org_agent(agent_id, agents), do: Enum.find(agents, &(&1.id == agent_id)) ``` Then call `find_org_agent(organization.default_agent_token_id, available_agents)` in both clauses. --- ## 9. ✅ DONE - `normalize_alert_type` Defined Twice in `alerts.ex` **File:** `lib/towerops/alerts.ex` ```elixir # Used for attrs map normalization defp normalize_attrs_alert_type(%{alert_type: alert_type} = attrs) when is_atom(alert_type) do %{attrs | alert_type: to_string(alert_type)} end defp normalize_attrs_alert_type(attrs), do: attrs # Used for query normalization defp normalize_alert_type(alert_type) when is_atom(alert_type), do: to_string(alert_type) defp normalize_alert_type(alert_type) when is_binary(alert_type), do: alert_type ``` The second function (`normalize_alert_type/1`) is a subset of the first. The atom-to-string conversion is the same in both. The attrs version can delegate: ```elixir defp normalize_attrs_alert_type(%{alert_type: type} = attrs) when is_atom(type), do: %{attrs | alert_type: normalize_alert_type(type)} defp normalize_attrs_alert_type(attrs), do: attrs ``` --- ## 10. ✅ DONE - `resolve_alert` and `resolve_alert_silent` Share Identical Changeset Logic **File:** `lib/towerops/alerts.ex` Both functions build the same changeset (`%{resolved_at: DateTime.truncate(...)}`) and call `Repo.update`. The only difference is the post-update side effects. Extract the update: ```elixir defp do_resolve_alert(alert) do alert |> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)}) |> Repo.update() end ``` Then `resolve_alert/1` and `resolve_alert_silent/1` call `do_resolve_alert/1` and handle their respective side effects. The same applies to `acknowledge_alert/2` and `acknowledge_alert_silent/1`. --- ## 11. ✅ DONE - `DateTime.truncate(DateTime.utc_now(), :second)` Repeated ~30+ Times **Files:** `lib/towerops/devices.ex`, `lib/towerops/alerts.ex`, `lib/towerops/sites.ex`, `lib/towerops/organizations.ex`, `lib/towerops/agents.ex`, `lib/towerops_web/channels/agent_channel.ex`, and many more. This expression appears over 30 times across the codebase. A single module-level helper (or a function in `Towerops.Repo` or a shared utility module) would eliminate the repetition: ```elixir defp now(), do: DateTime.truncate(DateTime.utc_now(), :second) ``` Or, since it's needed across many modules, a public helper in e.g. `Towerops.Time`: ```elixir defmodule Towerops.Time do def now(), do: DateTime.truncate(DateTime.utc_now(), :second) end ``` --- ## 12. ✅ DONE - `sanitize_like/1` Delegated Identically in Both `sites.ex` and `devices.ex` **Files:** `lib/towerops/sites.ex` (last line) and `lib/towerops/devices.ex` (last line) Both modules define: ```elixir defp sanitize_like(query), do: Towerops.QueryHelpers.sanitize_like(query) ``` This private wrapper adds no value — callers can just call `Towerops.QueryHelpers.sanitize_like/1` directly, or `import Towerops.QueryHelpers, only: [sanitize_like: 1]` at the top of each module. --- ## 13. ⏭️ SKIPPED - `handle_params` Tab/Filter Pattern Repeated Across Index LiveViews **Reason**: Pattern is already clear and simple (`Map.get(params, "filter", default)`). Helper function would add minimal value. **Files:** - `lib/towerops_web/live/alert_live/index.ex` (~lines 38–44) - `lib/towerops_web/live/maintenance_live/index.ex` (~lines 17–23) Both follow: ```elixir def handle_params(params, _url, socket) do filter = Map.get(params, "filter", "default") {:noreply, socket |> assign(:filter, filter) |> load_data(...)} end ``` This is a minor but consistent pattern. A shared helper `assign_from_params(socket, params, key, default)` would make the intent clearer and reduce boilerplate. --- ## 14. ✅ DONE - `verify_device_access` / `verify_site_access` in `AccessControl` Use `if` Instead of Pattern Matching **File:** `lib/towerops_web/live/helpers/access_control.ex` (~lines 34–43, 66–75) ```elixir def verify_device_access(device_id, organization_id) do case Devices.get_device(device_id) do nil -> {:error, :not_found} device -> if device.organization_id == organization_id do {:ok, device} else {:error, :unauthorized} end end end ``` The inner `if` should be a `case` or pattern match. More idiomatically, the `case` + `if` can collapse into a single `case` with a guard, or use `with`: ```elixir def verify_device_access(device_id, organization_id) do with %Device{organization_id: ^organization_id} = device <- Devices.get_device(device_id) do {:ok, device} else nil -> {:error, :not_found} %Device{} -> {:error, :unauthorized} end end ``` The same applies to `verify_site_access/2`. `verify_alert_access/2` has a more complex preload but the same structural issue. --- ## 15. ✅ DONE - `resolve_mikrotik_config` Uses `if` for a Nil-Default **File:** `lib/towerops/devices.ex` (~line 448) ```elixir use_ssl: if(device.mikrotik_use_ssl == nil, do: true, else: device.mikrotik_use_ssl), ``` This is better expressed as: ```elixir use_ssl: device.mikrotik_use_ssl != false, ``` or with a pattern-matching helper: ```elixir defp mikrotik_use_ssl?(nil), do: true defp mikrotik_use_ssl?(val), do: val ``` --- ## 16. ✅ DONE - `handle_snmp_changes` and `should_trigger_discovery?` Use `cond` for Boolean Logic **File:** `lib/towerops/devices.ex` (~lines 1080–1110) `should_trigger_discovery?/4` uses a `cond` with four boolean branches, all of which check `device.snmp_enabled` first. This is better expressed as pattern-matching function clauses: ```elixir defp should_trigger_discovery?(%{snmp_enabled: true}, false, _old_version, _old_port), do: true defp should_trigger_discovery?(%{snmp_enabled: true, snmp_version: v}, _old_snmp, v, _), do: false defp should_trigger_discovery?(%{snmp_enabled: true, snmp_version: _}, _, _, _), do: true defp should_trigger_discovery?(%{snmp_enabled: true, snmp_port: p}, _, _, p), do: false defp should_trigger_discovery?(%{snmp_enabled: true}, _, _, _), do: true defp should_trigger_discovery?(_, _, _, _), do: false ``` Similarly, `handle_monitoring_changes/2` and `handle_snmp_changes/4` use `cond` where function clauses with pattern matching on the boolean pairs would be cleaner. --- ## 17. ✅ DONE - `get_device` and `get_device!` Repeat the Same Preload **File:** `lib/towerops/devices.ex` (~lines 330–345) ```elixir def get_device(id) do DeviceSchema |> Repo.get(id) |> case do nil -> nil device -> Repo.preload(device, [:organization, site: :organization]) end end def get_device!(id) do DeviceSchema |> Repo.get!(id) |> Repo.preload([:organization, site: :organization]) end ``` The preload list `[:organization, site: :organization]` is duplicated. Extract it as a module attribute or a private function: ```elixir @device_preloads [:organization, site: :organization] def get_device(id) do case Repo.get(DeviceSchema, id) do nil -> nil device -> Repo.preload(device, @device_preloads) end end def get_device!(id), do: DeviceSchema |> Repo.get!(id) |> Repo.preload(@device_preloads) ``` The same pattern applies to `get_site/1` and `get_site!/1` in `lib/towerops/sites.ex` (both preload `[:parent_site, :child_sites, :device]`). --- ## 18. ✅ DONE - `list_organization_devices` Uses `if` for Query Composition **File:** `lib/towerops/devices.ex` (~lines 44–65) ```elixir query = if site_id = filters["site_id"] do where(query, [e], e.site_id == ^site_id) else query end query = if status = filters["status"] do where(query, [e], e.status == ^status) else query end ``` This is the idiomatic Ecto filter-building pattern, but the `if/else query` form is verbose. The more idiomatic approach uses `Enum.reduce` over the filters or a pipeline of `maybe_filter_*` helpers: ```elixir defp maybe_filter_site(query, nil), do: query defp maybe_filter_site(query, site_id), do: where(query, [e], e.site_id == ^site_id) defp maybe_filter_status(query, nil), do: query defp maybe_filter_status(query, status), do: where(query, [e], e.status == ^status) ``` --- ## 19. ⏭️ SKIPPED - `alert_live/index.ex` — `severity_color/1` and `severity_badge_class/1` Are Two Functions That Should Be One **Reason**: `severity_color/1` is used independently in template for text colors. Cannot collapse without breaking functionality. **File:** `lib/towerops_web/live/alert_live/index.ex` (~lines 220–250) `severity_color/1` returns a string like `"red"`, and `severity_badge_class/1` immediately maps that string to a CSS class. The intermediate string representation is only used to feed `severity_badge_class/1`. Collapse them: ```elixir defp severity_badge_class(alert) do cond do alert.resolved_at -> "bg-gray-300 dark:bg-gray-600" alert.alert_type == "device_down" and is_nil(alert.resolved_at) -> age_minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute) cond do age_minutes > 60 -> "bg-red-500" age_minutes > 15 -> "bg-orange-500" true -> "bg-yellow-500" end true -> "bg-green-500" end end ``` Unless `severity_color/1` is used independently in the template (in which case the indirection is intentional), the two-step conversion is unnecessary. --- ## 20. ⏭️ SKIPPED - `age_text/1`, `duration_text/1`, and `time_ago/1` Repeat Minute-Bucketing Logic **Reason**: Functions have different semantics (seconds vs minutes, "just now" vs exact seconds) and serve different contexts. Duplication is acceptable for clarity. **Files:** `lib/towerops_web/live/alert_live/index.ex` and `lib/towerops_web/live/device_live/helpers/formatters.ex` `age_text/1` and `duration_text/1` in `alert_live/index.ex` both compute `minutes` from a `DateTime.diff` and then bucket into `<1m / Xm / Xh / Xd`. The `Formatters` module already has `time_ago/1`. These should be consolidated in `Formatters` and called from the template. --- ## Summary of Priorities **High impact (most duplication removed):** 1. Merge `safe_source_to_atom` / `credential_source_atom` into one function (finding #1) 2. Extract `propagate_credential_change/3` helper (finding #2) 3. Merge `get_fallback_agent_token` variants (finding #6) 4. Extract `do_resolve_alert` / `do_acknowledge_alert` (finding #10) 5. Introduce `Towerops.Time.now/0` or a module-level `now/0` (finding #11) **Medium impact (idiomatic improvements):** 6. Replace `if` with `with` in `AccessControl.verify_*_access` (finding #14) 7. Replace `cond` with pattern-matching clauses in `device_live/form.ex`, `agents.ex`, `devices.ex` (findings #7, #16) 8. Extract `maybe_preselect_site/3` and `find_org_agent/2` (findings #7, #8) 9. Use `@device_preloads` module attribute for repeated preload lists (finding #17) 10. Use `maybe_filter_*` helpers in `list_organization_devices` (finding #18) **Low impact (minor cleanup):** 11. Remove `sanitize_like/1` private wrappers (finding #12) 12. Collapse `severity_color` + `severity_badge_class` (finding #19) 13. Consolidate time-formatting helpers into `Formatters` (finding #20) 14. Simplify `normalize_attrs_alert_type` to delegate to `normalize_alert_type` (finding #9)