diff --git a/assets/js/app.js b/assets/js/app.js
index cb89426..76761e9 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -26,6 +26,35 @@ import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help"
import topbar from "../vendor/topbar"
import {Hooks} from "./hooks"
+const systemTheme = () =>
+ matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
+
+const setTheme = theme => {
+ if (theme === "system") {
+ localStorage.removeItem("phx:theme")
+ document.documentElement.setAttribute("data-theme", systemTheme())
+ document.documentElement.setAttribute("data-theme-source", "system")
+ } else {
+ localStorage.setItem("phx:theme", theme)
+ document.documentElement.setAttribute("data-theme", theme)
+ document.documentElement.setAttribute("data-theme-source", "user")
+ }
+}
+
+if (!document.documentElement.hasAttribute("data-theme")) {
+ setTheme(localStorage.getItem("phx:theme") || "system")
+}
+
+window.addEventListener("storage", event => {
+ if (event.key === "phx:theme") setTheme(event.newValue || "system")
+})
+window.addEventListener("phx:set-theme", event => setTheme(event.target.dataset.phxTheme))
+matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
+ if (document.documentElement.getAttribute("data-theme-source") === "system") {
+ document.documentElement.setAttribute("data-theme", systemTheme())
+ }
+})
+
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
diff --git a/assets/js/hooks.js b/assets/js/hooks.js
index 0a4bd70..b6b0b04 100644
--- a/assets/js/hooks.js
+++ b/assets/js/hooks.js
@@ -76,9 +76,13 @@ export const Hooks = {
const points = JSON.parse(this.el.dataset.markers || "[]")
points.forEach(point => {
- const popup = new maplibregl.Popup({offset: 18}).setHTML(
- `${escapeHtml(point.title)}
${escapeHtml(point.location)}`
- )
+ const popupContent = document.createElement("div")
+ const title = document.createElement("strong")
+ title.textContent = point.title || ""
+ popupContent.append(title, document.createElement("br"))
+ popupContent.append(document.createTextNode(point.location || ""))
+
+ const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent)
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
.setLngLat([point.longitude, point.latitude])
.setPopup(popup)
@@ -99,10 +103,21 @@ export const Hooks = {
LocationPicker: {
mounted() {
this.el.addEventListener("click", () => {
+ const latitudeInput = document.querySelector(this.el.dataset.latitudeTarget)
+ const longitudeInput = document.querySelector(this.el.dataset.longitudeTarget)
+
+ if (!latitudeInput || !longitudeInput || !navigator.geolocation) {
+ this.el.textContent = "Location is unavailable"
+ this.el.classList.add("btn-error")
+ return
+ }
+
navigator.geolocation.getCurrentPosition(
position => {
- document.querySelector("#request-latitude").value = position.coords.latitude
- document.querySelector("#request-longitude").value = position.coords.longitude
+ latitudeInput.value = position.coords.latitude
+ longitudeInput.value = position.coords.longitude
+ latitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
+ longitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
this.el.textContent = "Location added"
this.el.classList.add("btn-success")
},
@@ -151,12 +166,6 @@ export const Hooks = {
}
}
-function escapeHtml(value) {
- const node = document.createElement("div")
- node.textContent = value || ""
- return node.innerHTML
-}
-
function supportsMapCanvas() {
const canvas = document.createElement("canvas")
const attributes = {
diff --git a/lib/mix/tasks/wnh.load_fixtures.ex b/lib/mix/tasks/wnh.load_fixtures.ex
index 0342790..2de5ace 100644
--- a/lib/mix/tasks/wnh.load_fixtures.ex
+++ b/lib/mix/tasks/wnh.load_fixtures.ex
@@ -123,7 +123,8 @@ defmodule Mix.Tasks.Wnh.LoadFixtures do
"urgency" => "now",
"location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 86_400, :second),
- "category_id" => category.id
+ "category_id" => category.id,
+ "safety_confirmed" => true
})
|> unwrap!("create request")
diff --git a/lib/mix/tasks/wnh.staging_android_e2e.ex b/lib/mix/tasks/wnh.staging_android_e2e.ex
index 9552eb6..cf5ed8f 100644
--- a/lib/mix/tasks/wnh.staging_android_e2e.ex
+++ b/lib/mix/tasks/wnh.staging_android_e2e.ex
@@ -108,7 +108,8 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
"urgency" => "now",
"location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 3 * 60 * 60, :second),
- "category_id" => category.id
+ "category_id" => category.id,
+ "safety_confirmed" => true
})
|> Ecto.Changeset.put_change(:status, :matched)
|> Repo.insert!()
diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex
index c5e32a8..f374e87 100644
--- a/lib/who_need_help/accounts.ex
+++ b/lib/who_need_help/accounts.ex
@@ -79,7 +79,9 @@ defmodule WhoNeedHelp.Accounts do
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
- if User.valid_password?(user, password), do: user
+
+ if User.valid_password?(user, password) and user.moderation_status != :suspended,
+ do: user
end
@doc """
@@ -124,7 +126,10 @@ defmodule WhoNeedHelp.Accounts do
when role in [:moderator, :admin],
do:
Repo.exists?(
- from user in User, where: user.id == ^id and user.role in [:moderator, :admin]
+ from user in User,
+ where:
+ user.id == ^id and user.role in [:moderator, :admin] and
+ user.moderation_status == :active
)
def moderator_authorized?(_user), do: false
@@ -133,7 +138,11 @@ defmodule WhoNeedHelp.Accounts do
def admin?(_user), do: false
def admin_authorized?(%User{id: id, role: :admin}),
- do: Repo.exists?(from user in User, where: user.id == ^id and user.role == :admin)
+ do:
+ Repo.exists?(
+ from user in User,
+ where: user.id == ^id and user.role == :admin and user.moderation_status == :active
+ )
def admin_authorized?(_user), do: false
@@ -158,47 +167,87 @@ defmodule WhoNeedHelp.Accounts do
end
def moderate_user(%User{} = moderator, user_id, attrs) do
- if moderator_authorized?(moderator) do
+ with {:ok, user_id} <- cast_id(user_id),
+ true <- moderator_authorized?(moderator) do
Repo.transact(fn ->
- user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!()
+ active_admins = lock_active_admins()
+ user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
- if user.id == moderator.id and attrs["moderation_status"] in ["restricted", "suspended"] do
- {:error, :cannot_restrict_self}
- else
- with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
- if user.moderation_status == :suspended do
- Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
- end
+ if user do
+ requested_status = attrs["moderation_status"] || attrs[:moderation_status]
- {:ok, user}
+ cond do
+ user.id == moderator.id and
+ requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
+ {:error, :cannot_restrict_self}
+
+ user.role == :admin and user.moderation_status == :active and
+ requested_status not in [:active, "active"] and length(active_admins) == 1 ->
+ {:error, :last_admin}
+
+ true ->
+ with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
+ session_tokens =
+ if user.moderation_status == :suspended do
+ tokens =
+ UserToken
+ |> where([token], token.user_id == ^user.id and token.context == "session")
+ |> select([token], token.token)
+ |> Repo.all()
+
+ Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
+ tokens
+ else
+ []
+ end
+
+ {:ok, %{user: user, expired_session_tokens: session_tokens}}
+ end
end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
def change_user_role(%User{} = admin, user_id, attrs) do
- if admin_authorized?(admin) do
+ with {:ok, user_id} <- cast_id(user_id),
+ true <- admin_authorized?(admin) do
Repo.transact(fn ->
- user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!()
- requested_role = attrs["role"] || attrs[:role]
+ active_admins = lock_active_admins()
+ user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
- admin_count =
- Repo.aggregate(from(candidate in User, where: candidate.role == :admin), :count)
+ if user do
+ requested_role = attrs["role"] || attrs[:role]
- if user.id == admin.id and requested_role not in [:admin, "admin"] and admin_count == 1 do
- {:error, :last_admin}
+ if user.role == :admin and user.moderation_status == :active and
+ requested_role not in [:admin, "admin"] and length(active_admins) == 1 do
+ {:error, :last_admin}
+ else
+ user |> User.role_changeset(attrs) |> Repo.update()
+ end
else
- user |> User.role_changeset(attrs) |> Repo.update()
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
+ defp lock_active_admins do
+ User
+ |> where([user], user.role == :admin and user.moderation_status == :active)
+ |> order_by([user], asc: user.id)
+ |> lock("FOR UPDATE")
+ |> Repo.all()
+ end
+
## User registration
@doc """
@@ -282,9 +331,12 @@ defmodule WhoNeedHelp.Accounts do
end
def delete_social_identity(%User{id: user_id}, identity_id) do
- case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
- nil -> {:error, :not_found}
- identity -> Repo.delete(identity)
+ with {:ok, identity_id} <- Ecto.UUID.cast(identity_id),
+ %SocialIdentity{} = identity <-
+ Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
+ Repo.delete(identity)
+ else
+ _invalid_or_missing -> {:error, :not_found}
end
end
@@ -401,7 +453,8 @@ defmodule WhoNeedHelp.Accounts do
"""
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
- {user, _token} <- Repo.one(query) do
+ {%User{moderation_status: status} = user, _token} <- Repo.one(query),
+ true <- status != :suspended do
user
else
_ -> nil
@@ -430,6 +483,10 @@ defmodule WhoNeedHelp.Accounts do
{:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do
+ {%User{moderation_status: :suspended}, token} ->
+ Repo.delete!(token)
+ {:error, :not_found}
+
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
@@ -502,6 +559,13 @@ defmodule WhoNeedHelp.Accounts do
)
end
+ defp cast_id(value) do
+ case Ecto.UUID.cast(value) do
+ {:ok, id} -> {:ok, id}
+ :error -> {:error, :not_found}
+ end
+ end
+
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex
index b7d3076..04da1ef 100644
--- a/lib/who_need_help/accounts/user.ex
+++ b/lib/who_need_help/accounts/user.ex
@@ -108,7 +108,8 @@ defmodule WhoNeedHelp.Accounts.User do
defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do
- %URI{scheme: scheme, host: host} when scheme in ["https", "http"] and is_binary(host) ->
+ %URI{scheme: scheme, host: host}
+ when scheme in ["https", "http"] and is_binary(host) and host != "" ->
[]
_ ->
diff --git a/lib/who_need_help/accounts/user_token.ex b/lib/who_need_help/accounts/user_token.ex
index 0bcbe25..9e13a3b 100644
--- a/lib/who_need_help/accounts/user_token.ex
+++ b/lib/who_need_help/accounts/user_token.ex
@@ -62,6 +62,7 @@ defmodule WhoNeedHelp.Accounts.UserToken do
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
+ where: user.moderation_status != :suspended,
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex
index e3eb830..1e5abc6 100644
--- a/lib/who_need_help/activities.ex
+++ b/lib/who_need_help/activities.ex
@@ -71,6 +71,25 @@ defmodule WhoNeedHelp.Activities do
paginate_my_activities(%Scope{user: user}).entries
end
+ def visible_open_activity?(%Scope{user: user}, %Activity{} = activity, filters \\ %{}) do
+ now = DateTime.utc_now(:second)
+ category_id = filters["category_id"] || filters[:category_id]
+
+ activity.status == :open and is_nil(activity.hidden_at) and
+ DateTime.after?(activity.starts_at, now) and
+ DateTime.after?(activity.join_deadline, now) and
+ (category_id in [nil, ""] or
+ to_string(activity.category_id) == to_string(category_id)) and
+ not Trust.blocked_between?(user.id, activity.creator_id)
+ end
+
+ def member_activity?(%Activity{} = activity, user_id) do
+ Enum.any?(
+ activity.participants,
+ &(&1.user_id == user_id and &1.status in [:requested, :approved])
+ )
+ end
+
def paginate_my_activities(%Scope{user: user}, options \\ []) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
@@ -124,35 +143,39 @@ defmodule WhoNeedHelp.Activities do
end
def get_activity(%Scope{user: user}, id) do
- case get_loaded_activity(id) do
- nil ->
- {:error, :not_found}
+ with {:ok, id} <- Ecto.UUID.cast(id),
+ %Activity{} = activity <- get_loaded_activity(id) do
+ cond do
+ activity.creator_id == user.id ->
+ {:ok, activity}
- activity ->
- cond do
- activity.creator_id == user.id ->
- {:ok, activity}
+ not is_nil(activity.hidden_at) ->
+ {:error, :not_found}
- approved_participant?(activity, user.id) ->
- {:ok, activity}
+ Trust.blocked_between?(activity.creator_id, user.id) ->
+ {:error, :not_found}
- not is_nil(activity.hidden_at) ->
- {:error, :not_found}
+ approved_participant?(activity, user.id) ->
+ {:ok, activity}
- Trust.blocked_between?(activity.creator_id, user.id) ->
- {:error, :not_found}
+ activity.status == :open ->
+ {:ok, activity_for_viewer(activity, user.id)}
- activity.status == :open ->
- {:ok, activity_for_viewer(activity, user.id)}
-
- true ->
- {:error, :not_found}
- end
+ true ->
+ {:error, :not_found}
+ end
+ else
+ _invalid_or_missing -> {:error, :not_found}
end
end
def paginate_messages(%Scope{user: user}, %Activity{} = activity, options \\ []) do
- if activity.creator_id == user.id or approved_participant?(activity, user.id) do
+ authorized =
+ activity.creator_id == user.id or
+ (approved_participant?(activity, user.id) and
+ not Trust.blocked_between?(activity.creator_id, user.id))
+
+ if authorized do
limit = Pagination.limit(options, 50)
cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
@@ -221,6 +244,8 @@ defmodule WhoNeedHelp.Activities do
activity = locked_activity(activity_id)
now = DateTime.utc_now(:second)
+ :ok = Trust.lock_user_pair(activity.creator_id, user.id)
+
cond do
activity.creator_id == user.id ->
{:error, :organizer_already_joined}
@@ -248,52 +273,57 @@ defmodule WhoNeedHelp.Activities do
def approve_participant(%Scope{user: organizer}, participant_id) do
result =
- Repo.transact(fn ->
- participant = locked_participant(participant_id)
- activity = locked_activity(participant.activity_id)
+ with {:ok, participant_id} <- cast_id(participant_id) do
+ Repo.transact(fn ->
+ participant = locked_participant(participant_id)
+ activity = locked_activity(participant.activity_id)
+ :ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
- cond do
- activity.creator_id != organizer.id ->
- {:error, :forbidden}
+ cond do
+ activity.creator_id != organizer.id ->
+ {:error, :forbidden}
- participant.status != :requested ->
- {:error, :invalid_transition}
+ participant.status != :requested ->
+ {:error, :invalid_transition}
- activity.status != :open ->
- {:error, :not_open}
+ activity.status != :open ->
+ {:error, :not_open}
- Trust.blocked_between?(activity.creator_id, participant.user_id) ->
- {:error, :blocked}
+ Trust.blocked_between?(activity.creator_id, participant.user_id) ->
+ {:error, :blocked}
- approved_count(activity.id) >= activity.capacity ->
- {:error, :capacity_reached}
+ approved_count(activity.id) >= activity.capacity ->
+ {:error, :capacity_reached}
- true ->
- with {:ok, participant} <-
- participant
- |> Participant.changeset(%{
- status: :approved,
- reviewed_at: DateTime.utc_now(:second)
- })
- |> Repo.update(),
- {:ok, _audit} <-
- Trust.audit(
- organizer.id,
- "activity.participant_approved",
- "activity_participant",
- participant.id,
- %{"activity_id" => activity.id, "user_id" => participant.user_id}
- ) do
- {:ok, participant}
- end
- end
- end)
+ true ->
+ with {:ok, participant} <-
+ participant
+ |> Participant.changeset(%{
+ status: :approved,
+ reviewed_at: DateTime.utc_now(:second)
+ })
+ |> Repo.update(),
+ {:ok, _audit} <-
+ Trust.audit(
+ organizer.id,
+ "activity.participant_approved",
+ "activity_participant",
+ participant.id,
+ %{"activity_id" => activity.id, "user_id" => participant.user_id}
+ ) do
+ {:ok, participant}
+ end
+ end
+ end)
+ end
after_participant_change(result, participant_activity_id(result), :participant_approved)
end
def decline_participant(%Scope{user: organizer}, participant_id) do
- transition_participant(organizer.id, participant_id, :declined)
+ with {:ok, participant_id} <- cast_id(participant_id) do
+ transition_participant(organizer.id, participant_id, :declined)
+ end
end
def leave_activity(%Scope{user: user}, activity_id) do
@@ -345,40 +375,54 @@ defmodule WhoNeedHelp.Activities do
end
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
- with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message),
- {:ok, activity} <- get_activity(scope, activity_id),
- true <- activity.status == :open,
- true <- approved_participant?(activity, user.id) do
- %Message{}
- |> Message.changeset(%{
- body: attrs["body"] || attrs[:body],
- activity_id: activity.id,
- sender_id: user.id
- })
- |> Repo.insert()
- |> case do
- {:ok, message} ->
- message = Repo.preload(message, sender: Accounts.public_user_query())
+ result =
+ with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
+ Repo.transact(fn ->
+ activity = locked_activity(activity_id)
+ :ok = Trust.lock_user_pair(activity.creator_id, user.id)
- Phoenix.PubSub.broadcast(
- WhoNeedHelp.PubSub,
- "activity:#{activity.id}",
- {:activity_message, message}
- )
+ cond do
+ activity.status != :open or not is_nil(activity.hidden_at) ->
+ {:error, :not_open}
- {:ok, message}
+ Trust.blocked_between?(activity.creator_id, user.id) ->
+ {:error, :blocked}
- other ->
- other
+ not approved_participant_id?(activity.id, user.id) ->
+ {:error, :forbidden}
+
+ true ->
+ %Message{}
+ |> Message.changeset(%{
+ body: attrs["body"] || attrs[:body],
+ activity_id: activity.id,
+ sender_id: user.id
+ })
+ |> Repo.insert()
+ end
+ end)
end
- else
- false -> {:error, :forbidden}
- other -> other
+
+ with {:ok, message} <- result do
+ message = Repo.preload(message, sender: Accounts.public_user_query())
+
+ Phoenix.PubSub.broadcast(
+ WhoNeedHelp.PubSub,
+ "activity:#{message.activity_id}",
+ {:activity_message, message}
+ )
+
+ {:ok, message}
end
end
def coordinates_for(%Scope{user: user}, %Activity{} = activity) do
- if activity.creator_id == user.id or approved_participant?(activity, user.id) do
+ authorized =
+ activity.creator_id == user.id or
+ (approved_participant?(activity, user.id) and
+ not Trust.blocked_between?(activity.creator_id, user.id))
+
+ if authorized do
Activity.exact_coordinates(activity)
else
Activity.public_coordinates(activity)
@@ -396,6 +440,15 @@ defmodule WhoNeedHelp.Activities do
)
end
+ defp approved_participant_id?(activity_id, user_id) do
+ Repo.exists?(
+ from participant in Participant,
+ where:
+ participant.activity_id == ^activity_id and participant.user_id == ^user_id and
+ participant.status == :approved
+ )
+ end
+
defp validate_category_and_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id)
@@ -644,6 +697,13 @@ defmodule WhoNeedHelp.Activities do
defp maybe_filter_category(query, value),
do: where(query, [activity], activity.category_id == ^value)
+ defp cast_id(value) do
+ case Ecto.UUID.cast(value) do
+ {:ok, id} -> {:ok, id}
+ :error -> {:error, :not_found}
+ end
+ end
+
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
defp broadcast_activity(id, message),
diff --git a/lib/who_need_help/activities/activity.ex b/lib/who_need_help/activities/activity.ex
index a64e3db..a74b550 100644
--- a/lib/who_need_help/activities/activity.ex
+++ b/lib/who_need_help/activities/activity.ex
@@ -25,6 +25,7 @@ defmodule WhoNeedHelp.Activities.Activity do
field :hidden_at, :utc_datetime
field :hidden_reason, :string
field :approved_participant_count, :integer, virtual: true, default: 0
+ field :safety_confirmed, :boolean, virtual: true, default: false
belongs_to :creator, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category
@@ -45,7 +46,8 @@ defmodule WhoNeedHelp.Activities.Activity do
:starts_at,
:join_deadline,
:capacity,
- :category_id
+ :category_id,
+ :safety_confirmed
])
|> put_location(attrs)
|> validate_required([
@@ -62,6 +64,9 @@ defmodule WhoNeedHelp.Activities.Activity do
|> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000)
|> validate_number(:capacity, greater_than_or_equal_to: 2)
+ |> validate_acceptance(:safety_confirmed,
+ message: "confirm the safety guidance before publishing"
+ )
|> validate_schedule()
end
diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex
index 4e0c84e..ff317ab 100644
--- a/lib/who_need_help/catalog.ex
+++ b/lib/who_need_help/catalog.ex
@@ -74,25 +74,37 @@ defmodule WhoNeedHelp.Catalog do
end
def vote(%Scope{user: user} = scope, proposal_id) do
- with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote),
- %CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do
- %CategoryVote{}
- |> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id})
- |> Repo.insert()
- else
- nil -> {:error, :not_found}
- %CategoryProposal{} -> {:error, :proposal_closed}
- other -> other
+ with {:ok, proposal_id} <- cast_id(proposal_id),
+ {:ok, _limit} <- Trust.authorize_action(scope, :category_vote) do
+ Repo.transact(fn ->
+ case CategoryProposal
+ |> where([proposal], proposal.id == ^proposal_id)
+ |> lock("FOR UPDATE")
+ |> Repo.one() do
+ nil ->
+ {:error, :not_found}
+
+ %CategoryProposal{status: :open} ->
+ %CategoryVote{}
+ |> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id})
+ |> Repo.insert()
+
+ %CategoryProposal{} ->
+ {:error, :proposal_closed}
+ end
+ end)
end
end
def unvote(%Scope{user: user}, proposal_id) do
- {count, _} =
- CategoryVote
- |> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
- |> Repo.delete_all()
+ with {:ok, proposal_id} <- cast_id(proposal_id) do
+ {count, _} =
+ CategoryVote
+ |> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
+ |> Repo.delete_all()
- {:ok, count}
+ {:ok, count}
+ end
end
def seed_defaults do
@@ -175,46 +187,53 @@ defmodule WhoNeedHelp.Catalog do
end
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, proposal_id} <- cast_id(proposal_id),
+ true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
- if proposal.status == :open do
- category_attrs =
- category_attrs
- |> Map.new(fn {key, value} -> {to_string(key), value} end)
- |> normalize_descriptions()
- |> Map.put_new("parent_id", proposal.parent_id)
- |> Map.put_new("mode", to_string(proposal.mode))
+ cond do
+ is_nil(proposal) ->
+ {:error, :not_found}
- with {:ok, category} <-
- %Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
- {:ok, proposal} <-
- proposal
- |> CategoryProposal.moderation_changeset(%{
- status: :approved,
- merged_into_id: category.id,
- reviewed_by_id: moderator.id,
- reviewed_at: DateTime.utc_now(:second),
- moderation_note: category_attrs["moderation_note"]
- })
- |> Repo.update(),
- {:ok, _audit} <-
- Trust.audit(
- moderator.id,
- "category_proposal.approved",
- "category_proposal",
- proposal.id,
- %{"category_id" => category.id}
- ) do
- {:ok, %{proposal: proposal, category: category}}
- end
- else
- {:error, :proposal_closed}
+ proposal.status == :open ->
+ category_attrs =
+ category_attrs
+ |> Map.new(fn {key, value} -> {to_string(key), value} end)
+ |> normalize_descriptions()
+ |> Map.put_new("parent_id", proposal.parent_id)
+ |> Map.put_new("mode", to_string(proposal.mode))
+
+ with {:ok, category} <-
+ %Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
+ {:ok, proposal} <-
+ proposal
+ |> CategoryProposal.moderation_changeset(%{
+ status: :approved,
+ merged_into_id: category.id,
+ reviewed_by_id: moderator.id,
+ reviewed_at: DateTime.utc_now(:second),
+ moderation_note: category_attrs["moderation_note"]
+ })
+ |> Repo.update(),
+ {:ok, _audit} <-
+ Trust.audit(
+ moderator.id,
+ "category_proposal.approved",
+ "category_proposal",
+ proposal.id,
+ %{"category_id" => category.id}
+ ) do
+ {:ok, %{proposal: proposal, category: category}}
+ end
+
+ true ->
+ {:error, :proposal_closed}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
@@ -223,45 +242,54 @@ defmodule WhoNeedHelp.Catalog do
end
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
- with %Category{} <- Repo.get(Category, category_id) do
+ with {:ok, category_id} <- cast_id(category_id),
+ %Category{} <- Repo.get(Category, category_id) do
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else
+ {:error, :not_found} = error -> error
nil -> {:error, :not_found}
end
end
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, proposal_id} <- cast_id(proposal_id),
+ true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
- if proposal.status == :open do
- with {:ok, proposal} <-
- proposal
- |> CategoryProposal.moderation_changeset(%{
- status: status,
- merged_into_id: merged_into_id,
- reviewed_by_id: moderator.id,
- reviewed_at: DateTime.utc_now(:second),
- moderation_note: note
- })
- |> Repo.update(),
- {:ok, _audit} <-
- Trust.audit(
- moderator.id,
- "category_proposal.#{status}",
- "category_proposal",
- proposal.id,
- %{"merged_into_id" => merged_into_id}
- ) do
- {:ok, proposal}
- end
- else
- {:error, :proposal_closed}
+ cond do
+ is_nil(proposal) ->
+ {:error, :not_found}
+
+ proposal.status == :open ->
+ with {:ok, proposal} <-
+ proposal
+ |> CategoryProposal.moderation_changeset(%{
+ status: status,
+ merged_into_id: merged_into_id,
+ reviewed_by_id: moderator.id,
+ reviewed_at: DateTime.utc_now(:second),
+ moderation_note: note
+ })
+ |> Repo.update(),
+ {:ok, _audit} <-
+ Trust.audit(
+ moderator.id,
+ "category_proposal.#{status}",
+ "category_proposal",
+ proposal.id,
+ %{"merged_into_id" => merged_into_id}
+ ) do
+ {:ok, proposal}
+ end
+
+ true ->
+ {:error, :proposal_closed}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
@@ -269,7 +297,7 @@ defmodule WhoNeedHelp.Catalog do
CategoryProposal
|> where([proposal], proposal.id == ^proposal_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
end
defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
@@ -347,9 +375,11 @@ defmodule WhoNeedHelp.Catalog do
case attrs["parent_id"] do
parent_id when parent_id not in [nil, ""] ->
- case Repo.get(Category, parent_id) do
- nil -> {:error, :invalid_parent}
- parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
+ with {:ok, parent_id} <- cast_id(parent_id),
+ %Category{} = parent <- Repo.get(Category, parent_id) do
+ {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
+ else
+ _invalid_or_missing -> {:error, :invalid_parent}
end
_ ->
@@ -863,6 +893,13 @@ defmodule WhoNeedHelp.Catalog do
)
end
+ defp cast_id(value) do
+ case Ecto.UUID.cast(value) do
+ {:ok, id} -> {:ok, id}
+ :error -> {:error, :not_found}
+ end
+ end
+
defp names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru}
defp option(value, label), do: %{"value" => value, "label" => label}
diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex
index 97da558..e1a799e 100644
--- a/lib/who_need_help/help.ex
+++ b/lib/who_need_help/help.ex
@@ -64,6 +64,13 @@ defmodule WhoNeedHelp.Help do
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
+ def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
+ request.status == :open and is_nil(request.hidden_at) and
+ DateTime.after?(request.expires_at, DateTime.utc_now(:second)) and
+ request_filter_matches?(request, filters) and
+ not Trust.blocked_between?(user.id, request.requester_id)
+ end
+
def list_my_requests(%Scope{user: user}) do
paginate_my_requests(%Scope{user: user}).entries
end
@@ -112,43 +119,37 @@ defmodule WhoNeedHelp.Help do
end
def get_request(%Scope{user: user} = scope, id) do
- request =
- HelpRequest
- |> Repo.get(id)
- |> case do
- nil ->
- nil
+ with {:ok, id} <- Ecto.UUID.cast(id),
+ %HelpRequest{} = request <- Repo.get(HelpRequest, id) do
+ request =
+ [request]
+ |> preload_request_relations(social_identities: true)
+ |> hd()
- request ->
- [request]
- |> preload_request_relations(social_identities: true)
- |> hd()
+ cond do
+ request.requester_id == user.id ->
+ {:ok, request}
+
+ Accounts.moderator_authorized?(user) ->
+ {:ok, request}
+
+ not is_nil(request.hidden_at) ->
+ {:error, :not_found}
+
+ Trust.blocked_between?(user.id, request.requester_id) ->
+ {:error, :not_found}
+
+ request.assignment && participant?(scope, request.assignment) ->
+ {:ok, request}
+
+ request.status == :open and DateTime.after?(request.expires_at, DateTime.utc_now(:second)) ->
+ {:ok, request}
+
+ true ->
+ {:error, :not_found}
end
-
- case request do
- nil ->
- {:error, :not_found}
-
- request ->
- cond do
- request.requester_id == user.id ->
- {:ok, request}
-
- request.assignment && participant?(scope, request.assignment) ->
- {:ok, request}
-
- Accounts.moderator_authorized?(user) ->
- {:ok, request}
-
- not is_nil(request.hidden_at) ->
- {:error, :not_found}
-
- Trust.blocked_between?(user.id, request.requester_id) ->
- {:error, :not_found}
-
- true ->
- {:ok, request}
- end
+ else
+ _invalid_or_missing -> {:error, :not_found}
end
end
@@ -211,6 +212,8 @@ defmodule WhoNeedHelp.Help do
|> lock("FOR UPDATE")
|> Repo.one!()
+ :ok = Trust.lock_user_pair(request.requester_id, helper.id)
+
cond do
request.requester_id == helper.id ->
{:error, :own_request}
@@ -284,6 +287,7 @@ defmodule WhoNeedHelp.Help do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id)
+ :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
cond do
user.id != assignment.helper_id ->
@@ -458,11 +462,15 @@ defmodule WhoNeedHelp.Help do
def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id
def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do
- participant =
- request.requester_id == scope.user.id or
- (not is_nil(request.assignment) and participant?(scope, request.assignment))
+ owner = request.requester_id == scope.user.id
- if participant and request.location_visibility in [:hidden, :exact_for_active_match] do
+ matched_participant =
+ not owner and not is_nil(request.assignment) and participant?(scope, request.assignment) and
+ request.assignment.status in [:accepted, :in_progress] and
+ not Trust.blocked_between?(scope.user.id, request.requester_id)
+
+ if (owner or matched_participant) and
+ request.location_visibility in [:hidden, :exact_for_active_match] do
%Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true}
else
@@ -486,6 +494,7 @@ defmodule WhoNeedHelp.Help do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id)
+ :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
now = DateTime.utc_now(:second)
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
@@ -629,6 +638,14 @@ defmodule WhoNeedHelp.Help do
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
end
+ defp request_filter_matches?(request, filters) do
+ category_id = filters["category_id"] || filters[:category_id]
+ urgency = filters["urgency"] || filters[:urgency]
+
+ (category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
+ (urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency))
+ end
+
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
defp maybe_filter(query, field, value),
@@ -638,7 +655,7 @@ defmodule WhoNeedHelp.Help do
category_id = Ecto.Changeset.get_field(changeset, :category_id)
case category_id && Repo.get(Category, category_id) do
- %Category{} = category ->
+ %Category{mode: :help, active: true} = category ->
data = Ecto.Changeset.get_field(changeset, :structured_data)
case Catalog.validate_structured_data(category, data) do
@@ -654,7 +671,7 @@ defmodule WhoNeedHelp.Help do
end
_ ->
- changeset
+ Ecto.Changeset.add_error(changeset, :category_id, "select an active help category")
end
end
end
diff --git a/lib/who_need_help/help/help_request.ex b/lib/who_need_help/help/help_request.ex
index 26088f1..1dc3c36 100644
--- a/lib/who_need_help/help/help_request.ex
+++ b/lib/who_need_help/help/help_request.ex
@@ -28,6 +28,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
field :completed_at, :utc_datetime
field :hidden_at, :utc_datetime
field :hidden_reason, :string
+ field :safety_confirmed, :boolean, virtual: true, default: false
belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category
has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id
@@ -46,7 +47,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
:urgency,
:location_visibility,
:expires_at,
- :category_id
+ :category_id,
+ :safety_confirmed
])
|> put_location(attrs)
|> validate_required([
@@ -62,6 +64,9 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000)
|> validate_length(:pickup_instructions, max: 1_000)
+ |> validate_acceptance(:safety_confirmed,
+ message: "confirm the safety guidance before publishing"
+ )
|> validate_expiry()
end
diff --git a/lib/who_need_help/messaging.ex b/lib/who_need_help/messaging.ex
index c878a7e..815736e 100644
--- a/lib/who_need_help/messaging.ex
+++ b/lib/who_need_help/messaging.ex
@@ -46,28 +46,38 @@ defmodule WhoNeedHelp.Messaging do
end
def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
- assignment = Repo.preload(assignment, :request)
-
- with {:ok, _limit} <- Trust.authorize_action(scope, :send_message),
- true <- Help.participant?(scope, assignment),
- false <- blocked_assignment?(scope, assignment) do
- request = Map.fetch!(assignment, :request)
- recipient_id = counterpart_id(user.id, assignment, request)
-
+ with {:ok, _limit} <- Trust.authorize_action(scope, :send_message) do
result =
Repo.transact(fn ->
- with {:ok, message} <-
- %Message{assignment_id: assignment.id, sender_id: user.id}
+ current =
+ Assignment
+ |> where([current], current.id == ^assignment.id)
+ |> lock("FOR UPDATE")
+ |> Repo.one!()
+ |> Repo.preload(:request)
+
+ request = current.request
+ recipient_id = counterpart_id(user.id, current, request)
+
+ with true <- Help.participant?(scope, current),
+ :ok <- Trust.lock_user_pair(user.id, recipient_id),
+ false <- Trust.blocked_between?(user.id, recipient_id),
+ {:ok, message} <-
+ %Message{assignment_id: current.id, sender_id: user.id}
|> Message.changeset(attrs)
|> Repo.insert(),
{:ok, _push_job} <-
Push.enqueue_message_created(
message.id,
- assignment.id,
+ current.id,
request.id,
recipient_id
) do
{:ok, message}
+ else
+ true -> {:error, :blocked}
+ false -> {:error, :forbidden}
+ other -> other
end
end)
@@ -76,16 +86,12 @@ defmodule WhoNeedHelp.Messaging do
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
- "messages:#{assignment.id}",
+ "messages:#{message.assignment_id}",
{:new_message, message}
)
{:ok, message}
end
- else
- true -> {:error, :blocked}
- false -> {:error, :forbidden}
- other -> other
end
end
diff --git a/lib/who_need_help/tracking.ex b/lib/who_need_help/tracking.ex
index 5bc5427..d1944c7 100644
--- a/lib/who_need_help/tracking.ex
+++ b/lib/who_need_help/tracking.ex
@@ -32,47 +32,49 @@ defmodule WhoNeedHelp.Tracking do
%Assignment{} = assignment,
visibility \\ :active_match
) do
- with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking),
- true <- Help.participant?(scope, assignment),
- true <- assignment.status in [:accepted, :in_progress] do
- now = DateTime.utc_now(:second)
+ with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking) do
+ Repo.transact(fn ->
+ with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
+ now = DateTime.utc_now(:second)
- %TrackingSession{}
- |> TrackingSession.changeset(%{
- assignment_id: assignment.id,
- user_id: user.id,
- visibility: visibility,
- started_at: now
- })
- |> Repo.insert(
- on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]],
- conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
- )
- else
- false -> {:error, :forbidden}
- other -> other
+ %TrackingSession{}
+ |> TrackingSession.changeset(%{
+ assignment_id: current.id,
+ user_id: user.id,
+ visibility: visibility,
+ started_at: now
+ })
+ |> Repo.insert(
+ on_conflict: [
+ set: [active: true, ended_at: nil, started_at: now, visibility: visibility]
+ ],
+ conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
+ )
+ end
+ end)
end
end
def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
- with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position),
- true <- Help.participant?(scope, assignment) do
+ with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position) do
result =
Repo.transact(fn ->
- session =
- TrackingSession
- |> where(
- [session],
- session.assignment_id == ^assignment.id and session.user_id == ^user.id and
- session.active
- )
- |> lock("FOR UPDATE")
- |> Repo.one()
+ with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
+ session =
+ TrackingSession
+ |> where(
+ [session],
+ session.assignment_id == ^current.id and session.user_id == ^user.id and
+ session.active
+ )
+ |> lock("FOR UPDATE")
+ |> Repo.one()
- if session do
- persist_position(session, assignment, user, attrs)
- else
- {:error, :tracking_not_active}
+ if session do
+ persist_position(session, current, user, attrs)
+ else
+ {:error, :tracking_not_active}
+ end
end
end)
@@ -85,9 +87,6 @@ defmodule WhoNeedHelp.Tracking do
{:ok, position}
end
- else
- false -> {:error, :forbidden}
- other -> other
end
end
@@ -119,15 +118,16 @@ defmodule WhoNeedHelp.Tracking do
end
def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do
- if Trust.eligible?(scope) and Help.participant?(scope, assignment) do
+ with true <- Trust.eligible?(scope),
+ {:ok, current} <- active_assignment(scope, assignment.id) do
TrackingSession
- |> where([s], s.assignment_id == ^assignment.id and s.active)
+ |> where([s], s.assignment_id == ^current.id and s.active)
|> join(:inner, [s], p in assoc(s, :position))
|> select([s, p], {s.user_id, p})
|> Repo.all()
|> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end)
else
- %{}
+ _ -> %{}
end
end
@@ -160,13 +160,93 @@ defmodule WhoNeedHelp.Tracking do
end)
end
+ def stop_all_sessions(user_id) do
+ result =
+ Repo.transact(fn ->
+ active_sessions =
+ TrackingSession
+ |> where([session], session.active and session.user_id == ^user_id)
+ |> select([session], {session.id, session.assignment_id})
+ |> lock("FOR UPDATE")
+ |> Repo.all()
+
+ session_ids = Enum.map(active_sessions, &elem(&1, 0))
+
+ if session_ids != [] do
+ now = DateTime.utc_now(:second)
+
+ Position
+ |> where([position], position.tracking_session_id in ^session_ids)
+ |> Repo.delete_all()
+
+ TrackingSession
+ |> where([session], session.id in ^session_ids)
+ |> Repo.update_all(set: [active: false, ended_at: now, updated_at: now])
+ end
+
+ {:ok, Enum.map(active_sessions, &elem(&1, 1))}
+ end)
+
+ with {:ok, assignment_ids} <- result do
+ Enum.each(assignment_ids, fn assignment_id ->
+ Phoenix.PubSub.broadcast(
+ WhoNeedHelp.PubSub,
+ "tracking:#{assignment_id}",
+ {:tracking_stopped, user_id}
+ )
+ end)
+
+ {:ok, %{sessions_ended: length(assignment_ids)}}
+ end
+ end
+
defp active_session(scope, assignment, user_id) do
- if Help.participant?(scope, assignment) do
+ with {:ok, current} <- active_assignment(scope, assignment.id) do
Repo.get_by(TrackingSession,
- assignment_id: assignment.id,
+ assignment_id: current.id,
user_id: user_id,
active: true
)
+ else
+ _ -> nil
+ end
+ end
+
+ defp active_assignment(%Scope{user: user} = scope, assignment_id, options \\ []) do
+ query =
+ Assignment
+ |> where([assignment], assignment.id == ^assignment_id)
+ |> preload(:request)
+
+ query = if Keyword.get(options, :lock, false), do: lock(query, "FOR UPDATE"), else: query
+
+ case Repo.one(query) do
+ nil ->
+ {:error, :not_found}
+
+ %Assignment{} = assignment ->
+ request = assignment.request
+
+ counterpart_id =
+ if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
+
+ if Keyword.get(options, :lock, false) do
+ :ok = Trust.lock_user_pair(user.id, counterpart_id)
+ end
+
+ cond do
+ not Help.participant?(scope, assignment) ->
+ {:error, :forbidden}
+
+ assignment.status not in [:accepted, :in_progress] ->
+ {:error, :assignment_inactive}
+
+ Trust.blocked_between?(user.id, counterpart_id) ->
+ {:error, :blocked}
+
+ true ->
+ {:ok, assignment}
+ end
end
end
diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex
index 44ab858..8ad59ee 100644
--- a/lib/who_need_help/trust.ex
+++ b/lib/who_need_help/trust.ex
@@ -47,55 +47,66 @@ defmodule WhoNeedHelp.Trust do
def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
attrs = stringify_keys(attrs)
- request =
- case assignment.request do
- %HelpRequest{} = request -> request
- _ -> Repo.get!(HelpRequest, assignment.request_id)
- end
-
- with {:ok, _limit} <- authorize_action(scope, :review),
- true <- assignment.status == :completed,
- true <- user.id in [assignment.helper_id, request.requester_id] do
- reviewee_id =
- if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
-
+ with {:ok, _limit} <- authorize_action(scope, :review) do
Repo.transact(fn ->
- with {:ok, review} <-
- %Review{}
- |> Review.changeset(
- Map.merge(attrs, %{
- "assignment_id" => assignment.id,
- "reviewer_id" => user.id,
- "reviewee_id" => reviewee_id
- })
- )
- |> Repo.insert(),
- reviews <- Repo.all(from r in Review, where: r.assignment_id == ^assignment.id),
- true <- length(reviews) <= 2 do
- if length(reviews) == 2 do
- now = DateTime.utc_now(:second)
+ current =
+ Assignment
+ |> where([current], current.id == ^assignment.id)
+ |> lock("FOR UPDATE")
+ |> Repo.one!()
- Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id),
- set: [revealed_at: now]
- )
- end
+ request = Repo.get!(HelpRequest, current.request_id)
- audit(user.id, "review.submitted", "assignment", assignment.id, %{
- "revealed" => length(reviews) == 2
- })
+ cond do
+ current.status != :completed ->
+ {:error, :forbidden}
- {:ok, review}
- else
- false -> {:error, :invalid_review_count}
- other -> other
+ user.id not in [current.helper_id, request.requester_id] ->
+ {:error, :forbidden}
+
+ true ->
+ reviewee_id =
+ if user.id == current.helper_id,
+ do: request.requester_id,
+ else: current.helper_id
+
+ with {:ok, review} <-
+ %Review{}
+ |> Review.changeset(
+ Map.merge(attrs, %{
+ "assignment_id" => current.id,
+ "reviewer_id" => user.id,
+ "reviewee_id" => reviewee_id
+ })
+ )
+ |> Repo.insert(),
+ reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
+ true <- length(reviews) <= 2,
+ revealed? <- length(reviews) == 2,
+ {_count, _rows} <-
+ maybe_reveal_reviews(current.id, revealed?),
+ {:ok, _audit} <-
+ audit(user.id, "review.submitted", "assignment", current.id, %{
+ "revealed" => revealed?
+ }) do
+ {:ok, review}
+ else
+ false -> {:error, :invalid_review_count}
+ other -> other
+ end
end
end)
- else
- false -> {:error, :forbidden}
- other -> other
end
end
+ defp maybe_reveal_reviews(assignment_id, true) do
+ Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),
+ set: [revealed_at: DateTime.utc_now(:second)]
+ )
+ end
+
+ defp maybe_reveal_reviews(_assignment_id, false), do: {0, nil}
+
def visible_reviews(user_id) do
paginate_visible_reviews(user_id).entries
end
@@ -259,11 +270,11 @@ defmodule WhoNeedHelp.Trust do
with {:ok, report} <-
%Report{reporter_id: user.id}
|> Report.changeset(attrs)
- |> Repo.insert() do
- audit(user.id, "report.created", "report", report.id, %{
- "reason" => to_string(report.reason)
- })
-
+ |> Repo.insert(),
+ {:ok, _audit} <-
+ audit(user.id, "report.created", "report", report.id, %{
+ "reason" => to_string(report.reason)
+ }) do
{:ok, report}
end
end)
@@ -301,7 +312,8 @@ defmodule WhoNeedHelp.Trust do
end
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, report_id} <- cast_id(report_id),
+ true <- Accounts.moderator_authorized?(moderator) do
attrs =
attrs
|> stringify_keys()
@@ -312,23 +324,29 @@ defmodule WhoNeedHelp.Trust do
Repo.transact(fn ->
report =
- Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one!()
+ Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one()
- with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update() do
- audit(moderator.id, "report.moderated", "report", report.id, %{
- "status" => to_string(report.status)
- })
-
- {:ok, report}
+ if report do
+ with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update(),
+ {:ok, _audit} <-
+ audit(moderator.id, "report.moderated", "report", report.id, %{
+ "status" => to_string(report.status)
+ }) do
+ {:ok, report}
+ end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
def report_evidence(%Scope{user: moderator}, report_id) do
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, report_id} <- cast_id(report_id),
+ true <- Accounts.moderator_authorized?(moderator) do
report =
Report
|> Repo.get(report_id)
@@ -394,41 +412,80 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found}
end
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
def block(%Scope{user: user} = scope, blocked_id) do
- with {:ok, _limit} <- authorize_action(scope, :block),
+ with {:ok, blocked_id} <- cast_id(blocked_id),
+ {:ok, _limit} <- authorize_action(scope, :block),
%User{} <- Repo.get(User, blocked_id) do
- Repo.transact(fn ->
- with {:ok, block} <-
- %Block{}
- |> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
- |> Repo.insert() do
- audit(user.id, "user.blocked", "user", blocked_id)
- clear_pair_tracking(user.id, blocked_id)
- {:ok, block}
- end
- end)
+ result =
+ Repo.transact(fn ->
+ with :ok <- lock_user_pair(user.id, blocked_id),
+ {:ok, block} <-
+ %Block{}
+ |> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
+ |> Repo.insert(),
+ {:ok, _audit} <- audit(user.id, "user.blocked", "user", blocked_id) do
+ tracking = clear_pair_tracking(user.id, blocked_id)
+ activity_ids = pair_activity_ids(user.id, blocked_id)
+ {:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}}
+ end
+ end)
+
+ with {:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}} <- result do
+ Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} ->
+ Phoenix.PubSub.broadcast(
+ WhoNeedHelp.PubSub,
+ "tracking:#{assignment_id}",
+ {:tracking_stopped, stopped_user_id}
+ )
+ end)
+
+ Enum.each(tracking.request_ids, &Help.notify_request_updated/1)
+ Enum.each(activity_ids, &Activities.notify_activity_updated/1)
+ {:ok, block}
+ end
else
nil -> {:error, :not_found}
other -> other
end
end
- def unblock(%Scope{user: user}, blocked_id) do
- case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
- nil ->
- {:ok, :already_unblocked}
+ defp pair_activity_ids(first_user_id, second_user_id) do
+ Activity
+ |> join(:inner, [activity], participant in Participant,
+ on: participant.activity_id == activity.id
+ )
+ |> where(
+ [activity, participant],
+ participant.status == :approved and
+ ((activity.creator_id == ^first_user_id and participant.user_id == ^second_user_id) or
+ (activity.creator_id == ^second_user_id and participant.user_id == ^first_user_id))
+ )
+ |> select([activity], activity.id)
+ |> distinct(true)
+ |> Repo.all()
+ end
- block ->
- Repo.transact(fn ->
- with {:ok, _block} <- Repo.delete(block) do
- audit(user.id, "user.unblocked", "user", blocked_id)
- {:ok, :unblocked}
- end
- end)
+ def unblock(%Scope{user: user}, blocked_id) do
+ with {:ok, blocked_id} <- cast_id(blocked_id) do
+ Repo.transact(fn ->
+ :ok = lock_user_pair(user.id, blocked_id)
+
+ case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
+ nil ->
+ {:ok, :already_unblocked}
+
+ block ->
+ with {:ok, _block} <- Repo.delete(block),
+ {:ok, _audit} <- audit(user.id, "user.unblocked", "user", blocked_id) do
+ {:ok, :unblocked}
+ end
+ end
+ end)
end
end
@@ -468,6 +525,17 @@ defmodule WhoNeedHelp.Trust do
)
end
+ @doc false
+ def lock_user_pair(first_user_id, second_user_id) do
+ User
+ |> where([user], user.id in ^Enum.sort([first_user_id, second_user_id]))
+ |> order_by([user], asc: user.id)
+ |> lock("FOR UPDATE")
+ |> Repo.all()
+
+ :ok
+ end
+
def record_completion_signals(%Assignment{} = assignment) do
request =
case assignment.request do
@@ -550,7 +618,8 @@ defmodule WhoNeedHelp.Trust do
end
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, signal_id} <- cast_id(signal_id),
+ true <- Accounts.moderator_authorized?(moderator) do
attrs =
attrs
|> stringify_keys()
@@ -564,44 +633,58 @@ defmodule WhoNeedHelp.Trust do
AbuseSignal
|> where([signal], signal.id == ^signal_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
- with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do
- audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
- "status" => to_string(signal.status)
- })
-
- {:ok, signal}
+ if signal do
+ with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update(),
+ {:ok, _audit} <-
+ audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
+ "status" => to_string(signal.status)
+ }) do
+ {:ok, signal}
+ end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
end
def hide_request(%Scope{user: moderator}, request_id, reason) do
result =
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, request_id} <- cast_id(request_id),
+ true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
- with {:ok, request} <-
- request
- |> HelpRequest.moderation_changeset(%{
- hidden_at: DateTime.utc_now(:second),
- hidden_reason: reason
- })
- |> Repo.update() do
- audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason})
- {:ok, request}
+ if request do
+ with {:ok, request} <-
+ request
+ |> HelpRequest.moderation_changeset(%{
+ hidden_at: DateTime.utc_now(:second),
+ hidden_reason: reason
+ })
+ |> Repo.update(),
+ {:ok, _audit} <-
+ audit(moderator.id, "request.hidden", "request", request.id, %{
+ "reason" => reason
+ }) do
+ {:ok, request}
+ end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
with {:ok, request} <- result do
@@ -612,24 +695,31 @@ defmodule WhoNeedHelp.Trust do
def restore_request(%Scope{user: moderator}, request_id) do
result =
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, request_id} <- cast_id(request_id),
+ true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
- with {:ok, request} <-
- request
- |> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
- |> Repo.update() do
- audit(moderator.id, "request.restored", "request", request.id)
- {:ok, request}
+ if request do
+ with {:ok, request} <-
+ request
+ |> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
+ |> Repo.update(),
+ {:ok, _audit} <-
+ audit(moderator.id, "request.restored", "request", request.id) do
+ {:ok, request}
+ end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
with {:ok, request} <- result do
@@ -640,30 +730,36 @@ defmodule WhoNeedHelp.Trust do
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
result =
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, activity_id} <- cast_id(activity_id),
+ true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
- with {:ok, activity} <-
- activity
- |> Activity.moderation_changeset(%{
- hidden_at: DateTime.utc_now(:second),
- hidden_reason: reason
- })
- |> Repo.update() do
- audit(moderator.id, "activity.hidden", "activity", activity.id, %{
- "reason" => reason
- })
-
- {:ok, activity}
+ if activity do
+ with {:ok, activity} <-
+ activity
+ |> Activity.moderation_changeset(%{
+ hidden_at: DateTime.utc_now(:second),
+ hidden_reason: reason
+ })
+ |> Repo.update(),
+ {:ok, _audit} <-
+ audit(moderator.id, "activity.hidden", "activity", activity.id, %{
+ "reason" => reason
+ }) do
+ {:ok, activity}
+ end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
with {:ok, activity} <- result do
@@ -674,24 +770,31 @@ defmodule WhoNeedHelp.Trust do
def restore_activity(%Scope{user: moderator}, activity_id) do
result =
- if Accounts.moderator_authorized?(moderator) do
+ with {:ok, activity_id} <- cast_id(activity_id),
+ true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
- with {:ok, activity} <-
- activity
- |> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
- |> Repo.update() do
- audit(moderator.id, "activity.restored", "activity", activity.id)
- {:ok, activity}
+ if activity do
+ with {:ok, activity} <-
+ activity
+ |> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
+ |> Repo.update(),
+ {:ok, _audit} <-
+ audit(moderator.id, "activity.restored", "activity", activity.id) do
+ {:ok, activity}
+ end
+ else
+ {:error, :not_found}
end
end)
else
- {:error, :forbidden}
+ false -> {:error, :forbidden}
+ {:error, :not_found} = error -> error
end
with {:ok, activity} <- result do
@@ -701,15 +804,43 @@ defmodule WhoNeedHelp.Trust do
end
def moderate_user(%Scope{user: moderator}, user_id, attrs) do
- Repo.transact(fn ->
- with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs),
- {:ok, _audit} <-
- audit(moderator.id, "user.moderated", "user", user.id, %{
- "moderation_status" => to_string(user.moderation_status)
- }) do
- {:ok, user}
- end
- end)
+ result =
+ Repo.transact(fn ->
+ with {:ok, %{user: user} = moderation} <-
+ Accounts.moderate_user(moderator, user_id, attrs),
+ {:ok, _audit} <-
+ audit(moderator.id, "user.moderated", "user", user.id, %{
+ "moderation_status" => to_string(user.moderation_status)
+ }) do
+ tracking =
+ if user.moderation_status == :suspended,
+ do: clear_user_tracking(user.id),
+ else: %{stopped: [], request_ids: []}
+
+ {:ok, Map.put(moderation, :tracking, tracking)}
+ end
+ end)
+
+ with {:ok, %{user: user, expired_session_tokens: tokens, tracking: tracking}} <-
+ result do
+ Enum.each(tokens, fn token ->
+ token
+ |> WhoNeedHelpWeb.UserAuth.live_socket_id()
+ |> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{})
+ end)
+
+ Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} ->
+ Phoenix.PubSub.broadcast(
+ WhoNeedHelp.PubSub,
+ "tracking:#{assignment_id}",
+ {:tracking_stopped, stopped_user_id}
+ )
+ end)
+
+ Enum.each(tracking.request_ids, &Help.notify_request_updated/1)
+
+ {:ok, user}
+ end
end
def moderate_role(%Scope{user: admin}, user_id, attrs) do
@@ -1057,7 +1188,7 @@ defmodule WhoNeedHelp.Trust do
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
defp clear_pair_tracking(first_user_id, second_user_id) do
- assignment_ids =
+ assignments =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
@@ -1067,27 +1198,95 @@ defmodule WhoNeedHelp.Trust do
(assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id)
)
- |> where([assignment], assignment.status in [:accepted, :in_progress])
- |> select([assignment], assignment.id)
+ |> select([assignment, request], {assignment.id, request.id, assignment.status})
+ |> Repo.all()
- session_ids =
+ active_assignment_ids =
+ assignments
+ |> Enum.filter(fn {_assignment_id, _request_id, status} ->
+ status in [:accepted, :in_progress]
+ end)
+ |> Enum.map(&elem(&1, 0))
+
+ active_sessions =
+ if active_assignment_ids == [] do
+ []
+ else
+ TrackingSession
+ |> where([session], session.active and session.assignment_id in ^active_assignment_ids)
+ |> select([session], {session.id, session.assignment_id, session.user_id})
+ |> Repo.all()
+ end
+
+ session_ids = Enum.map(active_sessions, &elem(&1, 0))
+
+ if session_ids != [] do
+ Repo.delete_all(
+ from position in Position, where: position.tracking_session_id in ^session_ids
+ )
+
+ Repo.update_all(
+ from(session in TrackingSession, where: session.id in ^session_ids),
+ set: [
+ active: false,
+ ended_at: DateTime.utc_now(:second),
+ updated_at: DateTime.utc_now(:second)
+ ]
+ )
+ end
+
+ %{
+ request_ids: assignments |> Enum.map(&elem(&1, 1)) |> Enum.uniq(),
+ stopped:
+ Enum.map(active_sessions, fn {_id, assignment_id, user_id} -> {assignment_id, user_id} end)
+ }
+ end
+
+ defp clear_user_tracking(user_id) do
+ active_sessions =
TrackingSession
- |> where([session], session.active and session.assignment_id in subquery(assignment_ids))
- |> select([session], session.id)
+ |> join(:inner, [session], assignment in Assignment,
+ on: assignment.id == session.assignment_id
+ )
+ |> join(:inner, [_session, assignment], request in HelpRequest,
+ on: request.id == assignment.request_id
+ )
+ |> where([session], session.active and session.user_id == ^user_id)
+ |> select([session, assignment, request], {session.id, assignment.id, request.id})
+ |> Repo.all()
- Repo.delete_all(
- from position in Position, where: position.tracking_session_id in subquery(session_ids)
- )
+ session_ids = Enum.map(active_sessions, &elem(&1, 0))
- Repo.update_all(
- from(session in TrackingSession, where: session.id in subquery(session_ids)),
- set: [active: false, ended_at: DateTime.utc_now(:second)]
- )
+ if session_ids != [] do
+ now = DateTime.utc_now(:second)
- :ok
+ Repo.delete_all(
+ from position in Position, where: position.tracking_session_id in ^session_ids
+ )
+
+ Repo.update_all(
+ from(session in TrackingSession, where: session.id in ^session_ids),
+ set: [active: false, ended_at: now, updated_at: now]
+ )
+ end
+
+ %{
+ stopped:
+ Enum.map(active_sessions, fn {_session_id, assignment_id, _request_id} ->
+ {assignment_id, user_id}
+ end),
+ request_ids: active_sessions |> Enum.map(&elem(&1, 2)) |> Enum.uniq()
+ }
end
defp stringify_keys(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end
+
+ defp cast_id(value) do
+ case Ecto.UUID.cast(value) do
+ {:ok, id} -> {:ok, id}
+ :error -> {:error, :not_found}
+ end
+ end
end
diff --git a/lib/who_need_help/trust/abuse_signal.ex b/lib/who_need_help/trust/abuse_signal.ex
index ad45c4a..eff3872 100644
--- a/lib/who_need_help/trust/abuse_signal.ex
+++ b/lib/who_need_help/trust/abuse_signal.ex
@@ -33,6 +33,7 @@ defmodule WhoNeedHelp.Trust.AbuseSignal do
|> unique_constraint([:kind, :subject_id, :assignment_id],
name: :abuse_signals_one_kind_per_assignment
)
+ |> unique_constraint(:subject_id, name: :abuse_signals_one_open_velocity_per_subject)
end
def moderation_changeset(signal, attrs) do
diff --git a/lib/who_need_help/workers/expire_requests.ex b/lib/who_need_help/workers/expire_requests.ex
index e1c9e71..81b0a0d 100644
--- a/lib/who_need_help/workers/expire_requests.ex
+++ b/lib/who_need_help/workers/expire_requests.ex
@@ -2,6 +2,7 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
use Oban.Worker, queue: :maintenance, unique: [period: 55]
import Ecto.Query
+ alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking
@@ -57,9 +58,15 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
end)
case result do
- {:ok, :empty} -> {:ok, expired}
- {:ok, %HelpRequest{}} -> expire_available(now, expired + 1)
- {:error, reason} -> {:error, reason}
+ {:ok, :empty} ->
+ {:ok, expired}
+
+ {:ok, %HelpRequest{} = request} ->
+ :ok = Help.notify_request_updated(request.id)
+ expire_available(now, expired + 1)
+
+ {:error, reason} ->
+ {:error, reason}
end
end
end
diff --git a/lib/who_need_help_web/components/layouts/root.html.heex b/lib/who_need_help_web/components/layouts/root.html.heex
index 84843ce..1cf33f4 100644
--- a/lib/who_need_help_web/components/layouts/root.html.heex
+++ b/lib/who_need_help_web/components/layouts/root.html.heex
@@ -18,34 +18,6 @@
-