defmodule MicrowavepropWeb.SubmitLive do
@moduledoc "`/submit` QSO submission form; enqueues enrichment jobs on save."
use MicrowavepropWeb, :live_view
use LiveStash, stored_keys: [:active_tab]
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio
alias Microwaveprop.Radio.AdifImport
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.CsvImport
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@band_options BandConfig.band_options()
@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: @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
# 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
email =
case current_user(socket.assigns) do
%{email: user_email} -> user_email
_ -> params |> Map.get("submitter_email", "") |> String.trim()
end
private = Map.get(params, "private") == "true"
if email == "" do
{:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
else
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
email =
case current_user(socket.assigns) do
%{email: user_email} -> user_email
_ -> params |> Map.get("submitter_email", "") |> String.trim()
end
private = Map.get(params, "private") == "true"
if email == "" do
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
else
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
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
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} ->
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
{:error, :no_records} ->
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
end
end
defp handle_csv_preview(content, email, private, socket) do
case CsvImport.preview(content, email) do
{:ok, preview} ->
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
{: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")}
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
defp error_to_string(:too_large), do: "File is too large"
defp error_to_string(:too_many_files), do: "Only one file allowed"
defp error_to_string(:not_accepted), do: "File type not accepted"
defp error_to_string(other), do: to_string(other)
@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.
Upload a CSV file with multiple contacts. Columns:
station1, station2, grid1, grid2, band, mode, qso_timestamp, notes
2024-06-15T14:30:00Z, 6/15/2024 2:30 PM, 2024-06-15 14:30). All times assumed UTC.
mode column entirely
(6 columns total) or leave its value blank.
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
Upload an ADIF (.adi / .adif) file exported from your logging program.
CALL, STATION_CALLSIGN
(or OPERATOR), GRIDSQUARE, MY_GRIDSQUARE, QSO_DATE, TIME_ON,
and FREQ
or BAND
NOTES
is carried over (falling back to COMMENT)
Mode handling
ADIF modes are normalized to the six modes we track. SUBMODE takes
precedence when present (e.g. MODE=MFSK, SUBMODE=FT8 imports as FT8).
| ADIF value | Stored as |
|---|---|
{adif} |
{mapped} |
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}. Nothing has been inserted yet — confirm below to import.
| Row | Errors |
|---|---|
| Row {row.row_num} |
{msg}
|
100} class="text-xs opacity-60 mt-1"> Showing first 100 of {@invalid_count} invalid rows.
These rows match an existing contact but add more precise data. Confirming will update the existing contact in place.
| Row | Station 1 | Station 2 | Band | Changes |
|---|---|---|---|---|
| Row {row.row_num} | {row.attrs["station1"]} {row.attrs["grid1"]} | {row.attrs["station2"]} {row.attrs["grid2"]} | {row.attrs["band"]} |
{field}:
{value}
|
length(@refinement_sample)} class="text-xs opacity-60 mt-1"> Showing first {length(@refinement_sample)} of {@refinement_count} refinements.
Same two callsigns at the same grids on the same band within an hour.
| Row | Station 1 | Station 2 | Band | Timestamp | Source |
|---|---|---|---|---|---|
| Row {row.row_num} | {row.attrs["station1"]} {row.attrs["grid1"]} | {row.attrs["station2"]} {row.attrs["grid2"]} | {row.attrs["band"]} | {Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")} | {duplicate_source(row.reason)} |
100} class="text-xs opacity-60 mt-1"> Showing first 100 of {@duplicate_count} duplicates.
| Row | Station 1 | Station 2 | Band | Mode | Timestamp |
|---|---|---|---|---|---|
| Row {row.row_num} | {row.attrs["station1"]} {row.attrs["grid1"]} | {row.attrs["station2"]} {row.attrs["grid2"]} | {row.attrs["band"]} | {row.attrs["mode"]} | {Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")} |
length(@valid_sample)} class="text-xs opacity-60 mt-1"> Showing first {length(@valid_sample)} of {@valid_count} valid rows.