938 lines
36 KiB
Elixir
938 lines
36 KiB
Elixir
defmodule WhoNeedHelp.Catalog do
|
||
@moduledoc "Data-driven categories, proposals, and community votes."
|
||
|
||
import Ecto.Query
|
||
alias WhoNeedHelp.Accounts
|
||
alias WhoNeedHelp.Accounts.Scope
|
||
alias WhoNeedHelp.Catalog.{Category, CategoryProposal, CategoryVote}
|
||
alias WhoNeedHelp.Pagination
|
||
alias WhoNeedHelp.Repo
|
||
alias WhoNeedHelp.Trust
|
||
|
||
def list_categories(mode \\ :help) when mode in [:help, :activity] do
|
||
Category
|
||
|> where([c], c.active and c.mode == ^mode)
|
||
|> order_by([c], asc: c.sort_order, asc: c.slug)
|
||
|> preload(:parent)
|
||
|> Repo.all()
|
||
end
|
||
|
||
def list_all_categories do
|
||
Category
|
||
|> where([category], category.active)
|
||
|> order_by([category], asc: category.mode, asc: category.sort_order, asc: category.slug)
|
||
|> preload(:parent)
|
||
|> Repo.all()
|
||
end
|
||
|
||
def get_category!(id), do: Repo.get!(Category, id)
|
||
|
||
def category_path(%Category{parent: %Category{} = parent} = category, locale) do
|
||
"#{Category.name(parent, locale)} — #{Category.name(category, locale)}"
|
||
end
|
||
|
||
def category_path(%Category{} = category, locale), do: Category.name(category, locale)
|
||
|
||
def list_proposals do
|
||
paginate_proposals().entries
|
||
end
|
||
|
||
def paginate_proposals(options \\ []) do
|
||
limit = Pagination.limit(options)
|
||
cursor = Pagination.cursor(options)
|
||
public_user = Accounts.public_user_query()
|
||
|
||
CategoryProposal
|
||
|> where([p], p.status == :open)
|
||
|> before_proposal(cursor)
|
||
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
||
|> limit(^(limit + 1))
|
||
|> with_vote_count()
|
||
|> preload([proposal], proposer: ^public_user, parent: [])
|
||
|> Repo.all()
|
||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||
end
|
||
|
||
def change_proposal(%CategoryProposal{} = proposal, attrs \\ %{}) do
|
||
CategoryProposal.changeset(proposal, attrs)
|
||
end
|
||
|
||
def propose(%Scope{user: user} = scope, attrs) do
|
||
with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal),
|
||
{:ok, attrs} <- normalize_proposal_mode(attrs) do
|
||
Repo.transact(fn ->
|
||
with {:ok, proposal} <-
|
||
%CategoryProposal{proposer_id: user.id}
|
||
|> CategoryProposal.changeset(attrs)
|
||
|> Repo.insert(),
|
||
{:ok, _audit} <-
|
||
Trust.audit(user.id, "category_proposal.created", "category_proposal", proposal.id) do
|
||
{:ok, proposal}
|
||
end
|
||
end)
|
||
end
|
||
end
|
||
|
||
def vote(%Scope{user: user} = scope, proposal_id) do
|
||
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
|
||
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}
|
||
end
|
||
end
|
||
|
||
def seed_defaults do
|
||
categories =
|
||
Enum.reduce(default_category_specs(), %{}, fn {parent_slug, attrs}, seeded ->
|
||
attrs =
|
||
case parent_slug do
|
||
nil -> attrs
|
||
slug -> Map.put(attrs, :parent_id, Map.fetch!(seeded, slug).id)
|
||
end
|
||
|
||
category = seed_category(attrs)
|
||
Map.put(seeded, category.slug, category)
|
||
end)
|
||
|
||
Map.fetch!(categories, "medicine-pickup")
|
||
end
|
||
|
||
def structured_fields(%Category{structured_fields: %{"fields" => fields}})
|
||
when is_list(fields),
|
||
do: fields
|
||
|
||
def structured_fields(_category), do: []
|
||
|
||
def validate_structured_data(%Category{} = category, data) when is_map(data) do
|
||
fields = structured_fields(category)
|
||
allowed = MapSet.new(Enum.map(fields, & &1["key"]))
|
||
unknown = data |> Map.keys() |> Enum.reject(&MapSet.member?(allowed, &1))
|
||
|
||
errors = Enum.map(unknown, &"#{&1} is not allowed")
|
||
|
||
{clean, errors} =
|
||
Enum.reduce(fields, {%{}, errors}, fn field, {clean, errors} ->
|
||
key = field["key"]
|
||
value = data[key]
|
||
|
||
cond do
|
||
field["required"] == true and value in [nil, ""] ->
|
||
{clean, ["#{key} is required" | errors]}
|
||
|
||
value in [nil, ""] ->
|
||
{clean, errors}
|
||
|
||
valid_structured_value?(field, value) ->
|
||
{Map.put(clean, key, normalize_structured_value(field, value)), errors}
|
||
|
||
true ->
|
||
{clean, ["#{key} has an invalid value" | errors]}
|
||
end
|
||
end)
|
||
|
||
if errors == [], do: {:ok, clean}, else: {:error, Enum.reverse(errors)}
|
||
end
|
||
|
||
def validate_structured_data(%Category{} = category, nil),
|
||
do: validate_structured_data(category, %{})
|
||
|
||
def validate_structured_data(_category, _data), do: {:error, ["must be an object"]}
|
||
|
||
def list_proposals_for_moderation(%Scope{user: user}) do
|
||
paginate_proposals_for_moderation(%Scope{user: user}).entries
|
||
end
|
||
|
||
def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do
|
||
if Accounts.moderator_authorized?(user) do
|
||
limit = Pagination.limit(options)
|
||
cursor = Pagination.cursor(options)
|
||
|
||
CategoryProposal
|
||
|> before_proposal(cursor)
|
||
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
||
|> limit(^(limit + 1))
|
||
|> with_vote_count()
|
||
|> preload([proposal], [:parent, :merged_into])
|
||
|> Repo.all()
|
||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||
else
|
||
%Pagination.Page{}
|
||
end
|
||
end
|
||
|
||
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
|
||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||
true <- Accounts.moderator_authorized?(moderator) do
|
||
Repo.transact(fn ->
|
||
proposal = locked_proposal(proposal_id)
|
||
|
||
cond do
|
||
is_nil(proposal) ->
|
||
{:error, :not_found}
|
||
|
||
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
|
||
false -> {:error, :forbidden}
|
||
{:error, :not_found} = error -> error
|
||
end
|
||
end
|
||
|
||
def reject_proposal(%Scope{user: moderator}, proposal_id, note) do
|
||
moderate_proposal(moderator, proposal_id, :rejected, nil, note)
|
||
end
|
||
|
||
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) 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
|
||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||
true <- Accounts.moderator_authorized?(moderator) do
|
||
Repo.transact(fn ->
|
||
proposal = locked_proposal(proposal_id)
|
||
|
||
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
|
||
false -> {:error, :forbidden}
|
||
{:error, :not_found} = error -> error
|
||
end
|
||
end
|
||
|
||
defp locked_proposal(proposal_id) do
|
||
CategoryProposal
|
||
|> where([proposal], proposal.id == ^proposal_id)
|
||
|> lock("FOR UPDATE")
|
||
|> Repo.one()
|
||
end
|
||
|
||
defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
|
||
when is_list(options) do
|
||
Enum.any?(options, fn
|
||
%{"value" => allowed} -> same_scalar_value?(allowed, value)
|
||
allowed -> same_scalar_value?(allowed, value)
|
||
end)
|
||
end
|
||
|
||
defp valid_structured_value?(%{"type" => "boolean"}, value),
|
||
do: value in [true, false, "true", "false"]
|
||
|
||
defp valid_structured_value?(%{"type" => "text"} = field, value) when is_binary(value) do
|
||
case field["max_length"] do
|
||
max when is_integer(max) and max > 0 -> String.length(value) <= max
|
||
_ -> true
|
||
end
|
||
end
|
||
|
||
defp valid_structured_value?(_field, _value), do: false
|
||
|
||
defp same_scalar_value?(left, right)
|
||
when (is_binary(left) or is_atom(left) or is_number(left) or is_boolean(left)) and
|
||
(is_binary(right) or is_atom(right) or is_number(right) or is_boolean(right)),
|
||
do: to_string(left) == to_string(right)
|
||
|
||
defp same_scalar_value?(_left, _right), do: false
|
||
|
||
defp normalize_structured_value(%{"type" => "boolean"}, "true"), do: true
|
||
defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false
|
||
defp normalize_structured_value(_field, value), do: value
|
||
|
||
defp normalize_descriptions(%{"descriptions" => descriptions} = attrs)
|
||
when is_map(descriptions),
|
||
do: attrs
|
||
|
||
defp normalize_descriptions(%{"description" => description} = attrs)
|
||
when is_binary(description) and description != "",
|
||
do: Map.put(attrs, "descriptions", %{"en" => description})
|
||
|
||
defp normalize_descriptions(attrs), do: attrs
|
||
|
||
defp seed_category(attrs) do
|
||
case Repo.get_by(Category, slug: attrs.slug) do
|
||
nil ->
|
||
%Category{} |> Category.changeset(attrs) |> Repo.insert!()
|
||
|
||
%Category{} = category ->
|
||
backfill =
|
||
%{}
|
||
|> put_backfill(:names, category.names in [nil, %{}], attrs.names)
|
||
|> put_backfill(
|
||
:descriptions,
|
||
category.descriptions in [nil, %{}],
|
||
attrs.descriptions
|
||
)
|
||
|> put_backfill(
|
||
:structured_fields,
|
||
category.structured_fields in [nil, %{}],
|
||
attrs.structured_fields
|
||
)
|
||
|> put_backfill(
|
||
:parent_id,
|
||
is_nil(category.parent_id) and not is_nil(attrs[:parent_id]),
|
||
attrs[:parent_id]
|
||
)
|
||
|
||
if map_size(backfill) == 0 do
|
||
category
|
||
else
|
||
category |> Category.changeset(backfill) |> Repo.update!()
|
||
end
|
||
end
|
||
end
|
||
|
||
defp put_backfill(attrs, _key, false, _value), do: attrs
|
||
defp put_backfill(attrs, key, true, value), do: Map.put(attrs, key, value)
|
||
|
||
defp normalize_proposal_mode(attrs) do
|
||
attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
||
|
||
case attrs["parent_id"] do
|
||
parent_id when parent_id not in [nil, ""] ->
|
||
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
|
||
|
||
_ ->
|
||
{:ok, Map.put_new(attrs, "mode", "help")}
|
||
end
|
||
end
|
||
|
||
defp default_category_specs do
|
||
[
|
||
{nil,
|
||
%{
|
||
slug: "urgent-delivery",
|
||
names: names("Urgent delivery", "Термінова доставка", "Срочная доставка"),
|
||
descriptions:
|
||
names(
|
||
"Time-sensitive delivery of an item that is already legally arranged.",
|
||
"Термінова доставка речі, отримання якої вже законно узгоджено.",
|
||
"Срочная доставка вещи, получение которой уже законно согласовано."
|
||
),
|
||
active: false,
|
||
sort_order: 0,
|
||
structured_fields: %{}
|
||
}},
|
||
{"urgent-delivery",
|
||
%{
|
||
slug: "medicine-pickup",
|
||
names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"),
|
||
descriptions:
|
||
names(
|
||
"Pickup and deliver a legal medicine that is already purchased or reserved.",
|
||
"Отримати й доставити дозволені ліки, які вже придбані або зарезервовані.",
|
||
"Получить и доставить разрешённое лекарство, которое уже куплено или зарезервировано."
|
||
),
|
||
sort_order: 10,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"pickup_status",
|
||
names(
|
||
"Medicine pickup status",
|
||
"Статус отримання ліків",
|
||
"Статус получения лекарства"
|
||
),
|
||
[
|
||
option("reserved", names("Reserved", "Зарезервовано", "Зарезервировано")),
|
||
option("already_paid", names("Already paid", "Вже оплачено", "Уже оплачено"))
|
||
]
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{nil,
|
||
%{
|
||
slug: "roadside-help",
|
||
names: names("Roadside help", "Допомога в дорозі", "Помощь в дороге"),
|
||
descriptions:
|
||
names(
|
||
"Non-emergency roadside assistance after people and vehicles are away from active danger.",
|
||
"Неекстрена допомога в дорозі після усунення безпосередньої небезпеки для людей і транспорту.",
|
||
"Неэкстренная помощь в дороге после устранения непосредственной опасности для людей и транспорта."
|
||
),
|
||
active: false,
|
||
sort_order: 100,
|
||
structured_fields: %{}
|
||
}},
|
||
{"roadside-help",
|
||
%{
|
||
slug: "fuel-delivery",
|
||
names: names("Out of fuel", "Закінчилося пальне", "Закончилось топливо"),
|
||
descriptions:
|
||
names(
|
||
"Bring a small lawful quantity of fuel to a vehicle in a secured location.",
|
||
"Привезти невелику дозволену кількість пального до транспорту в безпечному місці.",
|
||
"Привезти небольшое разрешённое количество топлива к транспорту в безопасном месте."
|
||
),
|
||
sort_order: 110,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field("fuel_type", names("Fuel type", "Тип пального", "Тип топлива"), [
|
||
option("petrol", names("Petrol", "Бензин", "Бензин")),
|
||
option("diesel", names("Diesel", "Дизель", "Дизель"))
|
||
]),
|
||
text_field(
|
||
"fuel_details",
|
||
names("Grade or details", "Марка або деталі", "Марка или детали"),
|
||
80,
|
||
false
|
||
),
|
||
boolean_field(
|
||
"vehicle_in_safe_place",
|
||
names(
|
||
"Vehicle is away from active traffic",
|
||
"Авто поза активним рухом",
|
||
"Авто вне активного движения"
|
||
)
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"roadside-help",
|
||
%{
|
||
slug: "car-wheel-help",
|
||
names: names("Car wheel help", "Допомога з колесом авто", "Помощь с колесом авто"),
|
||
descriptions:
|
||
names(
|
||
"Help with a flat tyre, inflation, or fitting an available spare in a secured location.",
|
||
"Допомога з проколом, підкачуванням або встановленням наявного запасного колеса в безпечному місці.",
|
||
"Помощь с проколом, подкачкой или установкой имеющегося запасного колеса в безопасном месте."
|
||
),
|
||
sort_order: 120,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"wheel_issue",
|
||
names("Wheel problem", "Проблема з колесом", "Проблема с колесом"),
|
||
[
|
||
option("flat_tyre", names("Flat tyre", "Проколоте колесо", "Проколото колесо")),
|
||
option(
|
||
"fit_spare",
|
||
names("Fit an available spare", "Встановити запаску", "Установить запаску")
|
||
),
|
||
option(
|
||
"inflate",
|
||
names("Inflation needed", "Потрібне підкачування", "Нужна подкачка")
|
||
)
|
||
]
|
||
),
|
||
boolean_field(
|
||
"spare_available",
|
||
names("Usable spare is available", "Є придатна запаска", "Есть пригодная запаска"),
|
||
false
|
||
),
|
||
boolean_field(
|
||
"vehicle_in_safe_place",
|
||
names(
|
||
"Vehicle is away from active traffic",
|
||
"Авто поза активним рухом",
|
||
"Авто вне активного движения"
|
||
)
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"roadside-help",
|
||
%{
|
||
slug: "bicycle-roadside-help",
|
||
names:
|
||
names(
|
||
"Bicycle roadside help",
|
||
"Допомога з велосипедом",
|
||
"Помощь с велосипедом"
|
||
),
|
||
descriptions:
|
||
names(
|
||
"Help with a bicycle puncture, chain, or brake problem.",
|
||
"Допомога з проколом, ланцюгом або гальмами велосипеда.",
|
||
"Помощь с проколом, цепью или тормозами велосипеда."
|
||
),
|
||
sort_order: 130,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"bicycle_issue",
|
||
names("Bicycle problem", "Проблема велосипеда", "Проблема велосипеда"),
|
||
[
|
||
option("puncture", names("Puncture", "Прокол", "Прокол")),
|
||
option(
|
||
"chain",
|
||
names("Chain problem", "Проблема з ланцюгом", "Проблема с цепью")
|
||
),
|
||
option(
|
||
"brakes",
|
||
names("Brake problem", "Проблема з гальмами", "Проблема с тормозами")
|
||
),
|
||
option("other", names("Other", "Інше", "Другое"))
|
||
]
|
||
),
|
||
boolean_field(
|
||
"tools_available",
|
||
names(
|
||
"Requester has tools",
|
||
"У заявника є інструменти",
|
||
"У заявителя есть инструменты"
|
||
),
|
||
false
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"roadside-help",
|
||
%{
|
||
slug: "motorcycle-roadside-help",
|
||
names:
|
||
names(
|
||
"Motorcycle roadside help",
|
||
"Допомога з мотоциклом",
|
||
"Помощь с мотоциклом"
|
||
),
|
||
descriptions:
|
||
names(
|
||
"Help with a motorcycle chain, tyre, or battery after the vehicle is secured.",
|
||
"Допомога з ланцюгом, колесом або акумулятором мотоцикла після його переміщення в безпечне місце.",
|
||
"Помощь с цепью, колесом или аккумулятором мотоцикла после его перемещения в безопасное место."
|
||
),
|
||
sort_order: 140,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"motorcycle_issue",
|
||
names("Motorcycle problem", "Проблема мотоцикла", "Проблема мотоцикла"),
|
||
[
|
||
option("broken_chain", names("Broken chain", "Порваний ланцюг", "Порвана цепь")),
|
||
option("flat_tyre", names("Flat tyre", "Проколоте колесо", "Проколото колесо")),
|
||
option("battery", names("Battery", "Акумулятор", "Аккумулятор")),
|
||
option("other", names("Other", "Інше", "Другое"))
|
||
]
|
||
),
|
||
boolean_field(
|
||
"vehicle_in_safe_place",
|
||
names(
|
||
"Motorcycle is away from active traffic",
|
||
"Мотоцикл поза активним рухом",
|
||
"Мотоцикл вне активного движения"
|
||
)
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"roadside-help",
|
||
%{
|
||
slug: "vehicle-breakdown-help",
|
||
names:
|
||
names(
|
||
"Vehicle breakdown",
|
||
"Поломка транспортного засобу",
|
||
"Поломка транспортного средства"
|
||
),
|
||
descriptions:
|
||
names(
|
||
"Non-emergency practical help with a stopped vehicle in a secured location.",
|
||
"Неекстрена практична допомога із зупиненим транспортом у безпечному місці.",
|
||
"Неэкстренная практическая помощь с остановившимся транспортом в безопасном месте."
|
||
),
|
||
sort_order: 150,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"vehicle_type",
|
||
names("Vehicle type", "Тип транспорту", "Тип транспорта"),
|
||
[
|
||
option("car", names("Car", "Авто", "Авто")),
|
||
option("van", names("Van", "Фургон", "Фургон")),
|
||
option("scooter", names("Scooter", "Скутер", "Скутер")),
|
||
option("other", names("Other", "Інше", "Другое"))
|
||
]
|
||
),
|
||
select_field(
|
||
"breakdown_type",
|
||
names("Problem type", "Тип проблеми", "Тип проблемы"),
|
||
[
|
||
option(
|
||
"battery",
|
||
names(
|
||
"Battery or jump-start",
|
||
"Акумулятор або запуск",
|
||
"Аккумулятор или запуск"
|
||
)
|
||
),
|
||
option("mechanical", names("Mechanical", "Механічна", "Механическая")),
|
||
option("unknown", names("Unknown", "Невідомо", "Неизвестно")),
|
||
option("other", names("Other", "Інше", "Другое"))
|
||
]
|
||
),
|
||
boolean_field(
|
||
"vehicle_in_safe_place",
|
||
names(
|
||
"Vehicle is away from active traffic",
|
||
"Транспорт поза активним рухом",
|
||
"Транспорт вне активного движения"
|
||
)
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"roadside-help",
|
||
%{
|
||
slug: "secured-incident-support",
|
||
names:
|
||
names(
|
||
"Support after a road incident",
|
||
"Допомога після дорожньої події",
|
||
"Помощь после дорожного происшествия"
|
||
),
|
||
descriptions:
|
||
names(
|
||
"Non-emergency presence or practical support only after immediate danger is handled.",
|
||
"Неекстрена присутність або практична підтримка лише після усунення безпосередньої небезпеки.",
|
||
"Неэкстренное присутствие или практическая поддержка только после устранения непосредственной опасности."
|
||
),
|
||
sort_order: 160,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"safety_status",
|
||
names("Safety status", "Стан безпеки", "Состояние безопасности"),
|
||
[
|
||
option(
|
||
"scene_secured",
|
||
names(
|
||
"No immediate danger; scene is secured",
|
||
"Негайної небезпеки немає",
|
||
"Немедленной опасности нет"
|
||
)
|
||
),
|
||
option(
|
||
"emergency_services_contacted",
|
||
names(
|
||
"Emergency services already contacted",
|
||
"Екстрені служби вже викликані",
|
||
"Экстренные службы уже вызваны"
|
||
)
|
||
)
|
||
]
|
||
),
|
||
select_field(
|
||
"support_needed",
|
||
names("Support needed", "Потрібна підтримка", "Нужна поддержка"),
|
||
[
|
||
option(
|
||
"safe_ride",
|
||
names("Safe ride or pickup", "Безпечна поїздка", "Безопасная поездка")
|
||
),
|
||
option("translation", names("Translation", "Переклад", "Перевод")),
|
||
option(
|
||
"wait_with_me",
|
||
names("Wait with me", "Почекати поруч", "Подождать рядом")
|
||
),
|
||
option(
|
||
"other",
|
||
names(
|
||
"Other practical support",
|
||
"Інша практична допомога",
|
||
"Другая практическая помощь"
|
||
)
|
||
)
|
||
]
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{nil,
|
||
%{
|
||
slug: "social-activity",
|
||
names: names("Social activity", "Спільне дозвілля", "Совместный досуг"),
|
||
descriptions:
|
||
names(
|
||
"Optional social plans with organizer-approved participants and no effect on helper reputation.",
|
||
"Необов’язкові спільні плани з учасниками, яких схвалює організатор, без впливу на репутацію помічника.",
|
||
"Необязательные совместные планы с участниками, которых одобряет организатор, без влияния на репутацию помощника."
|
||
),
|
||
mode: :activity,
|
||
active: false,
|
||
sort_order: 1_000,
|
||
structured_fields: %{}
|
||
}},
|
||
{"social-activity",
|
||
%{
|
||
slug: "coffee-meetup",
|
||
names: names("Coffee or tea", "Кава або чай", "Кофе или чай"),
|
||
descriptions:
|
||
names(
|
||
"Meet in a public place for coffee, tea, or conversation.",
|
||
"Зустрітися в публічному місці за кавою, чаєм або розмовою.",
|
||
"Встретиться в публичном месте за кофе, чаем или разговором."
|
||
),
|
||
mode: :activity,
|
||
sort_order: 1_010,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"setting",
|
||
names("Preferred setting", "Бажане місце", "Желаемое место"),
|
||
[
|
||
option("cafe", names("Café", "Кав'ярня", "Кофейня")),
|
||
option(
|
||
"outdoors",
|
||
names(
|
||
"Outdoor public place",
|
||
"Публічне місце надворі",
|
||
"Публичное место на улице"
|
||
)
|
||
),
|
||
option("either", names("Either", "Будь-яке", "Любое"))
|
||
]
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"social-activity",
|
||
%{
|
||
slug: "cinema-meetup",
|
||
names: names("Cinema", "Кіно", "Кино"),
|
||
descriptions:
|
||
names(
|
||
"Find company for a public cinema screening.",
|
||
"Знайти компанію для перегляду фільму в кінотеатрі.",
|
||
"Найти компанию для просмотра фильма в кинотеатре."
|
||
),
|
||
mode: :activity,
|
||
sort_order: 1_020,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
text_field(
|
||
"film_or_genre",
|
||
names("Film or genre", "Фільм або жанр", "Фильм или жанр"),
|
||
120,
|
||
true
|
||
)
|
||
]
|
||
}
|
||
}},
|
||
{"social-activity",
|
||
%{
|
||
slug: "city-walk",
|
||
names: names("Walk", "Прогулянка", "Прогулка"),
|
||
descriptions:
|
||
names(
|
||
"Arrange a walk in a public area.",
|
||
"Домовитися про прогулянку в публічному місці.",
|
||
"Договориться о прогулке в публичном месте."
|
||
),
|
||
mode: :activity,
|
||
sort_order: 1_030,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field("pace", names("Walking pace", "Темп прогулянки", "Темп прогулки"), [
|
||
option("relaxed", names("Relaxed", "Спокійний", "Спокойный")),
|
||
option("moderate", names("Moderate", "Середній", "Средний")),
|
||
option("active", names("Active", "Активний", "Активный"))
|
||
])
|
||
]
|
||
}
|
||
}},
|
||
{"social-activity",
|
||
%{
|
||
slug: "hiking-group",
|
||
names: names("Hiking", "Похід", "Поход"),
|
||
descriptions:
|
||
names(
|
||
"Plan a group hike with explicit route difficulty and safety preparation.",
|
||
"Спланувати груповий похід із зазначенням складності маршруту та підготовкою з безпеки.",
|
||
"Спланировать групповой поход с указанием сложности маршрута и подготовкой по безопасности."
|
||
),
|
||
mode: :activity,
|
||
sort_order: 1_040,
|
||
structured_fields: %{
|
||
"fields" => [
|
||
select_field(
|
||
"difficulty",
|
||
names("Route difficulty", "Складність маршруту", "Сложность маршрута"),
|
||
[
|
||
option("easy", names("Easy", "Легкий", "Легкий")),
|
||
option("moderate", names("Moderate", "Середній", "Средний")),
|
||
option("hard", names("Hard", "Складний", "Сложный"))
|
||
]
|
||
),
|
||
text_field(
|
||
"route_plan",
|
||
names("Public route summary", "Опис маршруту", "Описание маршрута"),
|
||
300,
|
||
true
|
||
),
|
||
boolean_field(
|
||
"safety_plan_confirmed",
|
||
names(
|
||
"Organizer has a safety and return plan",
|
||
"Організатор має план безпеки й повернення",
|
||
"У организатора есть план безопасности и возвращения"
|
||
)
|
||
)
|
||
]
|
||
}
|
||
}}
|
||
]
|
||
end
|
||
|
||
defp with_vote_count(query) do
|
||
counts =
|
||
CategoryVote
|
||
|> group_by([vote], vote.proposal_id)
|
||
|> select([vote], %{proposal_id: vote.proposal_id, vote_count: count(vote.id)})
|
||
|
||
query
|
||
|> join(:left, [proposal], count in subquery(counts),
|
||
on: count.proposal_id == proposal.id,
|
||
as: :vote_counts
|
||
)
|
||
|> select_merge([vote_counts: count], %{
|
||
vote_count: fragment("coalesce(?, 0)", count.vote_count)
|
||
})
|
||
end
|
||
|
||
defp before_proposal(query, nil), do: query
|
||
|
||
defp before_proposal(query, {inserted_at, id}) do
|
||
where(
|
||
query,
|
||
[proposal],
|
||
proposal.inserted_at < ^inserted_at or
|
||
(proposal.inserted_at == ^inserted_at and proposal.id < ^id)
|
||
)
|
||
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}
|
||
|
||
defp select_field(key, label, options, required \\ true) do
|
||
%{
|
||
"key" => key,
|
||
"type" => "select",
|
||
"required" => required,
|
||
"label" => label,
|
||
"options" => options
|
||
}
|
||
end
|
||
|
||
defp boolean_field(key, label, required \\ true) do
|
||
%{"key" => key, "type" => "boolean", "required" => required, "label" => label}
|
||
end
|
||
|
||
defp text_field(key, label, max_length, required) do
|
||
%{
|
||
"key" => key,
|
||
"type" => "text",
|
||
"required" => required,
|
||
"label" => label,
|
||
"max_length" => max_length
|
||
}
|
||
end
|
||
end
|