Force 24h timestamps and let logged-in owners edit their contacts directly

Submit and contact-detail forms now use a plain text input for the QSO
timestamp with a "YYYY-MM-DD HH:MM" placeholder and regex pattern. The
native datetime-local input ignores lang="en-GB" on macOS/iOS when the
system clock is 12-hour, so it was rendering AM/PM against the user's
wishes. Ecto's :utc_datetime cast already accepts this format (verified
space+seconds, T+seconds, and both with and without seconds/Z).

Contact edit workflow grows a third branch: admins still apply directly,
non-owners still go through the admin review queue, and now logged-in
owners of a contact (user_id == current_user.id) can apply edits
directly too. New helpers Radio.owner?/2 and Radio.apply_owner_edit/3
reuse the existing normalize + diff + apply_edit_to_contact path so the
grid-change → enrichment re-enqueue pipeline kicks in automatically.
Anonymous contacts (user_id nil) and other users' contacts both return
{:error, :not_owner}.

Nine new tests cover owner?/2 and apply_owner_edit/3 including the
no-changes, wrong-user, and anonymous-contact paths.
This commit is contained in:
Graham McIntire 2026-04-12 15:18:46 -05:00
parent ae76aea515
commit b49c08914e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 163 additions and 16 deletions

View file

@ -741,6 +741,42 @@ defmodule Microwaveprop.Radio do
|> Repo.update()
end
@doc """
Whether the given user submitted (owns) this contact. Returns `false`
for anonymous contacts (`user_id == nil`) and for a `nil` user.
"""
@spec owner?(Contact.t(), User.t() | nil) :: boolean()
def owner?(%Contact{user_id: nil}, _user), do: false
def owner?(%Contact{}, nil), do: false
def owner?(%Contact{user_id: uid}, %User{id: uid}), do: true
def owner?(%Contact{}, %User{}), do: false
@doc """
Allow the original submitter of a contact to edit it directly without
going through the admin review queue. Reuses the same normalization
and diff logic as `create_contact_edit/3` so owners can only push
meaningful changes (at least one field must actually differ).
Returns `{:ok, updated_contact}` on success, `{:error, :not_owner}` if
the user did not submit the contact, or `{:error, :no_changes}` if
every proposed value already matches the current contact.
"""
@spec apply_owner_edit(Contact.t(), User.t(), map()) ::
{:ok, Contact.t()} | {:error, :not_owner | :no_changes}
def apply_owner_edit(%Contact{} = contact, %User{} = user, proposed_changes) when is_map(proposed_changes) do
if owner?(contact, user) do
diffed = diff_against_contact(contact, normalize_proposed(proposed_changes))
if diffed == %{} do
{:error, :no_changes}
else
{:ok, apply_edit_to_contact(contact, diffed)}
end
else
{:error, :not_owner}
end
end
@doc "Apply proposed changes directly to a contact (admin use)."
@spec apply_edit_to_contact(Contact.t(), map()) :: Contact.t()
def apply_edit_to_contact(contact, proposed_changes) do

View file

@ -174,7 +174,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
"band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""),
"mode" => contact.mode,
"qso_timestamp" =>
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%dT%H:%M"), else: "")
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: "")
}
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
@ -198,10 +198,15 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp submit_edit(socket, user, params) do
contact = socket.assigns.contact
if admin?(socket.assigns) do
apply_admin_edit(socket, contact, params)
else
submit_user_edit(socket, contact, user, params)
cond do
admin?(socket.assigns) ->
apply_admin_edit(socket, contact, params)
Radio.owner?(contact, user) ->
apply_owner_edit(socket, contact, user, params)
true ->
submit_user_edit(socket, contact, user, params)
end
end
@ -220,6 +225,25 @@ defmodule MicrowavepropWeb.ContactLive.Show do
end
end
defp apply_owner_edit(socket, contact, user, params) do
case Radio.apply_owner_edit(contact, user, params) do
{:ok, _updated} ->
updated = Radio.get_contact!(contact.id)
socket
|> assign(contact: updated, editing: false, edit_form: nil)
|> put_flash(:info, "Contact updated.")
{:error, :no_changes} ->
put_flash(socket, :error, "No changes detected.")
{:error, :not_owner} ->
# Shouldn't happen because we checked owner? before dispatching,
# but guard anyway so the LiveView doesn't blow up on stale state.
put_flash(socket, :error, "You can only edit contacts you submitted.")
end
end
defp submit_user_edit(socket, contact, user, params) do
case Radio.create_contact_edit(contact, user, params) do
{:ok, _edit} ->
@ -679,7 +703,11 @@ defmodule MicrowavepropWeb.ContactLive.Show do
name={if @editing, do: "hero-x-mark", else: "hero-pencil-square"}
class="w-4 h-4"
/>
{if @editing, do: "Cancel", else: if(admin?(assigns), do: "Edit", else: "Suggest Edit")}
{cond do
@editing -> "Cancel"
admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) -> "Edit"
true -> "Suggest Edit"
end}
</button>
<% end %>
<button
@ -699,7 +727,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</:actions>
</.header>
<%= if @pending_edit && !@editing && !admin?(assigns) do %>
<%= if @pending_edit && !@editing && !admin?(assigns) && !Radio.owner?(@contact, current_user(assigns)) do %>
<div class="alert alert-info text-sm mb-4">
<.icon name="hero-clock" class="w-4 h-4 inline" />
You have a pending edit for this contact awaiting admin review.
@ -707,12 +735,13 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<% end %>
<%= if @editing do %>
<% direct_edit = admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) %>
<div class="bg-base-200 rounded-box p-4 mb-4">
<h3 class="font-semibold mb-3">
{if admin?(assigns), do: "Edit Contact", else: "Suggest Edit"}
{if direct_edit, do: "Edit Contact", else: "Suggest Edit"}
</h3>
<p class="text-sm opacity-70 mb-4">
{if admin?(assigns),
{if direct_edit,
do: "Change any fields below. Changes will be applied immediately.",
else: "Change any fields below. Only fields you modify will be submitted for review."}
</p>
@ -734,15 +763,16 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<.input field={@edit_form[:mode]} type="select" label="Mode" options={@mode_options} />
<.input
field={@edit_form[:qso_timestamp]}
type="datetime-local"
label="Timestamp (UTC)"
lang="en-GB"
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?"
/>
</div>
<div class="flex gap-2">
<.button type="submit" class="btn btn-primary btn-sm">
<.icon name="hero-paper-airplane" class="w-4 h-4" />
{if admin?(assigns), do: "Save Changes", else: "Submit for Review"}
{if direct_edit, do: "Save Changes", else: "Submit for Review"}
</.button>
<button type="button" phx-click="toggle_edit" class="btn btn-ghost btn-sm">
Cancel

View file

@ -367,9 +367,10 @@ defmodule MicrowavepropWeb.SubmitLive do
/>
<.input
field={@form[:qso_timestamp]}
type="datetime-local"
label="Timestamp (UTC)"
lang="en-GB"
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>

View file

@ -245,4 +245,84 @@ defmodule Microwaveprop.Radio.ContactEditTest do
assert Radio.pending_edit_count() == 2
end
end
describe "Radio.apply_owner_edit/3" do
setup [:create_user, :create_admin]
defp owned_contact(user) do
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.put(@contact_attrs, :user_id, user.id))
|> Microwaveprop.Repo.insert()
contact
end
test "owner can apply edits directly without going through review", %{user: user} do
contact = owned_contact(user)
assert {:ok, updated} =
Radio.apply_owner_edit(contact, user, %{"grid1" => "EM15ab"})
assert updated.grid1 == "EM15AB"
# No pending edits were created
assert Radio.pending_edit_for_user(contact.id, user.id) == nil
end
test "re-enqueues enrichment when owner changes a grid", %{user: user} do
contact = owned_contact(user)
{:ok, updated} = Radio.apply_owner_edit(contact, user, %{"grid1" => "EM15ab"})
assert updated.pos1 != contact.pos1
assert updated.hrrr_status == :pending
assert updated.terrain_status == :pending
end
test "returns :not_owner error when user did not submit the contact", %{user: user} do
{:ok, other} = Accounts.register_user(%{@user_attrs | email: "other@example.com", callsign: "W5OTH"})
contact = owned_contact(other)
assert {:error, :not_owner} = Radio.apply_owner_edit(contact, user, %{"grid1" => "EM15ab"})
unchanged = Radio.get_contact!(contact.id)
assert unchanged.grid1 == contact.grid1
end
test "returns :not_owner on anonymous contacts (user_id is nil)", %{user: user} do
{:ok, anon} = %Contact{} |> Contact.changeset(@contact_attrs) |> Microwaveprop.Repo.insert()
assert {:error, :not_owner} = Radio.apply_owner_edit(anon, user, %{"grid1" => "EM15ab"})
end
test "returns error tuple when no changes are actually proposed", %{user: user} do
contact = owned_contact(user)
assert {:error, :no_changes} =
Radio.apply_owner_edit(contact, user, %{"station1" => contact.station1})
end
end
describe "Radio.owner?/2" do
setup [:create_user]
test "true when contact.user_id matches the user", %{user: user} do
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.put(@contact_attrs, :user_id, user.id))
|> Microwaveprop.Repo.insert()
assert Radio.owner?(contact, user)
end
test "false when contact.user_id is nil", %{user: user} do
{:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Microwaveprop.Repo.insert()
refute Radio.owner?(contact, user)
end
test "false when user is nil" do
{:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Microwaveprop.Repo.insert()
refute Radio.owner?(contact, nil)
end
end
end