feat: add separate social activity mode

This commit is contained in:
SimpleTest 2026-07-18 20:40:58 +03:00
parent d20c5e8eb2
commit 633fa6cf34
25 changed files with 2052 additions and 22 deletions

View File

@ -17,6 +17,10 @@ thank-you link; money goes directly between users outside the platform.
- Data-driven, translated category tree with validated per-category fields, - Data-driven, translated category tree with validated per-category fields,
seven selectable urgent/roadside scenarios, community proposals/votes, and seven selectable urgent/roadside scenarios, community proposals/votes, and
human approve/reject/merge tools. human approve/reject/merge tools.
- Separate social Activity mode for coffee, cinema, walks, and hikes, with
organizer-approved membership, capacity-safe joins, private group chat, and
exact coordinates visible only to approved participants. Activities never
affect urgent-helper reputation.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel - Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths. and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix - PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
@ -47,11 +51,10 @@ thank-you link; money goes directly between users outside the platform.
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker - Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
replicas by default. replicas by default.
OAuth social verification, public coffee/cinema/hiking activities, background OAuth social verification, background PWA or unattended location tracking,
PWA or unattended location tracking, platform payments, production Android platform payments, production Android signing/store publication, iOS,
signing/store publication, iOS, automatic punitive fraud decisions, and automatic punitive fraud decisions, and jurisdiction-specific public-launch
jurisdiction-specific public-launch policies are deliberately not claimed as policies are deliberately not claimed as complete.
complete.
## Fast start with Docker Compose ## Fast start with Docker Compose

View File

@ -50,6 +50,8 @@ are separate operational work and are not represented as complete.
translated labels. translated labels.
- `Help`: requests, assignments, state transitions, handover codes, and - `Help`: requests, assignments, state transitions, handover codes, and
completion evidence. completion evidence.
- `Activities`: social plans, organizer-approved participants, private group
messages, capacity-safe joins, and participant-scoped meeting coordinates.
- `Messaging`: match chat and delivery receipts. - `Messaging`: match chat and delivery receipts.
- `Tracking`: consent-driven sharing sessions, ephemeral current positions, - `Tracking`: consent-driven sharing sessions, ephemeral current positions,
and derived proximity signals. and derived proximity signals.
@ -93,8 +95,8 @@ policy, not an application backend.
## Data durability and retention ## Data durability and retention
- Requests, state transitions, messages, reviews, reports, and audit events are - Requests, activities, membership decisions, messages, reviews, reports, and
durable database records. audit events are durable database records.
- A tracking session stores only its current position while active, never a - A tracking session stores only its current position while active, never a
route history. route history.
- Stopping sharing, completing/cancelling a match, or blocking the counterpart - Stopping sharing, completing/cancelling a match, or blocking the counterpart

View File

@ -50,6 +50,9 @@ helper per request. Every state transition is authorized and recorded.
- One-time handover confirmation. - One-time handover confirmation.
- Double-blind reviews and a reputation summary. - Double-blind reviews and a reputation summary.
- Category proposals and voting. - Category proposals and voting.
- Separate social activities for coffee, cinema, public walks, and hikes.
- Organizer-approved activity membership, capacity-safe joins, private group
chat, and exact meeting coordinates limited to approved participants.
- Bidirectional blocks, scoped request/assignment/message reports, and audited - Bidirectional blocks, scoped request/assignment/message reports, and audited
report-evidence access. report-evidence access.
- Role-protected report, account, abuse-signal, request, role, and category - Role-protected report, account, abuse-signal, request, role, and category
@ -70,8 +73,6 @@ helper per request. Every state transition is authorized and recorded.
- 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.
- Public activities such as coffee, cinema, or hiking. These will be a separate
future `Activity` mode, not an urgent-help category.
- Production-signed Android release, store publication, and native iOS - Production-signed Android release, store publication, and native iOS
application. application.
- OAuth verification and automated social-network identity checks. The MVP - OAuth verification and automated social-network identity checks. The MVP
@ -109,7 +110,8 @@ may be retained.
The data model reserves a direct-message policy for a future general messaging The data model reserves a direct-message policy for a future general messaging
feature. The current product has no unsolicited inbox: chat exists only for feature. The current product has no unsolicited inbox: chat exists only for
matched participants, and a block prevents new messages. matched urgent-help participants or members of an organizer-approved activity,
and a block prevents new discovery, joins, and messages.
## Success criteria for the hackathon ## Success criteria for the hackathon

View File

@ -13,6 +13,14 @@ The medicine category is limited to pickup/delivery of an already purchased or
reserved legal item. Requests for medical advice, prescriptions, cash advances, reserved legal item. Requests for medical advice, prescriptions, cash advances,
controlled substances, or suspicious repackaging are reportable and removable. controlled substances, or suspicious repackaging are reportable and removable.
Social activities are a separate product mode. They do not use handover codes,
helper reviews, live movement evidence, or the helper leaderboard. The
organizer approves participants individually. Public viewers see only an
approximate or hidden meeting location; exact coordinates and group chat are
available only to approved participants. Users are told to use a suitable
public first meeting point and to keep home addresses, access codes, travel
documents, and other sensitive details out of public text.
## Verified completion ## Verified completion
A completion is counted as verified only when all of these occur: A completion is counted as verified only when all of these occur:
@ -76,6 +84,8 @@ include `registration_email`, `magic_link_email`, `create_request`,
`accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`, `accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`,
`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`, `withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`,
`review`, `report`, `block`, `category_proposal`, and `category_vote`. `review`, `report`, `block`, `category_proposal`, and `category_vote`.
Activity actions additionally include `create_activity`, `join_activity`, and
`send_activity_message`.
The system does not claim to be bot-proof. Email confirmation, database The system does not claim to be bot-proof. Email confirmation, database
uniqueness, unique-counterpart ranking, location evidence, velocity policies, uniqueness, unique-counterpart ranking, location evidence, velocity policies,

View File

@ -10,6 +10,7 @@ results from product limits and unknown production properties.
| 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. | | 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. |
| 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. | | 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. | | 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. |
| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. | Activity reporting and moderator evidence are handled as part of the broader trust/moderation rollout; this does not guarantee participant identity or physical safety. |
| 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. | | 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. | | 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. | | 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. |
@ -22,8 +23,10 @@ results from product limits and unknown production properties.
## Reproducible checks ## Reproducible checks
- `./scripts/test.sh`: 127 tests, 0 failures after the urgent-roadside rollout - `./scripts/test.sh`: 134 tests, 0 failures after the Activity rollout
on Elixir 1.20.2 and Erlang/OTP 29.0.3. on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- The generated Activity migration was rolled back by exactly one step and
migrated forward again against `who_need_help_test`; both directions passed.
- `mix format --check-formatted`: passed in the final run. - `mix format --check-formatted`: passed in the final run.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and - Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`assembleDebug` passed; the final lint report contains no errors or warnings. `assembleDebug` passed; the final lint report contains no errors or warnings.
@ -70,6 +73,14 @@ 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 inactive grouping nodes. These are local scenario data; the database is not
assumed empty. assumed empty.
The Activity migration then preserved those same user/help/message/assignment
counts, added empty `activities`, `activity_participants`, and
`activity_messages` tables, and expanded the category tree to 14 rows: 9 help
rows and 5 Activity rows. A validated custom-format dump was created immediately
before that migration at
`output/backups/compose-before-activity-20260718-203930.dump`; this ignored local
artifact is not a substitute for the still-required isolated restore drill.
The final kind observation found 0 users, 0 help requests, and 0 messages. That 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 state was queried before changing the database workload. The migration from the
old `emptyDir` Deployment created a custom-format dump, validated it with old `emptyDir` Deployment created a custom-format dump, validated it with
@ -125,6 +136,10 @@ Certbot renewal timer were active. This verifies the current staging path; it
does not make the workstation or gateway a production availability does not make the workstation or gateway a production availability
environment. environment.
After the Activity rollout, headed Chrome rendered the updated public
navigation, followed the Activity link to the authenticated route, and received
the expected login redirect and flash with zero console errors or warnings.
## Known work before a public production launch ## Known work before a public production launch
- Replace the temporary staging origin with the production-owned domain and - Replace the temporary staging origin with the production-owned domain and
@ -139,5 +154,5 @@ environment.
prohibited-items, and voluntary-payment guidance after legal review. prohibited-items, and voluntary-payment guidance after legal review.
- Add OAuth identity verification only if the product chooses to make verified - Add OAuth identity verification only if the product chooses to make verified
social accounts a trust signal. social accounts a trust signal.
- Design the separate future social-activity mode rather than mixing it with - Extend scoped reporting/moderation evidence to Activity entities without
urgent-help safety and ranking rules. exposing unrelated group conversations.

View File

@ -0,0 +1,536 @@
defmodule WhoNeedHelp.Activities do
@moduledoc """
Social plans are separate from urgent-help assignments and reputation.
"""
import Ecto.Query
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.Block
@topic "activities"
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
def subscribe_activity(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}")
def list_open_activities(%Scope{user: user}, filters \\ %{}) do
now = DateTime.utc_now(:second)
Activity
|> where(
[activity],
activity.status == :open and activity.starts_at > ^now and
activity.join_deadline > ^now and is_nil(activity.hidden_at)
)
|> where(
[activity],
activity.creator_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[activity],
activity.creator_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> maybe_filter_category(filters["category_id"] || filters[:category_id])
|> order_by([activity], asc: activity.starts_at)
|> preload(category: :parent, creator: :social_identities, participants: :user)
|> Repo.all()
|> Enum.map(&public_activity/1)
end
def list_my_activities(%Scope{user: user}) do
participant_ids =
from participant in Participant,
where:
participant.user_id == ^user.id and
participant.status in [:requested, :approved],
select: participant.activity_id
Activity
|> where(
[activity],
activity.creator_id == ^user.id or activity.id in subquery(participant_ids)
)
|> order_by([activity], desc: activity.starts_at)
|> preload(category: :parent, participants: :user)
|> Repo.all()
end
def get_activity(%Scope{user: user}, id) do
case Repo.get(Activity, id) do
nil ->
{:error, :not_found}
activity ->
activity = load_activity(activity.id)
cond do
activity.creator_id == user.id ->
{:ok, activity}
approved_participant?(activity, user.id) ->
{:ok, activity}
not is_nil(activity.hidden_at) ->
{:error, :not_found}
Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :not_found}
activity.status == :open ->
{:ok, activity_for_viewer(activity, user.id)}
true ->
{:error, :not_found}
end
end
end
def change_activity(%Activity{} = activity, attrs \\ %{}) do
activity
|> Activity.create_changeset(attrs)
|> validate_category_and_data()
end
def create_activity(%Scope{user: user} = scope, attrs) do
result =
with {:ok, _limit} <- Trust.authorize_action(scope, :create_activity) do
Repo.transact(fn ->
with {:ok, activity} <-
%Activity{creator_id: user.id}
|> Activity.create_changeset(attrs)
|> validate_category_and_data()
|> Repo.insert(),
{:ok, _participant} <-
%Participant{}
|> Participant.changeset(%{
activity_id: activity.id,
user_id: user.id,
role: :organizer,
status: :approved,
reviewed_at: DateTime.utc_now(:second)
})
|> Repo.insert(),
{:ok, _audit} <-
Trust.audit(user.id, "activity.created", "activity", activity.id, %{
"category_id" => activity.category_id
}) do
{:ok, activity}
end
end)
end
with {:ok, activity} <- result do
activity = load_activity(activity.id)
broadcast({:activity_created, activity})
{:ok, activity}
end
end
def request_to_join(%Scope{user: user} = scope, activity_id) do
result =
with {:ok, _limit} <- Trust.authorize_action(scope, :join_activity) do
Repo.transact(fn ->
activity = locked_activity(activity_id)
now = DateTime.utc_now(:second)
cond do
activity.creator_id == user.id ->
{:error, :organizer_already_joined}
activity.status != :open or not is_nil(activity.hidden_at) ->
{:error, :not_open}
DateTime.compare(activity.join_deadline, now) != :gt ->
{:error, :join_closed}
Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :blocked}
approved_count(activity.id) >= activity.capacity ->
{:error, :capacity_reached}
true ->
upsert_join_request(activity.id, user.id, now)
end
end)
end
after_participant_change(result, activity_id, :join_requested)
end
def approve_participant(%Scope{user: organizer}, participant_id) do
result =
Repo.transact(fn ->
participant = locked_participant(participant_id)
activity = locked_activity(participant.activity_id)
cond do
activity.creator_id != organizer.id ->
{:error, :forbidden}
participant.status != :requested ->
{:error, :invalid_transition}
activity.status != :open ->
{:error, :not_open}
Trust.blocked_between?(activity.creator_id, participant.user_id) ->
{:error, :blocked}
approved_count(activity.id) >= activity.capacity ->
{:error, :capacity_reached}
true ->
with {:ok, participant} <-
participant
|> Participant.changeset(%{
status: :approved,
reviewed_at: DateTime.utc_now(:second)
})
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(
organizer.id,
"activity.participant_approved",
"activity_participant",
participant.id,
%{"activity_id" => activity.id, "user_id" => participant.user_id}
) do
{:ok, participant}
end
end
end)
after_participant_change(result, participant_activity_id(result), :participant_approved)
end
def decline_participant(%Scope{user: organizer}, participant_id) do
transition_participant(organizer.id, participant_id, :declined)
end
def leave_activity(%Scope{user: user}, activity_id) do
result =
Repo.transact(fn ->
participant =
Participant
|> where(
[participant],
participant.activity_id == ^activity_id and participant.user_id == ^user.id
)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
is_nil(participant) ->
{:error, :not_found}
participant.role == :organizer ->
{:error, :organizer_cannot_leave}
participant.status not in [:requested, :approved] ->
{:error, :invalid_transition}
true ->
with {:ok, participant} <-
participant
|> Participant.changeset(%{
status: :left,
left_at: DateTime.utc_now(:second)
})
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(user.id, "activity.left", "activity", activity_id) do
{:ok, participant}
end
end
end)
after_participant_change(result, activity_id, :participant_left)
end
def cancel_activity(%Scope{user: user}, activity_id) do
transition_activity(user.id, activity_id, :cancelled)
end
def complete_activity(%Scope{user: user}, activity_id) do
transition_activity(user.id, activity_id, :completed)
end
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message),
{:ok, activity} <- get_activity(scope, activity_id),
true <- activity.status == :open,
true <- approved_participant?(activity, user.id) do
%Message{}
|> Message.changeset(%{
body: attrs["body"] || attrs[:body],
activity_id: activity.id,
sender_id: user.id
})
|> Repo.insert()
|> case do
{:ok, message} ->
message = Repo.preload(message, :sender)
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"activity:#{activity.id}",
{:activity_message, message}
)
{:ok, message}
other ->
other
end
else
false -> {:error, :forbidden}
other -> other
end
end
def coordinates_for(%Scope{user: user}, %Activity{} = activity) do
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
Activity.exact_coordinates(activity)
else
Activity.public_coordinates(activity)
end
end
def participant_for(%Activity{} = activity, user_id) do
Enum.find(activity.participants, &(&1.user_id == user_id))
end
def approved_participant?(%Activity{} = activity, user_id) do
Enum.any?(
activity.participants,
&(&1.user_id == user_id and &1.status == :approved)
)
end
defp validate_category_and_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id)
case category_id && Repo.get(Category, category_id) do
%Category{mode: :activity, active: true} = category ->
data = Ecto.Changeset.get_field(changeset, :structured_data)
case Catalog.validate_structured_data(category, data) do
{:ok, clean} ->
Ecto.Changeset.put_change(changeset, :structured_data, clean)
{:error, errors} ->
Enum.reduce(
errors,
changeset,
&Ecto.Changeset.add_error(&2, :structured_data, &1)
)
end
_ ->
Ecto.Changeset.add_error(changeset, :category_id, "select an activity category")
end
end
defp upsert_join_request(activity_id, user_id, now) do
case Repo.get_by(Participant, activity_id: activity_id, user_id: user_id) do
nil ->
%Participant{}
|> Participant.changeset(%{
activity_id: activity_id,
user_id: user_id,
role: :participant,
status: :requested
})
|> Repo.insert()
%Participant{status: status} = participant when status in [:declined, :left] ->
participant
|> Participant.changeset(%{
status: :requested,
reviewed_at: nil,
left_at: nil
})
|> Repo.update()
%Participant{} ->
{:error, :already_joined}
end
|> case do
{:ok, participant} ->
with {:ok, _audit} <-
Trust.audit(user_id, "activity.join_requested", "activity", activity_id, %{
"requested_at" => DateTime.to_iso8601(now)
}) do
{:ok, participant}
end
other ->
other
end
end
defp transition_participant(organizer_id, participant_id, status) do
result =
Repo.transact(fn ->
participant = locked_participant(participant_id)
activity = locked_activity(participant.activity_id)
cond do
activity.creator_id != organizer_id ->
{:error, :forbidden}
participant.status != :requested ->
{:error, :invalid_transition}
true ->
with {:ok, participant} <-
participant
|> Participant.changeset(%{
status: status,
reviewed_at: DateTime.utc_now(:second)
})
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(
organizer_id,
"activity.participant_#{status}",
"activity_participant",
participant.id,
%{"activity_id" => activity.id, "user_id" => participant.user_id}
) do
{:ok, participant}
end
end
end)
after_participant_change(result, participant_activity_id(result), :participant_declined)
end
defp transition_activity(user_id, activity_id, target_status) do
result =
Repo.transact(fn ->
activity = locked_activity(activity_id)
cond do
activity.creator_id != user_id ->
{:error, :forbidden}
activity.status != :open ->
{:error, :invalid_transition}
true ->
timestamp = DateTime.utc_now(:second)
attrs =
case target_status do
:cancelled -> %{status: :cancelled, cancelled_at: timestamp}
:completed -> %{status: :completed, completed_at: timestamp}
end
with {:ok, activity} <- activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
{:ok, _audit} <-
Trust.audit(
user_id,
"activity.#{target_status}",
"activity",
activity.id
) do
{:ok, activity}
end
end
end)
with {:ok, activity} <- result do
activity = load_activity(activity.id)
broadcast({:activity_updated, activity})
broadcast_activity(activity.id, {:activity_updated, activity})
{:ok, activity}
end
end
defp after_participant_change({:ok, participant}, activity_id, event)
when is_binary(activity_id) do
activity = load_activity(activity_id)
broadcast({:activity_updated, activity})
broadcast_activity(activity.id, {event, participant})
{:ok, participant}
end
defp after_participant_change(result, _activity_id, _event), do: result
defp participant_activity_id({:ok, %Participant{activity_id: activity_id}}), do: activity_id
defp participant_activity_id(_result), do: nil
defp approved_count(activity_id) do
Repo.aggregate(
from(
participant in Participant,
where: participant.activity_id == ^activity_id and participant.status == :approved
),
:count
)
end
defp load_activity(id) do
Activity
|> Repo.get!(id)
|> Repo.preload(
category: :parent,
creator: :social_identities,
participants: [user: :social_identities],
messages: :sender
)
end
defp activity_for_viewer(activity, user_id) do
%{
activity
| messages: [],
participants:
Enum.filter(
activity.participants,
&(&1.status == :approved or &1.user_id == user_id)
)
}
end
defp public_activity(activity) do
%{activity | participants: Enum.filter(activity.participants, &(&1.status == :approved))}
end
defp locked_activity(id) do
Activity
|> where([activity], activity.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one!()
end
defp locked_participant(id) do
Participant
|> where([participant], participant.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one!()
end
defp maybe_filter_category(query, value) when value in [nil, ""], do: query
defp maybe_filter_category(query, value),
do: where(query, [activity], activity.category_id == ^value)
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
defp broadcast_activity(id, message),
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activity:#{id}", message)
end

View File

@ -0,0 +1,126 @@
defmodule WhoNeedHelp.Activities.Activity do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "activities" do
field :title, :string
field :description, :string
field :structured_data, :map, default: %{}
field :location_label, :string
field :location, Geo.PostGIS.Geometry
field :status, Ecto.Enum, values: [:open, :completed, :cancelled], default: :open
field :location_visibility, Ecto.Enum,
values: [:hidden, :approximate_public],
default: :approximate_public
field :starts_at, :utc_datetime
field :join_deadline, :utc_datetime
field :capacity, :integer
field :cancelled_at, :utc_datetime
field :completed_at, :utc_datetime
field :hidden_at, :utc_datetime
field :hidden_reason, :string
belongs_to :creator, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category
has_many :participants, WhoNeedHelp.Activities.Participant
has_many :messages, WhoNeedHelp.Activities.Message
timestamps(type: :utc_datetime)
end
def create_changeset(activity, attrs) do
activity
|> cast(attrs, [
:title,
:description,
:structured_data,
:location_label,
:location_visibility,
:starts_at,
:join_deadline,
:capacity,
:category_id
])
|> put_location(attrs)
|> validate_required([
:title,
:description,
:location_label,
:location,
:location_visibility,
:starts_at,
:join_deadline,
:capacity,
:category_id
])
|> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000)
|> validate_number(:capacity, greater_than_or_equal_to: 2)
|> validate_schedule()
end
def moderation_changeset(activity, attrs) do
activity
|> cast(attrs, [:hidden_at, :hidden_reason])
|> validate_length(:hidden_reason, max: 1_000)
end
def public_coordinates(%__MODULE__{
location_visibility: :approximate_public,
location: %Geo.Point{coordinates: {lng, lat}}
}) do
%{latitude: Float.round(lat, 2), longitude: Float.round(lng, 2), exact: false}
end
def public_coordinates(_activity), do: nil
def exact_coordinates(%__MODULE__{location: %Geo.Point{coordinates: {lng, lat}}}) do
%{latitude: lat, longitude: lng, exact: true}
end
defp put_location(changeset, attrs) do
latitude = attrs["latitude"] || attrs[:latitude]
longitude = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(latitude)),
{lng, ""} <- Float.parse(to_string(longitude)),
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
else
_ -> add_error(changeset, :location, "select a valid location")
end
end
defp validate_schedule(changeset) do
starts_at = get_field(changeset, :starts_at)
join_deadline = get_field(changeset, :join_deadline)
now = DateTime.utc_now()
changeset
|> then(fn changeset ->
if starts_at && DateTime.after?(starts_at, now),
do: changeset,
else: add_error(changeset, :starts_at, "must be in the future")
end)
|> then(fn changeset ->
cond do
is_nil(join_deadline) ->
changeset
not DateTime.after?(join_deadline, now) ->
add_error(changeset, :join_deadline, "must be in the future")
starts_at && DateTime.after?(join_deadline, starts_at) ->
add_error(changeset, :join_deadline, "must be on or before the start time")
true ->
changeset
end
end)
end
end

View File

@ -0,0 +1,22 @@
defmodule WhoNeedHelp.Activities.Message do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "activity_messages" do
field :body, :string
belongs_to :activity, WhoNeedHelp.Activities.Activity
belongs_to :sender, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(message, attrs) do
message
|> cast(attrs, [:body, :activity_id, :sender_id])
|> validate_required([:body, :activity_id, :sender_id])
|> validate_length(:body, min: 1, max: 2_000)
end
end

View File

@ -0,0 +1,29 @@
defmodule WhoNeedHelp.Activities.Participant do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "activity_participants" do
field :role, Ecto.Enum, values: [:organizer, :participant], default: :participant
field :status, Ecto.Enum,
values: [:requested, :approved, :declined, :left],
default: :requested
field :reviewed_at, :utc_datetime
field :left_at, :utc_datetime
belongs_to :activity, WhoNeedHelp.Activities.Activity
belongs_to :user, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(participant, attrs) do
participant
|> cast(attrs, [:activity_id, :user_id, :role, :status, :reviewed_at, :left_at])
|> validate_required([:activity_id, :user_id, :role, :status])
|> unique_constraint([:activity_id, :user_id])
end
end

View File

@ -8,14 +8,22 @@ defmodule WhoNeedHelp.Catalog do
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust alias WhoNeedHelp.Trust
def list_categories do def list_categories(mode \\ :help) when mode in [:help, :activity] do
Category Category
|> where([c], c.active) |> where([c], c.active and c.mode == ^mode)
|> order_by([c], asc: c.sort_order, asc: c.slug) |> order_by([c], asc: c.sort_order, asc: c.slug)
|> preload(:parent) |> preload(:parent)
|> Repo.all() |> Repo.all()
end end
def list_all_categories do
Category
|> where([category], category.active)
|> order_by([category], asc: category.mode, asc: category.sort_order, asc: category.slug)
|> preload(:parent)
|> Repo.all()
end
def get_category!(id), do: Repo.get!(Category, id) def get_category!(id), do: Repo.get!(Category, id)
def category_path(%Category{parent: %Category{} = parent} = category, locale) do def category_path(%Category{parent: %Category{} = parent} = category, locale) do
@ -37,7 +45,8 @@ defmodule WhoNeedHelp.Catalog do
end end
def propose(%Scope{user: user} = scope, attrs) do def propose(%Scope{user: user} = scope, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal) do with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal),
{:ok, attrs} <- normalize_proposal_mode(attrs) do
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, proposal} <- with {:ok, proposal} <-
%CategoryProposal{proposer_id: user.id} %CategoryProposal{proposer_id: user.id}
@ -151,6 +160,7 @@ defmodule WhoNeedHelp.Catalog do
category_attrs category_attrs
|> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.new(fn {key, value} -> {to_string(key), value} end)
|> Map.put_new("parent_id", proposal.parent_id) |> Map.put_new("parent_id", proposal.parent_id)
|> Map.put_new("mode", to_string(proposal.mode))
with {:ok, category} <- with {:ok, category} <-
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(), %Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
@ -297,6 +307,21 @@ 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 normalize_proposal_mode(attrs) do
attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
case attrs["parent_id"] do
parent_id when parent_id not in [nil, ""] ->
case Repo.get(Category, parent_id) do
nil -> {:error, :invalid_parent}
parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
end
_ ->
{:ok, Map.put_new(attrs, "mode", "help")}
end
end
defp default_category_specs do defp default_category_specs do
[ [
{nil, {nil,
@ -600,6 +625,115 @@ defmodule WhoNeedHelp.Catalog do
) )
] ]
} }
}},
{nil,
%{
slug: "social-activity",
names: names("Social activity", "Спільне дозвілля", "Совместный досуг"),
description:
"Optional social plans with organizer-approved participants and no effect on helper reputation.",
mode: :activity,
active: false,
sort_order: 1_000,
structured_fields: %{}
}},
{"social-activity",
%{
slug: "coffee-meetup",
names: names("Coffee or tea", "Кава або чай", "Кофе или чай"),
description: "Meet in a public place for coffee, tea, or conversation.",
mode: :activity,
sort_order: 1_010,
structured_fields: %{
"fields" => [
select_field(
"setting",
names("Preferred setting", "Бажане місце", "Желаемое место"),
[
option("cafe", names("Café", "Кав'ярня", "Кофейня")),
option(
"outdoors",
names(
"Outdoor public place",
"Публічне місце надворі",
"Публичное место на улице"
)
),
option("either", names("Either", "Будь-яке", "Любое"))
]
)
]
}
}},
{"social-activity",
%{
slug: "cinema-meetup",
names: names("Cinema", "Кіно", "Кино"),
description: "Find company for a public cinema screening.",
mode: :activity,
sort_order: 1_020,
structured_fields: %{
"fields" => [
text_field(
"film_or_genre",
names("Film or genre", "Фільм або жанр", "Фильм или жанр"),
120,
true
)
]
}
}},
{"social-activity",
%{
slug: "city-walk",
names: names("Walk", "Прогулянка", "Прогулка"),
description: "Arrange a walk in a public area.",
mode: :activity,
sort_order: 1_030,
structured_fields: %{
"fields" => [
select_field("pace", names("Walking pace", "Темп прогулянки", "Темп прогулки"), [
option("relaxed", names("Relaxed", "Спокійний", "Спокойный")),
option("moderate", names("Moderate", "Середній", "Средний")),
option("active", names("Active", "Активний", "Активный"))
])
]
}
}},
{"social-activity",
%{
slug: "hiking-group",
names: names("Hiking", "Похід", "Поход"),
description: "Plan a group hike with explicit route difficulty and safety preparation.",
mode: :activity,
sort_order: 1_040,
structured_fields: %{
"fields" => [
select_field(
"difficulty",
names("Route difficulty", "Складність маршруту", "Сложность маршрута"),
[
option("easy", names("Easy", "Легкий", "Легкий")),
option("moderate", names("Moderate", "Середній", "Средний")),
option("hard", names("Hard", "Складний", "Сложный"))
]
),
text_field(
"route_plan",
names("Public route summary", "Опис маршруту", "Описание маршрута"),
300,
true
),
boolean_field(
"safety_plan_confirmed",
names(
"Organizer has a safety and return plan",
"Організатор має план безпеки й повернення",
"У организатора есть план безопасности и возвращения"
)
)
]
}
}} }}
] ]
end end

View File

@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.Category do
field :slug, :string field :slug, :string
field :names, :map, default: %{} field :names, :map, default: %{}
field :description, :string field :description, :string
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
field :active, :boolean, default: true field :active, :boolean, default: true
field :sort_order, :integer, default: 0 field :sort_order, :integer, default: 0
field :structured_fields, :map, default: %{} field :structured_fields, :map, default: %{}
@ -24,6 +25,7 @@ defmodule WhoNeedHelp.Catalog.Category do
:slug, :slug,
:names, :names,
:description, :description,
:mode,
:active, :active,
:sort_order, :sort_order,
:structured_fields, :structured_fields,

View File

@ -8,6 +8,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
schema "category_proposals" do schema "category_proposals" do
field :proposed_name, :string field :proposed_name, :string
field :reason, :string field :reason, :string
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open
belongs_to :proposer, WhoNeedHelp.Accounts.User belongs_to :proposer, WhoNeedHelp.Accounts.User
belongs_to :parent, WhoNeedHelp.Catalog.Category belongs_to :parent, WhoNeedHelp.Catalog.Category
@ -22,8 +23,8 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
def changeset(proposal, attrs) do def changeset(proposal, attrs) do
proposal proposal
|> cast(attrs, [:proposed_name, :reason, :parent_id]) |> cast(attrs, [:proposed_name, :reason, :mode, :parent_id])
|> validate_required([:proposed_name, :reason]) |> validate_required([:proposed_name, :reason, :mode])
|> validate_length(:proposed_name, min: 2, max: 80) |> validate_length(:proposed_name, min: 2, max: 80)
|> validate_length(:reason, min: 5, max: 500) |> validate_length(:reason, min: 5, max: 500)
end end

View File

@ -17,6 +17,7 @@ defmodule WhoNeedHelp.CatalogModeration do
proposal_id: proposal.id, proposal_id: proposal.id,
proposed_name: proposal.proposed_name, proposed_name: proposal.proposed_name,
reason: proposal.reason, reason: proposal.reason,
mode: proposal.mode,
parent_slug: parent_slug(proposal.parent), parent_slug: parent_slug(proposal.parent),
community_votes: length(proposal.votes) community_votes: length(proposal.votes)
} }

View File

@ -49,6 +49,11 @@ defmodule WhoNeedHelpWeb.Layouts do
{gettext("Requests")} {gettext("Requests")}
</.link> </.link>
</li> </li>
<li>
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm">
{gettext("Activities")}
</.link>
</li>
<li> <li>
<.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm"> <.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm">
{gettext("Categories")} {gettext("Categories")}

View File

@ -25,6 +25,16 @@
</p> </p>
</section> </section>
<section class="rounded-3xl border border-info/30 bg-info/5 p-6">
<h2 class="text-xl font-bold">Social activities</h2>
<p class="mt-2 text-sm leading-6">
Activities are optional plans, not urgent help, and do not affect helper reputation.
Meet in a suitable public place first. Organizers approve every participant; exact
coordinates and group chat are limited to approved participants. Share route and safety
plans for hikes, and never publish home addresses, access codes, or travel documents.
</p>
</section>
<section class="rounded-3xl border border-base-300 p-6"> <section class="rounded-3xl border border-base-300 p-6">
<h2 class="text-xl font-bold">Free help and thanks</h2> <h2 class="text-xl font-bold">Free help and thanks</h2>
<p class="mt-2 text-sm leading-6"> <p class="mt-2 text-sm leading-6">

View File

@ -0,0 +1,161 @@
defmodule WhoNeedHelpWeb.ActivityLive.Index do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Activities, Catalog}
alias WhoNeedHelp.Activities.Activity
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Activities.subscribe()
{:ok,
socket
|> assign(:filters, %{"category_id" => ""})
|> load()}
end
@impl true
def handle_info({event, _activity}, socket)
when event in [:activity_created, :activity_updated] do
{:noreply, load(socket)}
end
@impl true
def handle_event("filter", %{"filters" => filters}, socket) do
{:noreply, socket |> assign(:filters, filters) |> load()}
end
defp load(socket) do
activities =
Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters)
socket
|> assign(:page_title, "Activities")
|> assign(:activities, activities)
|> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope))
|> assign(:categories, Catalog.list_categories(:activity))
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))
end
defp marker(activity) do
case Activity.public_coordinates(activity) do
nil ->
nil
coordinates ->
Map.merge(coordinates, %{
id: activity.id,
title: activity.title,
location: activity.location_label
})
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div class="text-sm font-semibold text-info">OPTIONAL SOCIAL PLANS</div>
<h1 class="mt-1 text-4xl font-black tracking-tight">Find people to join</h1>
<p class="mt-2 max-w-2xl text-base-content/65">
Coffee, cinema, walks, and hikes are separate from urgent help and never affect helper ratings.
</p>
</div>
<.link navigate={~p"/activities/new"} class="btn btn-info">Create activity</.link>
</div>
<div class="alert alert-info mt-5 text-sm">
<.icon name="hero-shield-check" class="size-5" />
<span>
Meet in public first, keep exact details inside the approved group, and leave or report anything that feels unsafe.
</span>
</div>
<.form
for={@filter_form}
phx-change="filter"
class="mt-5 rounded-2xl bg-base-200 p-4"
>
<.input
field={@filter_form[:category_id]}
type="select"
label="Activity type"
options={[
{"All activities", ""}
| Enum.map(
@categories,
&{Catalog.category_path(&1, @current_scope.user.locale), &1.id}
)
]}
/>
</.form>
<div class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]">
<div class="space-y-4">
<div
:if={@activities == []}
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
>
<h2 class="text-xl font-bold">No open activities yet</h2>
<p class="mt-2 text-base-content/60">Create a clear plan in a public place.</p>
</div>
<.link
:for={activity <- @activities}
navigate={~p"/activities/#{activity.id}"}
class="block rounded-3xl border border-base-300 bg-base-100 p-6 transition hover:border-info/50"
>
<div class="flex items-start justify-between gap-4">
<div>
<div class="mb-3 flex flex-wrap gap-2">
<span class="badge badge-info badge-outline">
{Catalog.category_path(activity.category, @current_scope.user.locale)}
</span>
<span class="badge badge-outline">
{Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity}
</span>
</div>
<h2 class="text-xl font-bold">{activity.title}</h2>
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{activity.description}</p>
</div>
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
</div>
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
<span>📍 {activity.location_label}</span>
<span>🕒 {Calendar.strftime(activity.starts_at, "%d %b, %H:%M UTC")}</span>
<span>by {activity.creator.display_name || "Community member"}</span>
</div>
</.link>
</div>
<aside>
<div
id="activity-map"
phx-hook="AidMap"
phx-update="ignore"
data-markers={@markers}
class="aid-map"
/>
</aside>
</div>
<section :if={@my_activities != []} class="mt-12">
<h2 class="text-2xl font-bold">Your activities</h2>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<.link
:for={activity <- @my_activities}
navigate={~p"/activities/#{activity.id}"}
class="rounded-2xl bg-base-200 p-4"
>
<span class="badge badge-sm">{activity.status}</span>
<div class="mt-2 font-semibold">{activity.title}</div>
</.link>
</div>
</section>
</Layouts.app>
"""
end
end

View File

@ -0,0 +1,278 @@
defmodule WhoNeedHelpWeb.ActivityLive.New do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Activities, Catalog}
alias WhoNeedHelp.Activities.Activity
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Create activity")
|> assign(:categories, Catalog.list_categories(:activity))
|> assign(:selected_category, nil)
|> assign(:structured_fields, [])
|> assign(:form, to_form(Activities.change_activity(%Activity{})))}
end
@impl true
def handle_event("validate", %{"activity" => params}, socket) do
changeset =
%Activity{}
|> Activities.change_activity(normalize_datetimes(params))
|> Map.put(:action, :validate)
{:noreply,
socket
|> assign(:form, to_form(changeset))
|> assign_category(params)}
end
def handle_event("save", %{"activity" => params}, socket) do
case Activities.create_activity(socket.assigns.current_scope, normalize_datetimes(params)) do
{:ok, activity} ->
{:noreply,
socket
|> put_flash(:info, "Activity published. You approve every participant.")
|> push_navigate(to: ~p"/activities/#{activity.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> assign(:form, to_form(changeset))
|> assign_category(params)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
end
end
defp normalize_datetimes(params) do
Enum.reduce(["starts_at", "join_deadline"], params, fn key, normalized ->
case normalized[key] do
value when is_binary(value) ->
parsed =
case NaiveDateTime.from_iso8601(
value <> if(String.length(value) == 16, do: ":00", else: "")
) do
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
_ -> value
end
Map.put(normalized, key, parsed)
_ ->
normalized
end
end)
end
defp assign_category(socket, params) do
category = Enum.find(socket.assigns.categories, &(&1.id == params["category_id"]))
socket
|> assign(:selected_category, category)
|> assign(:structured_fields, if(category, do: Catalog.structured_fields(category), else: []))
end
defp field_label(field, locale) do
labels = field["label"] || %{}
labels[to_string(locale)] || labels["en"] || field["key"]
end
defp option_label(option, locale) when is_map(option) do
labels = option["label"] || %{}
labels[to_string(locale)] || labels["en"] || option["value"]
end
defp option_label(option, _locale), do: to_string(option)
defp option_value(option) when is_map(option), do: option["value"]
defp option_value(option), do: option
defp structured_value(form, key) do
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
end
defp error_message(:account_not_eligible),
do: "Confirm your email and ensure your account is active before publishing."
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
defp error_message(reason), do: "Could not publish the activity: #{inspect(reason)}"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-3xl">
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
Back to activities
</.link>
<h1 class="text-4xl font-black">Create an activity</h1>
<p class="mt-2 text-base-content/65">
Publish a clear public plan. Join requests stay pending until you approve them.
</p>
<div class="alert alert-warning mt-6 text-sm">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>
Use a public first meeting point. Do not publish a home address, access code, travel document, or other sensitive information.
</span>
</div>
<.form
for={@form}
id="activity-form"
phx-change="validate"
phx-submit="save"
class="mt-8 space-y-5"
>
<.input
field={@form[:category_id]}
type="select"
label="Activity type"
prompt="Choose activity"
options={
Enum.map(
@categories,
&{Catalog.category_path(&1, @current_scope.user.locale), &1.id}
)
}
/>
<p
:if={@selected_category}
id="selected-activity-category-description"
class="-mt-3 text-sm text-base-content/60"
>
{@selected_category.description}
</p>
<div
:if={@structured_fields != []}
class="space-y-4 rounded-2xl border border-info/30 bg-info/5 p-5"
>
<h2 class="font-bold">Activity details</h2>
<div :for={field <- @structured_fields}>
<label class="fieldset">
<span class="label">{field_label(field, @current_scope.user.locale)}</span>
<select
:if={field["type"] == "select"}
name={"activity[structured_data][#{field["key"]}]"}
required={field["required"] == true}
class="select w-full"
>
<option value="">Choose</option>
<option
:for={option <- field["options"] || []}
value={option_value(option)}
selected={
to_string(structured_value(@form, field["key"])) ==
to_string(option_value(option))
}
>
{option_label(option, @current_scope.user.locale)}
</option>
</select>
<input
:if={field["type"] == "text"}
type="text"
name={"activity[structured_data][#{field["key"]}]"}
value={structured_value(@form, field["key"])}
required={field["required"] == true}
maxlength={field["max_length"]}
class="input w-full"
/>
<select
:if={field["type"] == "boolean"}
name={"activity[structured_data][#{field["key"]}]"}
required={field["required"] == true}
class="select w-full"
>
<option value="">Choose</option>
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
Yes
</option>
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
No
</option>
</select>
</label>
</div>
<p :for={{message, _opts} <- @form[:structured_data].errors} class="text-sm text-error">
{message}
</p>
</div>
<.input field={@form[:title]} label="Short title" placeholder="Coffee and conversation" />
<.input
field={@form[:description]}
type="textarea"
label="Plan and expectations"
placeholder="Describe the public plan, approximate duration, and who might enjoy it."
/>
<div class="grid gap-4 md:grid-cols-2">
<.input field={@form[:starts_at]} type="datetime-local" label="Starts at (UTC)" />
<.input
field={@form[:join_deadline]}
type="datetime-local"
label="Join requests close (UTC)"
/>
</div>
<.input field={@form[:capacity]} type="number" min="2" label="Total group capacity" />
<.input
field={@form[:location_label]}
label="Approximate public meeting area"
placeholder="City centre, near the main entrance"
/>
<.input
field={@form[:location_visibility]}
type="select"
label="Public map visibility"
options={[
{"Approximate area", "approximate_public"},
{"Hidden", "hidden"}
]}
/>
<div class="rounded-2xl bg-base-200 p-5">
<button
type="button"
phx-hook="LocationPicker"
id="activity-location-picker"
class="btn btn-outline btn-sm"
>
Use my current foreground location
</button>
<p class="mt-2 text-xs text-base-content/60">
Exact coordinates are shown only to participants you approve.
</p>
<div class="mt-3 grid grid-cols-2 gap-3">
<.input
field={@form[:latitude]}
id="activity-latitude"
type="number"
step="any"
label="Latitude"
/>
<.input
field={@form[:longitude]}
id="activity-longitude"
type="number"
step="any"
label="Longitude"
/>
</div>
</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-info mt-0.5" />
<span>
I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point.
</span>
</label>
<.button class="btn btn-info btn-lg w-full" phx-disable-with="Publishing…">
Publish activity
</.button>
</.form>
</div>
</Layouts.app>
"""
end
end

View File

@ -0,0 +1,359 @@
defmodule WhoNeedHelpWeb.ActivityLive.Show do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.Activities
@impl true
def mount(%{"id" => id}, _session, socket) do
case Activities.get_activity(socket.assigns.current_scope, id) do
{:ok, activity} ->
if connected?(socket), do: Activities.subscribe_activity(activity.id)
{:ok, load(socket, activity)}
{:error, _reason} ->
{:ok,
socket
|> put_flash(:error, "Activity is unavailable.")
|> push_navigate(to: ~p"/activities")}
end
end
@impl true
def handle_info({event, _payload}, socket)
when event in [
:activity_updated,
:join_requested,
:participant_approved,
:participant_declined,
:participant_left,
:activity_message
] do
{:noreply, reload(socket)}
end
@impl true
def handle_event("join", _params, socket) do
respond(
socket,
Activities.request_to_join(socket.assigns.current_scope, socket.assigns.activity.id)
)
end
def handle_event("approve", %{"id" => id}, socket) do
respond(socket, Activities.approve_participant(socket.assigns.current_scope, id))
end
def handle_event("decline", %{"id" => id}, socket) do
respond(socket, Activities.decline_participant(socket.assigns.current_scope, id))
end
def handle_event("leave", _params, socket) do
respond(
socket,
Activities.leave_activity(socket.assigns.current_scope, socket.assigns.activity.id)
)
end
def handle_event("cancel", _params, socket) do
respond(
socket,
Activities.cancel_activity(socket.assigns.current_scope, socket.assigns.activity.id)
)
end
def handle_event("complete", _params, socket) do
respond(
socket,
Activities.complete_activity(socket.assigns.current_scope, socket.assigns.activity.id)
)
end
def handle_event("send-message", %{"activity_message" => params}, socket) do
case Activities.send_message(
socket.assigns.current_scope,
socket.assigns.activity.id,
params
) do
{:ok, _message} ->
{:noreply, reload(socket)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
end
end
defp respond(socket, {:ok, _value}), do: {:noreply, reload(socket)}
defp respond(socket, {:error, reason}) do
{:noreply, put_flash(socket, :error, error_message(reason))}
end
defp reload(socket) do
case Activities.get_activity(socket.assigns.current_scope, socket.assigns.activity.id) do
{:ok, activity} ->
load(socket, activity)
{:error, _} ->
socket
|> put_flash(:error, "Activity is unavailable.")
|> push_navigate(to: ~p"/activities")
end
end
defp load(socket, activity) do
user = socket.assigns.current_scope.user
viewer_participant = Activities.participant_for(activity, user.id)
organizer? = activity.creator_id == user.id
approved? = Activities.approved_participant?(activity, user.id)
coordinates = Activities.coordinates_for(socket.assigns.current_scope, activity)
markers =
case coordinates do
nil ->
[]
coordinates ->
[
Map.merge(coordinates, %{
id: activity.id,
title: activity.title,
location: activity.location_label
})
]
end
socket
|> assign(:page_title, activity.title)
|> assign(:activity, activity)
|> assign(:viewer_participant, viewer_participant)
|> assign(:organizer?, organizer?)
|> assign(:approved?, approved?)
|> assign(:coordinates, coordinates)
|> assign(:markers, Jason.encode!(markers))
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
end
defp can_request_join?(activity, nil), do: activity.status == :open
defp can_request_join?(activity, participant) do
activity.status == :open and participant.status in [:declined, :left]
end
defp pending_participants(activity) do
Enum.filter(activity.participants, &(&1.status == :requested))
end
defp approved_participants(activity) do
Enum.filter(activity.participants, &(&1.status == :approved))
end
defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime)
defp error_message(:already_joined), do: "You already have a join request for this activity."
defp error_message(:capacity_reached), do: "The approved group has reached its capacity."
defp error_message(:join_closed), do: "Join requests are closed."
defp error_message(:blocked), do: "This action is unavailable."
defp error_message(:forbidden), do: "You are not allowed to do that."
defp error_message(:invalid_transition), do: "This activity state has already changed."
defp error_message(:not_open), do: "This activity is no longer open."
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
defp error_message(%Ecto.Changeset{}), do: "Please check the submitted message."
defp error_message(reason), do: "Could not complete the action: #{inspect(reason)}"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
Back to activities
</.link>
<div class="grid gap-7 lg:grid-cols-[minmax(0,1fr)_22rem]">
<main class="space-y-6">
<section class="rounded-3xl border border-base-300 p-6">
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-info badge-outline">
{WhoNeedHelp.Catalog.category_path(
@activity.category,
@current_scope.user.locale
)}
</span>
<span class="badge">{@activity.status}</span>
<span class="badge badge-outline">
{length(approved_participants(@activity))}/{@activity.capacity} approved
</span>
</div>
<h1 class="mt-4 text-4xl font-black">{@activity.title}</h1>
<p class="mt-4 whitespace-pre-wrap text-base-content/70">{@activity.description}</p>
<dl class="mt-6 grid gap-4 rounded-2xl bg-base-200 p-5 sm:grid-cols-2">
<div>
<dt class="text-xs text-base-content/50">Starts</dt>
<dd class="font-semibold">
{Calendar.strftime(@activity.starts_at, "%d %b %Y, %H:%M UTC")}
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">Join requests close</dt>
<dd class="font-semibold">
{Calendar.strftime(@activity.join_deadline, "%d %b %Y, %H:%M UTC")}
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">Meeting area</dt>
<dd class="font-semibold">{@activity.location_label}</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">Organizer</dt>
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
</div>
</dl>
<div :if={map_size(@activity.structured_data) > 0} class="mt-5">
<h2 class="font-bold">Plan details</h2>
<dl class="mt-3 grid gap-2 sm:grid-cols-2">
<div
:for={{key, value} <- @activity.structured_data}
class="rounded-xl border border-base-300 p-3"
>
<dt class="text-xs text-base-content/50">{String.replace(key, "_", " ")}</dt>
<dd class="font-medium">{to_string(value)}</dd>
</div>
</dl>
</div>
</section>
<section :if={@approved?} class="rounded-3xl border border-info/30 bg-info/5 p-6">
<h2 class="text-xl font-bold">Approved group chat</h2>
<p class="mt-1 text-sm text-base-content/60">
Only the organizer and approved participants can read or send these messages.
</p>
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto">
<p :if={@activity.messages == []} class="text-sm text-base-content/50">
No messages yet.
</p>
<article
:for={message <- ordered_messages(@activity)}
class="rounded-2xl bg-base-100 p-4"
>
<div class="text-xs font-semibold text-info">
{message.sender.display_name || "Participant"}
</div>
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
</article>
</div>
<.form
:if={@activity.status == :open}
for={@message_form}
id="activity-message-form"
phx-submit="send-message"
class="mt-4 flex gap-2"
>
<.input
field={@message_form[:body]}
placeholder="Message the approved group"
class="flex-1"
/>
<.button class="btn btn-info self-end">Send</.button>
</.form>
</section>
</main>
<aside class="space-y-5">
<div
id="activity-detail-map"
phx-hook="AidMap"
phx-update="ignore"
data-markers={@markers}
class="aid-map"
/>
<div class="rounded-2xl bg-base-200 p-5 text-sm">
<h2 class="font-bold">Location privacy</h2>
<p class="mt-2 text-base-content/65">
<%= if @approved? do %>
You are approved, so the map can show the exact meeting coordinates.
<% else %>
The public map is approximate or hidden. Exact coordinates appear only after approval.
<% end %>
</p>
</div>
<div :if={not @organizer?} class="rounded-2xl border border-base-300 p-5">
<button
:if={can_request_join?(@activity, @viewer_participant)}
phx-click="join"
class="btn btn-info w-full"
>
Request to join
</button>
<div :if={@viewer_participant && @viewer_participant.status == :requested}>
<span class="badge badge-warning">Approval pending</span>
<p class="mt-2 text-sm text-base-content/60">
The organizer has not approved your request yet.
</p>
</div>
<button
:if={@viewer_participant && @viewer_participant.status == :approved}
phx-click="leave"
class="btn btn-outline btn-error w-full"
>
Leave activity
</button>
</div>
<section :if={@organizer?} class="rounded-2xl border border-info/30 p-5">
<h2 class="font-bold">Organizer controls</h2>
<div class="mt-4 space-y-3">
<article
:for={participant <- pending_participants(@activity)}
class="rounded-xl bg-base-200 p-3"
>
<div class="font-semibold">{participant.user.display_name || "Community member"}</div>
<div class="mt-2 flex gap-2">
<button
phx-click="approve"
phx-value-id={participant.id}
class="btn btn-info btn-xs"
>
Approve
</button>
<button
phx-click="decline"
phx-value-id={participant.id}
class="btn btn-ghost btn-xs"
>
Decline
</button>
</div>
</article>
<p :if={pending_participants(@activity) == []} class="text-sm text-base-content/50">
No pending join requests.
</p>
</div>
<div :if={@activity.status == :open} class="mt-5 grid gap-2">
<button phx-click="complete" class="btn btn-success btn-sm">
Mark completed
</button>
<button phx-click="cancel" class="btn btn-outline btn-error btn-sm">
Cancel activity
</button>
</div>
</section>
<section class="rounded-2xl bg-base-200 p-5">
<h2 class="font-bold">Approved participants</h2>
<ul class="mt-3 space-y-2 text-sm">
<li :for={participant <- approved_participants(@activity)}>
{participant.user.display_name || "Community member"}
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
organizer
</span>
</li>
</ul>
</section>
</aside>
</div>
</Layouts.app>
"""
end
end

View File

@ -43,7 +43,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
socket socket
|> assign(:page_title, "Category proposals") |> assign(:page_title, "Category proposals")
|> assign(:proposals, Catalog.list_proposals()) |> assign(:proposals, Catalog.list_proposals())
|> assign(:categories, Catalog.list_categories()) |> assign(:categories, Catalog.list_all_categories())
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{}))) |> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
end end
@ -64,6 +64,12 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
label="Proposed category" label="Proposed category"
placeholder="Bicycle puncture" placeholder="Bicycle puncture"
/> />
<.input
field={@form[:mode]}
type="select"
label="Mode"
options={[{"Urgent help", "help"}, {"Social activity", "activity"}]}
/>
<.input <.input
field={@form[:parent_id]} field={@form[:parent_id]}
type="select" type="select"
@ -94,6 +100,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
<h3 class="text-lg font-bold">{proposal.proposed_name}</h3> <h3 class="text-lg font-bold">{proposal.proposed_name}</h3>
<span class="badge badge-sm badge-outline">{proposal.mode}</span>
<p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p> <p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p>
<p class="mt-3 text-xs text-base-content/45">by {proposal.proposer.display_name}</p> <p class="mt-3 text-xs text-base-content/45">by {proposal.proposer.display_name}</p>
</div> </div>

View File

@ -130,7 +130,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|> assign(:signals, Trust.list_abuse_signals(socket.assigns.current_scope)) |> assign(:signals, Trust.list_abuse_signals(socket.assigns.current_scope))
|> assign(:proposals, Catalog.list_proposals_for_moderation(socket.assigns.current_scope)) |> assign(:proposals, Catalog.list_proposals_for_moderation(socket.assigns.current_scope))
|> assign(:users, Accounts.list_users_for_moderation(socket.assigns.current_scope)) |> assign(:users, Accounts.list_users_for_moderation(socket.assigns.current_scope))
|> assign(:categories, Catalog.list_categories()) |> assign(:categories, Catalog.list_all_categories())
end end
defp error_message(:forbidden), do: "Moderator access is required." defp error_message(:forbidden), do: "Moderator access is required."

View File

@ -95,6 +95,9 @@ defmodule WhoNeedHelpWeb.Router do
live "/requests", RequestLive.Index, :index live "/requests", RequestLive.Index, :index
live "/requests/new", RequestLive.New, :new live "/requests/new", RequestLive.New, :new
live "/requests/:id", RequestLive.Show, :show live "/requests/:id", RequestLive.Show, :show
live "/activities", ActivityLive.Index, :index
live "/activities/new", ActivityLive.New, :new
live "/activities/:id", ActivityLive.Show, :show
live "/categories/proposals", CategoryProposalLive, :index live "/categories/proposals", CategoryProposalLive, :index
live "/profile", ProfileLive, :edit live "/profile", ProfileLive, :edit
live "/leaderboard", LeaderboardLive, :index live "/leaderboard", LeaderboardLive, :index

View File

@ -0,0 +1,84 @@
defmodule WhoNeedHelp.Repo.Migrations.CreateActivityMode do
use Ecto.Migration
def change do
alter table(:categories) do
add :mode, :string, null: false, default: "help"
end
create index(:categories, [:mode, :active, :sort_order])
alter table(:category_proposals) do
add :mode, :string, null: false, default: "help"
end
create index(:category_proposals, [:mode, :status])
create table(:activities, primary_key: false) do
add :id, :binary_id, primary_key: true
add :title, :string, null: false
add :description, :text, null: false
add :structured_data, :map, null: false, default: %{}
add :location_label, :string, null: false
add :location, :geometry, null: false
add :location_visibility, :string, null: false, default: "approximate_public"
add :status, :string, null: false, default: "open"
add :starts_at, :utc_datetime, null: false
add :join_deadline, :utc_datetime, null: false
add :capacity, :integer, null: false
add :cancelled_at, :utc_datetime
add :completed_at, :utc_datetime
add :hidden_at, :utc_datetime
add :hidden_reason, :text
add :creator_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
add :category_id, references(:categories, type: :binary_id, on_delete: :restrict),
null: false
timestamps(type: :utc_datetime)
end
create index(:activities, [:creator_id, :starts_at])
create index(:activities, [:category_id, :status, :starts_at])
create index(:activities, [:status, :hidden_at, :join_deadline])
create index(:activities, [:location], using: :gist)
create constraint(:activities, :activity_capacity_at_least_two, check: "capacity >= 2")
create constraint(:activities, :activity_join_before_start,
check: "join_deadline <= starts_at"
)
create table(:activity_participants, primary_key: false) do
add :id, :binary_id, primary_key: true
add :role, :string, null: false, default: "participant"
add :status, :string, null: false, default: "requested"
add :reviewed_at, :utc_datetime
add :left_at, :utc_datetime
add :activity_id, references(:activities, type: :binary_id, on_delete: :delete_all),
null: false
add :user_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:activity_participants, [:activity_id, :user_id])
create index(:activity_participants, [:user_id, :status])
create index(:activity_participants, [:activity_id, :status])
create table(:activity_messages, primary_key: false) do
add :id, :binary_id, primary_key: true
add :body, :text, null: false
add :activity_id, references(:activities, type: :binary_id, on_delete: :delete_all),
null: false
add :sender_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
timestamps(type: :utc_datetime)
end
create index(:activity_messages, [:activity_id, :inserted_at])
end
end

View File

@ -0,0 +1,158 @@
defmodule WhoNeedHelp.ActivitiesTest do
use WhoNeedHelp.DataCase, async: false
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Activities, Catalog, Trust}
alias WhoNeedHelp.Activities.Activity
setup do
Catalog.seed_defaults()
organizer = user_fixture(display_name: "Organizer")
participant = user_fixture(display_name: "Participant")
outsider = user_fixture(display_name: "Outsider")
coffee =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "coffee-meetup"))
attrs = %{
"title" => "Coffee and conversation",
"description" => "Meet at a public café for conversation and tea.",
"structured_data" => %{"setting" => "cafe"},
"location_label" => "Central square",
"latitude" => "50.4501",
"longitude" => "30.5234",
"location_visibility" => "approximate_public",
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 2,
"category_id" => coffee.id
}
{:ok,
organizer: organizer,
participant: participant,
outsider: outsider,
organizer_scope: user_scope_fixture(organizer),
participant_scope: user_scope_fixture(participant),
outsider_scope: user_scope_fixture(outsider),
coffee: coffee,
attrs: attrs}
end
test "organizer approves a participant and the approved group can chat", context do
assert {:ok, activity} =
Activities.create_activity(context.organizer_scope, context.attrs)
assert [%{role: :organizer, status: :approved}] = activity.participants
assert Activity.public_coordinates(activity) == %{
latitude: 50.45,
longitude: 30.52,
exact: false
}
assert Activities.coordinates_for(context.outsider_scope, activity).exact == false
assert Activities.coordinates_for(context.organizer_scope, activity).exact == true
assert {:error, :forbidden} =
Activities.send_message(context.outsider_scope, activity.id, %{"body" => "Hello"})
assert {:ok, request} =
Activities.request_to_join(context.participant_scope, activity.id)
assert request.status == :requested
assert {:ok, approved} =
Activities.approve_participant(context.organizer_scope, request.id)
assert approved.status == :approved
assert {:ok, message} =
Activities.send_message(context.participant_scope, activity.id, %{
"body" => "See you at the café."
})
assert message.body == "See you at the café."
assert {:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
assert [%{body: "See you at the café."}] = loaded.messages
assert Activities.coordinates_for(context.participant_scope, loaded).exact == true
assert {:error, :capacity_reached} =
Activities.request_to_join(context.outsider_scope, activity.id)
end
test "unapproved viewers receive no private chat or pending participant data", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
assert {:ok, public_activity} =
Activities.get_activity(context.outsider_scope, activity.id)
assert public_activity.messages == []
assert Enum.all?(public_activity.participants, &(&1.status == :approved))
refute Enum.any?(public_activity.participants, &(&1.id == request.id))
assert {:ok, requester_view} =
Activities.get_activity(context.participant_scope, activity.id)
assert requester_view.messages == []
assert Enum.any?(requester_view.participants, &(&1.id == request.id))
end
test "activity completion never changes urgent-help reputation", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
{:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
assert {:ok, completed} = Activities.complete_activity(context.organizer_scope, activity.id)
assert completed.status == :completed
assert Trust.reputation(context.organizer.id).completed == 0
assert Trust.reputation(context.participant.id).completed == 0
assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id)
assert {:ok, _activity} = Activities.get_activity(context.participant_scope, activity.id)
end
test "activity category and structured fields are enforced", context do
hiking =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "hiking-group"))
invalid =
context.attrs
|> Map.put("category_id", hiking.id)
|> Map.put("structured_data", %{
"difficulty" => "easy",
"route_plan" => "Marked public trail"
})
assert {:error, changeset} =
Activities.create_activity(context.organizer_scope, invalid)
assert "safety_plan_confirmed is required" in errors_on(changeset).structured_data
help_category = Catalog.seed_defaults()
invalid =
context.attrs
|> Map.put("category_id", help_category.id)
|> Map.put("structured_data", %{"pickup_status" => "reserved"})
assert {:error, changeset} =
Activities.create_activity(context.organizer_scope, invalid)
assert "select an activity category" in errors_on(changeset).category_id
end
test "blocking prevents activity discovery and joining", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
assert [] = Activities.list_open_activities(context.participant_scope)
assert {:error, :blocked} =
Activities.request_to_join(context.participant_scope, activity.id)
end
end

View File

@ -138,6 +138,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert proposal_export["proposal_id"] == proposal.id assert proposal_export["proposal_id"] == proposal.id
assert proposal_export["proposed_name"] == "Bicycle repair" assert proposal_export["proposed_name"] == "Bicycle repair"
assert proposal_export["mode"] == "help"
assert proposal_export["community_votes"] == 0 assert proposal_export["community_votes"] == 0
refute export =~ context.requester.email refute export =~ context.requester.email
@ -186,7 +187,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert context.category.id == Catalog.seed_defaults().id assert context.category.id == Catalog.seed_defaults().id
assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id}) assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id})
assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 9 assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 14
end end
test "roadside structured data is enforced when creating a request", context do test "roadside structured data is enforced when creating a request", context do

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Catalog, Help, Messaging, Trust} alias WhoNeedHelp.{Activities, Catalog, Help, Messaging, Trust}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
setup :register_and_log_in_user setup :register_and_log_in_user
@ -68,6 +68,87 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
) )
end end
test "activity form is category-driven and separate from urgent help", %{conn: conn} do
Catalog.seed_defaults()
hiking =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "hiking-group"))
{:ok, view, html} = live(conn, ~p"/activities/new")
assert html =~ "Social activity — Hiking"
refute html =~ "Medicine pickup status"
html =
render_change(view, "validate", %{
"activity" => %{"category_id" => hiking.id}
})
assert html =~ "Route difficulty"
assert html =~ "activity[structured_data][route_plan]"
assert html =~ "activity[structured_data][safety_plan_confirmed]"
assert html =~ "approve participants individually"
end
test "organizer approves an activity participant and group chat updates live", %{
conn: organizer_conn,
scope: organizer_scope
} do
Catalog.seed_defaults()
participant = user_fixture(display_name: "Activity participant")
participant_conn = build_conn() |> log_in_user(participant)
coffee =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "coffee-meetup"))
attrs = %{
"title" => "Public coffee meetup",
"description" => "Meet at a public café for tea and conversation.",
"structured_data" => %{"setting" => "cafe"},
"location_label" => "Central square",
"latitude" => "50.4501",
"longitude" => "30.5234",
"location_visibility" => "approximate_public",
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 3,
"category_id" => coffee.id
}
{:ok, activity} = Activities.create_activity(organizer_scope, attrs)
{:ok, organizer_view, _html} = live(organizer_conn, ~p"/activities/#{activity.id}")
{:ok, participant_view, html} = live(participant_conn, ~p"/activities/#{activity.id}")
assert html =~ "Request to join"
refute html =~ "Approved group chat"
participant_view
|> element("button[phx-click='join']")
|> render_click()
assert render(participant_view) =~ "Approval pending"
assert render(organizer_view) =~ "Activity participant"
organizer_view
|> element("button[phx-click='approve']")
|> render_click()
assert render(participant_view) =~ "Approved group chat"
html =
participant_view
|> form("form[phx-submit='send-message']", %{
"activity_message" => %{"body" => "I will be there."}
})
|> render_submit()
assert html =~ "I will be there."
assert render(organizer_view) =~ "I will be there."
stop_live_view(organizer_view)
stop_live_view(participant_view)
end
test "views opened before matching subscribe to the private chat after assignment", %{ test "views opened before matching subscribe to the private chat after assignment", %{
conn: requester_conn, conn: requester_conn,
scope: requester_scope scope: requester_scope