diff --git a/README.md b/README.md
index 3c0f415..3bd5c86 100644
--- a/README.md
+++ b/README.md
@@ -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,
seven selectable urgent/roadside scenarios, community proposals/votes, and
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
and expiry paths.
- 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
replicas by default.
-OAuth social verification, public coffee/cinema/hiking activities, background
-PWA or unattended location tracking, platform payments, production Android
-signing/store publication, iOS, automatic punitive fraud decisions, and
-jurisdiction-specific public-launch policies are deliberately not claimed as
-complete.
+OAuth social verification, background PWA or unattended location tracking,
+platform payments, production Android signing/store publication, iOS,
+automatic punitive fraud decisions, and jurisdiction-specific public-launch
+policies are deliberately not claimed as complete.
## Fast start with Docker Compose
diff --git a/docs/architecture.md b/docs/architecture.md
index 573cfdb..f7373c4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -50,6 +50,8 @@ are separate operational work and are not represented as complete.
translated labels.
- `Help`: requests, assignments, state transitions, handover codes, and
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.
- `Tracking`: consent-driven sharing sessions, ephemeral current positions,
and derived proximity signals.
@@ -93,8 +95,8 @@ policy, not an application backend.
## Data durability and retention
-- Requests, state transitions, messages, reviews, reports, and audit events are
- durable database records.
+- Requests, activities, membership decisions, messages, reviews, reports, and
+ audit events are durable database records.
- A tracking session stores only its current position while active, never a
route history.
- Stopping sharing, completing/cancelling a match, or blocking the counterpart
diff --git a/docs/product-spec.md b/docs/product-spec.md
index 7f661cc..1880966 100644
--- a/docs/product-spec.md
+++ b/docs/product-spec.md
@@ -50,6 +50,9 @@ helper per request. Every state transition is authorized and recorded.
- One-time handover confirmation.
- Double-blind reviews and a reputation summary.
- 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
report-evidence access.
- 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.
- Background PWA or unattended location tracking.
- 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
application.
- 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
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
diff --git a/docs/trust-safety.md b/docs/trust-safety.md
index 4cc84fc..4f8a7d8 100644
--- a/docs/trust-safety.md
+++ b/docs/trust-safety.md
@@ -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,
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
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`,
`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`,
`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
uniqueness, unique-counterpart ranking, location evidence, velocity policies,
diff --git a/docs/verification.md b/docs/verification.md
index 0ce0757..64cab86 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -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 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. |
+| 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. |
| 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. |
@@ -22,8 +23,10 @@ results from product limits and unknown production properties.
## 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.
+- 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.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`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
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
state was queried before changing the database workload. The migration from the
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
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
- 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.
- Add OAuth identity verification only if the product chooses to make verified
social accounts a trust signal.
-- Design the separate future social-activity mode rather than mixing it with
- urgent-help safety and ranking rules.
+- Extend scoped reporting/moderation evidence to Activity entities without
+ exposing unrelated group conversations.
diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex
new file mode 100644
index 0000000..1dc3518
--- /dev/null
+++ b/lib/who_need_help/activities.ex
@@ -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
diff --git a/lib/who_need_help/activities/activity.ex b/lib/who_need_help/activities/activity.ex
new file mode 100644
index 0000000..c570862
--- /dev/null
+++ b/lib/who_need_help/activities/activity.ex
@@ -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
diff --git a/lib/who_need_help/activities/message.ex b/lib/who_need_help/activities/message.ex
new file mode 100644
index 0000000..96fe33e
--- /dev/null
+++ b/lib/who_need_help/activities/message.ex
@@ -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
diff --git a/lib/who_need_help/activities/participant.ex b/lib/who_need_help/activities/participant.ex
new file mode 100644
index 0000000..ddcb91e
--- /dev/null
+++ b/lib/who_need_help/activities/participant.ex
@@ -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
diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex
index bb24ef6..57ad285 100644
--- a/lib/who_need_help/catalog.ex
+++ b/lib/who_need_help/catalog.ex
@@ -8,14 +8,22 @@ defmodule WhoNeedHelp.Catalog do
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust
- def list_categories do
+ def list_categories(mode \\ :help) when mode in [:help, :activity] do
Category
- |> where([c], c.active)
+ |> where([c], c.active and c.mode == ^mode)
|> order_by([c], asc: c.sort_order, asc: c.slug)
|> preload(:parent)
|> Repo.all()
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 category_path(%Category{parent: %Category{} = parent} = category, locale) do
@@ -37,7 +45,8 @@ defmodule WhoNeedHelp.Catalog do
end
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 ->
with {:ok, proposal} <-
%CategoryProposal{proposer_id: user.id}
@@ -151,6 +160,7 @@ defmodule WhoNeedHelp.Catalog do
category_attrs
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|> Map.put_new("parent_id", proposal.parent_id)
+ |> Map.put_new("mode", to_string(proposal.mode))
with {:ok, category} <-
%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, 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
[
{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
diff --git a/lib/who_need_help/catalog/category.ex b/lib/who_need_help/catalog/category.ex
index 7176699..152f9ff 100644
--- a/lib/who_need_help/catalog/category.ex
+++ b/lib/who_need_help/catalog/category.ex
@@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.Category do
field :slug, :string
field :names, :map, default: %{}
field :description, :string
+ field :mode, Ecto.Enum, values: [:help, :activity], default: :help
field :active, :boolean, default: true
field :sort_order, :integer, default: 0
field :structured_fields, :map, default: %{}
@@ -24,6 +25,7 @@ defmodule WhoNeedHelp.Catalog.Category do
:slug,
:names,
:description,
+ :mode,
:active,
:sort_order,
:structured_fields,
diff --git a/lib/who_need_help/catalog/category_proposal.ex b/lib/who_need_help/catalog/category_proposal.ex
index 22f758d..318d9c7 100644
--- a/lib/who_need_help/catalog/category_proposal.ex
+++ b/lib/who_need_help/catalog/category_proposal.ex
@@ -8,6 +8,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
schema "category_proposals" do
field :proposed_name, :string
field :reason, :string
+ field :mode, Ecto.Enum, values: [:help, :activity], default: :help
field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open
belongs_to :proposer, WhoNeedHelp.Accounts.User
belongs_to :parent, WhoNeedHelp.Catalog.Category
@@ -22,8 +23,8 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
def changeset(proposal, attrs) do
proposal
- |> cast(attrs, [:proposed_name, :reason, :parent_id])
- |> validate_required([:proposed_name, :reason])
+ |> cast(attrs, [:proposed_name, :reason, :mode, :parent_id])
+ |> validate_required([:proposed_name, :reason, :mode])
|> validate_length(:proposed_name, min: 2, max: 80)
|> validate_length(:reason, min: 5, max: 500)
end
diff --git a/lib/who_need_help/catalog_moderation.ex b/lib/who_need_help/catalog_moderation.ex
index 37cc7f7..8a15ac0 100644
--- a/lib/who_need_help/catalog_moderation.ex
+++ b/lib/who_need_help/catalog_moderation.ex
@@ -17,6 +17,7 @@ defmodule WhoNeedHelp.CatalogModeration do
proposal_id: proposal.id,
proposed_name: proposal.proposed_name,
reason: proposal.reason,
+ mode: proposal.mode,
parent_slug: parent_slug(proposal.parent),
community_votes: length(proposal.votes)
}
diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex
index 6abd614..01bd2f4 100644
--- a/lib/who_need_help_web/components/layouts.ex
+++ b/lib/who_need_help_web/components/layouts.ex
@@ -49,6 +49,11 @@ defmodule WhoNeedHelpWeb.Layouts do
{gettext("Requests")}
+
+ <.link navigate={~p"/activities"} class="btn btn-ghost btn-sm">
+ {gettext("Activities")}
+
+
<.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm">
{gettext("Categories")}
diff --git a/lib/who_need_help_web/controllers/page_html/safety.html.heex b/lib/who_need_help_web/controllers/page_html/safety.html.heex
index d6e2217..e1f069b 100644
--- a/lib/who_need_help_web/controllers/page_html/safety.html.heex
+++ b/lib/who_need_help_web/controllers/page_html/safety.html.heex
@@ -25,6 +25,16 @@
+
+ Social activities
+
+ 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.
+
+
+
Free help and thanks
diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex
new file mode 100644
index 0000000..40e6dfe
--- /dev/null
+++ b/lib/who_need_help_web/live/activity_live/index.ex
@@ -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"""
+
+
+
+
OPTIONAL SOCIAL PLANS
+
Find people to join
+
+ Coffee, cinema, walks, and hikes are separate from urgent help and never affect helper ratings.
+
+
+ <.link navigate={~p"/activities/new"} class="btn btn-info">Create activity
+
+
+
+ <.icon name="hero-shield-check" class="size-5" />
+
+ Meet in public first, keep exact details inside the approved group, and leave or report anything that feels unsafe.
+
+
+
+ <.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}
+ )
+ ]}
+ />
+
+
+
+
+
+
No open activities yet
+
Create a clear plan in a public place.
+
+
+ <.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"
+ >
+
+
+
+
+ {Catalog.category_path(activity.category, @current_scope.user.locale)}
+
+
+ {Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity}
+
+
+
{activity.title}
+
{activity.description}
+
+ <.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
+
+
+ 📍 {activity.location_label}
+ 🕒 {Calendar.strftime(activity.starts_at, "%d %b, %H:%M UTC")}
+ by {activity.creator.display_name || "Community member"}
+
+
+
+
+
+
+
+
+ Your activities
+
+ <.link
+ :for={activity <- @my_activities}
+ navigate={~p"/activities/#{activity.id}"}
+ class="rounded-2xl bg-base-200 p-4"
+ >
+
{activity.status}
+
{activity.title}
+
+
+
+
+ """
+ end
+end
diff --git a/lib/who_need_help_web/live/activity_live/new.ex b/lib/who_need_help_web/live/activity_live/new.ex
new file mode 100644
index 0000000..fb1301d
--- /dev/null
+++ b/lib/who_need_help_web/live/activity_live/new.ex
@@ -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"""
+
+
+ <.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
+ ← Back to activities
+
+
Create an activity
+
+ Publish a clear public plan. Join requests stay pending until you approve them.
+
+
+
+ <.icon name="hero-exclamation-triangle" class="size-5" />
+
+ Use a public first meeting point. Do not publish a home address, access code, travel document, or other sensitive information.
+
+
+
+ <.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}
+ )
+ }
+ />
+
+ {@selected_category.description}
+
+
+
+
Activity details
+
+
+ {field_label(field, @current_scope.user.locale)}
+
+ Choose…
+
+ {option_label(option, @current_scope.user.locale)}
+
+
+
+
+ Choose…
+
+ Yes
+
+
+ No
+
+
+
+
+
+ {message}
+
+
+
+ <.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."
+ />
+
+ <.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)"
+ />
+
+ <.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"}
+ ]}
+ />
+
+
+ Use my current foreground location
+
+
+ Exact coordinates are shown only to participants you approve.
+
+
+ <.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"
+ />
+
+
+
+
+
+ I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point.
+
+
+ <.button class="btn btn-info btn-lg w-full" phx-disable-with="Publishing…">
+ Publish activity
+
+
+
+
+ """
+ end
+end
diff --git a/lib/who_need_help_web/live/activity_live/show.ex b/lib/who_need_help_web/live/activity_live/show.ex
new file mode 100644
index 0000000..5344477
--- /dev/null
+++ b/lib/who_need_help_web/live/activity_live/show.ex
@@ -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"""
+
+ <.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
+ ← Back to activities
+
+
+
+
+
+
+
+ {WhoNeedHelp.Catalog.category_path(
+ @activity.category,
+ @current_scope.user.locale
+ )}
+
+ {@activity.status}
+
+ {length(approved_participants(@activity))}/{@activity.capacity} approved
+
+
+ {@activity.title}
+ {@activity.description}
+
+
+
+
Starts
+
+ {Calendar.strftime(@activity.starts_at, "%d %b %Y, %H:%M UTC")}
+
+
+
+
Join requests close
+
+ {Calendar.strftime(@activity.join_deadline, "%d %b %Y, %H:%M UTC")}
+
+
+
+
Meeting area
+ {@activity.location_label}
+
+
+
Organizer
+ {@activity.creator.display_name || "Community member"}
+
+
+
+ 0} class="mt-5">
+
Plan details
+
+
+
{String.replace(key, "_", " ")}
+ {to_string(value)}
+
+
+
+
+
+
+ Approved group chat
+
+ Only the organizer and approved participants can read or send these messages.
+
+
+
+ No messages yet.
+
+
+
+ {message.sender.display_name || "Participant"}
+
+ {message.body}
+
+
+ <.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
+
+
+
+
+
+
+
+
Location privacy
+
+ <%= 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 %>
+
+
+
+
+
+ Request to join
+
+
+
Approval pending
+
+ The organizer has not approved your request yet.
+
+
+
+ Leave activity
+
+
+
+
+ Organizer controls
+
+
+ {participant.user.display_name || "Community member"}
+
+
+ Approve
+
+
+ Decline
+
+
+
+
+ No pending join requests.
+
+
+
+
+ Mark completed
+
+
+ Cancel activity
+
+
+
+
+
+ Approved participants
+
+
+ {participant.user.display_name || "Community member"}
+
+ organizer
+
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/who_need_help_web/live/category_proposal_live.ex b/lib/who_need_help_web/live/category_proposal_live.ex
index 22497ea..1d0cd94 100644
--- a/lib/who_need_help_web/live/category_proposal_live.ex
+++ b/lib/who_need_help_web/live/category_proposal_live.ex
@@ -43,7 +43,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
socket
|> assign(:page_title, "Category 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{})))
end
@@ -64,6 +64,12 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
label="Proposed category"
placeholder="Bicycle puncture"
/>
+ <.input
+ field={@form[:mode]}
+ type="select"
+ label="Mode"
+ options={[{"Urgent help", "help"}, {"Social activity", "activity"}]}
+ />
<.input
field={@form[:parent_id]}
type="select"
@@ -94,6 +100,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
{proposal.proposed_name}
+
{proposal.mode}
{proposal.reason}
by {proposal.proposer.display_name}
diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex
index 5c9982e..7fd3121 100644
--- a/lib/who_need_help_web/live/moderation_live.ex
+++ b/lib/who_need_help_web/live/moderation_live.ex
@@ -130,7 +130,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|> assign(:signals, Trust.list_abuse_signals(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(:categories, Catalog.list_categories())
+ |> assign(:categories, Catalog.list_all_categories())
end
defp error_message(:forbidden), do: "Moderator access is required."
diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex
index 921138a..76da437 100644
--- a/lib/who_need_help_web/router.ex
+++ b/lib/who_need_help_web/router.ex
@@ -95,6 +95,9 @@ defmodule WhoNeedHelpWeb.Router do
live "/requests", RequestLive.Index, :index
live "/requests/new", RequestLive.New, :new
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 "/profile", ProfileLive, :edit
live "/leaderboard", LeaderboardLive, :index
diff --git a/priv/repo/migrations/20260718172944_create_activity_mode.exs b/priv/repo/migrations/20260718172944_create_activity_mode.exs
new file mode 100644
index 0000000..aa93942
--- /dev/null
+++ b/priv/repo/migrations/20260718172944_create_activity_mode.exs
@@ -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
diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs
new file mode 100644
index 0000000..067c84e
--- /dev/null
+++ b/test/who_need_help/activities_test.exs
@@ -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
diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs
index d6ff808..e0299ef 100644
--- a/test/who_need_help/mutual_aid_flow_test.exs
+++ b/test/who_need_help/mutual_aid_flow_test.exs
@@ -138,6 +138,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert proposal_export["proposal_id"] == proposal.id
assert proposal_export["proposed_name"] == "Bicycle repair"
+ assert proposal_export["mode"] == "help"
assert proposal_export["community_votes"] == 0
refute export =~ context.requester.email
@@ -186,7 +187,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert context.category.id == Catalog.seed_defaults().id
assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id})
- assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 9
+ assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 14
end
test "roadside structured data is enforced when creating a request", context do
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index b1faea2..987fb10 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures
- alias WhoNeedHelp.{Catalog, Help, Messaging, Trust}
+ alias WhoNeedHelp.{Activities, Catalog, Help, Messaging, Trust}
alias WhoNeedHelp.Repo
setup :register_and_log_in_user
@@ -68,6 +68,87 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
)
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", %{
conn: requester_conn,
scope: requester_scope