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"""
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.
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37). Antenna heights are optional but improve terrain-clearance analysis.