feat(medicine): support voluntary pharmacy purchase

This commit is contained in:
SimpleTest 2026-07-25 03:10:00 +03:00
parent 51de2d829b
commit 144167beae
15 changed files with 4188 additions and 2470 deletions

View File

@ -1,14 +1,17 @@
# Who Need Help # Who Need Help
Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help. Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help.
The first priority is pickup and delivery of a legal medicine that has already The first priority is asking whether a nearby volunteer is willing to purchase,
been purchased or reserved. The same urgent-help flow also supports safe, pick up, and deliver lawful medicine. Accepting never obliges a helper to spend
non-emergency fuel delivery, wheel help, bicycle and motorcycle problems, money; purchase and reimbursement arrangements remain directly between the
vehicle breakdowns, and practical support after a secured road incident. matched people. 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, It is not an emergency, medical, pharmacy, or payment service, does not
and does not process payments. A helper may publish an optional external prescribe or sell medicine, and does not guarantee reimbursement. A helper may
thank-you link; money goes directly between users outside the platform. publish an optional external thank-you link; money goes directly between users
outside the platform.
## OpenAI Build Week 2026 ## OpenAI Build Week 2026

View File

@ -7,11 +7,11 @@ Status: MVP specification for OpenAI Build Week, 2026-07-18.
Who Need Help connects an adult who needs urgent, practical nearby help with an Who Need Help connects an adult who needs urgent, practical nearby help with an
adult volunteer who can provide it without a mandatory fee. adult volunteer who can provide it without a mandatory fee.
The first-priority scenario lets a requester ask a nearby person to pick up and The first-priority scenario lets a requester ask whether a nearby person is
deliver medication that has already been legally purchased or reserved. The willing to purchase, pick up, and deliver lawful medicine. The urgent-help mode
urgent-help mode also covers non-emergency roadside tasks after people and also covers non-emergency roadside tasks after people and vehicles are away from
vehicles are away from active danger. The product does not diagnose, prescribe, active danger. The product does not diagnose, prescribe, recommend, sell,
recommend, sell, reimburse, or handle controlled substances. process payment, guarantee reimbursement, or handle controlled substances.
## Primary user journey ## Primary user journey
@ -71,8 +71,8 @@ helper per request. Every state transition is authorized and recorded.
## Explicitly outside the MVP ## Explicitly outside the MVP
- Medical advice, prescriptions, pharmacy integrations, medication sale, and - Medical advice, pharmacy integrations, medication sale, platform payment or
controlled-substance delivery. reimbursement, and controlled-substance delivery.
- Platform payments, escrow, fees, or compulsory compensation. - Platform payments, escrow, fees, or compulsory compensation.
- Background PWA or unattended location tracking. - Background PWA or unattended location tracking.
- Multiple simultaneous helpers on one request. - Multiple simultaneous helpers on one request.

View File

@ -9,9 +9,14 @@ Who Need Help is a coordination tool for adults. It is not an emergency service.
Every urgent screen must tell users to contact local emergency services when Every urgent screen must tell users to contact local emergency services when
life, health, fire, violence, or immediate danger is involved. life, health, fire, violence, or immediate danger is involved.
The medicine category is limited to pickup/delivery of an already purchased or The medicine category permits a requester to ask whether a volunteer is willing
reserved legal item. Requests for medical advice, prescriptions, cash advances, to purchase, pick up, and deliver a lawful medicine. Accepting a request never
controlled substances, or suspicious repackaging are reportable and removable. obliges a volunteer to spend money. The matched people arrange pharmacy or
prescription requirements, payment, reimbursement, storage, and handover
directly in private chat. The platform does not prescribe, recommend, sell,
process payment, extend credit, or guarantee reimbursement. Requests for medical
advice, controlled substances, unlawful supply, payment credentials, public
prescription disclosure, or suspicious repackaging are reportable and removable.
Social activities are a separate product mode. They do not use handover codes, Social activities are a separate product mode. They do not use handover codes,
helper reviews, live movement evidence, or the helper leaderboard. The helper reviews, live movement evidence, or the helper leaderboard. The

View File

@ -394,6 +394,7 @@ defmodule WhoNeedHelp.Catalog do
is_nil(category.parent_id) and not is_nil(attrs[:parent_id]), is_nil(category.parent_id) and not is_nil(attrs[:parent_id]),
attrs[:parent_id] attrs[:parent_id]
) )
|> refresh_core_category(category, attrs)
if map_size(backfill) == 0 do if map_size(backfill) == 0 do
category category
@ -406,6 +407,62 @@ defmodule WhoNeedHelp.Catalog do
defp put_backfill(attrs, _key, false, _value), do: attrs defp put_backfill(attrs, _key, false, _value), do: attrs
defp put_backfill(attrs, key, true, value), do: Map.put(attrs, key, value) defp put_backfill(attrs, key, true, value), do: Map.put(attrs, key, value)
defp refresh_core_category(attrs, %Category{slug: "medicine-pickup"} = category, defaults) do
descriptions = Map.merge(category.descriptions || %{}, defaults.descriptions)
structured_fields =
merge_seeded_structured_fields(category.structured_fields, defaults.structured_fields)
attrs
|> put_changed(:descriptions, category.descriptions, descriptions)
|> put_changed(:structured_fields, category.structured_fields, structured_fields)
end
defp refresh_core_category(attrs, _category, _defaults), do: attrs
defp put_changed(attrs, _key, current, current), do: attrs
defp put_changed(attrs, key, _current, updated), do: Map.put(attrs, key, updated)
defp merge_seeded_structured_fields(current, defaults) do
current = current || %{}
defaults = defaults || %{}
current_fields = current["fields"] || []
default_fields = defaults["fields"] || []
current_by_key = Map.new(current_fields, &{&1["key"], &1})
default_keys = MapSet.new(default_fields, & &1["key"])
merged_defaults =
Enum.map(default_fields, fn default_field ->
case current_by_key[default_field["key"]] do
nil ->
default_field
current_field ->
current_field
|> Map.merge(default_field)
|> Map.put(
"options",
merge_seeded_options(current_field["options"], default_field["options"])
)
end
end)
custom_fields =
Enum.reject(current_fields, &MapSet.member?(default_keys, &1["key"]))
current
|> Map.merge(defaults)
|> Map.put("fields", merged_defaults ++ custom_fields)
end
defp merge_seeded_options(current, defaults) when is_list(defaults) do
current = if is_list(current), do: current, else: []
default_values = MapSet.new(defaults, & &1["value"])
defaults ++ Enum.reject(current, &MapSet.member?(default_values, &1["value"]))
end
defp merge_seeded_options(current, _defaults), do: current
defp normalize_proposal_mode(attrs) do defp normalize_proposal_mode(attrs) do
attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end) attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
@ -445,9 +502,9 @@ defmodule WhoNeedHelp.Catalog do
names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"), names: names("Medicine pickup", "Доставка ліків", "Доставка лекарств"),
descriptions: descriptions:
names( names(
"Pickup and deliver a legal medicine that is already purchased or reserved.", "Buy, pick up, and deliver legal medicine when a volunteer freely agrees.",
"Отримати й доставити дозволені ліки, які вже придбані або зарезервовані.", "Придбати, отримати й доставити дозволені ліки, якщо волонтер добровільно погодиться.",
"Получить и доставить разрешённое лекарство, которое уже куплено или зарезервировано." "Купить, получить и доставить разрешённое лекарство, если волонтёр добровольно согласится."
), ),
sort_order: 10, sort_order: 10,
structured_fields: %{ structured_fields: %{
@ -460,7 +517,22 @@ defmodule WhoNeedHelp.Catalog do
"Статус получения лекарства" "Статус получения лекарства"
), ),
[ [
option("reserved", names("Reserved", "Зарезервовано", "Зарезервировано")), option(
"purchase_needed",
names(
"Needs to be purchased — only if the volunteer agrees",
"Потрібно придбати — лише за згодою волонтера",
"Нужно купить — только с согласия волонтёра"
)
),
option(
"reserved",
names(
"Reserved — payment may still be needed",
"Зарезервовано — може знадобитися оплата",
"Зарезервировано — может потребоваться оплата"
)
),
option("already_paid", names("Already paid", "Вже оплачено", "Уже оплачено")) option("already_paid", names("Already paid", "Вже оплачено", "Уже оплачено"))
] ]
) )

View File

@ -40,7 +40,7 @@
</h1> </h1>
<p class="mt-6 max-w-2xl text-lg leading-8 text-base-content/70"> <p class="mt-6 max-w-2xl text-lg leading-8 text-base-content/70">
{gettext( {gettext(
"Ask a nearby volunteer to collect medicine that is already purchased or help with a safe roadside problem. There is no mandatory fee." "Ask a nearby volunteer to buy or collect medicine if they agree, or help with a safe roadside problem. There is no mandatory fee."
)} )}
</p> </p>
<div class="mt-8 flex flex-wrap gap-3"> <div class="mt-8 flex flex-wrap gap-3">
@ -110,7 +110,9 @@
<.icon name="hero-shopping-bag" class="size-7 text-success" /> <.icon name="hero-shopping-bag" class="size-7 text-success" />
<h3 class="mt-5 font-bold">{gettext("Collect medicine")}</h3> <h3 class="mt-5 font-bold">{gettext("Collect medicine")}</h3>
<p class="mt-2 text-sm text-base-content/65"> <p class="mt-2 text-sm text-base-content/65">
{gettext("The medicine is already legally purchased or reserved for pickup.")} {gettext(
"The requester states whether the medicine is paid, reserved, or still needs to be purchased."
)}
</p> </p>
</article> </article>
<article class="help-card rounded-3xl border border-base-300 bg-base-100 p-6"> <article class="help-card rounded-3xl border border-base-300 bg-base-100 p-6">
@ -361,7 +363,7 @@
</summary> </summary>
<p class="mt-3 text-sm leading-6 text-base-content/65"> <p class="mt-3 text-sm leading-6 text-base-content/65">
{gettext( {gettext(
"The medicine must already be legally purchased or reserved. The service does not prescribe, recommend, sell, or handle controlled substances." "Yes, if the volunteer explicitly agrees. The people arrange the medicine, pharmacy requirements, payment, and any reimbursement directly. The service does not prescribe, recommend, sell medicine, process payments, or guarantee reimbursement."
)} )}
</p> </p>
</details> </details>

View File

@ -24,7 +24,7 @@
<h2 class="text-xl font-bold">{gettext("Medicine requests")}</h2> <h2 class="text-xl font-bold">{gettext("Medicine requests")}</h2>
<p class="mt-2 text-sm leading-6"> <p class="mt-2 text-sm leading-6">
{gettext( {gettext(
"Only request pickup of a legal item already reserved or paid for. Do not request medical advice, a prescription, controlled substances, cash advances, repackaging, or a purchase on your behalf. Follow pharmacy rules and local law." "Request only lawful purchase or pickup from a legitimate pharmacy. A volunteer is never required to pay. Agree privately on the medicine, pharmacy or prescription requirements, payment, reimbursement, storage, and handover. Who Need Help does not prescribe, recommend, sell medicine, process payments, or guarantee reimbursement. Do not request controlled substances or publish prescriptions or payment credentials. Follow pharmacy rules and local law."
)} )}
</p> </p>
</section> </section>

View File

@ -47,11 +47,18 @@
<p class="mt-3"> <p class="mt-3">
Follow applicable law, pharmacy and venue rules, the Safety Rules, and other people's Follow applicable law, pharmacy and venue rules, the Safety Rules, and other people's
consent and privacy. Do not request or provide unlawful goods or services, medical consent and privacy. Do not request or provide unlawful goods or services, medical
advice, prescriptions, controlled substances, cash advances, harassment, threats, advice, controlled substances, cash advances, harassment, threats,
impersonation, fraud, spam, sexual exploitation, non-consensual intimate material, impersonation, fraud, spam, sexual exploitation, non-consensual intimate material,
instructions for wrongdoing, or content that violates another person's rights. Do not instructions for wrongdoing, or content that violates another person's rights. Do not
manipulate matches, evidence, reviews, votes, tracking, or reputation. manipulate matches, evidence, reviews, votes, tracking, or reputation.
</p> </p>
<p class="mt-3">
A requester may ask whether a volunteer is willing to purchase lawful medicine. Accepting
a request never obliges a volunteer to spend money. The people involved arrange pharmacy
or prescription requirements, payment, reimbursement, storage, and handover directly and
at their own risk. Who Need Help does not sell medicine, process payment, extend credit,
guarantee reimbursement, or override pharmacy rules or applicable law.
</p>
</section> </section>
<section aria-labelledby="terms-content"> <section aria-labelledby="terms-content">

View File

@ -91,7 +91,7 @@
<p class="my-4 text-xs text-base-content/60"> <p class="my-4 text-xs text-base-content/60">
{gettext( {gettext(
"Who Need Help is not an emergency or medical service. Medicine requests are limited to pickup of legal items already purchased or reserved." "Who Need Help is not an emergency, medical, pharmacy, or payment service. A volunteer may agree to buy or collect lawful medicine, but is never required to spend money."
)} )}
</p> </p>

View File

@ -206,7 +206,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
do: WhoNeedHelp.Catalog.Category.description(category, locale) do: WhoNeedHelp.Catalog.Category.description(category, locale)
defp category_title_placeholder(%{slug: "medicine-pickup"}), defp category_title_placeholder(%{slug: "medicine-pickup"}),
do: gettext("Medicine is ready at the pharmacy") do: gettext("Medicine is needed from a pharmacy")
defp category_title_placeholder(%{parent: %{slug: "roadside-help"}}), defp category_title_placeholder(%{parent: %{slug: "roadside-help"}}),
do: gettext("Need roadside help in a safe location") do: gettext("Need roadside help in a safe location")
@ -215,7 +215,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
defp safety_confirmation(%{slug: "medicine-pickup"}) do defp safety_confirmation(%{slug: "medicine-pickup"}) do
gettext( gettext(
"I confirm this is not an emergency, medical advice request, controlled substance, or request to buy medicine on my behalf." "I confirm this is not an emergency or request for medical advice, the medicine and arrangement are lawful, and a volunteer chooses whether to spend money."
) )
end end
@ -368,6 +368,15 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
> >
{message} {message}
</p> </p>
<p
:if={@selected_category && @selected_category.slug == "medicine-pickup"}
id="medicine-purchase-guidance"
class="rounded-xl bg-base-100/80 p-3 text-sm leading-6 text-base-content/70"
>
{gettext(
"If purchase is needed, accepting the request does not require the volunteer to spend money. Agree on the medicine, pharmacy requirements, payment, and any reimbursement only in the private matched chat."
)}
</p>
</div> </div>
<.input <.input
field={@form[:title]} field={@form[:title]}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
defmodule WhoNeedHelp.CatalogTest do
use WhoNeedHelp.DataCase, async: false
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category
test "medicine defaults support voluntary purchase and upgrade an existing category" do
category = Catalog.seed_defaults()
old_structured_fields = %{
"fields" => [
%{
"key" => "pickup_status",
"type" => "select",
"required" => true,
"label" => %{"en" => "Medicine pickup status"},
"options" => [
%{"value" => "reserved", "label" => %{"en" => "Reserved"}},
%{"value" => "already_paid", "label" => %{"en" => "Already paid"}}
]
},
%{
"key" => "community_note",
"type" => "text",
"required" => false,
"label" => %{"en" => "Community note"},
"max_length" => 80
}
]
}
category
|> Category.changeset(%{
descriptions: %{"en" => "Old pickup-only description"},
structured_fields: old_structured_fields
})
|> Repo.update!()
upgraded = Catalog.seed_defaults()
fields = Catalog.structured_fields(upgraded)
pickup_status = Enum.find(fields, &(&1["key"] == "pickup_status"))
assert Enum.map(pickup_status["options"], & &1["value"]) == [
"purchase_needed",
"reserved",
"already_paid"
]
assert Enum.any?(fields, &(&1["key"] == "community_note"))
assert upgraded.descriptions["en"] =~ "freely agrees"
assert {:ok, %{"pickup_status" => "purchase_needed"}} =
Catalog.validate_structured_data(upgraded, %{
"pickup_status" => "purchase_needed"
})
end
end

View File

@ -393,8 +393,11 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}) })
assert html =~ "Medicine pickup status" assert html =~ "Medicine pickup status"
assert html =~ "Pickup and deliver a legal medicine" assert html =~ "Buy, pick up, and deliver legal medicine"
assert html =~ "Needs to be purchased"
assert html =~ "accepting the request does not require the volunteer to spend money"
assert html =~ "help_request[structured_data][pickup_status]" assert html =~ "help_request[structured_data][pickup_status]"
assert has_element?(view, "#medicine-purchase-guidance")
refute html =~ "Medicine pickup status is required." refute html =~ "Medicine pickup status is required."
refute html =~ "pickup_status is required" refute html =~ "pickup_status is required"
@ -434,7 +437,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"help_request" => %{"category_id" => category.id} "help_request" => %{"category_id" => category.id}
}) })
assert html =~ "Отримати й доставити дозволені ліки" assert html =~ "Придбати, отримати й доставити дозволені ліки"
assert html =~ "Потрібно придбати"
html = html =
render_change(view, "validate", %{ render_change(view, "validate", %{
@ -662,6 +666,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
{:ok, activity} = Activities.create_activity(organizer_scope, attrs) {:ok, activity} = Activities.create_activity(organizer_scope, attrs)
{:ok, organizer_view, _html} = live(organizer_conn, ~p"/activities/#{activity.id}") {:ok, organizer_view, _html} = live(organizer_conn, ~p"/activities/#{activity.id}")
{:ok, participant_view, html} = live(participant_conn, ~p"/activities/#{activity.id}") {:ok, participant_view, html} = live(participant_conn, ~p"/activities/#{activity.id}")
assert has_element?( assert has_element?(
participant_view, participant_view,
"#activity-detail-map[data-map-recenter-label='Show location on map']" "#activity-detail-map[data-map-recenter-label='Show location on map']"
@ -789,7 +794,11 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
{:ok, request} = Help.create_request(requester_scope, request_attrs(category)) {:ok, request} = Help.create_request(requester_scope, request_attrs(category))
{:ok, requester_view, _html} = live(requester_conn, ~p"/requests/#{request.id}") {:ok, requester_view, _html} = live(requester_conn, ~p"/requests/#{request.id}")
{:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}") {:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}")
assert has_element?(requester_view, "#show-map[data-map-recenter-label='Show location on map']")
assert has_element?(
requester_view,
"#show-map[data-map-recenter-label='Show location on map']"
)
helper_view helper_view
|> element("button[phx-click='accept']") |> element("button[phx-click='accept']")