feat: add urgent roadside help categories

This commit is contained in:
SimpleTest 2026-07-18 20:25:02 +03:00
parent 7671d6cd8f
commit d20c5e8eb2
10 changed files with 560 additions and 84 deletions

2
.gitignore vendored
View File

@ -15,6 +15,8 @@
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
core
core.*
# Also ignore archive artifacts (built via "mix archive.build").
*.ez

View File

@ -1,8 +1,10 @@
# Who Need Help
Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help.
The first supported scenario is pickup and delivery of a legal medicine that
has already been purchased or reserved.
The first priority is pickup and delivery of a legal medicine that has already
been purchased or reserved. The same urgent-help flow also supports safe,
non-emergency fuel delivery, wheel help, bicycle and motorcycle problems,
vehicle breakdowns, and practical support after a secured road incident.
It is not an emergency or medical service, does not prescribe or sell medicine,
and does not process payments. A helper may publish an optional external
@ -13,7 +15,8 @@ thank-you link; money goes directly between users outside the platform.
- Phoenix 1.8 LiveView application with email magic-link/password auth and 18+
self-attestation.
- Data-driven, translated category tree with validated per-category fields,
community proposals/votes, and human approve/reject/merge tools.
seven selectable urgent/roadside scenarios, community proposals/votes, and
human approve/reject/merge tools.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix

View File

@ -7,16 +7,17 @@ Status: MVP specification for OpenAI Build Week, 2026-07-18.
Who Need Help connects an adult who needs urgent, practical nearby help with an
adult volunteer who can provide it without a mandatory fee.
The hackathon MVP is deliberately narrow: a requester can ask a nearby person
to pick up and deliver medication that has already been legally purchased or
reserved. The product does not diagnose, prescribe, recommend, sell, reimburse,
or handle controlled substances.
The first-priority scenario lets a requester ask a nearby person to pick up and
deliver medication that has already been legally purchased or reserved. The
urgent-help mode also covers non-emergency roadside tasks after people and
vehicles are away from active danger. The product does not diagnose, prescribe,
recommend, sell, reimburse, or handle controlled substances.
## Primary user journey
1. A user creates an account and self-attests that they are at least 18.
2. The user creates an urgent request with a title, instructions, approximate
public location, expiry time, and medicine-pickup category.
public location, expiry time, and a validated urgent-help category.
3. Nearby helpers see the request on a list and map.
4. One helper accepts it; requester and helper receive a private matched chat.
5. Either party may explicitly start live location sharing for the active
@ -40,6 +41,8 @@ helper per request. Every state transition is authorized and recorded.
- Email magic-link/password authentication.
- English default interface plus Ukrainian and Russian translations.
- Medicine pickup/delivery requests.
- Safe, non-emergency fuel delivery, car-wheel help, bicycle and motorcycle
problems, vehicle breakdown help, and support after a secured road incident.
- Hierarchical, data-driven categories with validated structured request fields.
- Request list, filters, details, and a MapLibre map.
- Matching, presence, chat, and consent-driven live location updates.
@ -78,11 +81,12 @@ helper per request. Every state transition is authorized and recorded.
## Extensibility
Categories form a moderated tree and are not compiled into application code.
After the MVP the same request workflow can support fuel delivery, roadside
help, bicycle punctures, broken motorcycle chains, and urgent household help.
Each category can add validated text, select, and boolean fields through its
stored schema without changing the request form code or core state machine.
Categories form a moderated tree. The seeded urgent-delivery and roadside-help
trees already cover medicine pickup, fuel delivery, wheel help, bicycle
punctures and chains, motorcycle chains and tyres, vehicle breakdowns, and
secured incident support. Each category adds validated text, select, and
boolean fields through its stored schema without changing the request form or
core state machine. Moderated proposals can extend the tree further.
Social links are user-provided references and are visibly marked unverified.
The data model reserves `verified_at` for a future OAuth flow, but the current

View File

@ -8,7 +8,8 @@ results from product limits and unknown production properties.
| Requirement | Status | Observed evidence | Limit |
| --- | --- | --- | --- |
| Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. |
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities are intentionally a future `Activity` mode. |
| Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. |
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. |
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. |
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
@ -21,8 +22,8 @@ results from product limits and unknown production properties.
## Reproducible checks
- `./scripts/test.sh`: 124 tests, 0 failures in the final post-upgrade run on
Elixir 1.20.2 and Erlang/OTP 29.0.3.
- `./scripts/test.sh`: 127 tests, 0 failures after the urgent-roadside rollout
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- `mix format --check-formatted`: passed in the final run.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`assembleDebug` passed; the final lint report contains no errors or warnings.
@ -63,8 +64,11 @@ excluded from the root Docker build context.
## Database observations
The final local Compose observation found 2 users, 1 help request, and 7
messages. These are local scenario data; the database is not assumed empty.
The post-roadside local Compose observation found 2 users, 1 help request, 7
messages, and 1 assignment before and after deployment. Category seeding
changed only the category count from 1 to 9: 7 selectable categories and 2
inactive grouping nodes. These are local scenario data; the database is not
assumed empty.
The final kind observation found 0 users, 0 help requests, and 0 messages. That
state was queried before changing the database workload. The migration from the

View File

@ -12,11 +12,18 @@ defmodule WhoNeedHelp.Catalog do
Category
|> where([c], c.active)
|> order_by([c], asc: c.sort_order, asc: c.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
CategoryProposal
|> where([p], p.status == :open)
@ -67,61 +74,19 @@ defmodule WhoNeedHelp.Catalog do
end
def seed_defaults do
attrs = %{
slug: "medicine-pickup",
names: %{
"en" => "Medicine pickup",
"uk" => "Доставка ліків",
"ru" => "Доставка лекарств"
},
description: "Pickup and deliver a legal medicine that is already purchased or reserved.",
sort_order: 10,
structured_fields: %{
"fields" => [
%{
"key" => "pickup_status",
"type" => "select",
"required" => true,
"label" => %{
"en" => "Medicine pickup status",
"uk" => "Статус отримання ліків",
"ru" => "Статус получения лекарства"
},
"options" => [
%{
"value" => "reserved",
"label" => %{
"en" => "Reserved",
"uk" => "Зарезервовано",
"ru" => "Зарезервировано"
}
},
%{
"value" => "already_paid",
"label" => %{
"en" => "Already paid",
"uk" => "Вже оплачено",
"ru" => "Уже оплачено"
}
}
]
}
]
}
}
case Repo.get_by(Category, slug: attrs.slug) do
nil ->
%Category{} |> Category.changeset(attrs) |> Repo.insert!()
%Category{structured_fields: fields} = category when fields == %{} ->
category
|> Category.changeset(%{structured_fields: attrs.structured_fields})
|> Repo.update!()
category ->
category
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}})
@ -135,10 +100,7 @@ defmodule WhoNeedHelp.Catalog do
allowed = MapSet.new(Enum.map(fields, & &1["key"]))
unknown = data |> Map.keys() |> Enum.reject(&MapSet.member?(allowed, &1))
errors =
if unknown == [],
do: [],
else: ["contains fields that are not configured for this category"]
errors = Enum.map(unknown, &"#{&1} is not allowed")
{clean, errors} =
Enum.reduce(fields, {%{}, errors}, fn field, {clean, errors} ->
@ -153,7 +115,7 @@ defmodule WhoNeedHelp.Catalog do
{clean, errors}
valid_structured_value?(field, value) ->
{Map.put(clean, key, value), errors}
{Map.put(clean, key, normalize_structured_value(field, value)), errors}
true ->
{clean, ["#{key} has an invalid value" | errors]}
@ -294,4 +256,379 @@ defmodule WhoNeedHelp.Catalog do
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 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(
:description,
category.description in [nil, ""],
attrs.description
)
|> 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 default_category_specs do
[
{nil,
%{
slug: "urgent-delivery",
names: names("Urgent delivery", "Термінова доставка", "Срочная доставка"),
description: "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", "Доставка ліків", "Доставка лекарств"),
description:
"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", "Допомога в дорозі", "Помощь в дороге"),
description:
"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", "Закінчилося пальне", "Закончилось топливо"),
description: "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", "Допомога з колесом авто", "Помощь с колесом авто"),
description:
"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",
"Допомога з велосипедом",
"Помощь с велосипедом"
),
description: "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",
"Допомога з мотоциклом",
"Помощь с мотоциклом"
),
description:
"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",
"Поломка транспортного засобу",
"Поломка транспортного средства"
),
description:
"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",
"Допомога після дорожньої події",
"Помощь после дорожного происшествия"
),
description:
"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",
"Інша практична допомога",
"Другая практическая помощь"
)
)
]
)
]
}
}}
]
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

View File

@ -8,8 +8,8 @@
{gettext("Help can be closer than you think.")}
</h1>
<p class="mt-6 max-w-2xl text-lg leading-8 text-base-content/70">
Who Need Help connects people who urgently need a legal medicine picked up
with nearby volunteers who can help for free.
Who Need Help connects people who urgently need medicine pickup or safe
roadside assistance with nearby volunteers who can help for free.
</p>
<div class="mt-8 flex flex-wrap gap-3">
<.link navigate={~p"/requests"} class="btn btn-primary btn-lg">

View File

@ -84,7 +84,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{"All categories", ""}
| Enum.map(
@categories,
&{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id}
&{WhoNeedHelp.Catalog.category_path(&1, @current_scope.user.locale), &1.id}
)
]}
/>

View File

@ -101,6 +101,31 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
end
defp category_description(nil), do: nil
defp category_description(category), do: category.description
defp category_title_placeholder(%{slug: "medicine-pickup"}),
do: "Medicine is ready at the pharmacy"
defp category_title_placeholder(%{parent: %{slug: "roadside-help"}}),
do: "Need roadside help in a safe location"
defp category_title_placeholder(_category), do: "Briefly describe the help you need"
defp safety_confirmation(%{slug: "medicine-pickup"}) do
"I confirm this is not an emergency, medical advice request, controlled substance, " <>
"or request to buy medicine on my behalf."
end
defp safety_confirmation(%{parent: %{slug: "roadside-help"}}) do
"I confirm there is no immediate danger, people and vehicles are away from active traffic " <>
"or emergency services have already been contacted, and the requested task is lawful."
end
defp safety_confirmation(_category) do
"I confirm this is not an emergency, there is no immediate danger, and the requested task is lawful."
end
defp error_message(:account_not_eligible),
do: "Confirm your email and ensure your account is active before publishing."
@ -115,8 +140,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm mb-4"> Back to requests</.link>
<h1 class="text-4xl font-black">Ask for urgent help</h1>
<p class="mt-2 text-base-content/65">
The service is medicine-first. Moderated categories can add their own structured details
without changing this form's code.
Medicine pickup remains the first priority. Roadside categories use the same moderated,
extensible request flow with details specific to each type of help.
</p>
<div class="alert alert-warning mt-6 text-sm">
@ -142,10 +167,17 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
options={
Enum.map(
@categories,
&{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id}
&{WhoNeedHelp.Catalog.category_path(&1, @current_scope.user.locale), &1.id}
)
}
/>
<p
:if={category_description(@selected_category)}
id="selected-category-description"
class="-mt-3 text-sm text-base-content/60"
>
{category_description(@selected_category)}
</p>
<div
:if={@structured_fields != []}
class="space-y-4 rounded-2xl border border-success/30 bg-success/5 p-5"
@ -204,7 +236,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
<.input
field={@form[:title]}
label="Short title"
placeholder="Medicine is ready at the pharmacy"
placeholder={category_title_placeholder(@selected_category)}
/>
<.input
field={@form[:description]}
@ -274,7 +306,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
</div>
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm">
<input type="checkbox" required class="checkbox checkbox-success mt-0.5" />
<span>I confirm this is not an emergency, medical advice request, controlled substance, or request to buy medicine on my behalf.</span>
<span>{safety_confirmation(@selected_category)}</span>
</label>
<.button class="btn btn-primary btn-lg w-full" phx-disable-with="Publishing…">Publish request</.button>
</.form>

View File

@ -144,6 +144,77 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
refute export =~ context.requester.display_name
end
test "default urgent and roadside categories are hierarchical and idempotent", context do
first_ids =
Catalog.list_categories()
|> Map.new(&{&1.slug, &1.id})
assert Map.keys(first_ids) |> Enum.sort() ==
Enum.sort([
"bicycle-roadside-help",
"car-wheel-help",
"fuel-delivery",
"medicine-pickup",
"motorcycle-roadside-help",
"secured-incident-support",
"vehicle-breakdown-help"
])
motorcycle = Enum.find(Catalog.list_categories(), &(&1.slug == "motorcycle-roadside-help"))
assert Catalog.category_path(motorcycle, :en) == "Roadside help — Motorcycle roadside help"
assert Catalog.category_path(motorcycle, :ru) == "Помощь в дороге — Помощь с мотоциклом"
assert {:ok,
%{
"motorcycle_issue" => "broken_chain",
"vehicle_in_safe_place" => true
}} =
Catalog.validate_structured_data(motorcycle, %{
"motorcycle_issue" => "broken_chain",
"vehicle_in_safe_place" => "true"
})
assert {:error, errors} =
Catalog.validate_structured_data(motorcycle, %{
"motorcycle_issue" => "invented",
"vehicle_in_safe_place" => "true",
"unexpected" => "value"
})
assert "motorcycle_issue has an invalid value" in errors
assert "unexpected is not allowed" in errors
assert context.category.id == Catalog.seed_defaults().id
assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id})
assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 9
end
test "roadside structured data is enforced when creating a request", context do
motorcycle =
Catalog.list_categories()
|> Enum.find(&(&1.slug == "motorcycle-roadside-help"))
attrs =
context.request_attrs
|> Map.put("category_id", motorcycle.id)
|> Map.put("structured_data", %{
"motorcycle_issue" => "broken_chain",
"vehicle_in_safe_place" => "true"
})
assert {:ok, request} = Help.create_request(context.requester_scope, attrs)
assert request.structured_data["motorcycle_issue"] == "broken_chain"
assert request.structured_data["vehicle_in_safe_place"] == true
invalid =
attrs
|> Map.put("title", "Another roadside request")
|> Map.put("structured_data", %{"motorcycle_issue" => "broken_chain"})
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid)
assert "vehicle_in_safe_place is required" in errors_on(changeset).structured_data
end
test "manual social links stay unverified and owner-controlled", context do
{:ok, identity} =
Accounts.add_social_identity(context.requester, %{

View File

@ -29,6 +29,29 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "help_request[structured_data][pickup_status]"
end
test "new request form renders hierarchical roadside fields and safety copy", %{conn: conn} do
Catalog.seed_defaults()
motorcycle =
Catalog.list_categories()
|> Enum.find(&(&1.slug == "motorcycle-roadside-help"))
{:ok, view, html} = live(conn, ~p"/requests/new")
assert html =~ "Roadside help — Motorcycle roadside help"
html =
render_change(view, "validate", %{
"help_request" => %{"category_id" => motorcycle.id}
})
assert html =~ "Motorcycle problem"
assert html =~ "Broken chain"
assert html =~ "help_request[structured_data][motorcycle_issue]"
assert html =~ "help_request[structured_data][vehicle_in_safe_place]"
assert html =~ "people and vehicles are away from active traffic"
refute html =~ "controlled substance"
end
test "new request form uses the profile location visibility default", %{
conn: conn,
user: user