prop/lib/microwaveprop_web/live/submit_live.ex
Graham McIntire 25c703401b Link to existing contact on duplicate submission
Return the conflicting contact from create_contact so the UI can redirect
to it with a flash message instead of just showing a form error.
2026-04-11 17:30:46 -05:00

668 lines
22 KiB
Elixir

defmodule MicrowavepropWeb.SubmitLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveStash
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio
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 = allow_upload(socket, :csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000)
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_result: nil,
csv_email: ""
)
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
{:ok, socket}
_ ->
{:ok, socket}
end
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
socket = assign(socket, active_tab: String.to_existing_atom(tab))
{:noreply, LiveStash.stash_assigns(socket, [:active_tab])}
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
# 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 = merge_user_params(contact_params, socket)
case Radio.create_contact(contact_params) 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
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, 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("confirm_csv", _params, socket) do
case socket.assigns.csv_preview do
%{valid: valid_rows} when valid_rows != [] ->
{:ok, commit_result} = CsvImport.commit(valid_rows)
{:noreply,
socket
|> assign(csv_preview: nil, csv_result: commit_result)
|> put_flash(:info, "#{length(commit_result.imported)} contacts submitted.")}
_ ->
{: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_result: nil)}
end
def handle_event("reset_csv", _params, socket) do
{:noreply, assign(socket, csv_preview: nil, csv_result: nil)}
end
defp handle_csv_preview(content, email, socket) do
case CsvImport.preview(content, email) do
{:ok, preview} ->
{:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)}
{: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} ->
params
|> Map.put("user_id", user_id)
|> Map.put("submitter_email", user_email)
_ ->
params
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: "Only .csv files are accepted"
defp error_to_string(other), do: to_string(other)
@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="bg-info/10 border border-info/30 rounded-box p-4 mb-6 text-sm leading-relaxed">
<p class="font-semibold text-info 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 902 MHz and up. The
more contacts we have, the better our forecasts get for everyone.
</p>
</div>
<div class="bg-base-200 rounded-box p-4 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 class="flex gap-1 bg-base-200 rounded-lg p-1 mb-6 w-fit">
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :single,
do: "bg-primary text-primary-content",
else: "hover:bg-base-300"
)
]}
phx-click="switch_tab"
phx-value-tab="single"
>
Single Contact
</button>
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :csv, do: "bg-primary text-primary-content", else: "hover:bg-base-300")
]}
phx-click="switch_tab"
phx-value-tab="csv"
>
Upload CSV
</button>
</div>
<%= if @active_tab == :single do %>
<.single_contact_form
form={@form}
band_options={@band_options}
mode_options={@mode_options}
current_user={current_user(assigns)}
/>
<% else %>
<%= cond do %>
<% @csv_result -> %>
<.csv_results result={@csv_result} />
<% @csv_preview -> %>
<.csv_preview preview={@csv_preview} />
<% true -> %>
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<% end %>
<% end %>
</Layouts.app>
"""
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">
<div class="grid grid-cols-1 md:grid-cols-2 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[:station2]} type="text" label="Station 2" placeholder="K5TR" required />
<.input
field={@form[:grid2]}
type="text"
label="Grid 2"
placeholder="EM00cd"
phx-debounce="blur"
required
/>
</div>
<p class="text-sm text-base-content/60 -mt-2">
Be as specific as possible with grid squares (6 characters preferred, e.g. EM12kp).
</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="Select mode"
options={@mode_options}
required
/>
<.input field={@form[:qso_timestamp]} type="datetime-local" label="Timestamp (UTC)" 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 %>
<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
defp csv_upload_form(assigns) do
~H"""
<div class="space-y-6">
<div class="bg-base-200 rounded-box p-4 text-sm">
<p class="mb-2">
Upload a CSV file with multiple contacts. Required columns:
</p>
<code class="text-xs">station1, station2, grid1, grid2, band, mode, qso_timestamp</code>
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
<li>
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>, <code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
</li>
<li>
Grid squares should be as detailed as possible (6 characters preferred, e.g. EM12kp)
</li>
<li>Band in MHz (e.g. 10000, 24000)</li>
</ul>
<p class="mt-2">
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
</a>
</p>
</div>
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
<div class="fieldset mb-2">
<label>
<span class="label mb-1">CSV File</span>
<.live_file_input upload={@uploads.csv} class="file-input file-input-bordered w-full" />
</label>
</div>
<div :for={entry <- @uploads.csv.entries} class="space-y-1">
<div class="flex items-center justify-between text-xs tabular-nums">
<span class="truncate pr-2">{entry.client_name}</span>
<span class="flex items-center gap-2 shrink-0">
<span>{entry.progress}%</span>
<button
type="button"
class="btn btn-ghost btn-xs"
phx-click="cancel_csv_upload"
phx-value-ref={entry.ref}
aria-label="Cancel upload"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</span>
</div>
<progress
class="progress progress-primary w-full"
value={entry.progress}
max="100"
>
</progress>
<div :for={err <- upload_errors(@uploads.csv, entry)} class="text-xs text-error">
{error_to_string(err)}
</div>
</div>
<div :for={err <- upload_errors(@uploads.csv)} class="text-xs text-error">
{error_to_string(err)}
</div>
<%= if @current_user do %>
<input type="hidden" name="submitter_email" value={@current_user.email} />
<% else %>
<div class="fieldset mb-2">
<label>
<span class="label mb-1">Your Email</span>
<input
type="email"
name="submitter_email"
class="w-full input"
placeholder="you@example.com"
value=""
/>
</label>
</div>
<% end %>
<div class="mt-6">
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
</button>
</div>
</form>
</div>
"""
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),
valid_sample: Enum.take(assigns.preview.valid, 20)
)
~H"""
<div class="space-y-6">
<div>
<h2 class="text-xl font-bold mb-2">Review before submitting</h2>
<p class="text-sm opacity-70">
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}.
Nothing has been inserted yet — confirm below to import.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<.summary_card
tone="success"
label="Valid"
value={@valid_count}
hint="Will be inserted"
/>
<.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"
/>
</div>
<div :if={@invalid_count > 0}>
<h3 class="font-semibold mb-2">Invalid rows ({@invalid_count})</h3>
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table table-sm">
<thead>
<tr>
<th class="w-24">Row</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<tr :for={row <- Enum.take(@preview.invalid, 100)}>
<td class="font-mono">Row {row.row_num}</td>
<td>
<div :for={msg <- row.messages}>{msg}</div>
</td>
</tr>
</tbody>
</table>
</div>
<p :if={@invalid_count > 100} class="text-xs opacity-60 mt-1">
Showing first 100 of {@invalid_count} invalid rows.
</p>
</div>
<div :if={@duplicate_count > 0}>
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
<p class="text-xs opacity-60 mb-2">
Same two callsigns at the same grids on the same band within an hour.
</p>
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table table-sm">
<thead>
<tr>
<th class="w-20">Row</th>
<th>Station 1</th>
<th>Station 2</th>
<th>Band</th>
<th>Timestamp</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<tr :for={row <- Enum.take(@preview.duplicates, 100)}>
<td class="font-mono">Row {row.row_num}</td>
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
<td class="tabular-nums">{row.attrs["band"]}</td>
<td class="font-mono text-xs">
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
</td>
<td class="text-xs opacity-70">{duplicate_source(row.reason)}</td>
</tr>
</tbody>
</table>
</div>
<p :if={@duplicate_count > 100} class="text-xs opacity-60 mt-1">
Showing first 100 of {@duplicate_count} duplicates.
</p>
</div>
<div :if={@valid_count > 0}>
<h3 class="font-semibold mb-2">Valid rows ready to import ({@valid_count})</h3>
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table table-sm">
<thead>
<tr>
<th class="w-20">Row</th>
<th>Station 1</th>
<th>Station 2</th>
<th>Band</th>
<th>Mode</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
<tr :for={row <- @valid_sample}>
<td class="font-mono">Row {row.row_num}</td>
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
<td class="tabular-nums">{row.attrs["band"]}</td>
<td>{row.attrs["mode"]}</td>
<td class="font-mono text-xs">
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
</td>
</tr>
</tbody>
</table>
</div>
<p :if={@valid_count > length(@valid_sample)} class="text-xs opacity-60 mt-1">
Showing first {length(@valid_sample)} of {@valid_count} valid rows.
</p>
</div>
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
<button
:if={@valid_count > 0}
type="button"
class="btn btn-primary btn-lg"
phx-click="confirm_csv"
phx-disable-with="Importing..."
data-confirm={"Import #{@valid_count} contacts?"}
>
<.icon name="hero-check" class="w-5 h-5" />
Looks good — submit {@valid_count} {if @valid_count == 1,
do: "contact",
else: "contacts"}
</button>
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
Cancel
</button>
</div>
</div>
"""
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"""
<div class={[
"rounded-box border p-4",
case @tone do
"success" -> "border-success/30 bg-success/10"
"warning" -> "border-warning/30 bg-warning/10"
"error" -> "border-error/30 bg-error/10"
_ -> "border-base-300 bg-base-200"
end
]}>
<div class="text-xs uppercase tracking-wider opacity-70">{@label}</div>
<div class="text-3xl font-bold tabular-nums">{@value}</div>
<div class="text-xs opacity-60">{@hint}</div>
</div>
"""
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 csv_results(assigns) do
assigns =
assign(assigns,
imported_count: length(assigns.result.imported),
error_count: length(assigns.result.errors)
)
~H"""
<div class="space-y-6">
<div
:if={@imported_count > 0}
class="rounded-box border border-success/40 bg-success/10 p-8 text-center"
>
<.icon name="hero-check-circle" class="w-16 h-16 text-success mx-auto mb-3" />
<div class="text-3xl font-bold tabular-nums">
{@imported_count} {if @imported_count == 1, do: "contact", else: "contacts"} submitted
</div>
<p class="text-sm opacity-70 mt-2">
Weather, terrain, and propagation data will be attached shortly.
</p>
</div>
<div :if={@error_count > 0} class="space-y-2">
<div class="alert alert-error">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>
{@error_count} {if @error_count == 1, do: "row", else: "rows"} had errors at insert time
</span>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Row</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<tr :for={{row_num, messages} <- @result.errors}>
<td class="font-mono">Row {row_num}</td>
<td>
<div :for={msg <- messages}>{msg}</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<button type="button" class="btn btn-outline" phx-click="reset_csv">
Upload another
</button>
</div>
"""
end
end