From b49c08914e36a625010c4bf82c01448d4456000d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 12 Apr 2026 15:18:46 -0500 Subject: [PATCH] Force 24h timestamps and let logged-in owners edit their contacts directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/radio.ex | 36 +++++++++ .../live/contact_live/show.ex | 56 ++++++++++--- lib/microwaveprop_web/live/submit_live.ex | 7 +- .../microwaveprop/radio/contact_edit_test.exs | 80 +++++++++++++++++++ 4 files changed, 163 insertions(+), 16 deletions(-) diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index eb7a9f99..77e7e02c 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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 diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 17354ccb..dbe20ccb 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -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} <% end %>