prop/lib/microwaveprop_web/live/submit_live.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign
  normalization, lat/lon validation, grid/latlon derivation across 6 schemas
- Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue
  for Oban workers, UUID casting to replace CastError rescues
- Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions),
  subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2
- Extract Propagation.ScoreStore (528 lines): separate file I/O and cache
  management from scoring logic, 13 defdelegate passthroughs
- Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into
  3 function component modules (csv_upload, adif_upload, preview)
- Update 16 LiveViews to use shared helpers
2026-08-06 18:06:50 -05:00

474 lines
14 KiB
Elixir

defmodule MicrowavepropWeb.SubmitLive do
@moduledoc "`/submit` QSO submission form; enqueues enrichment jobs on save."
use MicrowavepropWeb, :live_view
use LiveStash, stored_keys: [:active_tab]
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
import MicrowavepropWeb.SubmitLive.AdifUploadComponent
import MicrowavepropWeb.SubmitLive.CsvUploadComponent
import MicrowavepropWeb.SubmitLive.PreviewComponent
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio
alias Microwaveprop.Radio.AdifImport
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.CsvImport
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@mode_options ~w(CW SSB FM FT8 FT4 Q65)
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> allow_upload(:csv,
accept: ~w(.csv),
max_entries: 1,
max_file_size: 10_000_000,
auto_upload: false
)
|> allow_upload(:adif,
accept: :any,
max_entries: 1,
max_file_size: 10_000_000,
auto_upload: false
)
changeset = Radio.change_contact(%Contact{})
socket =
assign(socket,
page_title: "Submit Contact",
form: to_form(changeset),
band_options: BandConfig.band_options(),
mode_options: @mode_options,
submitted_at: nil,
active_tab: :single,
csv_preview: nil,
csv_email: ""
)
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
{:ok, socket}
_ ->
{:ok, socket}
end
end
@valid_tabs ~w(single csv adif)a
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
case Enum.find(@valid_tabs, &(Atom.to_string(&1) == tab)) do
nil ->
# Stale or forged client event — ignore instead of crashing the
# LiveView via String.to_existing_atom/1.
{:noreply, socket}
atom ->
socket = assign(socket, active_tab: atom)
{:noreply, MicrowavepropWeb.LiveStashGuard.stash(socket)}
end
end
def handle_event("validate", %{"contact" => contact_params}, socket) do
changeset =
%Contact{}
|> Radio.change_contact(contact_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("validate_csv", _params, socket) do
{:noreply, socket}
end
def handle_event("validate_adif", _params, socket) do
{:noreply, socket}
end
@max_import_rows 2_000
# Minimum milliseconds between submissions per LiveView session.
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
@submission_cooldown_ms 30_000
def handle_event("save", %{"contact" => contact_params}, socket) do
if recently_submitted?(socket) do
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
else
{contact_params, user_id} = merge_user_params(contact_params, socket)
case Radio.create_contact(contact_params, user_id) do
{:ok, contact} ->
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
{:noreply,
socket
|> assign(submitted_at: System.monotonic_time(:millisecond))
|> put_flash(:info, "Contact submitted successfully!")
|> push_navigate(to: ~p"/contacts/#{contact.id}")}
{:error, :duplicate, existing} ->
{:noreply,
socket
|> put_flash(
:error,
"A matching contact already exists: #{existing.station1}#{existing.station2} on #{existing.band} MHz"
)
|> push_navigate(to: ~p"/contacts/#{existing.id}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
end
def handle_event("upload_csv", params, socket) do
cond do
recently_submitted?(socket) ->
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
!current_user(socket.assigns) ->
{:noreply, put_flash(socket, :error, "Please sign in to upload files.")}
true ->
user = current_user(socket.assigns)
email = user.email
private = Map.get(params, "private") == "true"
uploaded_contents =
consume_uploaded_entries(socket, :csv, fn %{path: path}, _entry ->
{:ok, File.read!(path)}
end)
case uploaded_contents do
[content] ->
handle_csv_preview(content, email, private, socket)
[] ->
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
end
end
end
def handle_event("cancel_csv_upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :csv, ref)}
end
def handle_event("upload_adif", params, socket) do
cond do
recently_submitted?(socket) ->
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
!current_user(socket.assigns) ->
{:noreply, put_flash(socket, :error, "Please sign in to upload files.")}
true ->
user = current_user(socket.assigns)
email = user.email
private = Map.get(params, "private") == "true"
uploaded_contents =
consume_uploaded_entries(socket, :adif, fn %{path: path}, _entry ->
{:ok, File.read!(path)}
end)
case uploaded_contents do
[content] ->
handle_adif_preview(content, email, private, socket)
[] ->
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
end
end
end
def handle_event("cancel_adif_upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :adif, ref)}
end
def handle_event("confirm_csv", _params, socket) do
cond do
recently_submitted?(socket) ->
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
!current_user(socket.assigns) ->
{:noreply, put_flash(socket, :error, "Please sign in to upload files.")}
true ->
case socket.assigns.csv_preview do
%{valid: valid_rows, refinements: refinements} = preview
when valid_rows != [] or refinements != [] ->
private = socket.assigns[:csv_private] || false
{:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email, private: private)
{:noreply,
socket
|> assign(csv_preview: nil, csv_private: false)
|> push_navigate(to: ~p"/imports/#{run_id}")}
_ ->
{:noreply, put_flash(socket, :error, "Nothing to import.")}
end
end
end
def handle_event("cancel_csv", _params, socket) do
{:noreply, assign(socket, csv_preview: nil, csv_private: false)}
end
defp handle_adif_preview(content, email, private, socket) do
case AdifImport.preview(content, email) do
{:ok, preview} ->
if preview.total_rows > @max_import_rows do
{:noreply, put_flash(socket, :error, "ADIF file has too many rows (max #{@max_import_rows}).")}
else
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
end
{:error, :no_records} ->
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
{:error, :too_many_rows} ->
{:noreply, put_flash(socket, :error, "ADIF file has too many rows (max #{@max_import_rows}).")}
end
end
defp handle_csv_preview(content, email, private, socket) do
case CsvImport.preview(content, email) do
{:ok, preview} ->
if preview.total_rows > @max_import_rows do
{:noreply, put_flash(socket, :error, "CSV file has too many rows (max #{@max_import_rows}).")}
else
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
end
{:error, :empty_csv} ->
{:noreply, put_flash(socket, :error, "CSV file is empty")}
{:error, :no_data_rows} ->
{:noreply, put_flash(socket, :error, "CSV file has no data rows")}
{:error, :too_many_rows} ->
{:noreply, put_flash(socket, :error, "CSV file has too many rows (max #{@max_import_rows}).")}
end
end
defp recently_submitted?(socket) do
case socket.assigns.submitted_at do
nil -> false
ts -> System.monotonic_time(:millisecond) - ts < @submission_cooldown_ms
end
end
defp merge_user_params(params, socket) do
case current_user(socket.assigns) do
%{id: user_id, email: user_email} ->
{Map.put(params, "submitter_email", user_email), user_id}
_ ->
{params, nil}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Submit Contact
<:subtitle>Help us build a better propagation model</:subtitle>
</.header>
<div class="alert alert-info mb-6 text-sm leading-relaxed">
<div>
<p class="font-semibold mb-1">Every contact matters</p>
<p>
Our propagation model improves with real-world data. Each verified contact
you submit is matched against atmospheric conditions at the time of your contact,
helping us calibrate predictions for all amateur bands from 50 MHz and up. The
more contacts we have, the better our forecasts get for everyone.
</p>
</div>
</div>
<div class="alert mb-6 text-sm leading-relaxed">
<p>
Your contact will be available on the
<.link navigate="/contacts" class="link link-primary">contacts page</.link>
shortly after submission, with terrain analysis, atmospheric data, and propagation scoring
added automatically.
</p>
</div>
<div role="tablist" class="tabs tabs-box mb-6">
<button
role="tab"
class={["tab", @active_tab == :single && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="single"
>
Single Contact
</button>
<%= if current_user(assigns) do %>
<button
role="tab"
class={["tab", @active_tab == :csv && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="csv"
>
Upload CSV
</button>
<button
role="tab"
class={["tab", @active_tab == :adif && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="adif"
>
Upload ADIF
</button>
<% end %>
</div>
<%= case @active_tab do %>
<% :single -> %>
<.single_contact_form
form={@form}
band_options={@band_options}
mode_options={@mode_options}
current_user={current_user(assigns)}
/>
<% :csv -> %>
<%= if @csv_preview do %>
<.csv_preview preview={@csv_preview} />
<% else %>
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<% end %>
<% :adif -> %>
<%= if @csv_preview do %>
<.csv_preview preview={@csv_preview} />
<% else %>
<.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<% end %>
<% end %>
</Layouts.app>
"""
end
defp single_contact_form(assigns) do
~H"""
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" required />
<.input
field={@form[:grid1]}
type="text"
label="Grid 1"
placeholder="EM12kp"
phx-debounce="blur"
required
/>
<.input
field={@form[:height1_ft]}
type="number"
label="Height 1 (ft AGL)"
min="0"
max="1000"
placeholder="Optional"
/>
<.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" required />
<.input
field={@form[:grid2]}
type="text"
label="Grid 2"
placeholder="EM00cd"
phx-debounce="blur"
required
/>
<.input
field={@form[:height2_ft]}
type="number"
label="Height 2 (ft AGL)"
min="0"
max="1000"
placeholder="Optional"
/>
</div>
<p class="text-sm text-base-content/60 -mt-2">
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).
Antenna heights are optional but improve terrain-clearance analysis.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input
field={@form[:band]}
type="select"
label="Band"
prompt="Select band"
options={@band_options}
required
/>
<.input
field={@form[:mode]}
type="select"
label="Mode"
prompt="Optional"
options={@mode_options}
/>
<.input
field={@form[:qso_timestamp]}
type="text"
label="Timestamp (UTC, 24h)"
placeholder="YYYY-MM-DD HH:MM"
pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
required
/>
</div>
<%= if @current_user do %>
<input
type="hidden"
name="contact[submitter_email]"
value={@current_user.email}
/>
<% else %>
<.input
field={@form[:submitter_email]}
type="email"
label="Your Email"
placeholder="you@example.com"
required
/>
<% end %>
<.input
field={@form[:notes]}
type="textarea"
label="Notes (optional)"
placeholder="Operator notes — propagation mode, weather, equipment, QSO commentary…"
rows="3"
maxlength="2000"
/>
<div class="mt-4">
<.input
field={@form[:private]}
type="checkbox"
label="Private — only visible to me and administrators"
/>
</div>
<div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
</.button>
</div>
</.form>
"""
end
end