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 :user_submitted, :boolean, default: false
field :submitter_email, :string field :submitter_email, :string
field :user_id, :binary_id
field :flagged_invalid, :boolean, default: false field :flagged_invalid, :boolean, default: false
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@required_fields ~w(station1 station2 qso_timestamp mode band)a @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 def changeset(contact, attrs) do
contact contact
@ -53,14 +54,16 @@ defmodule Microwaveprop.Radio.Contact do
|> validate_required(@required_fields) |> validate_required(@required_fields)
end 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_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) @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 def submission_changeset(contact, attrs) do
contact contact
|> cast(attrs, @submission_fields) |> cast(attrs, @submission_fields)
|> validate_required(@submission_fields) |> validate_required(@submission_required)
|> validate_user_or_email()
|> sanitize_callsign(:station1) |> sanitize_callsign(:station1)
|> sanitize_callsign(:station2) |> sanitize_callsign(:station2)
|> sanitize_grid(:grid1) |> sanitize_grid(:grid1)
@ -71,12 +74,33 @@ defmodule Microwaveprop.Radio.Contact do
|> validate_grid_format(:grid2) |> validate_grid_format(:grid2)
|> validate_inclusion(:mode, @allowed_modes) |> validate_inclusion(:mode, @allowed_modes)
|> validate_inclusion(:band, @allowed_bands) |> validate_inclusion(:band, @allowed_bands)
|> validate_format(:submitter_email, ~r/^[^\s<>]+@[^\s<>]+\.[^\s<>]+$/) |> validate_email_format()
|> validate_length(:submitter_email, max: 254) |> validate_length(:submitter_email, max: 254)
|> validate_length(:station1, max: 20) |> validate_length(:station1, max: 20)
|> validate_length(:station2, max: 20) |> validate_length(:station2, max: 20)
end 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 # Strip whitespace and upcase callsigns
defp sanitize_callsign(changeset, field) do defp sanitize_callsign(changeset, field) do
case get_change(changeset, field) do case get_change(changeset, field) do

View file

@ -66,6 +66,8 @@ defmodule MicrowavepropWeb.SubmitLive do
if recently_submitted?(socket) do if recently_submitted?(socket) do
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")} {:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
else else
contact_params = merge_user_params(contact_params, socket)
case Radio.create_contact(contact_params) do case Radio.create_contact(contact_params) do
{:ok, contact} -> {:ok, contact} ->
ContactWeatherEnqueueWorker.enqueue_for_contact(contact) ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
@ -82,8 +84,14 @@ defmodule MicrowavepropWeb.SubmitLive do
end end
end end
def handle_event("upload_csv", %{"submitter_email" => email}, socket) do def handle_event("upload_csv", params, socket) do
if String.trim(email) == "" 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")} {:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
else else
uploaded_contents = uploaded_contents =
@ -144,6 +152,18 @@ defmodule MicrowavepropWeb.SubmitLive do
end end
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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~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"> <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 class="font-semibold text-info mb-1">Every contact matters</p>
<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, 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 helping us calibrate predictions for all amateur bands from 902 MHz and up. The
we have, the better our forecasts get for everyone. more contacts we have, the better our forecasts get for everyone.
</p> </p>
</div> </div>
@ -199,7 +219,12 @@ defmodule MicrowavepropWeb.SubmitLive do
</div> </div>
<%= if @active_tab == :single do %> <%= 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 %> <% else %>
<%= cond do %> <%= cond do %>
<% @csv_result -> %> <% @csv_result -> %>
@ -207,13 +232,17 @@ defmodule MicrowavepropWeb.SubmitLive do
<% @csv_preview -> %> <% @csv_preview -> %>
<.csv_preview preview={@csv_preview} /> <.csv_preview preview={@csv_preview} />
<% true -> %> <% true -> %>
<.csv_upload_form uploads={@uploads} /> <.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<% end %> <% end %>
<% end %> <% end %>
</Layouts.app> </Layouts.app>
""" """
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 defp single_contact_form(assigns) do
~H""" ~H"""
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4"> <.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 /> <.input field={@form[:qso_timestamp]} type="datetime-local" label="Timestamp (UTC)" required />
</div> </div>
<.input <%= if @current_user do %>
field={@form[:submitter_email]} <input
type="email" type="hidden"
label="Your Email" name="contact[submitter_email]"
placeholder="you@example.com" value={@current_user.email}
required />
/> <% else %>
<.input
field={@form[:submitter_email]}
type="email"
label="Your Email"
placeholder="you@example.com"
required
/>
<% end %>
<div class="mt-6"> <div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto"> <.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> </label>
</div> </div>
<div class="fieldset mb-2"> <%= if @current_user do %>
<label> <input type="hidden" name="submitter_email" value={@current_user.email} />
<span class="label mb-1">Your Email</span> <% else %>
<input <div class="fieldset mb-2">
type="email" <label>
name="submitter_email" <span class="label mb-1">Your Email</span>
class="w-full input" <input
placeholder="you@example.com" type="email"
value="" name="submitter_email"
/> class="w-full input"
</label> placeholder="you@example.com"
</div> value=""
/>
</label>
</div>
<% end %>
<div class="mt-6"> <div class="mt-6">
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto"> <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