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""" <.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.

<%= 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 # In-template variant — takes an assigns map directly (not a socket). defp current_user(%{current_scope: %{user: %{} = user}}), do: user defp current_user(_), do: nil 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 defp csv_upload_form(assigns) do ~H"""

Upload a CSV file with multiple contacts. Columns:

station1, station2, grid1, grid2, band, mode, qso_timestamp, notes
  • Timestamps in most formats accepted (e.g. 2024-06-15T14:30:00Z, 6/15/2024 2:30 PM, 2024-06-15 14:30). All times assumed UTC.
  • Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
  • Band in MHz (e.g. 10000, 24000)
  • Mode is optional — omit the mode column entirely (6 columns total) or leave its value blank.
  • Notes is optional — free-form operator commentary up to 2000 characters. Leave the cell blank to store NULL.

<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV

{entry.client_name} {entry.progress}%
{error_to_string(err)}
{error_to_string(err)}
<%= if @current_user do %> <% else %>
<% end %>
""" end defp adif_upload_form(assigns) do assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference()) ~H"""

Upload an ADIF (.adi / .adif) file exported from your logging program.

  • Required fields: CALL, STATION_CALLSIGN (or OPERATOR), GRIDSQUARE, MY_GRIDSQUARE, QSO_DATE, TIME_ON, and FREQ or BAND
  • Contacts on amateur bands from 50 MHz and up will be imported
  • HF contacts (below 50 MHz) are silently skipped
  • Frequencies are fuzzy-matched to the nearest amateur band
  • Operator commentary from 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}
{entry.client_name} {entry.progress}%
{error_to_string(err)}
{error_to_string(err)}
<%= if @current_user do %> <% else %>
<% end %>
""" end defp csv_preview(assigns) do assigns = assign(assigns, valid_count: length(assigns.preview.valid), invalid_count: length(assigns.preview.invalid), duplicate_count: length(assigns.preview.duplicates), refinement_count: length(assigns.preview.refinements || []), valid_sample: Enum.take(assigns.preview.valid, 20), refinement_sample: Enum.take(assigns.preview.refinements || [], 50) ) ~H"""

Review before submitting

Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}. Nothing has been inserted yet — confirm below to import.

<.summary_card tone="success" label="Valid" value={@valid_count} hint="Will be inserted" /> <.summary_card tone="info" label="Refinements" value={@refinement_count} hint="Will update existing contacts" /> <.summary_card tone="warning" label="Duplicates" value={@duplicate_count} hint="Match existing or earlier rows" /> <.summary_card tone="error" label="Invalid" value={@invalid_count} hint="Skipped — see errors" />
0}>

Invalid rows ({@invalid_count})

Row Errors
Row {row.row_num}
{msg}

100} class="text-xs opacity-60 mt-1"> Showing first 100 of {@invalid_count} invalid rows.

0}>

Refinements ({@refinement_count})

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.

0}>

Duplicates ({@duplicate_count})

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.

0}>

Valid rows ready to import ({@valid_count})

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.

""" end attr :tone, :string, required: true attr :label, :string, required: true attr :value, :integer, required: true attr :hint, :string, required: true defp summary_card(assigns) do ~H"""
"border-success/30 bg-success/10" "warning" -> "border-warning/30 bg-warning/10" "error" -> "border-error/30 bg-error/10" "info" -> "border-info/30 bg-info/10" _ -> "border-base-300 bg-base-200" end ]}>
{@label}
{@value}
{@hint}
""" end defp duplicate_source(:existing_contact), do: "Already in database" defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload" defp duplicate_source(_), do: "Duplicate" defp pluralize(1, word), do: word defp pluralize(_, word), do: word <> "s" # An upload entry is actively streaming when its progress is partway # between 0 and 100. With `auto_upload: false` entries sit at # `progress: 0` after the user picks a file and only start streaming # when the form is submitted, so gating the progress bar and percent # text on the 0 < progress < 100 window keeps them hidden until the # upload is actually in flight. defp uploading?(%{progress: progress}), do: progress > 0 and progress < 100 defp uploading?(_), do: false defp confirm_button_label(valid, 0) do "Looks good — submit #{valid} #{pluralize(valid, "contact")}" end defp confirm_button_label(0, refined) do "Looks good — refine #{refined} existing #{pluralize(refined, "contact")}" end defp confirm_button_label(valid, refined) do "Looks good — submit #{valid} and refine #{refined}" end defp confirm_prompt(valid, 0), do: "Import #{valid} contacts?" defp confirm_prompt(0, refined), do: "Refine #{refined} existing contacts?" defp confirm_prompt(valid, refined) do "Import #{valid} contacts and refine #{refined} existing?" end end