who_need_help/lib/who_need_help/catalog.ex

894 lines
34 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, _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
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()
{:ok, count}
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
if 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))
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}
end
end)
else
{:error, :forbidden}
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 %Category{} <- Repo.get(Category, category_id) do
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else
nil -> {:error, :not_found}
end
end
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
if 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}
end
end)
else
{:error, :forbidden}
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} -> to_string(allowed) == to_string(value)
allowed -> to_string(allowed) == to_string(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 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, ""] ->
case Repo.get(Category, parent_id) do
nil -> {:error, :invalid_parent}
parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
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 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