feat: add urgent roadside help categories
This commit is contained in:
parent
7671d6cd8f
commit
d20c5e8eb2
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" => "Уже оплачено"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
case Repo.get_by(Category, slug: attrs.slug) do
|
||||
nil ->
|
||||
%Category{} |> Category.changeset(attrs) |> Repo.insert!()
|
||||
category = seed_category(attrs)
|
||||
Map.put(seeded, category.slug, category)
|
||||
end)
|
||||
|
||||
%Category{structured_fields: fields} = category when fields == %{} ->
|
||||
category
|
||||
|> Category.changeset(%{structured_fields: attrs.structured_fields})
|
||||
|> Repo.update!()
|
||||
|
||||
category ->
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, %{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user