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""" <.header> Submit Contact <:subtitle>Help us build a better propagation model

Every contact matters

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.

Your contact will be available on the <.link navigate="/contacts" class="link link-primary">contacts page shortly after submission, with terrain analysis, atmospheric data, and propagation scoring added automatically.

<%= if current_user(assigns) do %> <% end %>
<%= 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 %>
""" end defp single_contact_form(assigns) do ~H""" <.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-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" />

Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37). Antenna heights are optional but improve terrain-clearance analysis.

<.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 />
<%= if @current_user do %> <% 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" />
<.input field={@form[:private]} type="checkbox" label="Private — only visible to me and administrators" />
<.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
""" end end