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

    +
    + +
    +

    + {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"} + ]} + /> +
    + +

    + 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" + /> +
    +
    + + <.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 + +
    +
    + + +
    +
    + """ + 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