feat: add separate social activity mode
This commit is contained in:
parent
d20c5e8eb2
commit
633fa6cf34
13
README.md
13
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,
|
- Data-driven, translated category tree with validated per-category fields,
|
||||||
seven selectable urgent/roadside scenarios, community proposals/votes, and
|
seven selectable urgent/roadside scenarios, community proposals/votes, and
|
||||||
human approve/reject/merge tools.
|
human approve/reject/merge tools.
|
||||||
|
- Separate social Activity mode for coffee, cinema, walks, and hikes, with
|
||||||
|
organizer-approved membership, capacity-safe joins, private group chat, and
|
||||||
|
exact coordinates visible only to approved participants. Activities never
|
||||||
|
affect urgent-helper reputation.
|
||||||
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
|
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
|
||||||
and expiry paths.
|
and expiry paths.
|
||||||
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
|
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
|
||||||
|
|
@ -47,11 +51,10 @@ thank-you link; money goes directly between users outside the platform.
|
||||||
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
|
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
|
||||||
replicas by default.
|
replicas by default.
|
||||||
|
|
||||||
OAuth social verification, public coffee/cinema/hiking activities, background
|
OAuth social verification, background PWA or unattended location tracking,
|
||||||
PWA or unattended location tracking, platform payments, production Android
|
platform payments, production Android signing/store publication, iOS,
|
||||||
signing/store publication, iOS, automatic punitive fraud decisions, and
|
automatic punitive fraud decisions, and jurisdiction-specific public-launch
|
||||||
jurisdiction-specific public-launch policies are deliberately not claimed as
|
policies are deliberately not claimed as complete.
|
||||||
complete.
|
|
||||||
|
|
||||||
## Fast start with Docker Compose
|
## Fast start with Docker Compose
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ are separate operational work and are not represented as complete.
|
||||||
translated labels.
|
translated labels.
|
||||||
- `Help`: requests, assignments, state transitions, handover codes, and
|
- `Help`: requests, assignments, state transitions, handover codes, and
|
||||||
completion evidence.
|
completion evidence.
|
||||||
|
- `Activities`: social plans, organizer-approved participants, private group
|
||||||
|
messages, capacity-safe joins, and participant-scoped meeting coordinates.
|
||||||
- `Messaging`: match chat and delivery receipts.
|
- `Messaging`: match chat and delivery receipts.
|
||||||
- `Tracking`: consent-driven sharing sessions, ephemeral current positions,
|
- `Tracking`: consent-driven sharing sessions, ephemeral current positions,
|
||||||
and derived proximity signals.
|
and derived proximity signals.
|
||||||
|
|
@ -93,8 +95,8 @@ policy, not an application backend.
|
||||||
|
|
||||||
## Data durability and retention
|
## Data durability and retention
|
||||||
|
|
||||||
- Requests, state transitions, messages, reviews, reports, and audit events are
|
- Requests, activities, membership decisions, messages, reviews, reports, and
|
||||||
durable database records.
|
audit events are durable database records.
|
||||||
- A tracking session stores only its current position while active, never a
|
- A tracking session stores only its current position while active, never a
|
||||||
route history.
|
route history.
|
||||||
- Stopping sharing, completing/cancelling a match, or blocking the counterpart
|
- Stopping sharing, completing/cancelling a match, or blocking the counterpart
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ helper per request. Every state transition is authorized and recorded.
|
||||||
- One-time handover confirmation.
|
- One-time handover confirmation.
|
||||||
- Double-blind reviews and a reputation summary.
|
- Double-blind reviews and a reputation summary.
|
||||||
- Category proposals and voting.
|
- Category proposals and voting.
|
||||||
|
- Separate social activities for coffee, cinema, public walks, and hikes.
|
||||||
|
- Organizer-approved activity membership, capacity-safe joins, private group
|
||||||
|
chat, and exact meeting coordinates limited to approved participants.
|
||||||
- Bidirectional blocks, scoped request/assignment/message reports, and audited
|
- Bidirectional blocks, scoped request/assignment/message reports, and audited
|
||||||
report-evidence access.
|
report-evidence access.
|
||||||
- Role-protected report, account, abuse-signal, request, role, and category
|
- Role-protected report, account, abuse-signal, request, role, and category
|
||||||
|
|
@ -70,8 +73,6 @@ helper per request. Every state transition is authorized and recorded.
|
||||||
- Platform payments, escrow, fees, or compulsory compensation.
|
- Platform payments, escrow, fees, or compulsory compensation.
|
||||||
- Background PWA or unattended location tracking.
|
- Background PWA or unattended location tracking.
|
||||||
- Multiple simultaneous helpers on one request.
|
- Multiple simultaneous helpers on one request.
|
||||||
- Public activities such as coffee, cinema, or hiking. These will be a separate
|
|
||||||
future `Activity` mode, not an urgent-help category.
|
|
||||||
- Production-signed Android release, store publication, and native iOS
|
- Production-signed Android release, store publication, and native iOS
|
||||||
application.
|
application.
|
||||||
- OAuth verification and automated social-network identity checks. The MVP
|
- OAuth verification and automated social-network identity checks. The MVP
|
||||||
|
|
@ -109,7 +110,8 @@ may be retained.
|
||||||
|
|
||||||
The data model reserves a direct-message policy for a future general messaging
|
The data model reserves a direct-message policy for a future general messaging
|
||||||
feature. The current product has no unsolicited inbox: chat exists only for
|
feature. The current product has no unsolicited inbox: chat exists only for
|
||||||
matched participants, and a block prevents new messages.
|
matched urgent-help participants or members of an organizer-approved activity,
|
||||||
|
and a block prevents new discovery, joins, and messages.
|
||||||
|
|
||||||
## Success criteria for the hackathon
|
## Success criteria for the hackathon
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ The medicine category is limited to pickup/delivery of an already purchased or
|
||||||
reserved legal item. Requests for medical advice, prescriptions, cash advances,
|
reserved legal item. Requests for medical advice, prescriptions, cash advances,
|
||||||
controlled substances, or suspicious repackaging are reportable and removable.
|
controlled substances, or suspicious repackaging are reportable and removable.
|
||||||
|
|
||||||
|
Social activities are a separate product mode. They do not use handover codes,
|
||||||
|
helper reviews, live movement evidence, or the helper leaderboard. The
|
||||||
|
organizer approves participants individually. Public viewers see only an
|
||||||
|
approximate or hidden meeting location; exact coordinates and group chat are
|
||||||
|
available only to approved participants. Users are told to use a suitable
|
||||||
|
public first meeting point and to keep home addresses, access codes, travel
|
||||||
|
documents, and other sensitive details out of public text.
|
||||||
|
|
||||||
## Verified completion
|
## Verified completion
|
||||||
|
|
||||||
A completion is counted as verified only when all of these occur:
|
A completion is counted as verified only when all of these occur:
|
||||||
|
|
@ -76,6 +84,8 @@ include `registration_email`, `magic_link_email`, `create_request`,
|
||||||
`accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`,
|
`accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`,
|
||||||
`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`,
|
`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`,
|
||||||
`review`, `report`, `block`, `category_proposal`, and `category_vote`.
|
`review`, `report`, `block`, `category_proposal`, and `category_vote`.
|
||||||
|
Activity actions additionally include `create_activity`, `join_activity`, and
|
||||||
|
`send_activity_message`.
|
||||||
|
|
||||||
The system does not claim to be bot-proof. Email confirmation, database
|
The system does not claim to be bot-proof. Email confirmation, database
|
||||||
uniqueness, unique-counterpart ranking, location evidence, velocity policies,
|
uniqueness, unique-counterpart ranking, location evidence, velocity policies,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ results from product limits and unknown production properties.
|
||||||
| Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. |
|
| Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. |
|
||||||
| Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. |
|
| Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. |
|
||||||
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. |
|
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. |
|
||||||
|
| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. | Activity reporting and moderator evidence are handled as part of the broader trust/moderation rollout; this does not guarantee participant identity or physical safety. |
|
||||||
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. |
|
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. |
|
||||||
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
|
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
|
||||||
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
|
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
|
||||||
|
|
@ -22,8 +23,10 @@ results from product limits and unknown production properties.
|
||||||
|
|
||||||
## Reproducible checks
|
## Reproducible checks
|
||||||
|
|
||||||
- `./scripts/test.sh`: 127 tests, 0 failures after the urgent-roadside rollout
|
- `./scripts/test.sh`: 134 tests, 0 failures after the Activity rollout
|
||||||
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
||||||
|
- The generated Activity migration was rolled back by exactly one step and
|
||||||
|
migrated forward again against `who_need_help_test`; both directions passed.
|
||||||
- `mix format --check-formatted`: passed in the final run.
|
- `mix format --check-formatted`: passed in the final run.
|
||||||
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
|
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
|
||||||
`assembleDebug` passed; the final lint report contains no errors or warnings.
|
`assembleDebug` passed; the final lint report contains no errors or warnings.
|
||||||
|
|
@ -70,6 +73,14 @@ changed only the category count from 1 to 9: 7 selectable categories and 2
|
||||||
inactive grouping nodes. These are local scenario data; the database is not
|
inactive grouping nodes. These are local scenario data; the database is not
|
||||||
assumed empty.
|
assumed empty.
|
||||||
|
|
||||||
|
The Activity migration then preserved those same user/help/message/assignment
|
||||||
|
counts, added empty `activities`, `activity_participants`, and
|
||||||
|
`activity_messages` tables, and expanded the category tree to 14 rows: 9 help
|
||||||
|
rows and 5 Activity rows. A validated custom-format dump was created immediately
|
||||||
|
before that migration at
|
||||||
|
`output/backups/compose-before-activity-20260718-203930.dump`; this ignored local
|
||||||
|
artifact is not a substitute for the still-required isolated restore drill.
|
||||||
|
|
||||||
The final kind observation found 0 users, 0 help requests, and 0 messages. That
|
The final kind observation found 0 users, 0 help requests, and 0 messages. That
|
||||||
state was queried before changing the database workload. The migration from the
|
state was queried before changing the database workload. The migration from the
|
||||||
old `emptyDir` Deployment created a custom-format dump, validated it with
|
old `emptyDir` Deployment created a custom-format dump, validated it with
|
||||||
|
|
@ -125,6 +136,10 @@ Certbot renewal timer were active. This verifies the current staging path; it
|
||||||
does not make the workstation or gateway a production availability
|
does not make the workstation or gateway a production availability
|
||||||
environment.
|
environment.
|
||||||
|
|
||||||
|
After the Activity rollout, headed Chrome rendered the updated public
|
||||||
|
navigation, followed the Activity link to the authenticated route, and received
|
||||||
|
the expected login redirect and flash with zero console errors or warnings.
|
||||||
|
|
||||||
## Known work before a public production launch
|
## Known work before a public production launch
|
||||||
|
|
||||||
- Replace the temporary staging origin with the production-owned domain and
|
- Replace the temporary staging origin with the production-owned domain and
|
||||||
|
|
@ -139,5 +154,5 @@ environment.
|
||||||
prohibited-items, and voluntary-payment guidance after legal review.
|
prohibited-items, and voluntary-payment guidance after legal review.
|
||||||
- Add OAuth identity verification only if the product chooses to make verified
|
- Add OAuth identity verification only if the product chooses to make verified
|
||||||
social accounts a trust signal.
|
social accounts a trust signal.
|
||||||
- Design the separate future social-activity mode rather than mixing it with
|
- Extend scoped reporting/moderation evidence to Activity entities without
|
||||||
urgent-help safety and ranking rules.
|
exposing unrelated group conversations.
|
||||||
|
|
|
||||||
536
lib/who_need_help/activities.ex
Normal file
536
lib/who_need_help/activities.ex
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
defmodule WhoNeedHelp.Activities do
|
||||||
|
@moduledoc """
|
||||||
|
Social plans are separate from urgent-help assignments and reputation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Accounts.Scope
|
||||||
|
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
|
||||||
|
alias WhoNeedHelp.Catalog
|
||||||
|
alias WhoNeedHelp.Catalog.Category
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
alias WhoNeedHelp.Trust
|
||||||
|
alias WhoNeedHelp.Trust.Block
|
||||||
|
|
||||||
|
@topic "activities"
|
||||||
|
|
||||||
|
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
|
||||||
|
|
||||||
|
def subscribe_activity(id),
|
||||||
|
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}")
|
||||||
|
|
||||||
|
def list_open_activities(%Scope{user: user}, filters \\ %{}) do
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
Activity
|
||||||
|
|> where(
|
||||||
|
[activity],
|
||||||
|
activity.status == :open and activity.starts_at > ^now and
|
||||||
|
activity.join_deadline > ^now and is_nil(activity.hidden_at)
|
||||||
|
)
|
||||||
|
|> where(
|
||||||
|
[activity],
|
||||||
|
activity.creator_id not in subquery(
|
||||||
|
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> where(
|
||||||
|
[activity],
|
||||||
|
activity.creator_id not in subquery(
|
||||||
|
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> maybe_filter_category(filters["category_id"] || filters[:category_id])
|
||||||
|
|> order_by([activity], asc: activity.starts_at)
|
||||||
|
|> preload(category: :parent, creator: :social_identities, participants: :user)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(&public_activity/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_my_activities(%Scope{user: user}) do
|
||||||
|
participant_ids =
|
||||||
|
from participant in Participant,
|
||||||
|
where:
|
||||||
|
participant.user_id == ^user.id and
|
||||||
|
participant.status in [:requested, :approved],
|
||||||
|
select: participant.activity_id
|
||||||
|
|
||||||
|
Activity
|
||||||
|
|> where(
|
||||||
|
[activity],
|
||||||
|
activity.creator_id == ^user.id or activity.id in subquery(participant_ids)
|
||||||
|
)
|
||||||
|
|> order_by([activity], desc: activity.starts_at)
|
||||||
|
|> preload(category: :parent, participants: :user)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_activity(%Scope{user: user}, id) do
|
||||||
|
case Repo.get(Activity, id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
activity ->
|
||||||
|
activity = load_activity(activity.id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
activity.creator_id == user.id ->
|
||||||
|
{:ok, activity}
|
||||||
|
|
||||||
|
approved_participant?(activity, user.id) ->
|
||||||
|
{:ok, activity}
|
||||||
|
|
||||||
|
not is_nil(activity.hidden_at) ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
activity.status == :open ->
|
||||||
|
{:ok, activity_for_viewer(activity, user.id)}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_activity(%Activity{} = activity, attrs \\ %{}) do
|
||||||
|
activity
|
||||||
|
|> Activity.create_changeset(attrs)
|
||||||
|
|> validate_category_and_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_activity(%Scope{user: user} = scope, attrs) do
|
||||||
|
result =
|
||||||
|
with {:ok, _limit} <- Trust.authorize_action(scope, :create_activity) do
|
||||||
|
Repo.transact(fn ->
|
||||||
|
with {:ok, activity} <-
|
||||||
|
%Activity{creator_id: user.id}
|
||||||
|
|> Activity.create_changeset(attrs)
|
||||||
|
|> validate_category_and_data()
|
||||||
|
|> Repo.insert(),
|
||||||
|
{:ok, _participant} <-
|
||||||
|
%Participant{}
|
||||||
|
|> Participant.changeset(%{
|
||||||
|
activity_id: activity.id,
|
||||||
|
user_id: user.id,
|
||||||
|
role: :organizer,
|
||||||
|
status: :approved,
|
||||||
|
reviewed_at: DateTime.utc_now(:second)
|
||||||
|
})
|
||||||
|
|> Repo.insert(),
|
||||||
|
{:ok, _audit} <-
|
||||||
|
Trust.audit(user.id, "activity.created", "activity", activity.id, %{
|
||||||
|
"category_id" => activity.category_id
|
||||||
|
}) do
|
||||||
|
{:ok, activity}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
with {:ok, activity} <- result do
|
||||||
|
activity = load_activity(activity.id)
|
||||||
|
broadcast({:activity_created, activity})
|
||||||
|
{:ok, activity}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_to_join(%Scope{user: user} = scope, activity_id) do
|
||||||
|
result =
|
||||||
|
with {:ok, _limit} <- Trust.authorize_action(scope, :join_activity) do
|
||||||
|
Repo.transact(fn ->
|
||||||
|
activity = locked_activity(activity_id)
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
activity.creator_id == user.id ->
|
||||||
|
{:error, :organizer_already_joined}
|
||||||
|
|
||||||
|
activity.status != :open or not is_nil(activity.hidden_at) ->
|
||||||
|
{:error, :not_open}
|
||||||
|
|
||||||
|
DateTime.compare(activity.join_deadline, now) != :gt ->
|
||||||
|
{:error, :join_closed}
|
||||||
|
|
||||||
|
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||||
|
{:error, :blocked}
|
||||||
|
|
||||||
|
approved_count(activity.id) >= activity.capacity ->
|
||||||
|
{:error, :capacity_reached}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
upsert_join_request(activity.id, user.id, now)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
after_participant_change(result, activity_id, :join_requested)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_participant(%Scope{user: organizer}, participant_id) do
|
||||||
|
result =
|
||||||
|
Repo.transact(fn ->
|
||||||
|
participant = locked_participant(participant_id)
|
||||||
|
activity = locked_activity(participant.activity_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
activity.creator_id != organizer.id ->
|
||||||
|
{:error, :forbidden}
|
||||||
|
|
||||||
|
participant.status != :requested ->
|
||||||
|
{:error, :invalid_transition}
|
||||||
|
|
||||||
|
activity.status != :open ->
|
||||||
|
{:error, :not_open}
|
||||||
|
|
||||||
|
Trust.blocked_between?(activity.creator_id, participant.user_id) ->
|
||||||
|
{:error, :blocked}
|
||||||
|
|
||||||
|
approved_count(activity.id) >= activity.capacity ->
|
||||||
|
{:error, :capacity_reached}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
with {:ok, participant} <-
|
||||||
|
participant
|
||||||
|
|> Participant.changeset(%{
|
||||||
|
status: :approved,
|
||||||
|
reviewed_at: DateTime.utc_now(:second)
|
||||||
|
})
|
||||||
|
|> Repo.update(),
|
||||||
|
{:ok, _audit} <-
|
||||||
|
Trust.audit(
|
||||||
|
organizer.id,
|
||||||
|
"activity.participant_approved",
|
||||||
|
"activity_participant",
|
||||||
|
participant.id,
|
||||||
|
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||||
|
) do
|
||||||
|
{:ok, participant}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_participant_change(result, participant_activity_id(result), :participant_approved)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decline_participant(%Scope{user: organizer}, participant_id) do
|
||||||
|
transition_participant(organizer.id, participant_id, :declined)
|
||||||
|
end
|
||||||
|
|
||||||
|
def leave_activity(%Scope{user: user}, activity_id) do
|
||||||
|
result =
|
||||||
|
Repo.transact(fn ->
|
||||||
|
participant =
|
||||||
|
Participant
|
||||||
|
|> where(
|
||||||
|
[participant],
|
||||||
|
participant.activity_id == ^activity_id and participant.user_id == ^user.id
|
||||||
|
)
|
||||||
|
|> lock("FOR UPDATE")
|
||||||
|
|> Repo.one()
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(participant) ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
participant.role == :organizer ->
|
||||||
|
{:error, :organizer_cannot_leave}
|
||||||
|
|
||||||
|
participant.status not in [:requested, :approved] ->
|
||||||
|
{:error, :invalid_transition}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
with {:ok, participant} <-
|
||||||
|
participant
|
||||||
|
|> Participant.changeset(%{
|
||||||
|
status: :left,
|
||||||
|
left_at: DateTime.utc_now(:second)
|
||||||
|
})
|
||||||
|
|> Repo.update(),
|
||||||
|
{:ok, _audit} <-
|
||||||
|
Trust.audit(user.id, "activity.left", "activity", activity_id) do
|
||||||
|
{:ok, participant}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_participant_change(result, activity_id, :participant_left)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_activity(%Scope{user: user}, activity_id) do
|
||||||
|
transition_activity(user.id, activity_id, :cancelled)
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete_activity(%Scope{user: user}, activity_id) do
|
||||||
|
transition_activity(user.id, activity_id, :completed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
|
||||||
|
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message),
|
||||||
|
{:ok, activity} <- get_activity(scope, activity_id),
|
||||||
|
true <- activity.status == :open,
|
||||||
|
true <- approved_participant?(activity, user.id) do
|
||||||
|
%Message{}
|
||||||
|
|> Message.changeset(%{
|
||||||
|
body: attrs["body"] || attrs[:body],
|
||||||
|
activity_id: activity.id,
|
||||||
|
sender_id: user.id
|
||||||
|
})
|
||||||
|
|> Repo.insert()
|
||||||
|
|> case do
|
||||||
|
{:ok, message} ->
|
||||||
|
message = Repo.preload(message, :sender)
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
WhoNeedHelp.PubSub,
|
||||||
|
"activity:#{activity.id}",
|
||||||
|
{:activity_message, message}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, message}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
else
|
||||||
|
false -> {:error, :forbidden}
|
||||||
|
other -> other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def coordinates_for(%Scope{user: user}, %Activity{} = activity) do
|
||||||
|
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
|
||||||
|
Activity.exact_coordinates(activity)
|
||||||
|
else
|
||||||
|
Activity.public_coordinates(activity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def participant_for(%Activity{} = activity, user_id) do
|
||||||
|
Enum.find(activity.participants, &(&1.user_id == user_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def approved_participant?(%Activity{} = activity, user_id) do
|
||||||
|
Enum.any?(
|
||||||
|
activity.participants,
|
||||||
|
&(&1.user_id == user_id and &1.status == :approved)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_category_and_data(changeset) do
|
||||||
|
category_id = Ecto.Changeset.get_field(changeset, :category_id)
|
||||||
|
|
||||||
|
case category_id && Repo.get(Category, category_id) do
|
||||||
|
%Category{mode: :activity, active: true} = category ->
|
||||||
|
data = Ecto.Changeset.get_field(changeset, :structured_data)
|
||||||
|
|
||||||
|
case Catalog.validate_structured_data(category, data) do
|
||||||
|
{:ok, clean} ->
|
||||||
|
Ecto.Changeset.put_change(changeset, :structured_data, clean)
|
||||||
|
|
||||||
|
{:error, errors} ->
|
||||||
|
Enum.reduce(
|
||||||
|
errors,
|
||||||
|
changeset,
|
||||||
|
&Ecto.Changeset.add_error(&2, :structured_data, &1)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Ecto.Changeset.add_error(changeset, :category_id, "select an activity category")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upsert_join_request(activity_id, user_id, now) do
|
||||||
|
case Repo.get_by(Participant, activity_id: activity_id, user_id: user_id) do
|
||||||
|
nil ->
|
||||||
|
%Participant{}
|
||||||
|
|> Participant.changeset(%{
|
||||||
|
activity_id: activity_id,
|
||||||
|
user_id: user_id,
|
||||||
|
role: :participant,
|
||||||
|
status: :requested
|
||||||
|
})
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
%Participant{status: status} = participant when status in [:declined, :left] ->
|
||||||
|
participant
|
||||||
|
|> Participant.changeset(%{
|
||||||
|
status: :requested,
|
||||||
|
reviewed_at: nil,
|
||||||
|
left_at: nil
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
%Participant{} ->
|
||||||
|
{:error, :already_joined}
|
||||||
|
end
|
||||||
|
|> case do
|
||||||
|
{:ok, participant} ->
|
||||||
|
with {:ok, _audit} <-
|
||||||
|
Trust.audit(user_id, "activity.join_requested", "activity", activity_id, %{
|
||||||
|
"requested_at" => DateTime.to_iso8601(now)
|
||||||
|
}) do
|
||||||
|
{:ok, participant}
|
||||||
|
end
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transition_participant(organizer_id, participant_id, status) do
|
||||||
|
result =
|
||||||
|
Repo.transact(fn ->
|
||||||
|
participant = locked_participant(participant_id)
|
||||||
|
activity = locked_activity(participant.activity_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
activity.creator_id != organizer_id ->
|
||||||
|
{:error, :forbidden}
|
||||||
|
|
||||||
|
participant.status != :requested ->
|
||||||
|
{:error, :invalid_transition}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
with {:ok, participant} <-
|
||||||
|
participant
|
||||||
|
|> Participant.changeset(%{
|
||||||
|
status: status,
|
||||||
|
reviewed_at: DateTime.utc_now(:second)
|
||||||
|
})
|
||||||
|
|> Repo.update(),
|
||||||
|
{:ok, _audit} <-
|
||||||
|
Trust.audit(
|
||||||
|
organizer_id,
|
||||||
|
"activity.participant_#{status}",
|
||||||
|
"activity_participant",
|
||||||
|
participant.id,
|
||||||
|
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||||
|
) do
|
||||||
|
{:ok, participant}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_participant_change(result, participant_activity_id(result), :participant_declined)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transition_activity(user_id, activity_id, target_status) do
|
||||||
|
result =
|
||||||
|
Repo.transact(fn ->
|
||||||
|
activity = locked_activity(activity_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
activity.creator_id != user_id ->
|
||||||
|
{:error, :forbidden}
|
||||||
|
|
||||||
|
activity.status != :open ->
|
||||||
|
{:error, :invalid_transition}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
timestamp = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
case target_status do
|
||||||
|
:cancelled -> %{status: :cancelled, cancelled_at: timestamp}
|
||||||
|
:completed -> %{status: :completed, completed_at: timestamp}
|
||||||
|
end
|
||||||
|
|
||||||
|
with {:ok, activity} <- activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
|
||||||
|
{:ok, _audit} <-
|
||||||
|
Trust.audit(
|
||||||
|
user_id,
|
||||||
|
"activity.#{target_status}",
|
||||||
|
"activity",
|
||||||
|
activity.id
|
||||||
|
) do
|
||||||
|
{:ok, activity}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
with {:ok, activity} <- result do
|
||||||
|
activity = load_activity(activity.id)
|
||||||
|
broadcast({:activity_updated, activity})
|
||||||
|
broadcast_activity(activity.id, {:activity_updated, activity})
|
||||||
|
{:ok, activity}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp after_participant_change({:ok, participant}, activity_id, event)
|
||||||
|
when is_binary(activity_id) do
|
||||||
|
activity = load_activity(activity_id)
|
||||||
|
broadcast({:activity_updated, activity})
|
||||||
|
broadcast_activity(activity.id, {event, participant})
|
||||||
|
{:ok, participant}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp after_participant_change(result, _activity_id, _event), do: result
|
||||||
|
|
||||||
|
defp participant_activity_id({:ok, %Participant{activity_id: activity_id}}), do: activity_id
|
||||||
|
defp participant_activity_id(_result), do: nil
|
||||||
|
|
||||||
|
defp approved_count(activity_id) do
|
||||||
|
Repo.aggregate(
|
||||||
|
from(
|
||||||
|
participant in Participant,
|
||||||
|
where: participant.activity_id == ^activity_id and participant.status == :approved
|
||||||
|
),
|
||||||
|
:count
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_activity(id) do
|
||||||
|
Activity
|
||||||
|
|> Repo.get!(id)
|
||||||
|
|> Repo.preload(
|
||||||
|
category: :parent,
|
||||||
|
creator: :social_identities,
|
||||||
|
participants: [user: :social_identities],
|
||||||
|
messages: :sender
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp activity_for_viewer(activity, user_id) do
|
||||||
|
%{
|
||||||
|
activity
|
||||||
|
| messages: [],
|
||||||
|
participants:
|
||||||
|
Enum.filter(
|
||||||
|
activity.participants,
|
||||||
|
&(&1.status == :approved or &1.user_id == user_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp public_activity(activity) do
|
||||||
|
%{activity | participants: Enum.filter(activity.participants, &(&1.status == :approved))}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp locked_activity(id) do
|
||||||
|
Activity
|
||||||
|
|> where([activity], activity.id == ^id)
|
||||||
|
|> lock("FOR UPDATE")
|
||||||
|
|> Repo.one!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp locked_participant(id) do
|
||||||
|
Participant
|
||||||
|
|> where([participant], participant.id == ^id)
|
||||||
|
|> lock("FOR UPDATE")
|
||||||
|
|> Repo.one!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_filter_category(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_filter_category(query, value),
|
||||||
|
do: where(query, [activity], activity.category_id == ^value)
|
||||||
|
|
||||||
|
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
|
||||||
|
|
||||||
|
defp broadcast_activity(id, message),
|
||||||
|
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activity:#{id}", message)
|
||||||
|
end
|
||||||
126
lib/who_need_help/activities/activity.ex
Normal file
126
lib/who_need_help/activities/activity.ex
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
defmodule WhoNeedHelp.Activities.Activity do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "activities" do
|
||||||
|
field :title, :string
|
||||||
|
field :description, :string
|
||||||
|
field :structured_data, :map, default: %{}
|
||||||
|
field :location_label, :string
|
||||||
|
field :location, Geo.PostGIS.Geometry
|
||||||
|
field :status, Ecto.Enum, values: [:open, :completed, :cancelled], default: :open
|
||||||
|
|
||||||
|
field :location_visibility, Ecto.Enum,
|
||||||
|
values: [:hidden, :approximate_public],
|
||||||
|
default: :approximate_public
|
||||||
|
|
||||||
|
field :starts_at, :utc_datetime
|
||||||
|
field :join_deadline, :utc_datetime
|
||||||
|
field :capacity, :integer
|
||||||
|
field :cancelled_at, :utc_datetime
|
||||||
|
field :completed_at, :utc_datetime
|
||||||
|
field :hidden_at, :utc_datetime
|
||||||
|
field :hidden_reason, :string
|
||||||
|
|
||||||
|
belongs_to :creator, WhoNeedHelp.Accounts.User
|
||||||
|
belongs_to :category, WhoNeedHelp.Catalog.Category
|
||||||
|
has_many :participants, WhoNeedHelp.Activities.Participant
|
||||||
|
has_many :messages, WhoNeedHelp.Activities.Message
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_changeset(activity, attrs) do
|
||||||
|
activity
|
||||||
|
|> cast(attrs, [
|
||||||
|
:title,
|
||||||
|
:description,
|
||||||
|
:structured_data,
|
||||||
|
:location_label,
|
||||||
|
:location_visibility,
|
||||||
|
:starts_at,
|
||||||
|
:join_deadline,
|
||||||
|
:capacity,
|
||||||
|
:category_id
|
||||||
|
])
|
||||||
|
|> put_location(attrs)
|
||||||
|
|> validate_required([
|
||||||
|
:title,
|
||||||
|
:description,
|
||||||
|
:location_label,
|
||||||
|
:location,
|
||||||
|
:location_visibility,
|
||||||
|
:starts_at,
|
||||||
|
:join_deadline,
|
||||||
|
:capacity,
|
||||||
|
:category_id
|
||||||
|
])
|
||||||
|
|> validate_length(:title, min: 5, max: 120)
|
||||||
|
|> validate_length(:description, min: 10, max: 2_000)
|
||||||
|
|> validate_number(:capacity, greater_than_or_equal_to: 2)
|
||||||
|
|> validate_schedule()
|
||||||
|
end
|
||||||
|
|
||||||
|
def moderation_changeset(activity, attrs) do
|
||||||
|
activity
|
||||||
|
|> cast(attrs, [:hidden_at, :hidden_reason])
|
||||||
|
|> validate_length(:hidden_reason, max: 1_000)
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_coordinates(%__MODULE__{
|
||||||
|
location_visibility: :approximate_public,
|
||||||
|
location: %Geo.Point{coordinates: {lng, lat}}
|
||||||
|
}) do
|
||||||
|
%{latitude: Float.round(lat, 2), longitude: Float.round(lng, 2), exact: false}
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_coordinates(_activity), do: nil
|
||||||
|
|
||||||
|
def exact_coordinates(%__MODULE__{location: %Geo.Point{coordinates: {lng, lat}}}) do
|
||||||
|
%{latitude: lat, longitude: lng, exact: true}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_location(changeset, attrs) do
|
||||||
|
latitude = attrs["latitude"] || attrs[:latitude]
|
||||||
|
longitude = attrs["longitude"] || attrs[:longitude]
|
||||||
|
|
||||||
|
with {lat, ""} <- Float.parse(to_string(latitude)),
|
||||||
|
{lng, ""} <- Float.parse(to_string(longitude)),
|
||||||
|
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
||||||
|
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
|
||||||
|
else
|
||||||
|
_ -> add_error(changeset, :location, "select a valid location")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_schedule(changeset) do
|
||||||
|
starts_at = get_field(changeset, :starts_at)
|
||||||
|
join_deadline = get_field(changeset, :join_deadline)
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
changeset
|
||||||
|
|> then(fn changeset ->
|
||||||
|
if starts_at && DateTime.after?(starts_at, now),
|
||||||
|
do: changeset,
|
||||||
|
else: add_error(changeset, :starts_at, "must be in the future")
|
||||||
|
end)
|
||||||
|
|> then(fn changeset ->
|
||||||
|
cond do
|
||||||
|
is_nil(join_deadline) ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
not DateTime.after?(join_deadline, now) ->
|
||||||
|
add_error(changeset, :join_deadline, "must be in the future")
|
||||||
|
|
||||||
|
starts_at && DateTime.after?(join_deadline, starts_at) ->
|
||||||
|
add_error(changeset, :join_deadline, "must be on or before the start time")
|
||||||
|
|
||||||
|
true ->
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
22
lib/who_need_help/activities/message.ex
Normal file
22
lib/who_need_help/activities/message.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule WhoNeedHelp.Activities.Message do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "activity_messages" do
|
||||||
|
field :body, :string
|
||||||
|
belongs_to :activity, WhoNeedHelp.Activities.Activity
|
||||||
|
belongs_to :sender, WhoNeedHelp.Accounts.User
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(message, attrs) do
|
||||||
|
message
|
||||||
|
|> cast(attrs, [:body, :activity_id, :sender_id])
|
||||||
|
|> validate_required([:body, :activity_id, :sender_id])
|
||||||
|
|> validate_length(:body, min: 1, max: 2_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
29
lib/who_need_help/activities/participant.ex
Normal file
29
lib/who_need_help/activities/participant.ex
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
defmodule WhoNeedHelp.Activities.Participant do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "activity_participants" do
|
||||||
|
field :role, Ecto.Enum, values: [:organizer, :participant], default: :participant
|
||||||
|
|
||||||
|
field :status, Ecto.Enum,
|
||||||
|
values: [:requested, :approved, :declined, :left],
|
||||||
|
default: :requested
|
||||||
|
|
||||||
|
field :reviewed_at, :utc_datetime
|
||||||
|
field :left_at, :utc_datetime
|
||||||
|
belongs_to :activity, WhoNeedHelp.Activities.Activity
|
||||||
|
belongs_to :user, WhoNeedHelp.Accounts.User
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(participant, attrs) do
|
||||||
|
participant
|
||||||
|
|> cast(attrs, [:activity_id, :user_id, :role, :status, :reviewed_at, :left_at])
|
||||||
|
|> validate_required([:activity_id, :user_id, :role, :status])
|
||||||
|
|> unique_constraint([:activity_id, :user_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -8,14 +8,22 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
|
|
||||||
def list_categories do
|
def list_categories(mode \\ :help) when mode in [:help, :activity] do
|
||||||
Category
|
Category
|
||||||
|> where([c], c.active)
|
|> where([c], c.active and c.mode == ^mode)
|
||||||
|> order_by([c], asc: c.sort_order, asc: c.slug)
|
|> order_by([c], asc: c.sort_order, asc: c.slug)
|
||||||
|> preload(:parent)
|
|> preload(:parent)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_all_categories do
|
||||||
|
Category
|
||||||
|
|> where([category], category.active)
|
||||||
|
|> order_by([category], asc: category.mode, asc: category.sort_order, asc: category.slug)
|
||||||
|
|> preload(:parent)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
def get_category!(id), do: Repo.get!(Category, id)
|
def get_category!(id), do: Repo.get!(Category, id)
|
||||||
|
|
||||||
def category_path(%Category{parent: %Category{} = parent} = category, locale) do
|
def category_path(%Category{parent: %Category{} = parent} = category, locale) do
|
||||||
|
|
@ -37,7 +45,8 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
end
|
end
|
||||||
|
|
||||||
def propose(%Scope{user: user} = scope, attrs) do
|
def propose(%Scope{user: user} = scope, attrs) do
|
||||||
with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal) do
|
with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal),
|
||||||
|
{:ok, attrs} <- normalize_proposal_mode(attrs) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
with {:ok, proposal} <-
|
with {:ok, proposal} <-
|
||||||
%CategoryProposal{proposer_id: user.id}
|
%CategoryProposal{proposer_id: user.id}
|
||||||
|
|
@ -151,6 +160,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
category_attrs
|
category_attrs
|
||||||
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
||||||
|> Map.put_new("parent_id", proposal.parent_id)
|
|> Map.put_new("parent_id", proposal.parent_id)
|
||||||
|
|> Map.put_new("mode", to_string(proposal.mode))
|
||||||
|
|
||||||
with {:ok, category} <-
|
with {:ok, category} <-
|
||||||
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
|
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
|
||||||
|
|
@ -297,6 +307,21 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
defp put_backfill(attrs, _key, false, _value), do: attrs
|
defp put_backfill(attrs, _key, false, _value), do: attrs
|
||||||
defp put_backfill(attrs, key, true, value), do: Map.put(attrs, key, value)
|
defp put_backfill(attrs, key, true, value), do: Map.put(attrs, key, value)
|
||||||
|
|
||||||
|
defp normalize_proposal_mode(attrs) do
|
||||||
|
attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
||||||
|
|
||||||
|
case attrs["parent_id"] do
|
||||||
|
parent_id when parent_id not in [nil, ""] ->
|
||||||
|
case Repo.get(Category, parent_id) do
|
||||||
|
nil -> {:error, :invalid_parent}
|
||||||
|
parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:ok, Map.put_new(attrs, "mode", "help")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp default_category_specs do
|
defp default_category_specs do
|
||||||
[
|
[
|
||||||
{nil,
|
{nil,
|
||||||
|
|
@ -600,6 +625,115 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}},
|
||||||
|
{nil,
|
||||||
|
%{
|
||||||
|
slug: "social-activity",
|
||||||
|
names: names("Social activity", "Спільне дозвілля", "Совместный досуг"),
|
||||||
|
description:
|
||||||
|
"Optional social plans with organizer-approved participants and no effect on helper reputation.",
|
||||||
|
mode: :activity,
|
||||||
|
active: false,
|
||||||
|
sort_order: 1_000,
|
||||||
|
structured_fields: %{}
|
||||||
|
}},
|
||||||
|
{"social-activity",
|
||||||
|
%{
|
||||||
|
slug: "coffee-meetup",
|
||||||
|
names: names("Coffee or tea", "Кава або чай", "Кофе или чай"),
|
||||||
|
description: "Meet in a public place for coffee, tea, or conversation.",
|
||||||
|
mode: :activity,
|
||||||
|
sort_order: 1_010,
|
||||||
|
structured_fields: %{
|
||||||
|
"fields" => [
|
||||||
|
select_field(
|
||||||
|
"setting",
|
||||||
|
names("Preferred setting", "Бажане місце", "Желаемое место"),
|
||||||
|
[
|
||||||
|
option("cafe", names("Café", "Кав'ярня", "Кофейня")),
|
||||||
|
option(
|
||||||
|
"outdoors",
|
||||||
|
names(
|
||||||
|
"Outdoor public place",
|
||||||
|
"Публічне місце надворі",
|
||||||
|
"Публичное место на улице"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
option("either", names("Either", "Будь-яке", "Любое"))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"social-activity",
|
||||||
|
%{
|
||||||
|
slug: "cinema-meetup",
|
||||||
|
names: names("Cinema", "Кіно", "Кино"),
|
||||||
|
description: "Find company for a public cinema screening.",
|
||||||
|
mode: :activity,
|
||||||
|
sort_order: 1_020,
|
||||||
|
structured_fields: %{
|
||||||
|
"fields" => [
|
||||||
|
text_field(
|
||||||
|
"film_or_genre",
|
||||||
|
names("Film or genre", "Фільм або жанр", "Фильм или жанр"),
|
||||||
|
120,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"social-activity",
|
||||||
|
%{
|
||||||
|
slug: "city-walk",
|
||||||
|
names: names("Walk", "Прогулянка", "Прогулка"),
|
||||||
|
description: "Arrange a walk in a public area.",
|
||||||
|
mode: :activity,
|
||||||
|
sort_order: 1_030,
|
||||||
|
structured_fields: %{
|
||||||
|
"fields" => [
|
||||||
|
select_field("pace", names("Walking pace", "Темп прогулянки", "Темп прогулки"), [
|
||||||
|
option("relaxed", names("Relaxed", "Спокійний", "Спокойный")),
|
||||||
|
option("moderate", names("Moderate", "Середній", "Средний")),
|
||||||
|
option("active", names("Active", "Активний", "Активный"))
|
||||||
|
])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"social-activity",
|
||||||
|
%{
|
||||||
|
slug: "hiking-group",
|
||||||
|
names: names("Hiking", "Похід", "Поход"),
|
||||||
|
description: "Plan a group hike with explicit route difficulty and safety preparation.",
|
||||||
|
mode: :activity,
|
||||||
|
sort_order: 1_040,
|
||||||
|
structured_fields: %{
|
||||||
|
"fields" => [
|
||||||
|
select_field(
|
||||||
|
"difficulty",
|
||||||
|
names("Route difficulty", "Складність маршруту", "Сложность маршрута"),
|
||||||
|
[
|
||||||
|
option("easy", names("Easy", "Легкий", "Легкий")),
|
||||||
|
option("moderate", names("Moderate", "Середній", "Средний")),
|
||||||
|
option("hard", names("Hard", "Складний", "Сложный"))
|
||||||
|
]
|
||||||
|
),
|
||||||
|
text_field(
|
||||||
|
"route_plan",
|
||||||
|
names("Public route summary", "Опис маршруту", "Описание маршрута"),
|
||||||
|
300,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
boolean_field(
|
||||||
|
"safety_plan_confirmed",
|
||||||
|
names(
|
||||||
|
"Organizer has a safety and return plan",
|
||||||
|
"Організатор має план безпеки й повернення",
|
||||||
|
"У организатора есть план безопасности и возвращения"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
field :slug, :string
|
field :slug, :string
|
||||||
field :names, :map, default: %{}
|
field :names, :map, default: %{}
|
||||||
field :description, :string
|
field :description, :string
|
||||||
|
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
|
||||||
field :active, :boolean, default: true
|
field :active, :boolean, default: true
|
||||||
field :sort_order, :integer, default: 0
|
field :sort_order, :integer, default: 0
|
||||||
field :structured_fields, :map, default: %{}
|
field :structured_fields, :map, default: %{}
|
||||||
|
|
@ -24,6 +25,7 @@ defmodule WhoNeedHelp.Catalog.Category do
|
||||||
:slug,
|
:slug,
|
||||||
:names,
|
:names,
|
||||||
:description,
|
:description,
|
||||||
|
:mode,
|
||||||
:active,
|
:active,
|
||||||
:sort_order,
|
:sort_order,
|
||||||
:structured_fields,
|
:structured_fields,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
|
||||||
schema "category_proposals" do
|
schema "category_proposals" do
|
||||||
field :proposed_name, :string
|
field :proposed_name, :string
|
||||||
field :reason, :string
|
field :reason, :string
|
||||||
|
field :mode, Ecto.Enum, values: [:help, :activity], default: :help
|
||||||
field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open
|
field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open
|
||||||
belongs_to :proposer, WhoNeedHelp.Accounts.User
|
belongs_to :proposer, WhoNeedHelp.Accounts.User
|
||||||
belongs_to :parent, WhoNeedHelp.Catalog.Category
|
belongs_to :parent, WhoNeedHelp.Catalog.Category
|
||||||
|
|
@ -22,8 +23,8 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
|
||||||
|
|
||||||
def changeset(proposal, attrs) do
|
def changeset(proposal, attrs) do
|
||||||
proposal
|
proposal
|
||||||
|> cast(attrs, [:proposed_name, :reason, :parent_id])
|
|> cast(attrs, [:proposed_name, :reason, :mode, :parent_id])
|
||||||
|> validate_required([:proposed_name, :reason])
|
|> validate_required([:proposed_name, :reason, :mode])
|
||||||
|> validate_length(:proposed_name, min: 2, max: 80)
|
|> validate_length(:proposed_name, min: 2, max: 80)
|
||||||
|> validate_length(:reason, min: 5, max: 500)
|
|> validate_length(:reason, min: 5, max: 500)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ defmodule WhoNeedHelp.CatalogModeration do
|
||||||
proposal_id: proposal.id,
|
proposal_id: proposal.id,
|
||||||
proposed_name: proposal.proposed_name,
|
proposed_name: proposal.proposed_name,
|
||||||
reason: proposal.reason,
|
reason: proposal.reason,
|
||||||
|
mode: proposal.mode,
|
||||||
parent_slug: parent_slug(proposal.parent),
|
parent_slug: parent_slug(proposal.parent),
|
||||||
community_votes: length(proposal.votes)
|
community_votes: length(proposal.votes)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,11 @@ defmodule WhoNeedHelpWeb.Layouts do
|
||||||
{gettext("Requests")}
|
{gettext("Requests")}
|
||||||
</.link>
|
</.link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm">
|
||||||
|
{gettext("Activities")}
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm">
|
<.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm">
|
||||||
{gettext("Categories")}
|
{gettext("Categories")}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-3xl border border-info/30 bg-info/5 p-6">
|
||||||
|
<h2 class="text-xl font-bold">Social activities</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6">
|
||||||
|
Activities are optional plans, not urgent help, and do not affect helper reputation.
|
||||||
|
Meet in a suitable public place first. Organizers approve every participant; exact
|
||||||
|
coordinates and group chat are limited to approved participants. Share route and safety
|
||||||
|
plans for hikes, and never publish home addresses, access codes, or travel documents.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<section class="rounded-3xl border border-base-300 p-6">
|
||||||
<h2 class="text-xl font-bold">Free help and thanks</h2>
|
<h2 class="text-xl font-bold">Free help and thanks</h2>
|
||||||
<p class="mt-2 text-sm leading-6">
|
<p class="mt-2 text-sm leading-6">
|
||||||
|
|
|
||||||
161
lib/who_need_help_web/live/activity_live/index.ex
Normal file
161
lib/who_need_help_web/live/activity_live/index.ex
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
|
alias WhoNeedHelp.{Activities, Catalog}
|
||||||
|
alias WhoNeedHelp.Activities.Activity
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
if connected?(socket), do: Activities.subscribe()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:filters, %{"category_id" => ""})
|
||||||
|
|> load()}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({event, _activity}, socket)
|
||||||
|
when event in [:activity_created, :activity_updated] do
|
||||||
|
{:noreply, load(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||||
|
{:noreply, socket |> assign(:filters, filters) |> load()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load(socket) do
|
||||||
|
activities =
|
||||||
|
Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Activities")
|
||||||
|
|> assign(:activities, activities)
|
||||||
|
|> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope))
|
||||||
|
|> assign(:categories, Catalog.list_categories(:activity))
|
||||||
|
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
||||||
|
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp marker(activity) do
|
||||||
|
case Activity.public_coordinates(activity) do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
coordinates ->
|
||||||
|
Map.merge(coordinates, %{
|
||||||
|
id: activity.id,
|
||||||
|
title: activity.title,
|
||||||
|
location: activity.location_label
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-info">OPTIONAL SOCIAL PLANS</div>
|
||||||
|
<h1 class="mt-1 text-4xl font-black tracking-tight">Find people to join</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-base-content/65">
|
||||||
|
Coffee, cinema, walks, and hikes are separate from urgent help and never affect helper ratings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<.link navigate={~p"/activities/new"} class="btn btn-info">Create activity</.link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-5 text-sm">
|
||||||
|
<.icon name="hero-shield-check" class="size-5" />
|
||||||
|
<span>
|
||||||
|
Meet in public first, keep exact details inside the approved group, and leave or report anything that feels unsafe.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.form
|
||||||
|
for={@filter_form}
|
||||||
|
phx-change="filter"
|
||||||
|
class="mt-5 rounded-2xl bg-base-200 p-4"
|
||||||
|
>
|
||||||
|
<.input
|
||||||
|
field={@filter_form[:category_id]}
|
||||||
|
type="select"
|
||||||
|
label="Activity type"
|
||||||
|
options={[
|
||||||
|
{"All activities", ""}
|
||||||
|
| Enum.map(
|
||||||
|
@categories,
|
||||||
|
&{Catalog.category_path(&1, @current_scope.user.locale), &1.id}
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<div class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
:if={@activities == []}
|
||||||
|
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-bold">No open activities yet</h2>
|
||||||
|
<p class="mt-2 text-base-content/60">Create a clear plan in a public place.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.link
|
||||||
|
:for={activity <- @activities}
|
||||||
|
navigate={~p"/activities/#{activity.id}"}
|
||||||
|
class="block rounded-3xl border border-base-300 bg-base-100 p-6 transition hover:border-info/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-3 flex flex-wrap gap-2">
|
||||||
|
<span class="badge badge-info badge-outline">
|
||||||
|
{Catalog.category_path(activity.category, @current_scope.user.locale)}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline">
|
||||||
|
{Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-bold">{activity.title}</h2>
|
||||||
|
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{activity.description}</p>
|
||||||
|
</div>
|
||||||
|
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
|
||||||
|
<span>📍 {activity.location_label}</span>
|
||||||
|
<span>🕒 {Calendar.strftime(activity.starts_at, "%d %b, %H:%M UTC")}</span>
|
||||||
|
<span>by {activity.creator.display_name || "Community member"}</span>
|
||||||
|
</div>
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside>
|
||||||
|
<div
|
||||||
|
id="activity-map"
|
||||||
|
phx-hook="AidMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-markers={@markers}
|
||||||
|
class="aid-map"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section :if={@my_activities != []} class="mt-12">
|
||||||
|
<h2 class="text-2xl font-bold">Your activities</h2>
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<.link
|
||||||
|
:for={activity <- @my_activities}
|
||||||
|
navigate={~p"/activities/#{activity.id}"}
|
||||||
|
class="rounded-2xl bg-base-200 p-4"
|
||||||
|
>
|
||||||
|
<span class="badge badge-sm">{activity.status}</span>
|
||||||
|
<div class="mt-2 font-semibold">{activity.title}</div>
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
278
lib/who_need_help_web/live/activity_live/new.ex
Normal file
278
lib/who_need_help_web/live/activity_live/new.ex
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
defmodule WhoNeedHelpWeb.ActivityLive.New do
|
||||||
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
|
alias WhoNeedHelp.{Activities, Catalog}
|
||||||
|
alias WhoNeedHelp.Activities.Activity
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Create activity")
|
||||||
|
|> assign(:categories, Catalog.list_categories(:activity))
|
||||||
|
|> assign(:selected_category, nil)
|
||||||
|
|> assign(:structured_fields, [])
|
||||||
|
|> assign(:form, to_form(Activities.change_activity(%Activity{})))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"activity" => params}, socket) do
|
||||||
|
changeset =
|
||||||
|
%Activity{}
|
||||||
|
|> Activities.change_activity(normalize_datetimes(params))
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:form, to_form(changeset))
|
||||||
|
|> assign_category(params)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"activity" => params}, socket) do
|
||||||
|
case Activities.create_activity(socket.assigns.current_scope, normalize_datetimes(params)) do
|
||||||
|
{:ok, activity} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Activity published. You approve every participant.")
|
||||||
|
|> push_navigate(to: ~p"/activities/#{activity.id}")}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:form, to_form(changeset))
|
||||||
|
|> assign_category(params)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_datetimes(params) do
|
||||||
|
Enum.reduce(["starts_at", "join_deadline"], params, fn key, normalized ->
|
||||||
|
case normalized[key] do
|
||||||
|
value when is_binary(value) ->
|
||||||
|
parsed =
|
||||||
|
case NaiveDateTime.from_iso8601(
|
||||||
|
value <> if(String.length(value) == 16, do: ":00", else: "")
|
||||||
|
) do
|
||||||
|
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
|
||||||
|
_ -> value
|
||||||
|
end
|
||||||
|
|
||||||
|
Map.put(normalized, key, parsed)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_category(socket, params) do
|
||||||
|
category = Enum.find(socket.assigns.categories, &(&1.id == params["category_id"]))
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:selected_category, category)
|
||||||
|
|> assign(:structured_fields, if(category, do: Catalog.structured_fields(category), else: []))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp field_label(field, locale) do
|
||||||
|
labels = field["label"] || %{}
|
||||||
|
labels[to_string(locale)] || labels["en"] || field["key"]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp option_label(option, locale) when is_map(option) do
|
||||||
|
labels = option["label"] || %{}
|
||||||
|
labels[to_string(locale)] || labels["en"] || option["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp option_label(option, _locale), do: to_string(option)
|
||||||
|
defp option_value(option) when is_map(option), do: option["value"]
|
||||||
|
defp option_value(option), do: option
|
||||||
|
|
||||||
|
defp structured_value(form, key) do
|
||||||
|
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error_message(:account_not_eligible),
|
||||||
|
do: "Confirm your email and ensure your account is active before publishing."
|
||||||
|
|
||||||
|
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
|
||||||
|
defp error_message(reason), do: "Could not publish the activity: #{inspect(reason)}"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
||||||
|
← Back to activities
|
||||||
|
</.link>
|
||||||
|
<h1 class="text-4xl font-black">Create an activity</h1>
|
||||||
|
<p class="mt-2 text-base-content/65">
|
||||||
|
Publish a clear public plan. Join requests stay pending until you approve them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mt-6 text-sm">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>
|
||||||
|
Use a public first meeting point. Do not publish a home address, access code, travel document, or other sensitive information.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.form
|
||||||
|
for={@form}
|
||||||
|
id="activity-form"
|
||||||
|
phx-change="validate"
|
||||||
|
phx-submit="save"
|
||||||
|
class="mt-8 space-y-5"
|
||||||
|
>
|
||||||
|
<.input
|
||||||
|
field={@form[:category_id]}
|
||||||
|
type="select"
|
||||||
|
label="Activity type"
|
||||||
|
prompt="Choose activity"
|
||||||
|
options={
|
||||||
|
Enum.map(
|
||||||
|
@categories,
|
||||||
|
&{Catalog.category_path(&1, @current_scope.user.locale), &1.id}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
:if={@selected_category}
|
||||||
|
id="selected-activity-category-description"
|
||||||
|
class="-mt-3 text-sm text-base-content/60"
|
||||||
|
>
|
||||||
|
{@selected_category.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={@structured_fields != []}
|
||||||
|
class="space-y-4 rounded-2xl border border-info/30 bg-info/5 p-5"
|
||||||
|
>
|
||||||
|
<h2 class="font-bold">Activity details</h2>
|
||||||
|
<div :for={field <- @structured_fields}>
|
||||||
|
<label class="fieldset">
|
||||||
|
<span class="label">{field_label(field, @current_scope.user.locale)}</span>
|
||||||
|
<select
|
||||||
|
:if={field["type"] == "select"}
|
||||||
|
name={"activity[structured_data][#{field["key"]}]"}
|
||||||
|
required={field["required"] == true}
|
||||||
|
class="select w-full"
|
||||||
|
>
|
||||||
|
<option value="">Choose…</option>
|
||||||
|
<option
|
||||||
|
:for={option <- field["options"] || []}
|
||||||
|
value={option_value(option)}
|
||||||
|
selected={
|
||||||
|
to_string(structured_value(@form, field["key"])) ==
|
||||||
|
to_string(option_value(option))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option_label(option, @current_scope.user.locale)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
:if={field["type"] == "text"}
|
||||||
|
type="text"
|
||||||
|
name={"activity[structured_data][#{field["key"]}]"}
|
||||||
|
value={structured_value(@form, field["key"])}
|
||||||
|
required={field["required"] == true}
|
||||||
|
maxlength={field["max_length"]}
|
||||||
|
class="input w-full"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
:if={field["type"] == "boolean"}
|
||||||
|
name={"activity[structured_data][#{field["key"]}]"}
|
||||||
|
required={field["required"] == true}
|
||||||
|
class="select w-full"
|
||||||
|
>
|
||||||
|
<option value="">Choose…</option>
|
||||||
|
<option value="true" selected={structured_value(@form, field["key"]) == "true"}>
|
||||||
|
Yes
|
||||||
|
</option>
|
||||||
|
<option value="false" selected={structured_value(@form, field["key"]) == "false"}>
|
||||||
|
No
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p :for={{message, _opts} <- @form[:structured_data].errors} class="text-sm text-error">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.input field={@form[:title]} label="Short title" placeholder="Coffee and conversation" />
|
||||||
|
<.input
|
||||||
|
field={@form[:description]}
|
||||||
|
type="textarea"
|
||||||
|
label="Plan and expectations"
|
||||||
|
placeholder="Describe the public plan, approximate duration, and who might enjoy it."
|
||||||
|
/>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<.input field={@form[:starts_at]} type="datetime-local" label="Starts at (UTC)" />
|
||||||
|
<.input
|
||||||
|
field={@form[:join_deadline]}
|
||||||
|
type="datetime-local"
|
||||||
|
label="Join requests close (UTC)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.input field={@form[:capacity]} type="number" min="2" label="Total group capacity" />
|
||||||
|
<.input
|
||||||
|
field={@form[:location_label]}
|
||||||
|
label="Approximate public meeting area"
|
||||||
|
placeholder="City centre, near the main entrance"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:location_visibility]}
|
||||||
|
type="select"
|
||||||
|
label="Public map visibility"
|
||||||
|
options={[
|
||||||
|
{"Approximate area", "approximate_public"},
|
||||||
|
{"Hidden", "hidden"}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl bg-base-200 p-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-hook="LocationPicker"
|
||||||
|
id="activity-location-picker"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
Use my current foreground location
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-xs text-base-content/60">
|
||||||
|
Exact coordinates are shown only to participants you approve.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<.input
|
||||||
|
field={@form[:latitude]}
|
||||||
|
id="activity-latitude"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
label="Latitude"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:longitude]}
|
||||||
|
id="activity-longitude"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
label="Longitude"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm">
|
||||||
|
<input type="checkbox" required class="checkbox checkbox-info mt-0.5" />
|
||||||
|
<span>
|
||||||
|
I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<.button class="btn btn-info btn-lg w-full" phx-disable-with="Publishing…">
|
||||||
|
Publish activity
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
359
lib/who_need_help_web/live/activity_live/show.ex
Normal file
359
lib/who_need_help_web/live/activity_live/show.ex
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Activities
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
|
case Activities.get_activity(socket.assigns.current_scope, id) do
|
||||||
|
{:ok, activity} ->
|
||||||
|
if connected?(socket), do: Activities.subscribe_activity(activity.id)
|
||||||
|
{:ok, load(socket, activity)}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Activity is unavailable.")
|
||||||
|
|> push_navigate(to: ~p"/activities")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({event, _payload}, socket)
|
||||||
|
when event in [
|
||||||
|
:activity_updated,
|
||||||
|
:join_requested,
|
||||||
|
:participant_approved,
|
||||||
|
:participant_declined,
|
||||||
|
:participant_left,
|
||||||
|
:activity_message
|
||||||
|
] do
|
||||||
|
{:noreply, reload(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("join", _params, socket) do
|
||||||
|
respond(
|
||||||
|
socket,
|
||||||
|
Activities.request_to_join(socket.assigns.current_scope, socket.assigns.activity.id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("approve", %{"id" => id}, socket) do
|
||||||
|
respond(socket, Activities.approve_participant(socket.assigns.current_scope, id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("decline", %{"id" => id}, socket) do
|
||||||
|
respond(socket, Activities.decline_participant(socket.assigns.current_scope, id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("leave", _params, socket) do
|
||||||
|
respond(
|
||||||
|
socket,
|
||||||
|
Activities.leave_activity(socket.assigns.current_scope, socket.assigns.activity.id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("cancel", _params, socket) do
|
||||||
|
respond(
|
||||||
|
socket,
|
||||||
|
Activities.cancel_activity(socket.assigns.current_scope, socket.assigns.activity.id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("complete", _params, socket) do
|
||||||
|
respond(
|
||||||
|
socket,
|
||||||
|
Activities.complete_activity(socket.assigns.current_scope, socket.assigns.activity.id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("send-message", %{"activity_message" => params}, socket) do
|
||||||
|
case Activities.send_message(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.activity.id,
|
||||||
|
params
|
||||||
|
) do
|
||||||
|
{:ok, _message} ->
|
||||||
|
{:noreply, reload(socket)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp respond(socket, {:ok, _value}), do: {:noreply, reload(socket)}
|
||||||
|
|
||||||
|
defp respond(socket, {:error, reason}) do
|
||||||
|
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reload(socket) do
|
||||||
|
case Activities.get_activity(socket.assigns.current_scope, socket.assigns.activity.id) do
|
||||||
|
{:ok, activity} ->
|
||||||
|
load(socket, activity)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Activity is unavailable.")
|
||||||
|
|> push_navigate(to: ~p"/activities")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load(socket, activity) do
|
||||||
|
user = socket.assigns.current_scope.user
|
||||||
|
viewer_participant = Activities.participant_for(activity, user.id)
|
||||||
|
organizer? = activity.creator_id == user.id
|
||||||
|
approved? = Activities.approved_participant?(activity, user.id)
|
||||||
|
coordinates = Activities.coordinates_for(socket.assigns.current_scope, activity)
|
||||||
|
|
||||||
|
markers =
|
||||||
|
case coordinates do
|
||||||
|
nil ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
coordinates ->
|
||||||
|
[
|
||||||
|
Map.merge(coordinates, %{
|
||||||
|
id: activity.id,
|
||||||
|
title: activity.title,
|
||||||
|
location: activity.location_label
|
||||||
|
})
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, activity.title)
|
||||||
|
|> assign(:activity, activity)
|
||||||
|
|> assign(:viewer_participant, viewer_participant)
|
||||||
|
|> assign(:organizer?, organizer?)
|
||||||
|
|> assign(:approved?, approved?)
|
||||||
|
|> assign(:coordinates, coordinates)
|
||||||
|
|> assign(:markers, Jason.encode!(markers))
|
||||||
|
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_request_join?(activity, nil), do: activity.status == :open
|
||||||
|
|
||||||
|
defp can_request_join?(activity, participant) do
|
||||||
|
activity.status == :open and participant.status in [:declined, :left]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending_participants(activity) do
|
||||||
|
Enum.filter(activity.participants, &(&1.status == :requested))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp approved_participants(activity) do
|
||||||
|
Enum.filter(activity.participants, &(&1.status == :approved))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime)
|
||||||
|
|
||||||
|
defp error_message(:already_joined), do: "You already have a join request for this activity."
|
||||||
|
defp error_message(:capacity_reached), do: "The approved group has reached its capacity."
|
||||||
|
defp error_message(:join_closed), do: "Join requests are closed."
|
||||||
|
defp error_message(:blocked), do: "This action is unavailable."
|
||||||
|
defp error_message(:forbidden), do: "You are not allowed to do that."
|
||||||
|
defp error_message(:invalid_transition), do: "This activity state has already changed."
|
||||||
|
defp error_message(:not_open), do: "This activity is no longer open."
|
||||||
|
defp error_message(:rate_limited), do: "Too many actions in the configured time window."
|
||||||
|
defp error_message(%Ecto.Changeset{}), do: "Please check the submitted message."
|
||||||
|
defp error_message(reason), do: "Could not complete the action: #{inspect(reason)}"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm mb-4">
|
||||||
|
← Back to activities
|
||||||
|
</.link>
|
||||||
|
|
||||||
|
<div class="grid gap-7 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<main class="space-y-6">
|
||||||
|
<section class="rounded-3xl border border-base-300 p-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-info badge-outline">
|
||||||
|
{WhoNeedHelp.Catalog.category_path(
|
||||||
|
@activity.category,
|
||||||
|
@current_scope.user.locale
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span class="badge">{@activity.status}</span>
|
||||||
|
<span class="badge badge-outline">
|
||||||
|
{length(approved_participants(@activity))}/{@activity.capacity} approved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-4 text-4xl font-black">{@activity.title}</h1>
|
||||||
|
<p class="mt-4 whitespace-pre-wrap text-base-content/70">{@activity.description}</p>
|
||||||
|
|
||||||
|
<dl class="mt-6 grid gap-4 rounded-2xl bg-base-200 p-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-base-content/50">Starts</dt>
|
||||||
|
<dd class="font-semibold">
|
||||||
|
{Calendar.strftime(@activity.starts_at, "%d %b %Y, %H:%M UTC")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-base-content/50">Join requests close</dt>
|
||||||
|
<dd class="font-semibold">
|
||||||
|
{Calendar.strftime(@activity.join_deadline, "%d %b %Y, %H:%M UTC")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-base-content/50">Meeting area</dt>
|
||||||
|
<dd class="font-semibold">{@activity.location_label}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-base-content/50">Organizer</dt>
|
||||||
|
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div :if={map_size(@activity.structured_data) > 0} class="mt-5">
|
||||||
|
<h2 class="font-bold">Plan details</h2>
|
||||||
|
<dl class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
:for={{key, value} <- @activity.structured_data}
|
||||||
|
class="rounded-xl border border-base-300 p-3"
|
||||||
|
>
|
||||||
|
<dt class="text-xs text-base-content/50">{String.replace(key, "_", " ")}</dt>
|
||||||
|
<dd class="font-medium">{to_string(value)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section :if={@approved?} class="rounded-3xl border border-info/30 bg-info/5 p-6">
|
||||||
|
<h2 class="text-xl font-bold">Approved group chat</h2>
|
||||||
|
<p class="mt-1 text-sm text-base-content/60">
|
||||||
|
Only the organizer and approved participants can read or send these messages.
|
||||||
|
</p>
|
||||||
|
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto">
|
||||||
|
<p :if={@activity.messages == []} class="text-sm text-base-content/50">
|
||||||
|
No messages yet.
|
||||||
|
</p>
|
||||||
|
<article
|
||||||
|
:for={message <- ordered_messages(@activity)}
|
||||||
|
class="rounded-2xl bg-base-100 p-4"
|
||||||
|
>
|
||||||
|
<div class="text-xs font-semibold text-info">
|
||||||
|
{message.sender.display_name || "Participant"}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<.form
|
||||||
|
:if={@activity.status == :open}
|
||||||
|
for={@message_form}
|
||||||
|
id="activity-message-form"
|
||||||
|
phx-submit="send-message"
|
||||||
|
class="mt-4 flex gap-2"
|
||||||
|
>
|
||||||
|
<.input
|
||||||
|
field={@message_form[:body]}
|
||||||
|
placeholder="Message the approved group"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<.button class="btn btn-info self-end">Send</.button>
|
||||||
|
</.form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="space-y-5">
|
||||||
|
<div
|
||||||
|
id="activity-detail-map"
|
||||||
|
phx-hook="AidMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-markers={@markers}
|
||||||
|
class="aid-map"
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl bg-base-200 p-5 text-sm">
|
||||||
|
<h2 class="font-bold">Location privacy</h2>
|
||||||
|
<p class="mt-2 text-base-content/65">
|
||||||
|
<%= if @approved? do %>
|
||||||
|
You are approved, so the map can show the exact meeting coordinates.
|
||||||
|
<% else %>
|
||||||
|
The public map is approximate or hidden. Exact coordinates appear only after approval.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={not @organizer?} class="rounded-2xl border border-base-300 p-5">
|
||||||
|
<button
|
||||||
|
:if={can_request_join?(@activity, @viewer_participant)}
|
||||||
|
phx-click="join"
|
||||||
|
class="btn btn-info w-full"
|
||||||
|
>
|
||||||
|
Request to join
|
||||||
|
</button>
|
||||||
|
<div :if={@viewer_participant && @viewer_participant.status == :requested}>
|
||||||
|
<span class="badge badge-warning">Approval pending</span>
|
||||||
|
<p class="mt-2 text-sm text-base-content/60">
|
||||||
|
The organizer has not approved your request yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@viewer_participant && @viewer_participant.status == :approved}
|
||||||
|
phx-click="leave"
|
||||||
|
class="btn btn-outline btn-error w-full"
|
||||||
|
>
|
||||||
|
Leave activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section :if={@organizer?} class="rounded-2xl border border-info/30 p-5">
|
||||||
|
<h2 class="font-bold">Organizer controls</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<article
|
||||||
|
:for={participant <- pending_participants(@activity)}
|
||||||
|
class="rounded-xl bg-base-200 p-3"
|
||||||
|
>
|
||||||
|
<div class="font-semibold">{participant.user.display_name || "Community member"}</div>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
phx-click="approve"
|
||||||
|
phx-value-id={participant.id}
|
||||||
|
class="btn btn-info btn-xs"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
phx-click="decline"
|
||||||
|
phx-value-id={participant.id}
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<p :if={pending_participants(@activity) == []} class="text-sm text-base-content/50">
|
||||||
|
No pending join requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div :if={@activity.status == :open} class="mt-5 grid gap-2">
|
||||||
|
<button phx-click="complete" class="btn btn-success btn-sm">
|
||||||
|
Mark completed
|
||||||
|
</button>
|
||||||
|
<button phx-click="cancel" class="btn btn-outline btn-error btn-sm">
|
||||||
|
Cancel activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-base-200 p-5">
|
||||||
|
<h2 class="font-bold">Approved participants</h2>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
<li :for={participant <- approved_participants(@activity)}>
|
||||||
|
{participant.user.display_name || "Community member"}
|
||||||
|
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
||||||
|
organizer
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -43,7 +43,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Category proposals")
|
|> assign(:page_title, "Category proposals")
|
||||||
|> assign(:proposals, Catalog.list_proposals())
|
|> assign(:proposals, Catalog.list_proposals())
|
||||||
|> assign(:categories, Catalog.list_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -64,6 +64,12 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
label="Proposed category"
|
label="Proposed category"
|
||||||
placeholder="Bicycle puncture"
|
placeholder="Bicycle puncture"
|
||||||
/>
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:mode]}
|
||||||
|
type="select"
|
||||||
|
label="Mode"
|
||||||
|
options={[{"Urgent help", "help"}, {"Social activity", "activity"}]}
|
||||||
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:parent_id]}
|
field={@form[:parent_id]}
|
||||||
type="select"
|
type="select"
|
||||||
|
|
@ -94,6 +100,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold">{proposal.proposed_name}</h3>
|
<h3 class="text-lg font-bold">{proposal.proposed_name}</h3>
|
||||||
|
<span class="badge badge-sm badge-outline">{proposal.mode}</span>
|
||||||
<p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p>
|
<p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p>
|
||||||
<p class="mt-3 text-xs text-base-content/45">by {proposal.proposer.display_name}</p>
|
<p class="mt-3 text-xs text-base-content/45">by {proposal.proposer.display_name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
|> assign(:signals, Trust.list_abuse_signals(socket.assigns.current_scope))
|
|> assign(:signals, Trust.list_abuse_signals(socket.assigns.current_scope))
|
||||||
|> assign(:proposals, Catalog.list_proposals_for_moderation(socket.assigns.current_scope))
|
|> assign(:proposals, Catalog.list_proposals_for_moderation(socket.assigns.current_scope))
|
||||||
|> assign(:users, Accounts.list_users_for_moderation(socket.assigns.current_scope))
|
|> assign(:users, Accounts.list_users_for_moderation(socket.assigns.current_scope))
|
||||||
|> assign(:categories, Catalog.list_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_message(:forbidden), do: "Moderator access is required."
|
defp error_message(:forbidden), do: "Moderator access is required."
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
live "/requests", RequestLive.Index, :index
|
live "/requests", RequestLive.Index, :index
|
||||||
live "/requests/new", RequestLive.New, :new
|
live "/requests/new", RequestLive.New, :new
|
||||||
live "/requests/:id", RequestLive.Show, :show
|
live "/requests/:id", RequestLive.Show, :show
|
||||||
|
live "/activities", ActivityLive.Index, :index
|
||||||
|
live "/activities/new", ActivityLive.New, :new
|
||||||
|
live "/activities/:id", ActivityLive.Show, :show
|
||||||
live "/categories/proposals", CategoryProposalLive, :index
|
live "/categories/proposals", CategoryProposalLive, :index
|
||||||
live "/profile", ProfileLive, :edit
|
live "/profile", ProfileLive, :edit
|
||||||
live "/leaderboard", LeaderboardLive, :index
|
live "/leaderboard", LeaderboardLive, :index
|
||||||
|
|
|
||||||
84
priv/repo/migrations/20260718172944_create_activity_mode.exs
Normal file
84
priv/repo/migrations/20260718172944_create_activity_mode.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule WhoNeedHelp.Repo.Migrations.CreateActivityMode do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:categories) do
|
||||||
|
add :mode, :string, null: false, default: "help"
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:categories, [:mode, :active, :sort_order])
|
||||||
|
|
||||||
|
alter table(:category_proposals) do
|
||||||
|
add :mode, :string, null: false, default: "help"
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:category_proposals, [:mode, :status])
|
||||||
|
|
||||||
|
create table(:activities, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :title, :string, null: false
|
||||||
|
add :description, :text, null: false
|
||||||
|
add :structured_data, :map, null: false, default: %{}
|
||||||
|
add :location_label, :string, null: false
|
||||||
|
add :location, :geometry, null: false
|
||||||
|
add :location_visibility, :string, null: false, default: "approximate_public"
|
||||||
|
add :status, :string, null: false, default: "open"
|
||||||
|
add :starts_at, :utc_datetime, null: false
|
||||||
|
add :join_deadline, :utc_datetime, null: false
|
||||||
|
add :capacity, :integer, null: false
|
||||||
|
add :cancelled_at, :utc_datetime
|
||||||
|
add :completed_at, :utc_datetime
|
||||||
|
add :hidden_at, :utc_datetime
|
||||||
|
add :hidden_reason, :text
|
||||||
|
add :creator_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
|
||||||
|
|
||||||
|
add :category_id, references(:categories, type: :binary_id, on_delete: :restrict),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:activities, [:creator_id, :starts_at])
|
||||||
|
create index(:activities, [:category_id, :status, :starts_at])
|
||||||
|
create index(:activities, [:status, :hidden_at, :join_deadline])
|
||||||
|
create index(:activities, [:location], using: :gist)
|
||||||
|
create constraint(:activities, :activity_capacity_at_least_two, check: "capacity >= 2")
|
||||||
|
|
||||||
|
create constraint(:activities, :activity_join_before_start,
|
||||||
|
check: "join_deadline <= starts_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
create table(:activity_participants, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :role, :string, null: false, default: "participant"
|
||||||
|
add :status, :string, null: false, default: "requested"
|
||||||
|
add :reviewed_at, :utc_datetime
|
||||||
|
add :left_at, :utc_datetime
|
||||||
|
|
||||||
|
add :activity_id, references(:activities, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :user_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:activity_participants, [:activity_id, :user_id])
|
||||||
|
create index(:activity_participants, [:user_id, :status])
|
||||||
|
create index(:activity_participants, [:activity_id, :status])
|
||||||
|
|
||||||
|
create table(:activity_messages, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :body, :text, null: false
|
||||||
|
|
||||||
|
add :activity_id, references(:activities, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :sender_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:activity_messages, [:activity_id, :inserted_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
158
test/who_need_help/activities_test.exs
Normal file
158
test/who_need_help/activities_test.exs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
|
use WhoNeedHelp.DataCase, async: false
|
||||||
|
|
||||||
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
|
alias WhoNeedHelp.{Activities, Catalog, Trust}
|
||||||
|
alias WhoNeedHelp.Activities.Activity
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
organizer = user_fixture(display_name: "Organizer")
|
||||||
|
participant = user_fixture(display_name: "Participant")
|
||||||
|
outsider = user_fixture(display_name: "Outsider")
|
||||||
|
|
||||||
|
coffee =
|
||||||
|
Catalog.list_categories(:activity)
|
||||||
|
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
"title" => "Coffee and conversation",
|
||||||
|
"description" => "Meet at a public café for conversation and tea.",
|
||||||
|
"structured_data" => %{"setting" => "cafe"},
|
||||||
|
"location_label" => "Central square",
|
||||||
|
"latitude" => "50.4501",
|
||||||
|
"longitude" => "30.5234",
|
||||||
|
"location_visibility" => "approximate_public",
|
||||||
|
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||||
|
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
|
||||||
|
"capacity" => 2,
|
||||||
|
"category_id" => coffee.id
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
organizer: organizer,
|
||||||
|
participant: participant,
|
||||||
|
outsider: outsider,
|
||||||
|
organizer_scope: user_scope_fixture(organizer),
|
||||||
|
participant_scope: user_scope_fixture(participant),
|
||||||
|
outsider_scope: user_scope_fixture(outsider),
|
||||||
|
coffee: coffee,
|
||||||
|
attrs: attrs}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "organizer approves a participant and the approved group can chat", context do
|
||||||
|
assert {:ok, activity} =
|
||||||
|
Activities.create_activity(context.organizer_scope, context.attrs)
|
||||||
|
|
||||||
|
assert [%{role: :organizer, status: :approved}] = activity.participants
|
||||||
|
|
||||||
|
assert Activity.public_coordinates(activity) == %{
|
||||||
|
latitude: 50.45,
|
||||||
|
longitude: 30.52,
|
||||||
|
exact: false
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Activities.coordinates_for(context.outsider_scope, activity).exact == false
|
||||||
|
assert Activities.coordinates_for(context.organizer_scope, activity).exact == true
|
||||||
|
|
||||||
|
assert {:error, :forbidden} =
|
||||||
|
Activities.send_message(context.outsider_scope, activity.id, %{"body" => "Hello"})
|
||||||
|
|
||||||
|
assert {:ok, request} =
|
||||||
|
Activities.request_to_join(context.participant_scope, activity.id)
|
||||||
|
|
||||||
|
assert request.status == :requested
|
||||||
|
|
||||||
|
assert {:ok, approved} =
|
||||||
|
Activities.approve_participant(context.organizer_scope, request.id)
|
||||||
|
|
||||||
|
assert approved.status == :approved
|
||||||
|
|
||||||
|
assert {:ok, message} =
|
||||||
|
Activities.send_message(context.participant_scope, activity.id, %{
|
||||||
|
"body" => "See you at the café."
|
||||||
|
})
|
||||||
|
|
||||||
|
assert message.body == "See you at the café."
|
||||||
|
|
||||||
|
assert {:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
|
||||||
|
assert [%{body: "See you at the café."}] = loaded.messages
|
||||||
|
assert Activities.coordinates_for(context.participant_scope, loaded).exact == true
|
||||||
|
|
||||||
|
assert {:error, :capacity_reached} =
|
||||||
|
Activities.request_to_join(context.outsider_scope, activity.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unapproved viewers receive no private chat or pending participant data", context do
|
||||||
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||||
|
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
|
||||||
|
|
||||||
|
assert {:ok, public_activity} =
|
||||||
|
Activities.get_activity(context.outsider_scope, activity.id)
|
||||||
|
|
||||||
|
assert public_activity.messages == []
|
||||||
|
assert Enum.all?(public_activity.participants, &(&1.status == :approved))
|
||||||
|
refute Enum.any?(public_activity.participants, &(&1.id == request.id))
|
||||||
|
|
||||||
|
assert {:ok, requester_view} =
|
||||||
|
Activities.get_activity(context.participant_scope, activity.id)
|
||||||
|
|
||||||
|
assert requester_view.messages == []
|
||||||
|
assert Enum.any?(requester_view.participants, &(&1.id == request.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "activity completion never changes urgent-help reputation", context do
|
||||||
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||||
|
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
|
||||||
|
{:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
|
||||||
|
assert {:ok, completed} = Activities.complete_activity(context.organizer_scope, activity.id)
|
||||||
|
assert completed.status == :completed
|
||||||
|
|
||||||
|
assert Trust.reputation(context.organizer.id).completed == 0
|
||||||
|
assert Trust.reputation(context.participant.id).completed == 0
|
||||||
|
assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id)
|
||||||
|
assert {:ok, _activity} = Activities.get_activity(context.participant_scope, activity.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "activity category and structured fields are enforced", context do
|
||||||
|
hiking =
|
||||||
|
Catalog.list_categories(:activity)
|
||||||
|
|> Enum.find(&(&1.slug == "hiking-group"))
|
||||||
|
|
||||||
|
invalid =
|
||||||
|
context.attrs
|
||||||
|
|> Map.put("category_id", hiking.id)
|
||||||
|
|> Map.put("structured_data", %{
|
||||||
|
"difficulty" => "easy",
|
||||||
|
"route_plan" => "Marked public trail"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, changeset} =
|
||||||
|
Activities.create_activity(context.organizer_scope, invalid)
|
||||||
|
|
||||||
|
assert "safety_plan_confirmed is required" in errors_on(changeset).structured_data
|
||||||
|
|
||||||
|
help_category = Catalog.seed_defaults()
|
||||||
|
|
||||||
|
invalid =
|
||||||
|
context.attrs
|
||||||
|
|> Map.put("category_id", help_category.id)
|
||||||
|
|> Map.put("structured_data", %{"pickup_status" => "reserved"})
|
||||||
|
|
||||||
|
assert {:error, changeset} =
|
||||||
|
Activities.create_activity(context.organizer_scope, invalid)
|
||||||
|
|
||||||
|
assert "select an activity category" in errors_on(changeset).category_id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "blocking prevents activity discovery and joining", context do
|
||||||
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||||
|
assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
|
||||||
|
assert [] = Activities.list_open_activities(context.participant_scope)
|
||||||
|
|
||||||
|
assert {:error, :blocked} =
|
||||||
|
Activities.request_to_join(context.participant_scope, activity.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -138,6 +138,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
|
|
||||||
assert proposal_export["proposal_id"] == proposal.id
|
assert proposal_export["proposal_id"] == proposal.id
|
||||||
assert proposal_export["proposed_name"] == "Bicycle repair"
|
assert proposal_export["proposed_name"] == "Bicycle repair"
|
||||||
|
assert proposal_export["mode"] == "help"
|
||||||
assert proposal_export["community_votes"] == 0
|
assert proposal_export["community_votes"] == 0
|
||||||
|
|
||||||
refute export =~ context.requester.email
|
refute export =~ context.requester.email
|
||||||
|
|
@ -186,7 +187,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
|
|
||||||
assert context.category.id == Catalog.seed_defaults().id
|
assert context.category.id == Catalog.seed_defaults().id
|
||||||
assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id})
|
assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id})
|
||||||
assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 9
|
assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 14
|
||||||
end
|
end
|
||||||
|
|
||||||
test "roadside structured data is enforced when creating a request", context do
|
test "roadside structured data is enforced when creating a request", context do
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import WhoNeedHelp.AccountsFixtures
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
alias WhoNeedHelp.{Catalog, Help, Messaging, Trust}
|
alias WhoNeedHelp.{Activities, Catalog, Help, Messaging, Trust}
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
setup :register_and_log_in_user
|
setup :register_and_log_in_user
|
||||||
|
|
@ -68,6 +68,87 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "activity form is category-driven and separate from urgent help", %{conn: conn} do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
hiking =
|
||||||
|
Catalog.list_categories(:activity)
|
||||||
|
|> Enum.find(&(&1.slug == "hiking-group"))
|
||||||
|
|
||||||
|
{:ok, view, html} = live(conn, ~p"/activities/new")
|
||||||
|
assert html =~ "Social activity — Hiking"
|
||||||
|
refute html =~ "Medicine pickup status"
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_change(view, "validate", %{
|
||||||
|
"activity" => %{"category_id" => hiking.id}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ "Route difficulty"
|
||||||
|
assert html =~ "activity[structured_data][route_plan]"
|
||||||
|
assert html =~ "activity[structured_data][safety_plan_confirmed]"
|
||||||
|
assert html =~ "approve participants individually"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "organizer approves an activity participant and group chat updates live", %{
|
||||||
|
conn: organizer_conn,
|
||||||
|
scope: organizer_scope
|
||||||
|
} do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
participant = user_fixture(display_name: "Activity participant")
|
||||||
|
participant_conn = build_conn() |> log_in_user(participant)
|
||||||
|
|
||||||
|
coffee =
|
||||||
|
Catalog.list_categories(:activity)
|
||||||
|
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
"title" => "Public coffee meetup",
|
||||||
|
"description" => "Meet at a public café for tea and conversation.",
|
||||||
|
"structured_data" => %{"setting" => "cafe"},
|
||||||
|
"location_label" => "Central square",
|
||||||
|
"latitude" => "50.4501",
|
||||||
|
"longitude" => "30.5234",
|
||||||
|
"location_visibility" => "approximate_public",
|
||||||
|
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||||
|
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
|
||||||
|
"capacity" => 3,
|
||||||
|
"category_id" => coffee.id
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, activity} = Activities.create_activity(organizer_scope, attrs)
|
||||||
|
{:ok, organizer_view, _html} = live(organizer_conn, ~p"/activities/#{activity.id}")
|
||||||
|
{:ok, participant_view, html} = live(participant_conn, ~p"/activities/#{activity.id}")
|
||||||
|
assert html =~ "Request to join"
|
||||||
|
refute html =~ "Approved group chat"
|
||||||
|
|
||||||
|
participant_view
|
||||||
|
|> element("button[phx-click='join']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert render(participant_view) =~ "Approval pending"
|
||||||
|
assert render(organizer_view) =~ "Activity participant"
|
||||||
|
|
||||||
|
organizer_view
|
||||||
|
|> element("button[phx-click='approve']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert render(participant_view) =~ "Approved group chat"
|
||||||
|
|
||||||
|
html =
|
||||||
|
participant_view
|
||||||
|
|> form("form[phx-submit='send-message']", %{
|
||||||
|
"activity_message" => %{"body" => "I will be there."}
|
||||||
|
})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "I will be there."
|
||||||
|
assert render(organizer_view) =~ "I will be there."
|
||||||
|
|
||||||
|
stop_live_view(organizer_view)
|
||||||
|
stop_live_view(participant_view)
|
||||||
|
end
|
||||||
|
|
||||||
test "views opened before matching subscribe to the private chat after assignment", %{
|
test "views opened before matching subscribe to the private chat after assignment", %{
|
||||||
conn: requester_conn,
|
conn: requester_conn,
|
||||||
scope: requester_scope
|
scope: requester_scope
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user