Link submissions to the logged-in user on /submit

Contacts now carry a user_id FK to users. When a logged-in user
submits a contact (single or CSV upload), the server injects their
user_id and the email from their account into the submission params,
and the visible email input is replaced with a hidden input holding
that value — logged-in users don't have to retype their address.

The submission changeset now requires at least one of user_id or
submitter_email (rather than hard-requiring email), and email format
is only validated when one is actually present.

Reworded the submit-page blurb from "10 GHz to 241 GHz" to "902 MHz
and up" so it matches the actual band coverage of the project.
This commit is contained in:
Graham McIntire 2026-04-09 13:01:10 -05:00
parent 8637253fda
commit 5724b33e8f
3 changed files with 106 additions and 30 deletions

View file

@ -39,13 +39,14 @@ defmodule Microwaveprop.Radio.Contact do
field :user_submitted, :boolean, default: false
field :submitter_email, :string
field :user_id, :binary_id
field :flagged_invalid, :boolean, default: false
timestamps(type: :utc_datetime)
end
@required_fields ~w(station1 station2 qso_timestamp mode band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)a
def changeset(contact, attrs) do
contact
@ -53,14 +54,16 @@ defmodule Microwaveprop.Radio.Contact do
|> validate_required(@required_fields)
end
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_id)a
@submission_required ~w(station1 station2 qso_timestamp mode band grid1 grid2)a
@allowed_modes ~w(CW SSB FM FT8 FT4)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
def submission_changeset(contact, attrs) do
contact
|> cast(attrs, @submission_fields)
|> validate_required(@submission_fields)
|> validate_required(@submission_required)
|> validate_user_or_email()
|> sanitize_callsign(:station1)
|> sanitize_callsign(:station2)
|> sanitize_grid(:grid1)
@ -71,12 +74,33 @@ defmodule Microwaveprop.Radio.Contact do
|> validate_grid_format(:grid2)
|> validate_inclusion(:mode, @allowed_modes)
|> validate_inclusion(:band, @allowed_bands)
|> validate_format(:submitter_email, ~r/^[^\s<>]+@[^\s<>]+\.[^\s<>]+$/)
|> validate_email_format()
|> validate_length(:submitter_email, max: 254)
|> validate_length(:station1, max: 20)
|> validate_length(:station2, max: 20)
end
# Either a user_id (logged-in submitter) or a submitter_email (anonymous)
# must be present so we know who submitted the contact.
defp validate_user_or_email(changeset) do
user_id = get_field(changeset, :user_id)
email = get_field(changeset, :submitter_email)
if user_id || (email && email != "") do
changeset
else
add_error(changeset, :submitter_email, "can't be blank")
end
end
defp validate_email_format(changeset) do
case get_field(changeset, :submitter_email) do
nil -> changeset
"" -> changeset
_email -> validate_format(changeset, :submitter_email, ~r/^[^\s<>]+@[^\s<>]+\.[^\s<>]+$/)
end
end
# Strip whitespace and upcase callsigns
defp sanitize_callsign(changeset, field) do
case get_change(changeset, field) do

View file

@ -66,6 +66,8 @@ defmodule MicrowavepropWeb.SubmitLive 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)
@ -82,8 +84,14 @@ defmodule MicrowavepropWeb.SubmitLive do
end
end
def handle_event("upload_csv", %{"submitter_email" => email}, socket) do
if String.trim(email) == "" do
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 =
@ -144,6 +152,18 @@ defmodule MicrowavepropWeb.SubmitLive do
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
@impl true
def render(assigns) do
~H"""
@ -156,10 +176,10 @@ defmodule MicrowavepropWeb.SubmitLive do
<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 microwave contact
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 bands from 10 GHz to 241 GHz. The more contacts
we have, the better our forecasts get for everyone.
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>
@ -199,7 +219,12 @@ defmodule MicrowavepropWeb.SubmitLive do
</div>
<%= if @active_tab == :single do %>
<.single_contact_form form={@form} band_options={@band_options} mode_options={@mode_options} />
<.single_contact_form
form={@form}
band_options={@band_options}
mode_options={@mode_options}
current_user={current_user(assigns)}
/>
<% else %>
<%= cond do %>
<% @csv_result -> %>
@ -207,13 +232,17 @@ defmodule MicrowavepropWeb.SubmitLive do
<% @csv_preview -> %>
<.csv_preview preview={@csv_preview} />
<% true -> %>
<.csv_upload_form uploads={@uploads} />
<.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">
@ -262,13 +291,21 @@ defmodule MicrowavepropWeb.SubmitLive do
<.input field={@form[:qso_timestamp]} type="datetime-local" label="Timestamp (UTC)" required />
</div>
<.input
field={@form[:submitter_email]}
type="email"
label="Your Email"
placeholder="you@example.com"
required
/>
<%= 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">
@ -311,18 +348,22 @@ defmodule MicrowavepropWeb.SubmitLive do
</label>
</div>
<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>
<%= 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">

View file

@ -0,0 +1,11 @@
defmodule Microwaveprop.Repo.Migrations.AddUserIdToContacts do
use Ecto.Migration
def change do
alter table(:contacts) do
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
end
create index(:contacts, [:user_id])
end
end