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:
parent
ae76aea515
commit
b49c08914e
4 changed files with 163 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue