diff --git a/.gitignore b/.gitignore index e554e2d..08ded93 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index f4724d0..3c0f415 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/product-spec.md b/docs/product-spec.md index 0348595..7f661cc 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -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 diff --git a/docs/verification.md b/docs/verification.md index dc236be..0ce0757 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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 diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index 5e3924f..bb24ef6 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -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 diff --git a/lib/who_need_help_web/controllers/page_html/home.html.heex b/lib/who_need_help_web/controllers/page_html/home.html.heex index 2984822..8f36ca5 100644 --- a/lib/who_need_help_web/controllers/page_html/home.html.heex +++ b/lib/who_need_help_web/controllers/page_html/home.html.heex @@ -8,8 +8,8 @@ {gettext("Help can be closer than you think.")}
- 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.
- 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.
+ {category_description(@selected_category)} +
<.input field={@form[:description]} @@ -274,7 +306,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do