- 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
474 lines
14 KiB
Elixir
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
|