From 6484b310664d7a4e1aadfd32d81f91daf975b59d Mon Sep 17 00:00:00 2001 From: Max Veytsman Date: Tue, 20 Oct 2026 22:11:55 -0400 Subject: [PATCH 1/2] Add feature flags for users Mirror the existing rider flags: a User.Flags embedded schema stored in a new users.flags column, editable by admins in a "Flags" section on the user form, and rolled out with `mix feature_flags users `. The first flag, unified_messaging, will gate the new messaging inbox. The user form now validates with the admin changeset so admin-only fields (dispatcher, flags) keep their state while the form is being edited. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/bike_brigade/accounts.ex | 8 +++ lib/bike_brigade/accounts/user.ex | 17 +++++++ .../live/user_live/form_component.ex | 4 +- .../live/user_live/form_component.html.heex | 22 ++++++++ lib/mix/tasks/feature_flags.ex | 50 ++++++++++++------- .../20261001000000_add_flags_to_users.exs | 9 ++++ test/bike_brigade/accounts_test.exs | 11 ++++ test/bike_brigade_web/live/user_live_test.exs | 24 +++++++++ test/mix/tasks/feature_flags_test.exs | 22 ++++++++ 9 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 priv/repo/migrations/20261001000000_add_flags_to_users.exs create mode 100644 test/mix/tasks/feature_flags_test.exs diff --git a/lib/bike_brigade/accounts.ex b/lib/bike_brigade/accounts.ex index 40078975..b5ec4340 100644 --- a/lib/bike_brigade/accounts.ex +++ b/lib/bike_brigade/accounts.ex @@ -188,6 +188,14 @@ defmodule BikeBrigade.Accounts do User.changeset(user, attrs) end + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes made by an admin, + including dispatcher status and feature flags. + """ + def change_user_as_admin(%User{} = user, attrs \\ %{}) do + User.admin_changeset(user, attrs) + end + def get_dispatcher_phone_numbers() do query = from u in User, diff --git a/lib/bike_brigade/accounts/user.ex b/lib/bike_brigade/accounts/user.ex index da42d01a..fe5178ca 100644 --- a/lib/bike_brigade/accounts/user.ex +++ b/lib/bike_brigade/accounts/user.ex @@ -6,6 +6,21 @@ defmodule BikeBrigade.Accounts.User do alias BikeBrigade.EctoPhoneNumber + defmodule Flags do + use BikeBrigade.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :unified_messaging, :boolean, default: false + end + + def changeset(flags, attrs \\ %{}) do + flags + |> cast(attrs, [:unified_messaging]) + end + end + schema "users" do field :email, :string field :name, :string @@ -13,6 +28,7 @@ defmodule BikeBrigade.Accounts.User do field :is_dispatcher, :boolean, default: false field :signature_on_messages, :string belongs_to :rider, Rider + embeds_one :flags, Flags, on_replace: :update, defaults_to_struct: true timestamps() end @@ -31,5 +47,6 @@ defmodule BikeBrigade.Accounts.User do user |> changeset(attrs) |> cast(attrs, [:is_dispatcher, :rider_id]) + |> cast_embed(:flags) end end diff --git a/lib/bike_brigade_web/live/user_live/form_component.ex b/lib/bike_brigade_web/live/user_live/form_component.ex index 4f17525c..3adf7f05 100644 --- a/lib/bike_brigade_web/live/user_live/form_component.ex +++ b/lib/bike_brigade_web/live/user_live/form_component.ex @@ -7,7 +7,7 @@ defmodule BikeBrigadeWeb.UserLive.FormComponent do @impl true def update(%{user: user} = assigns, socket) do user = Repo.preload(user, :rider) - changeset = Accounts.change_user(user) + changeset = Accounts.change_user_as_admin(user) {:ok, socket @@ -20,7 +20,7 @@ defmodule BikeBrigadeWeb.UserLive.FormComponent do def handle_event("validate", %{"user" => user_params}, socket) do changeset = socket.assigns.user - |> Accounts.change_user(user_params) + |> Accounts.change_user_as_admin(user_params) |> Map.put(:action, :validate) {:noreply, assign(socket, :changeset, changeset)} diff --git a/lib/bike_brigade_web/live/user_live/form_component.html.heex b/lib/bike_brigade_web/live/user_live/form_component.html.heex index 3fa6f0cc..4cbf58e7 100644 --- a/lib/bike_brigade_web/live/user_live/form_component.html.heex +++ b/lib/bike_brigade_web/live/user_live/form_component.html.heex @@ -52,6 +52,28 @@ <% end %> +
+
+

+ Flags +

+

+ Opt the user into various features +

+
+ + <.inputs_for :let={flag_form} field={f[:flags]}> +
+ <.input + type="checkbox" + field={flag_form[:unified_messaging]} + label="Unified messaging inbox" + help_text="Use the new messaging inbox with campaign rosters and saved bulk messages." + /> +
+ +
+ <:actions> <.button type="submit" phx-disable-with="Saving...">Save diff --git a/lib/mix/tasks/feature_flags.ex b/lib/mix/tasks/feature_flags.ex index 47b756d6..eb76134e 100644 --- a/lib/mix/tasks/feature_flags.ex +++ b/lib/mix/tasks/feature_flags.ex @@ -1,38 +1,50 @@ defmodule Mix.Tasks.FeatureFlags do use Mix.Task - alias BikeBrigade.Riders + alias BikeBrigade.{Accounts, Riders} # TODO this should live somewhere else as gigalixir doesn't let me run tasks directly @requirements ["app.start"] - @shortdoc "Opt riders into a feature flag" + @shortdoc "Opt riders or users into a feature flag" + + @moduledoc """ + Rolls a feature flag out to a percentage of riders or users. + + mix feature_flags <0|25|50|75|100> + mix feature_flags users <0|25|50|75|100> + + Buckets are deterministic per flag and ID. `0` turns the flag off for everyone; + other percentages only turn it on, so individual opt-ins are kept. + """ + + @percentages ["0", "25", "50", "75", "100"] @impl Mix.Task - def run([flag, opt_in]) when opt_in in ["0", "25", "50", "75", "100"] do + def run(["users", flag, opt_in]) when opt_in in @percentages do + rollout(Accounts.list_users(), flag, opt_in, &Accounts.update_user_as_admin/2) + end + + def run([flag, opt_in]) when opt_in in @percentages do + rollout(Riders.list_riders(), flag, opt_in, &Riders.update_rider/2) + end + + defp rollout(records, flag, opt_in, update) do flag = String.to_atom(flag) case opt_in do "0" -> - for r <- Riders.list_riders(), do: Riders.update_rider(r, %{flags: %{flag => false}}) - - "25" -> - for r <- Riders.list_riders(), - opt_in(flag, r.id, 0.25), - do: Riders.update_rider(r, %{flags: %{flag => true}}) + for r <- records, do: update.(r, %{flags: %{flag => false}}) - "50" -> - for r <- Riders.list_riders(), - opt_in(flag, r.id, 0.50), - do: Riders.update_rider(r, %{flags: %{flag => true}}) + "100" -> + for r <- records, do: update.(r, %{flags: %{flag => true}}) - "75" -> - for r <- Riders.list_riders(), - opt_in(flag, r.id, 0.75), - do: Riders.update_rider(r, %{flags: %{flag => true}}) + _ -> + percentage = String.to_integer(opt_in) / 100 - "100" -> - for r <- Riders.list_riders(), do: Riders.update_rider(r, %{flags: %{flag => true}}) + for r <- records, + opt_in(flag, r.id, percentage), + do: update.(r, %{flags: %{flag => true}}) end end diff --git a/priv/repo/migrations/20261001000000_add_flags_to_users.exs b/priv/repo/migrations/20261001000000_add_flags_to_users.exs new file mode 100644 index 00000000..c2383f92 --- /dev/null +++ b/priv/repo/migrations/20261001000000_add_flags_to_users.exs @@ -0,0 +1,9 @@ +defmodule BikeBrigade.Repo.Migrations.AddFlagsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :flags, :map, null: false, default: %{} + end + end +end diff --git a/test/bike_brigade/accounts_test.exs b/test/bike_brigade/accounts_test.exs index 6edf2c39..7a39c99d 100644 --- a/test/bike_brigade/accounts_test.exs +++ b/test/bike_brigade/accounts_test.exs @@ -95,5 +95,16 @@ defmodule BikeBrigade.AccountsTest do user = fixture(:user) assert user.signature_on_messages == nil end + + test "flags default to off and can be set by admins" do + user = fixture(:user) + refute Accounts.get_user(user.id).flags.unified_messaging + + {:ok, user} = Accounts.update_user_as_admin(user, %{flags: %{unified_messaging: true}}) + assert user.flags.unified_messaging + + {:ok, user} = Accounts.update_user(user, %{flags: %{unified_messaging: false}}) + assert user.flags.unified_messaging + end end end diff --git a/test/bike_brigade_web/live/user_live_test.exs b/test/bike_brigade_web/live/user_live_test.exs index b5146610..3df75dc4 100644 --- a/test/bike_brigade_web/live/user_live_test.exs +++ b/test/bike_brigade_web/live/user_live_test.exs @@ -62,6 +62,30 @@ defmodule BikeBrigadeWeb.UserLiveTest do assert Floki.text(rider_row) =~ "🚴" end + test "Can opt a user into a feature flag", ctx do + {:ok, view, _html} = live(ctx.conn, ~p"/users/#{ctx.user.id}/edit") + + refute has_element?( + view, + "#user-form input[type=checkbox][name*=unified_messaging][checked]" + ) + + view + |> form("#user-form", user: %{flags: %{unified_messaging: "true"}}) + |> render_change() + + assert has_element?( + view, + "#user-form input[type=checkbox][name*=unified_messaging][checked]" + ) + + view + |> form("#user-form", user: %{flags: %{unified_messaging: "true"}}) + |> render_submit() + + assert BikeBrigade.Accounts.get_user(ctx.user.id).flags.unified_messaging + end + test "Can make a user into a dispatcher", ctx do # first, upgrade a rider to a user. rider = fixture(:rider) diff --git a/test/mix/tasks/feature_flags_test.exs b/test/mix/tasks/feature_flags_test.exs new file mode 100644 index 00000000..d50e6f46 --- /dev/null +++ b/test/mix/tasks/feature_flags_test.exs @@ -0,0 +1,22 @@ +defmodule Mix.Tasks.FeatureFlagsTest do + use BikeBrigade.DataCase + + alias BikeBrigade.Accounts + + test "rolls a flag out to users" do + user = fixture(:user) + + Mix.Tasks.FeatureFlags.run(["users", "unified_messaging", "100"]) + assert Accounts.get_user(user.id).flags.unified_messaging + + Mix.Tasks.FeatureFlags.run(["users", "unified_messaging", "0"]) + refute Accounts.get_user(user.id).flags.unified_messaging + end + + test "rolls a flag out to riders" do + rider = fixture(:rider, %{flags: %{opt_in_to_new_number: false}}) + + Mix.Tasks.FeatureFlags.run(["opt_in_to_new_number", "100"]) + assert BikeBrigade.Riders.get_rider(rider.id).flags.opt_in_to_new_number + end +end From 0942419e8d7e2e6fe1d043751a4841b158df3aa0 Mon Sep 17 00:00:00 2001 From: Max Veytsman Date: Wed, 21 Oct 2026 01:13:07 -0400 Subject: [PATCH 2/2] Add an opt-in unified messaging inbox Dispatchers opted into the `unified_messaging` flag get a new inbox at /messages/inbox; everyone else keeps the existing messages page, which redirects opted-in users (including /messages/:id, /messages/new and /messages/:id/tasks links). - Three tabs: all conversations, this week's campaigns (optionally only campaigns the dispatcher leads), and saved bulk messages. Each rider still gets an individual conversation. - New bulk messages can be saved as a group (creator, original body and a snapshot of recipients), shared between dispatchers. "Message everyone" sends to a campaign roster or saved group; partial failures name the riders to retry individually. - A deliveries panel shows the selected rider's tasks from yesterday and today beside the chat on wide screens (a summary bar opens it on narrow screens) and lets dispatchers mark deliveries done. Campaign rosters show per-rider delivery progress. - PubSub events update only what changed (the affected conversation, roster row, delivery counts or group list) instead of reloading the page. - Bulk messaging lives in Messaging (send_bulk_message/3, change_bulk_message/2, list/create_bulk_message_group); campaign and delivery queries in Delivery (list_campaigns/1 :lead_id, delivery_progress_by_rider/1). The inbox is gated by a FeatureFlags on_mount hook. - Shared rider display helpers move to LiveHelpers, and the conversation list shows initials instead of gravatars on both messages pages. - priv/repo/seeds/messaging_inbox.exs seeds campaigns, assignments and conversations for trying it out locally. Co-Authored-By: Claude Opus 5.5 (1M context) --- assets/js/hooks/index.js | 4 +- assets/js/hooks/messaging_inbox.js | 22 + assets/js/hooks/rider_selection_list.js | 2 +- lib/bike_brigade/delivery.ex | 27 +- lib/bike_brigade/delivery/campaign.ex | 1 + lib/bike_brigade/messaging.ex | 96 ++- lib/bike_brigade/messaging/bulk_message.ex | 49 ++ .../messaging/bulk_message_group.ex | 25 + .../components/conversation_component.ex | 31 +- .../conversation_component.html.heex | 30 +- .../components/layouts/app.html.heex | 5 +- .../components/messaging_inbox.ex | 360 +++++++++ .../components/presence_components.ex | 57 ++ .../components/rider_deliveries_component.ex | 138 ++++ .../components/rider_selection_component.ex | 1 + .../components/sms_message_list_component.ex | 9 +- .../campaign_message_list.html.heex | 7 + lib/bike_brigade_web/live/live_helpers.ex | 16 + .../live/sms_message_live/form_component.ex | 4 - .../sms_message_live/form_component.html.heex | 2 +- .../live/sms_message_live/inbox.ex | 726 ++++++++++++++++++ .../live/sms_message_live/inbox.html.heex | 214 ++++++ .../live/sms_message_live/index.ex | 77 +- .../live_hooks/feature_flags.ex | 22 + lib/bike_brigade_web/router.ex | 1 + lib/mix/tasks/feature_flags.ex | 54 +- .../20261001000001_add_unified_messaging.exs | 23 + priv/repo/seeds/messaging_inbox.exs | 126 +++ test/bike_brigade/delivery_test.exs | 36 + test/bike_brigade/messaging_test.exs | 117 +++ .../live/messaging_inbox_live_test.exs | 439 +++++++++++ .../live/sms_message_live.exs | 27 - test/mix/tasks/feature_flags_test.exs | 19 + test/support/fixtures.ex | 16 + 34 files changed, 2663 insertions(+), 120 deletions(-) create mode 100644 assets/js/hooks/messaging_inbox.js create mode 100644 lib/bike_brigade/messaging/bulk_message.ex create mode 100644 lib/bike_brigade/messaging/bulk_message_group.ex create mode 100644 lib/bike_brigade_web/components/messaging_inbox.ex create mode 100644 lib/bike_brigade_web/components/presence_components.ex create mode 100644 lib/bike_brigade_web/components/rider_deliveries_component.ex create mode 100644 lib/bike_brigade_web/live/sms_message_live/inbox.ex create mode 100644 lib/bike_brigade_web/live/sms_message_live/inbox.html.heex create mode 100644 lib/bike_brigade_web/live_hooks/feature_flags.ex create mode 100644 priv/repo/migrations/20261001000001_add_unified_messaging.exs create mode 100644 priv/repo/seeds/messaging_inbox.exs create mode 100644 test/bike_brigade_web/live/messaging_inbox_live_test.exs delete mode 100644 test/bike_brigade_web/live/sms_message_live.exs diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index b8d869fc..ffb48e20 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -11,6 +11,7 @@ import StatsDashboardChart from './stats_dashboard_chart'; import Frame from './frame'; import TagsComponent from './tags_component'; import LeafletMap from './leaflet_map'; +import MessagingInbox from './messaging_inbox'; export default { @@ -26,5 +27,6 @@ export default { CheckboxAll, Frame, TagsComponent, - LeafletMap + LeafletMap, + MessagingInbox } diff --git a/assets/js/hooks/messaging_inbox.js b/assets/js/hooks/messaging_inbox.js new file mode 100644 index 00000000..507af575 --- /dev/null +++ b/assets/js/hooks/messaging_inbox.js @@ -0,0 +1,22 @@ +// Sizes the inbox workspace to fill the window below the tabs. +const MessagingInbox = { + mounted() { + this.resize = () => { + const pane = this.el.querySelector("#inbox-workspace"); + if (pane) { + const height = Math.max(320, window.innerHeight - pane.getBoundingClientRect().top - 24); + this.el.style.setProperty("--inbox-workspace-height", `${height}px`); + } + }; + window.addEventListener("resize", this.resize); + this.resize(); + }, + updated() { + this.resize(); + }, + destroyed() { + window.removeEventListener("resize", this.resize); + } +}; + +export default MessagingInbox; diff --git a/assets/js/hooks/rider_selection_list.js b/assets/js/hooks/rider_selection_list.js index 586c3cb8..92cf4170 100644 --- a/assets/js/hooks/rider_selection_list.js +++ b/assets/js/hooks/rider_selection_list.js @@ -1,7 +1,7 @@ const RiderSelectionList = { mounted() { this.el.addEventListener("scroll", e => { - gap = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight; + const gap = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight; if (gap < 10) { this.pushEventTo(`[id="${this.el.id}"]`, "load_more", {}, (_reply, _ref) => { // do something on event return? diff --git a/lib/bike_brigade/delivery.ex b/lib/bike_brigade/delivery.ex index ca6c91d8..2b30ef21 100644 --- a/lib/bike_brigade/delivery.ex +++ b/lib/bike_brigade/delivery.ex @@ -249,12 +249,14 @@ defmodule BikeBrigade.Delivery do campaign_ids = Keyword.get(opts, :campaign_ids, nil) start_date = Keyword.get(opts, :start_date, nil) end_date = Keyword.get(opts, :end_date, nil) + lead_id = Keyword.get(opts, :lead_id, nil) true |> filter_public(public) |> filter_campaign_ids(campaign_ids) |> filter_start_date(start_date) |> filter_end_date(end_date) + |> filter_lead_id(lead_id) end defp filter_public(filter, nil), do: filter @@ -280,6 +282,12 @@ defmodule BikeBrigade.Delivery do dynamic([campaign: c], ^filter and c.delivery_start >= ^date_time) end + defp filter_lead_id(filter, nil), do: filter + + defp filter_lead_id(filter, lead_id) do + dynamic([program: p], ^filter and p.lead_id == ^lead_id) + end + defp filter_end_date(filter, nil), do: filter defp filter_end_date(filter, date) do @@ -613,7 +621,7 @@ defmodule BikeBrigade.Delivery do where: c.delivery_start <= ^end_of_today and c.delivery_start >= ^start_of_yesterday, where: t.assigned_rider_id == ^rider.id, select: c, - select_merge: %{delivery_url_token: cr.token}, + select_merge: %{delivery_url_token: cr.token, rider_pickup_window: cr.pickup_window}, order_by: [desc: c.delivery_start, asc: t.id], preload: [tasks: t] @@ -621,6 +629,23 @@ defmodule BikeBrigade.Delivery do |> Repo.preload([:program, tasks: [:dropoff_location, task_items: :item]]) end + @doc """ + Assigned and completed task counts for each rider in a campaign, as + `%{rider_id => {total, completed}}`. + """ + def delivery_progress_by_rider(campaign_id) do + query = + from t in Task, + where: t.campaign_id == ^campaign_id and not is_nil(t.assigned_rider_id), + group_by: t.assigned_rider_id, + select: + {t.assigned_rider_id, + {count(t.id), filter(count(t.id), t.delivery_status == :completed)}} + + Repo.all(query) + |> Map.new() + end + def campaigns_per_rider(rider) do query = from c in CampaignRider, where: c.rider_id == ^rider.id, select: count(c.id) Repo.one(query) diff --git a/lib/bike_brigade/delivery/campaign.ex b/lib/bike_brigade/delivery/campaign.ex index a5720903..e0d2be8d 100644 --- a/lib/bike_brigade/delivery/campaign.ex +++ b/lib/bike_brigade/delivery/campaign.ex @@ -52,6 +52,7 @@ defmodule BikeBrigade.Delivery.Campaign do has_one :stats, CampaignStats field :delivery_url_token, :string, virtual: true + field :rider_pickup_window, :string, virtual: true has_one :campaign_latest_message, CampaignLatestMessage has_one :latest_message, through: [:campaign_latest_message, :sms_message] diff --git a/lib/bike_brigade/messaging.ex b/lib/bike_brigade/messaging.ex index e1289ac5..fc7fe8e1 100644 --- a/lib/bike_brigade/messaging.ex +++ b/lib/bike_brigade/messaging.ex @@ -7,7 +7,7 @@ defmodule BikeBrigade.Messaging do alias BikeBrigade.Repo alias BikeBrigade.Accounts - alias BikeBrigade.Messaging.{SmsMessage, ScheduledMessage} + alias BikeBrigade.Messaging.{SmsMessage, ScheduledMessage, BulkMessage, BulkMessageGroup} alias BikeBrigade.Riders alias BikeBrigade.Riders.Rider alias BikeBrigade.SmsService @@ -280,7 +280,7 @@ defmodule BikeBrigade.Messaging do {:error, :send_message, reason, %{create_message: sms_message}} -> message_error(sms_message, "Failed to send message: #{reason}") - {:error, name, reason, %{create_message: sms_message}} -> + {:error, name, reason, _changes} -> message_error(sms_message, "Failed in step #{name} for #{inspect(reason)}") end end @@ -364,6 +364,98 @@ defmodule BikeBrigade.Messaging do Repo.all(q, opts) end + @doc "Saved bulk message groups, newest first, with their creator and riders." + def list_bulk_message_groups do + Repo.all( + from g in BulkMessageGroup, + order_by: [desc: g.inserted_at, desc: g.id], + preload: [:creator, :riders] + ) + end + + @doc """ + Saves a group of riders that a dispatcher messaged together, so it can be reopened + and messaged again. Broadcasts `:bulk_message_group_created`. + """ + def create_bulk_message_group(%Accounts.User{} = creator, riders, attrs) do + %BulkMessageGroup{creator_id: creator.id} + |> BulkMessageGroup.changeset(attrs) + |> Ecto.Changeset.put_assoc(:riders, riders) + |> Repo.insert() + |> broadcast(:bulk_message_group_created) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for a bulk message. `attrs` takes `rider_ids`, `body`, + `campaign_id`, `save_group` and `group_name`. + + Pass `has_media: true` when attachments come with the message, which makes the body + optional. + """ + def change_bulk_message(attrs \\ %{}, opts \\ []) do + BulkMessage.changeset(%BulkMessage{has_media: Keyword.get(opts, :has_media, false)}, attrs) + end + + @doc """ + Sends the same message to each rider individually, optionally saving the riders as a + bulk message group first. See `change_bulk_message/2` for `attrs`; `media` is a list of + uploaded attachments. + + Returns `{:ok, %{group: group | nil, sent: count, failed: riders}}`, where riders whose + send failed are listed so they can be retried individually, or `{:error, changeset}`. + """ + def send_bulk_message(%Accounts.User{} = sender, attrs, media \\ []) do + changeset = change_bulk_message(attrs, has_media: media != []) + + with {:ok, bulk_message} <- Ecto.Changeset.apply_action(changeset, :insert), + {:ok, riders} <- fetch_bulk_message_riders(changeset, bulk_message), + {:ok, group} <- maybe_create_bulk_message_group(changeset, sender, riders, bulk_message) do + {sent, failed} = + riders + |> Enum.map(fn rider -> + message = new_sms_message(rider, sent_by: sender) + + result = + send_sms_message(message, %{ + body: bulk_message.body, + campaign_id: bulk_message.campaign_id, + media: media + }) + + {rider, result} + end) + |> Enum.split_with(fn {_, result} -> match?({:ok, _}, result) end) + + {:ok, + %{group: group, sent: length(sent), failed: Enum.map(failed, fn {rider, _} -> rider end)}} + end + end + + defp fetch_bulk_message_riders(changeset, bulk_message) do + case Riders.get_riders(Enum.uniq(bulk_message.rider_ids)) do + [] -> invalid_bulk_message(changeset, :rider_ids, "Select at least one rider") + riders -> {:ok, riders} + end + end + + defp maybe_create_bulk_message_group(changeset, sender, riders, %{save_group: true} = message) do + case create_bulk_message_group(sender, riders, %{ + name: message.group_name, + body: message.body || "Attachment" + }) do + {:ok, group} -> {:ok, group} + {:error, _} -> invalid_bulk_message(changeset, :group_name, "Could not save this group") + end + end + + defp maybe_create_bulk_message_group(_changeset, _sender, _riders, _message), do: {:ok, nil} + + defp invalid_bulk_message(changeset, field, message) do + changeset + |> Ecto.Changeset.add_error(field, message) + |> Ecto.Changeset.apply_action(:insert) + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking sms_message changes for a message to be sent. diff --git a/lib/bike_brigade/messaging/bulk_message.ex b/lib/bike_brigade/messaging/bulk_message.ex new file mode 100644 index 00000000..59ad87f5 --- /dev/null +++ b/lib/bike_brigade/messaging/bulk_message.ex @@ -0,0 +1,49 @@ +defmodule BikeBrigade.Messaging.BulkMessage do + @moduledoc """ + A message sent to several riders at once (each rider receives it individually), + optionally saving the riders as a `BulkMessageGroup`. Not persisted. + """ + use BikeBrigade.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :rider_ids, {:array, :integer}, default: [] + field :body, :string + field :campaign_id, :integer + field :save_group, :boolean, default: false + field :group_name, :string + # Attachments are uploaded separately; the body may be empty when there are some. + field :has_media, :boolean, default: false + end + + def changeset(bulk_message, attrs) do + bulk_message + |> cast(attrs, [:rider_ids, :body, :campaign_id, :save_group, :group_name]) + |> update_change(:body, &String.trim/1) + |> update_change(:group_name, &String.trim/1) + |> validate_length(:rider_ids, min: 1, message: "Select at least one rider") + |> validate_body() + |> validate_length(:body, max: 1600, message: "Messages can be up to 1600 characters") + |> validate_group_name() + end + + defp validate_body(changeset) do + if get_field(changeset, :has_media), + do: changeset, + else: validate_required(changeset, :body, message: "Enter a message") + end + + defp validate_group_name(changeset) do + if get_field(changeset, :save_group) do + changeset + |> validate_required(:group_name, message: "Enter a name for the group") + |> validate_length(:group_name, + max: 100, + message: "Group names can be up to 100 characters" + ) + else + changeset + end + end +end diff --git a/lib/bike_brigade/messaging/bulk_message_group.ex b/lib/bike_brigade/messaging/bulk_message_group.ex new file mode 100644 index 00000000..94fbd506 --- /dev/null +++ b/lib/bike_brigade/messaging/bulk_message_group.ex @@ -0,0 +1,25 @@ +defmodule BikeBrigade.Messaging.BulkMessageGroup do + use BikeBrigade.Schema + import Ecto.Changeset + + schema "bulk_message_groups" do + field :name, :string + field :body, :string + belongs_to :creator, BikeBrigade.Accounts.User + + many_to_many :riders, BikeBrigade.Riders.Rider, + join_through: "bulk_message_group_riders", + join_keys: [group_id: :id, rider_id: :id], + on_replace: :delete + + timestamps() + end + + def changeset(group, attrs) do + group + |> cast(attrs, [:name, :body]) + |> validate_required([:name, :body]) + |> validate_length(:name, max: 100) + |> validate_length(:body, max: 1600) + end +end diff --git a/lib/bike_brigade_web/components/conversation_component.ex b/lib/bike_brigade_web/components/conversation_component.ex index 3973d962..4f22ff09 100644 --- a/lib/bike_brigade_web/components/conversation_component.ex +++ b/lib/bike_brigade_web/components/conversation_component.ex @@ -27,6 +27,7 @@ defmodule BikeBrigadeWeb.Components.ConversationComponent do |> Map.put_new(:back_patch, ~p"/messages") |> Map.put_new(:mobile_visible, assigns.live_action == :show) |> Map.put_new(:show_tasks_link, true) + |> Map.put_new(:unified_header, false) conversation = Messaging.latest_messages(rider) @@ -36,10 +37,26 @@ defmodule BikeBrigadeWeb.Components.ConversationComponent do [] -> nil end + earliest_timestamp = + if socket.assigns[:sms_message] && socket.assigns.sms_message.rider_id == rider.id && + socket.assigns[:earliest_timestamp] do + case earliest_timestamp do + nil -> + socket.assigns.earliest_timestamp + + latest -> + if DateTime.compare(socket.assigns.earliest_timestamp, latest) == :lt, + do: socket.assigns.earliest_timestamp, + else: latest + end + else + earliest_timestamp + end + {:ok, socket |> assign(assigns) - |> assign_new_sms_message() + |> maybe_assign_new_sms_message(rider) |> stream(:conversation, conversation, dom_id: fn message -> "message:#{message.id}" end) |> assign(earliest_timestamp: earliest_timestamp)} end @@ -60,6 +77,14 @@ defmodule BikeBrigadeWeb.Components.ConversationComponent do |> assign(assigns)} end + defp maybe_assign_new_sms_message(socket, rider) do + if socket.assigns[:sms_message] && socket.assigns.sms_message.rider_id == rider.id do + socket + else + assign_new_sms_message(socket) + end + end + @impl Phoenix.LiveComponent def handle_event("cancel_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :media, ref)} @@ -126,10 +151,6 @@ defmodule BikeBrigadeWeb.Components.ConversationComponent do |> assign(sms_message: sms_message) end - def error_to_string(:too_large), do: "Too large" - def error_to_string(:too_many_files), do: "You have selected too many files" - def error_to_string(:not_accepted), do: "You have selected an unacceptable file type" - def media_type(%SmsMessage.MediaItem{content_type: content_type}) do String.split(content_type, "/") |> List.first() diff --git a/lib/bike_brigade_web/components/conversation_component.html.heex b/lib/bike_brigade_web/components/conversation_component.html.heex index b4d71ef9..935f2a7a 100644 --- a/lib/bike_brigade_web/components/conversation_component.html.heex +++ b/lib/bike_brigade_web/components/conversation_component.html.heex @@ -1,11 +1,29 @@
-
+ + +
<.link patch={@back_patch} aria-label="Back to conversations"> @@ -223,7 +241,7 @@

- {error_to_string(err)} + {upload_error_to_string(err)}

diff --git a/lib/bike_brigade_web/components/layouts/app.html.heex b/lib/bike_brigade_web/components/layouts/app.html.heex index 7c9e340b..44b22ab1 100644 --- a/lib/bike_brigade_web/components/layouts/app.html.heex +++ b/lib/bike_brigade_web/components/layouts/app.html.heex @@ -154,7 +154,10 @@
-
+
<.flash kind={:info} title="Success!" flash={@flash} /> <.flash kind={:warn} title="Warning!" flash={@flash} /> <.flash kind={:error} title="Error!" flash={@flash} /> diff --git a/lib/bike_brigade_web/components/messaging_inbox.ex b/lib/bike_brigade_web/components/messaging_inbox.ex new file mode 100644 index 00000000..da26baba --- /dev/null +++ b/lib/bike_brigade_web/components/messaging_inbox.ex @@ -0,0 +1,360 @@ +defmodule BikeBrigadeWeb.Components.MessagingInbox do + @moduledoc "Building blocks for the unified messaging inbox (`SmsMessageLive.Inbox`)." + use BikeBrigadeWeb, :html + + alias BikeBrigadeWeb.Components.RiderDeliveriesComponent + + @doc """ + A campaign or bulk-message roster: the riders' individual conversations, the + "Message everyone" drawer, and the selected rider's chat (the `:chat` slot) in a + drawer over the roster. + """ + attr :context, :any, required: true + attr :rows, :list, required: true + attr :rider, :any, required: true + attr :tab, :string, required: true + attr :close, :string, required: true + attr :search, :string, required: true + attr :mobile_open, :boolean, required: true + attr :back_to_contexts, :string, required: true + attr :uploads, :any, default: nil + attr :everyone_open, :boolean, default: false + attr :everyone_form, :any, required: true + attr :confirm_send, :string, default: "" + attr :wide_chat_xl, :boolean, default: false, doc: "widen the chat drawer on xl screens" + slot :chat, required: true + + def merged_inbox(assigns) do + ~H""" +
+
+
+ <.link + id="back-to-contexts" + patch={@back_to_contexts} + class="mb-4 inline-flex items-center gap-1 text-sm text-indigo-600 lg:hidden" + > + + {if @tab == "campaigns", do: "Campaigns", else: "Bulk messages"} + +
+
+

+ {if @context, + do: @context.name, + else: if(@tab == "groups", do: "Choose a bulk message", else: "Choose a campaign")} +

+

+ {length(@rows)} riders · Individual conversations +

+
+ <.button + :if={@context} + id="message-everyone" + type="button" + phx-click="open_everyone" + color={:white} + size={:small} + > + Message everyone + +
+
+ + Original message + {@context.group.body} + + +

+ {@context.group.body} +

+

+ Sent by {@context.group.creator} · {@context.group.created} +

+
+
+ <.input + errors={[]} + id="merged-search" + name="search" + value={@search} + placeholder="Search for Riders" + phx-debounce="150" + /> +
+
+
+
+ <.link + patch={row.link} + class={[ + "flex items-start gap-3 p-5 hover:bg-gray-50", + @rider && @rider.id == row.rider.id && "bg-indigo-50" + ]} + > + + {rider_initials(row.rider.name)} + +
+
+ + {row.rider.name} + + + + +
+

+ {if row.latest, + do: String.slice(row.latest.body, 0, 220), + else: "No messages yet"} +

+
+ + +
+

No riders match this view.

+
+
+
+ +
+

Message everyone

+

+ {@context.name} · {length(@context.ids)} riders +

+
+
+ <.form + for={@everyone_form} + id="group-message-form" + class="p-5" + phx-submit="message_group" + phx-change="validate_everyone" + > + <.input + field={@everyone_form[:body]} + id="group-message-body" + type="textarea" + label="Message everyone" + placeholder="Write to everyone…" + maxlength="1600" + /> + <.attachments :if={@uploads} upload={@uploads.media} /> +
+ Delivered separately to each rider.<.button + type="submit" + size={:small} + phx-disable-with="Sending…" + data-confirm={@confirm_send != "" && @confirm_send} + >Send to all {length(@context.ids)} riders +
+ + +
+ <%= if @rider do %> + <.link + patch={@close} + aria-label="Close rider chat" + class="absolute inset-0 z-10 rounded-lg bg-gray-900/20" + > + Close rider chat + +
+ {render_slot(@chat)} +
+ <% end %> +
+ """ + end + + @doc "The inbox's header for a rider's conversation, with a back link to the list." + attr :rider, :any, required: true + attr :close, :string, required: true + + def rider_header(assigns) do + ~H""" +
+
+ <.link + patch={@close} + id="back-to-riders" + aria-label="Back to conversations" + class="-ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-indigo-600 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500" + > + + + +
+
+

+ <.link + navigate={~p"/riders/#{@rider}"} + class="hover:text-indigo-600 hover:underline" + > + {rider_display_name(@rider)} + +

+ + {@rider.pronouns} + +
+
+ + {@rider.phone} +
+
+
+
+ """ + end + + @doc "Delivery progress under the rider header, for screens too narrow to show the panel." + attr :progress, :map, required: true + attr :open, :string, required: true, doc: "path that opens the deliveries panel" + + def deliveries_bar(assigns) do + ~H""" + <.link + id="deliveries-bar" + patch={@open} + class="group flex shrink-0 items-center gap-2 border-b border-gray-200 bg-gray-50 px-4 py-2 text-sm sm:px-6 xl:hidden" + > + + + Deliveries + + {@progress.done} of {@progress.total} done + + + + """ + end + + @doc """ + The photo attachment button and previews, styled like the conversation composer's + (the file input itself is hidden behind the label). + """ + attr :upload, :any, required: true, doc: "the `@uploads.media` upload config" + + def attachments(assigns) do + ~H""" +
+ +
+
+ <.live_img_preview entry={entry} class="h-20 w-full rounded object-cover" /> +
+ {entry.client_name} + +
+ <.error :for={err <- upload_errors(@upload, entry)}> + {upload_error_to_string(err)} + +
+
+
+ """ + end +end diff --git a/lib/bike_brigade_web/components/presence_components.ex b/lib/bike_brigade_web/components/presence_components.ex new file mode 100644 index 00000000..0e9bedce --- /dev/null +++ b/lib/bike_brigade_web/components/presence_components.ex @@ -0,0 +1,57 @@ +defmodule BikeBrigadeWeb.Components.PresenceComponents do + @moduledoc "Shows which other dispatchers are on the same page." + use BikeBrigadeWeb, :html + + @doc "A floating badge listing the other dispatchers viewing this page." + attr :others_present, :list, required: true + + def currently_viewing(assigns) do + ~H""" + + """ + end +end diff --git a/lib/bike_brigade_web/components/rider_deliveries_component.ex b/lib/bike_brigade_web/components/rider_deliveries_component.ex new file mode 100644 index 00000000..87aa70f9 --- /dev/null +++ b/lib/bike_brigade_web/components/rider_deliveries_component.ex @@ -0,0 +1,138 @@ +defmodule BikeBrigadeWeb.Components.RiderDeliveriesComponent do + @moduledoc """ + A rider's recent deliveries (see `Delivery.latest_campaign_tasks/1`), shown next + to their conversation so dispatchers can look up and update tasks while chatting. + """ + use BikeBrigadeWeb, :live_component + + alias BikeBrigade.Delivery + alias BikeBrigadeWeb.CampaignHelpers + + import BikeBrigadeWeb.CampaignHelpers, only: [request_type: 1] + + @doc "Delivery progress as `%{done: done, total: total}`, or nil when there are no deliveries." + def progress(campaigns) when is_list(campaigns) do + tasks = Enum.flat_map(campaigns, & &1.tasks) + progress(length(tasks), Enum.count(tasks, &delivered?/1)) + end + + def progress(0, _done), do: nil + def progress(total, done), do: %{done: done, total: total} + + def progress_label(%{done: done, total: 1}), do: "#{done} of 1 delivery done" + def progress_label(%{done: done, total: total}), do: "#{done} of #{total} deliveries done" + + def progress_complete?(%{done: done, total: total}), do: done == total + + attr :progress, :map, required: true + + @doc "Compact progress like \"1/3\", with a check once everything is delivered." + def progress_chip_content(assigns) do + ~H""" + + + + {progress_label(@progress)} + """ + end + + @impl true + def handle_event("toggle_delivered", %{"task_id" => task_id}, socket) do + # The parent LiveView refreshes from the :task_updated (or :task_deleted) broadcast, + # so a task deleted since this panel rendered is simply skipped. + if task = Delivery.get_task(task_id) do + status = if delivered?(task), do: :pending, else: :completed + Delivery.update_task(task, %{delivery_status: status}) + end + + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+
+ <.link + patch={@close} + aria-label="Back to conversation" + class="-ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-indigo-600 hover:bg-indigo-50 xl:hidden" + > + + +
+

Deliveries

+

Yesterday and today

+
+
+
+
+
+
+ <.link + navigate={~p"/campaigns/#{campaign}"} + class="text-sm font-semibold text-indigo-600 hover:text-indigo-500" + > + {CampaignHelpers.name(campaign)} + +

+ {format_date(campaign.delivery_start)} · {campaign.rider_pickup_window || + CampaignHelpers.pickup_window(campaign)} · {Enum.count( + campaign.tasks, + &delivered?/1 + )} of {length(campaign.tasks)} delivered +

+
+
+ +
    +
  • + +
    +

    + {task.dropoff_name} +

    +

    {task.dropoff_location.address}

    +

    + {request_type(task)} +

    +

    + {task.delivery_instructions} +

    +
    +
  • +
+
+
+
+ """ + end + + defp delivered?(task), do: task.delivery_status == :completed +end diff --git a/lib/bike_brigade_web/components/rider_selection_component.ex b/lib/bike_brigade_web/components/rider_selection_component.ex index a890562d..8f88e132 100644 --- a/lib/bike_brigade_web/components/rider_selection_component.ex +++ b/lib/bike_brigade_web/components/rider_selection_component.ex @@ -168,6 +168,7 @@ defmodule BikeBrigadeWeb.Components.RiderSelectionComponent do <.link :if={@on_unselect} phx-click={@on_unselect} + aria-label={"Remove #{@rider.name}"} class="block text-sm text-gray-400 bg-white rounded-md font-base hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > diff --git a/lib/bike_brigade_web/components/sms_message_list_component.ex b/lib/bike_brigade_web/components/sms_message_list_component.ex index acdb46bf..499676f1 100644 --- a/lib/bike_brigade_web/components/sms_message_list_component.ex +++ b/lib/bike_brigade_web/components/sms_message_list_component.ex @@ -48,9 +48,12 @@ defmodule BikeBrigadeWeb.Components.SMSMessageListComponent do >
- + <%= if last_message do %>
diff --git a/lib/bike_brigade_web/live/campaign_live/campaign_message_list.html.heex b/lib/bike_brigade_web/live/campaign_live/campaign_message_list.html.heex index 3020ea1a..cafd1f76 100644 --- a/lib/bike_brigade_web/live/campaign_live/campaign_message_list.html.heex +++ b/lib/bike_brigade_web/live/campaign_live/campaign_message_list.html.heex @@ -1,4 +1,11 @@
+ <.link + :if={@current_user.flags.unified_messaging} + navigate={~p"/messages/inbox?#{%{tab: "campaigns", context: @campaign_id, mine: false}}"} + class="mb-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600" + > + Open in messaging inbox + <%= if is_nil(@selected_rider) do %>
No riders have been added to this campaign. Add some riders to be able to send messages. diff --git a/lib/bike_brigade_web/live/live_helpers.ex b/lib/bike_brigade_web/live/live_helpers.ex index a1a79f89..34dafd42 100644 --- a/lib/bike_brigade_web/live/live_helpers.ex +++ b/lib/bike_brigade_web/live/live_helpers.ex @@ -15,6 +15,22 @@ defmodule BikeBrigadeWeb.LiveHelpers do def gravatar(nil), do: gravatar("") + @doc "A rider's name, falling back to their phone number for riders without one." + def rider_display_name(rider), do: rider.name || rider.phone || "Unnamed rider" + + @doc "Up to two initials for an avatar badge, e.g. \"Ada Lovelace\" -> \"AL\"." + def rider_initials(name) do + (name || "Unnamed rider") + |> String.split() + |> Enum.map(&String.first/1) + |> Enum.take(2) + |> Enum.join() + end + + def upload_error_to_string(:too_large), do: "Too large" + def upload_error_to_string(:too_many_files), do: "You have selected too many files" + def upload_error_to_string(:not_accepted), do: "You have selected an unacceptable file type" + def format_date(datetime), do: format_date(datetime, "%b %-d, %Y") def format_date(%DateTime{} = datetime, format) do diff --git a/lib/bike_brigade_web/live/sms_message_live/form_component.ex b/lib/bike_brigade_web/live/sms_message_live/form_component.ex index 4705b969..bd1d2727 100644 --- a/lib/bike_brigade_web/live/sms_message_live/form_component.ex +++ b/lib/bike_brigade_web/live/sms_message_live/form_component.ex @@ -76,8 +76,4 @@ defmodule BikeBrigadeWeb.SmsMessageLive.FormComponent do def handle_event("cancel_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :media, ref)} end - - def error_to_string(:too_large), do: "Too large" - def error_to_string(:too_many_files), do: "You have selected too many files" - def error_to_string(:not_accepted), do: "You have selected an unacceptable file type" end diff --git a/lib/bike_brigade_web/live/sms_message_live/form_component.html.heex b/lib/bike_brigade_web/live/sms_message_live/form_component.html.heex index 6e5c44f5..2981f4f1 100644 --- a/lib/bike_brigade_web/live/sms_message_live/form_component.html.heex +++ b/lib/bike_brigade_web/live/sms_message_live/form_component.html.heex @@ -59,7 +59,7 @@ <.error :for={err <- upload_errors(@uploads.media, entry)}> - {error_to_string(err)} + {upload_error_to_string(err)}
diff --git a/lib/bike_brigade_web/live/sms_message_live/inbox.ex b/lib/bike_brigade_web/live/sms_message_live/inbox.ex new file mode 100644 index 00000000..4d47b65c --- /dev/null +++ b/lib/bike_brigade_web/live/sms_message_live/inbox.ex @@ -0,0 +1,726 @@ +defmodule BikeBrigadeWeb.SmsMessageLive.Inbox do + use BikeBrigadeWeb, :live_view + + alias BikeBrigade.{Messaging, Riders, Delivery, Presence, LocalizedDateTime} + alias BikeBrigadeWeb.CampaignHelpers + alias BikeBrigadeWeb.Components.{ConversationComponent, RiderDeliveriesComponent} + + import BikeBrigadeWeb.Components.SMSMessageListComponent + + import BikeBrigadeWeb.Components.MessagingInbox, only: [merged_inbox: 1, attachments: 1] + import BikeBrigadeWeb.Components.PresenceComponents, only: [currently_viewing: 1] + + @presence_topic "messaging_presence" + + on_mount {BikeBrigadeWeb.LiveHooks.FeatureFlags, {:require, :unified_messaging, "/messages"}} + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Messaging.subscribe() + Delivery.subscribe() + BikeBrigadeWeb.Endpoint.subscribe(@presence_topic) + Presence.track(self(), @presence_topic, socket.assigns.current_user.id, %{}) + end + + presence = Presence.list(@presence_topic) + + {:ok, + socket + |> allow_upload(:media, accept: ~w(.gif .png .jpg .jpeg), max_entries: 10) + |> assign( + page: :messages, + page_title: "Messages", + wide_content: true, + presence: presence, + others_present: others_present(socket.assigns.current_user, presence), + confirm_send: BikeBrigade.SmsService.sending_confirmation_message(), + loaded: %{} + ) + |> reset_bulk_form() + |> reset_everyone_form()} + end + + @impl true + def handle_params(params, _url, socket) do + # Every inbox param is a plain string; drop anything else (e.g. ?search[]=x). + params = for {key, value} <- params, is_binary(value), into: %{}, do: {key, value} + + {:noreply, + socket + |> assign(params: params, modal: params["compose"] == "new", everyone_open: false) + |> load()} + end + + @impl true + def handle_event("toggle_mine", _params, socket) do + mine = to_string(!socket.assigns.mine) + + {:noreply, + push_patch(socket, + to: inbox_path(socket.assigns, %{"mine" => mine, "context" => nil, "rider" => nil}) + )} + end + + def handle_event("search", %{"search" => search}, socket) do + {:noreply, + push_patch(socket, to: inbox_path(socket.assigns, %{"search" => search}), replace: true)} + end + + def handle_event("bulk", _params, socket) do + socket = + Enum.reduce(socket.assigns.uploads.media.entries, socket, fn entry, socket -> + cancel_upload(socket, :media, entry.ref) + end) + + {:noreply, socket |> reset_bulk_form() |> assign(modal: true)} + end + + # Opened from /messages/new, the drawer is part of the URL; otherwise it's just state. + def handle_event("close_bulk", _params, socket) do + if socket.assigns.params["compose"] do + {:noreply, push_patch(socket, to: inbox_path(socket.assigns, %{"compose" => nil}))} + else + {:noreply, assign(socket, modal: false)} + end + end + + def handle_event("validate_bulk", %{"bulk_message" => attrs}, socket) do + changeset = + attrs + |> Messaging.change_bulk_message(has_media: has_media?(socket)) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, bulk_form: to_form(changeset))} + end + + def handle_event("toggle_save_group", _params, socket) do + form = socket.assigns.bulk_form + attrs = Map.put(form.params, "save_group", to_string(!save_group?(form))) + + changeset = + attrs + |> Messaging.change_bulk_message(has_media: has_media?(socket)) + |> Map.put(:action, form.source.action) + + {:noreply, assign(socket, bulk_form: to_form(changeset))} + end + + def handle_event("open_everyone", _params, socket) do + {:noreply, socket |> reset_everyone_form() |> assign(everyone_open: true)} + end + + def handle_event("close_everyone", _params, socket), + do: {:noreply, assign(socket, everyone_open: false)} + + def handle_event("validate_everyone", %{"everyone" => attrs}, socket), + do: {:noreply, assign(socket, everyone_form: to_form(attrs, as: :everyone))} + + def handle_event("cancel_upload", %{"ref" => ref}, socket), + do: {:noreply, cancel_upload(socket, :media, ref)} + + # On success the slideover closes by patching `compose` out of the URL (handle_params + # derives `modal` from it), and a saved group is opened. + def handle_event("send_bulk", %{"bulk_message" => attrs}, socket) do + case send_with_media(socket, attrs) do + {:ok, %{group: nil} = result} -> + {:noreply, + socket + |> finish_send(result) + |> reset_bulk_form() + |> push_patch(to: inbox_path(socket.assigns, %{"compose" => nil}))} + + {:ok, result} -> + path = + inbox_path(socket.assigns, %{ + "tab" => "groups", + "context" => result.group.id, + "rider" => nil, + "compose" => nil + }) + + {:noreply, + socket + |> finish_send(result) + |> reset_bulk_form() + |> invalidate([:lists]) + |> push_patch(to: path)} + + {:error, :media} -> + {:noreply, put_flash(socket, :error, media_error(socket))} + + {:error, changeset} -> + {:noreply, assign(socket, bulk_form: to_form(changeset))} + end + end + + def handle_event( + "message_group", + %{"everyone" => %{"body" => body}}, + %{assigns: %{context: context}} = socket + ) + when not is_nil(context) do + # Always use the complete server-resolved roster, even when search narrows the visible list. + attrs = %{ + rider_ids: context.ids, + body: body, + campaign_id: if(socket.assigns.tab == "campaigns", do: context.id) + } + + case send_with_media(socket, attrs) do + {:ok, result} -> + {:noreply, socket |> finish_send(result) |> assign(everyone_open: false)} + + {:error, :media} -> + {:noreply, put_flash(socket, :error, media_error(socket))} + + {:error, changeset} -> + # Body errors show under the field; an empty roster can only be reported here. + socket = assign(socket, everyone_form: to_form(changeset, as: :everyone)) + + case translate_errors(changeset.errors, :rider_ids) do + [] -> {:noreply, socket} + errors -> {:noreply, put_flash(socket, :error, Enum.join(errors, " "))} + end + end + end + + # PubSub updates only touch what the event affects; everything else stays as loaded. + @impl true + def handle_info({:message_created, message}, socket) do + {:noreply, + socket |> update_selected_conversation(message) |> move_conversation_to_top(message)} + end + + def handle_info({:message_updated, message}, socket) do + {:noreply, update_selected_conversation(socket, message)} + end + + def handle_info({event, %Delivery.Task{} = task}, socket) + when event in [:task_created, :task_updated, :task_deleted] do + %{context: context, rider: rider, deliveries: deliveries, tab: tab} = socket.assigns + + stale = + [ + tab == "campaigns" && context && context.id == task.campaign_id && :delivery_counts, + rider && (task.assigned_rider_id == rider.id || task_shown?(deliveries, task)) && + :deliveries + ] + |> Enum.filter(& &1) + + {:noreply, reload(socket, stale)} + end + + def handle_info({event, %Delivery.CampaignRider{} = campaign_rider}, socket) + when event in [:campaign_rider_created, :campaign_rider_updated, :campaign_rider_deleted] do + %{tab: tab, contexts: contexts, rider: rider} = socket.assigns + + stale = + [ + tab == "campaigns" && Enum.any?(contexts, &(&1.id == campaign_rider.campaign_id)) && + :lists, + tab == "campaigns" && socket.assigns.linked_context && + socket.assigns.linked_context.id == campaign_rider.campaign_id && :linked_context, + rider && rider.id == campaign_rider.rider_id && :deliveries + ] + |> Enum.filter(& &1) + + {:noreply, reload(socket, stale)} + end + + def handle_info({:bulk_message_group_created, group}, socket) do + # Our own sends already reloaded the lists before opening the new group. + if Enum.any?(socket.assigns.contexts, &(&1.group && &1.id == group.id)), + do: {:noreply, socket}, + else: {:noreply, reload(socket, [:lists])} + end + + def handle_info({:campaign_updated, _campaign}, socket) do + if socket.assigns.tab == "campaigns", + do: {:noreply, reload(socket, [:lists])}, + else: {:noreply, socket} + end + + def handle_info(%{event: "presence_diff", payload: %{joins: joins, leaves: leaves}}, socket) do + presence = socket.assigns.presence |> Map.merge(joins) |> Map.drop(Map.keys(leaves)) + + {:noreply, + assign(socket, + presence: presence, + others_present: others_present(socket.assigns.current_user, presence) + )} + end + + def handle_info(_message, socket), do: {:noreply, socket} + + defp update_selected_conversation(socket, message) do + if socket.assigns.rider && socket.assigns.rider.id == message.rider_id do + send_update(ConversationComponent, id: message.rider_id, conversation: [message]) + end + + socket + end + + defp move_conversation_to_top(%{assigns: %{tab: "all"}} = socket, message) do + with %Riders.Rider{} = rider <- Riders.get_rider(message.rider_id), + rider = with_display_name(rider), + true <- matches_search?(rider, socket.assigns.search) do + socket + |> stream_delete_by_dom_id(:conversations, conversation_dom_id(rider)) + |> stream_insert(:conversations, {rider, message}, at: 0) + else + _ -> socket + end + end + + defp move_conversation_to_top(%{assigns: %{context: context}} = socket, message) + when not is_nil(context) do + case List.keytake(socket.assigns.conversations, message.rider_id, 0) do + {{_id, rider, _previous}, rest} -> + socket + |> assign(conversations: [{rider.id, rider, message} | rest]) + |> assign_rows() + + nil -> + socket + end + end + + defp move_conversation_to_top(socket, _message), do: socket + + defp task_shown?(deliveries, task) do + Enum.any?(deliveries, fn campaign -> Enum.any?(campaign.tasks, &(&1.id == task.id)) end) + end + + defp reset_bulk_form(socket) do + assign(socket, bulk_form: to_form(Messaging.change_bulk_message())) + end + + defp reset_everyone_form(socket) do + assign(socket, everyone_form: to_form(%{"body" => ""}, as: :everyone)) + end + + # Attachments with errors (too large, wrong type) are listed but never uploaded, so + # only count valid ones; see send_with_media/2 for why they also block sending. + defp has_media?(socket), do: Enum.any?(socket.assigns.uploads.media.entries, & &1.valid?) + + defp media_ready?(%{entries: entries} = upload) do + upload_errors(upload) == [] and Enum.all?(entries, &(&1.valid? and &1.done?)) + end + + defp media_error(socket) do + if Enum.all?(socket.assigns.uploads.media.entries, & &1.valid?), + do: "Photos are still uploading. Try sending again in a moment.", + else: "Remove the photos that can't be attached, then send again." + end + + defp save_group?(form), do: Phoenix.HTML.Form.input_value(form, :save_group) in [true, "true"] + + # The rider picker has no field of its own to mark as used, so only show its error + # once the form has been submitted. + defp rider_errors(%{source: %{action: :insert} = changeset}), + do: translate_errors(changeset.errors, :rider_ids) + + defp rider_errors(_form), do: [] + + # Validate before consuming uploads so a rejected send keeps the attachments. LiveView + # can only consume finished, valid uploads, so anything else blocks the send. + defp send_with_media(socket, attrs) do + changeset = Messaging.change_bulk_message(attrs, has_media: has_media?(socket)) + + cond do + not media_ready?(socket.assigns.uploads.media) -> + {:error, :media} + + changeset.valid? -> + media = + consume_uploaded_entries(socket, :media, fn %{path: path}, + %{client_type: content_type} -> + {:ok, BikeBrigade.MediaStorage.upload_file!(path, content_type)} + end) + + Messaging.send_bulk_message(socket.assigns.current_user, attrs, media) + + true -> + Ecto.Changeset.apply_action(changeset, :insert) + end + end + + # The conversation list updates itself from the :message_created broadcasts. + defp finish_send(socket, %{failed: [], sent: sent}) do + put_flash(socket, :info, "Sent to #{sent} riders.") + end + + defp finish_send(socket, %{failed: failed, sent: sent}) do + names = Enum.map_join(failed, ", ", &rider_display_name/1) + + put_flash( + socket, + :error, + "Sent to #{sent} riders. Could not send to #{names}. Open their chats to retry individually." + ) + end + + defp others_present(current_user, presence) do + for {_, %{user: user}} <- presence, user && user.id != current_user.id, do: user + end + + # Each piece of the page is loaded from a key derived from the URL params, and only + # reloaded when that key changes (or when an event invalidates it): + # + # * :lists - this week's campaigns and the saved groups (tab counts, contexts) + # * :conversations - all conversations ("All" tab, streamed) or the context's roster + # * :delivery_counts - per-rider delivery progress for the selected campaign + # * :deliveries - the selected rider's deliveries panel + defp load(socket) do + %{params: params} = socket.assigns + tab = if params["tab"] in ["campaigns", "groups"], do: params["tab"], else: "all" + + socket + |> assign(tab: tab, mine: params["mine"] != "false", search: params["search"] || "") + |> load_lists() + |> load_linked_context() + |> assign_context() + |> load_conversations() + |> assign_rider() + |> load_delivery_counts() + |> load_deliveries() + |> assign_rows() + |> assign( + deliveries_link: inbox_path(socket.assigns, %{"panel" => "deliveries"}), + deliveries_back: inbox_path(socket.assigns, %{"panel" => nil}) + ) + |> push_selected_rider() + end + + defp push_selected_rider(%{assigns: %{rider: rider}} = socket), + do: push_event(socket, "conversation_list:select_rider", %{id: rider && rider.id}) + + defp invalidate(socket, keys) do + assign(socket, loaded: Map.drop(socket.assigns.loaded, keys)) + end + + defp reload(socket, []), do: socket + defp reload(socket, keys), do: socket |> invalidate(keys) |> load() + + defp loaded?(socket, key, value), do: Map.get(socket.assigns.loaded, key, :none) == value + + defp mark_loaded(socket, key, value), + do: assign(socket, loaded: Map.put(socket.assigns.loaded, key, value)) + + defp load_lists(socket) do + %{tab: tab, mine: mine, current_user: current_user} = socket.assigns + key = {tab, mine} + + if loaded?(socket, :lists, key) do + socket + else + week_start = Date.beginning_of_week(LocalizedDateTime.today()) + + campaigns = + Delivery.list_campaigns( + start_date: week_start, + end_date: Date.add(week_start, 6), + lead_id: if(mine, do: current_user.id), + preload: [:program, :riders] + ) + |> Enum.reverse() + + groups = Messaging.list_bulk_message_groups() + + contexts = + case tab do + "campaigns" -> Enum.map(campaigns, &campaign_context/1) + "groups" -> Enum.map(groups, &group_context/1) + "all" -> [] + end + + socket + |> assign( + contexts: contexts, + week_label: "#{format_day(week_start)}–#{format_day(Date.add(week_start, 6))}", + tabs: [ + %{value: "all", label: "All conversations", count: nil}, + %{value: "campaigns", label: "Campaigns", count: length(campaigns)}, + %{value: "groups", label: "Bulk messages", count: length(groups)} + ] + ) + |> mark_loaded(:lists, key) + end + end + + # The campaign page links here with its campaign as the context, which may not be in + # this week's list (or may be led by someone else), so it's loaded on its own. + defp load_linked_context(socket) do + %{tab: tab, contexts: contexts, params: params} = socket.assigns + id = params["context"] + + key = + tab == "campaigns" && id && !Enum.any?(contexts, &(to_string(&1.id) == id)) && id + + if loaded?(socket, :linked_context, key) do + socket + else + linked = + with id when is_binary(id) <- key, + {id, ""} <- Integer.parse(id), + %Delivery.Campaign{} = campaign <- + Delivery.get_campaign(id, preload: [:program, :riders]) do + campaign_context(campaign) + else + _ -> nil + end + + socket + |> assign(linked_context: linked) + |> mark_loaded(:linked_context, key) + end + end + + # Without a context in the URL, the first one is selected. An unknown context selects + # nothing, so "Message everyone" can never fall back to a different roster. + defp assign_context(socket) do + %{contexts: contexts, params: params, linked_context: linked} = socket.assigns + + context = + case params["context"] do + nil -> List.first(contexts) + id -> Enum.find(contexts, &(to_string(&1.id) == id)) || linked + end + + assign(socket, context: context) + end + + # The "All" tab can hold every conversation, so it's streamed and filtered by the + # query; a context's roster is small enough to keep in memory and filter locally. + defp load_conversations(%{assigns: %{tab: "all", search: search}} = socket) do + key = {:all, search} + + if loaded?(socket, :conversations, key) do + socket + else + visible = + Messaging.list_sms_conversations() + |> Enum.map(fn {rider, message} -> {with_display_name(rider), message} end) + |> Enum.filter(fn {rider, _} -> matches_search?(rider, search) end) + + socket + |> assign( + conversations: [], + first_conversation_rider: + case visible do + [{rider, _} | _] -> rider + [] -> nil + end + ) + |> stream(:conversations, visible, + reset: true, + dom_id: fn {rider, _} -> conversation_dom_id(rider) end + ) + |> mark_loaded(:conversations, key) + end + end + + defp load_conversations(%{assigns: %{context: context}} = socket) do + key = context && {context.id, context.ids} + + if loaded?(socket, :conversations, key) do + socket + else + conversations = + if context do + for {rider, message} <- Messaging.list_sms_conversations_for_riders(context.ids), + do: {rider.id, with_display_name(rider), message} + else + [] + end + + socket + |> assign(conversations: conversations, first_conversation_rider: nil) + |> mark_loaded(:conversations, key) + end + end + + defp assign_rider(socket) do + %{params: params, tab: tab, conversations: conversations} = socket.assigns + current = socket.assigns[:rider] + + rider = + case {params["rider"], tab} do + {nil, "all"} -> + socket.assigns.first_conversation_rider + + {nil, _} -> + nil + + {id, "all"} -> + # A rider who has never been messaged can still be opened from "All". + if current && to_string(current.id) == id, + do: current, + else: rider_from_param(id) + + {id, _} -> + Enum.find_value(conversations, fn {_, rider, _} -> + to_string(rider.id) == id && rider + end) + end + + assign(socket, rider: rider) + end + + defp load_delivery_counts(socket) do + %{tab: tab, context: context} = socket.assigns + key = tab == "campaigns" && context && context.id + + if loaded?(socket, :delivery_counts, key) do + socket + else + socket + |> assign(delivery_counts: if(key, do: Delivery.delivery_progress_by_rider(key), else: %{})) + |> mark_loaded(:delivery_counts, key) + end + end + + defp load_deliveries(socket) do + %{rider: rider, params: params} = socket.assigns + key = rider && rider.id + + socket = + if loaded?(socket, :deliveries, key) do + socket + else + socket + |> assign(deliveries: if(rider, do: Delivery.latest_campaign_tasks(rider), else: [])) + |> mark_loaded(:deliveries, key) + end + + # Always shown beside the chat on xl screens; narrower screens open it in place + # of the chat with ?panel=deliveries. + assign(socket, + deliveries_open: socket.assigns.deliveries != [] and params["panel"] == "deliveries" + ) + end + + defp assign_rows(socket) do + %{conversations: conversations, search: search, context: context} = socket.assigns + delivery_counts = socket.assigns.delivery_counts + + rows = + for {_id, rider, message} <- conversations, matches_search?(rider, search) do + {total, delivered} = Map.get(delivery_counts, rider.id, {0, 0}) + + %{ + rider: rider, + deliveries: RiderDeliveriesComponent.progress(total, delivered), + latest: + message && + %{ + incoming: message.from == rider.phone, + body: message.body || preview_message(message) + }, + link: + inbox_path(socket.assigns, %{"rider" => rider.id, "context" => context && context.id}) + } + end + + assign(socket, rows: rows) + end + + attr :rider, :any, required: true + attr :current_user, :any, required: true + attr :close, :string, required: true + attr :deliveries, :list, required: true + attr :deliveries_open, :boolean, required: true + attr :deliveries_link, :string, required: true + attr :deliveries_back, :string, required: true + + # The rider's conversation, with their deliveries beside it on xl screens. Narrower + # screens show a summary bar instead, which opens the deliveries in place of the chat. + defp rider_pane(assigns) do + ~H""" +
+
+ <.live_component + module={ConversationComponent} + id={@rider.id} + rider={@rider} + current_user={@current_user} + live_action={:show} + mobile_visible={true} + back_patch={@close} + unified_header={true} + deliveries={RiderDeliveriesComponent.progress(@deliveries)} + deliveries_link={@deliveries_link} + /> +
+ <.live_component + :if={@deliveries != []} + module={RiderDeliveriesComponent} + id="rider-deliveries" + rider={@rider} + campaigns={@deliveries} + close={@deliveries_back} + class={[ + if(@deliveries_open, do: "flex w-full", else: "hidden"), + "xl:flex xl:w-80 xl:shrink-0" + ]} + /> +
+ """ + end + + defp campaign_context(campaign) do + %{ + id: campaign.id, + name: CampaignHelpers.name(campaign), + ids: Enum.map(campaign.riders, & &1.id), + detail: + "#{format_day(LocalizedDateTime.localize(campaign.delivery_start))} · #{campaign.program.name}", + group: nil + } + end + + defp group_context(group) do + created = LocalizedDateTime.localize(group.inserted_at) + + %{ + id: group.id, + name: group.name, + ids: Enum.map(group.riders, & &1.id), + detail: "Started by #{group.creator.name} · #{format_day(created)}", + group: %{ + body: group.body, + creator: group.creator.name, + created: format_day(created) + } + } + end + + defp rider_from_param(id) do + with {id, ""} when id > 0 <- Integer.parse(id), + %Riders.Rider{} = rider <- Riders.get_rider(id) do + with_display_name(rider) + else + _ -> nil + end + end + + defp with_display_name(rider), do: %{rider | name: rider_display_name(rider)} + + defp matches_search?(rider, search), + do: String.contains?(String.downcase(rider.name), String.downcase(search)) + + defp conversation_dom_id(rider), do: "conversation-list-item:#{rider.id}" + + defp format_day(date), do: Calendar.strftime(date, "%b %-d") + + defp inbox_path(assigns, changes) do + params = + assigns.params |> Map.merge(changes) |> Enum.reject(fn {_, v} -> is_nil(v) end) |> Map.new() + + ~p"/messages/inbox?#{params}" + end +end diff --git a/lib/bike_brigade_web/live/sms_message_live/inbox.html.heex b/lib/bike_brigade_web/live/sms_message_live/inbox.html.heex new file mode 100644 index 00000000..b6955426 --- /dev/null +++ b/lib/bike_brigade_web/live/sms_message_live/inbox.html.heex @@ -0,0 +1,214 @@ +
+ <.currently_viewing others_present={@others_present} /> + <.header class="mb-4">Messages + + <%!-- isolate keeps the pane's drawers (z-10..30) below page-level overlays like the slideover --%> +
+ + <.merged_inbox + :if={@tab != "all"} + uploads={if @modal, do: nil, else: @uploads} + context={@context} + rows={@rows} + rider={@rider} + tab={@tab} + close={inbox_path(assigns, %{"rider" => nil})} + wide_chat_xl={@deliveries != []} + everyone_open={@everyone_open} + everyone_form={@everyone_form} + confirm_send={@confirm_send} + search={@search} + mobile_open={!!(@params["context"] || @params["rider"])} + back_to_contexts={inbox_path(assigns, %{"context" => nil, "rider" => nil, "search" => nil})} + > + <:chat> + <.rider_pane + :if={@rider} + rider={@rider} + current_user={@current_user} + close={inbox_path(assigns, %{"rider" => nil})} + deliveries={@deliveries} + deliveries_open={@deliveries_open} + deliveries_link={@deliveries_link} + deliveries_back={@deliveries_back} + /> + + +
+
+

All conversations

+
+ <.input + errors={[]} + id="inbox-search" + name="search" + value={@search} + placeholder="Search for Riders" + phx-debounce="150" + /> +
+
+
+ <.sms_message_list + conversations={@streams.conversations} + rider_link_fn={fn id -> inbox_path(assigns, %{"rider" => id}) end} + /> +
+
+
+ <.rider_pane + :if={@rider} + rider={@rider} + current_user={@current_user} + close={inbox_path(assigns, %{"rider" => nil})} + deliveries={@deliveries} + deliveries_open={@deliveries_open} + deliveries_link={@deliveries_link} + deliveries_back={@deliveries_back} + /> +

+ No conversations yet. +

+
+
+ <.slideover :if={@modal} id="new-bulk-message" show on_cancel={JS.push("close_bulk")}> + <:title>New bulk message + <:subtitle>Each rider receives an individual message. + <.form + for={@bulk_form} + id="inbox-bulk-form" + phx-change="validate_bulk" + phx-submit="send_bulk" + class="space-y-6" + > +
+ <.rider_select + id="inbox-rider-select" + field={@bulk_form[:rider_ids]} + multi={true} + label="To" + /> + <.error :for={msg <- rider_errors(@bulk_form)}>{msg} +
+
+ <.input field={@bulk_form[:body]} type="textarea" label="Message" maxlength="1600" /> + <.attachments upload={@uploads.media} /> +
+
+ <%!-- The switch is a button, so the hidden input carries its value with the form --%> + + <.toggle + id="save-group-toggle" + enabled={save_group?(@bulk_form)} + label="Save this group" + phx-click="toggle_save_group" + /> + <.input + :if={save_group?(@bulk_form)} + field={@bulk_form[:group_name]} + label="Group name" + maxlength="100" + /> +
+

{@confirm_send}

+ + <:cancel>Cancel + <:confirm form="inbox-bulk-form" type="submit" phx-disable-with="Sending…">Send + +
diff --git a/lib/bike_brigade_web/live/sms_message_live/index.ex b/lib/bike_brigade_web/live/sms_message_live/index.ex index deef749a..1fc5d108 100644 --- a/lib/bike_brigade_web/live/sms_message_live/index.ex +++ b/lib/bike_brigade_web/live/sms_message_live/index.ex @@ -5,6 +5,7 @@ defmodule BikeBrigadeWeb.SmsMessageLive.Index do alias BikeBrigade.{Presence, Messaging, Delivery, Riders, Riders.RiderSearch} import BikeBrigadeWeb.Components.SMSMessageListComponent + import BikeBrigadeWeb.Components.PresenceComponents, only: [currently_viewing: 1] import BikeBrigadeWeb.CampaignHelpers, only: [request_type: 1] alias BikeBrigadeWeb.CampaignHelpers @@ -12,7 +13,29 @@ defmodule BikeBrigadeWeb.SmsMessageLive.Index do defdelegate pickup_window(campaign, rider), to: CampaignHelpers @impl true - def mount(_params, _session, socket) do + def mount(params, session, socket) do + if socket.assigns.current_user.flags.unified_messaging do + action = socket.assigns.live_action + + query = + params + |> Map.delete("id") + |> Map.merge(%{ + "tab" => if(action == :new, do: "groups", else: "all"), + "rider" => params["id"], + "compose" => if(action == :new, do: "new"), + "panel" => if(action == :tasks, do: "deliveries") + }) + |> Enum.reject(fn {_, v} -> is_nil(v) end) + |> Map.new() + + {:ok, push_navigate(socket, to: ~p"/messages/inbox?#{query}")} + else + mount_legacy(params, session, socket) + end + end + + defp mount_legacy(_params, _session, socket) do conversations = Messaging.list_sms_conversations(limit: 10) # Show the most recent conversation if we have any rider = @@ -232,56 +255,4 @@ defmodule BikeBrigadeWeb.SmsMessageLive.Index do u end end - - attr :others_present, :list, required: true - - defp currently_viewing(assigns) do - ~H""" - - """ - end end diff --git a/lib/bike_brigade_web/live_hooks/feature_flags.ex b/lib/bike_brigade_web/live_hooks/feature_flags.ex new file mode 100644 index 00000000..658468f2 --- /dev/null +++ b/lib/bike_brigade_web/live_hooks/feature_flags.ex @@ -0,0 +1,22 @@ +defmodule BikeBrigadeWeb.LiveHooks.FeatureFlags do + @moduledoc """ + Gates a LiveView behind one of the current user's `BikeBrigade.Accounts.User.Flags`. + + on_mount {BikeBrigadeWeb.LiveHooks.FeatureFlags, {:require, :unified_messaging, "/messages"}} + + Users without the flag are sent to the given path with a flash message. Must run after a hook that + assigns `:current_user`. + """ + import Phoenix.LiveView + + def on_mount({:require, flag, redirect_to}, _params, _session, socket) do + if Map.get(socket.assigns.current_user.flags, flag) do + {:cont, socket} + else + {:halt, + socket + |> put_flash(:error, "That page isn't enabled for your account yet") + |> push_navigate(to: redirect_to)} + end + end +end diff --git a/lib/bike_brigade_web/router.ex b/lib/bike_brigade_web/router.ex index aa288e6a..586463a5 100644 --- a/lib/bike_brigade_web/router.ex +++ b/lib/bike_brigade_web/router.ex @@ -157,6 +157,7 @@ defmodule BikeBrigadeWeb.Router do live "/users/:id/show/edit", UserLive.Show, :edit live "/messages", SmsMessageLive.Index, :index + live "/messages/inbox", SmsMessageLive.Inbox, :index live "/messages/new", SmsMessageLive.Index, :new live "/messages/:id", SmsMessageLive.Index, :show live "/messages/:id/tasks", SmsMessageLive.Index, :tasks diff --git a/lib/mix/tasks/feature_flags.ex b/lib/mix/tasks/feature_flags.ex index eb76134e..68d99a11 100644 --- a/lib/mix/tasks/feature_flags.ex +++ b/lib/mix/tasks/feature_flags.ex @@ -2,6 +2,8 @@ defmodule Mix.Tasks.FeatureFlags do use Mix.Task alias BikeBrigade.{Accounts, Riders} + alias BikeBrigade.Accounts.User + alias BikeBrigade.Riders.Rider # TODO this should live somewhere else as gigalixir doesn't let me run tasks directly @@ -22,30 +24,52 @@ defmodule Mix.Tasks.FeatureFlags do @impl Mix.Task def run(["users", flag, opt_in]) when opt_in in @percentages do - rollout(Accounts.list_users(), flag, opt_in, &Accounts.update_user_as_admin/2) + with {:ok, flag} <- fetch_flag(User.Flags, flag) do + rollout(Accounts.list_users(), flag, opt_in, &Accounts.update_user_as_admin/2) + end end def run([flag, opt_in]) when opt_in in @percentages do - rollout(Riders.list_riders(), flag, opt_in, &Riders.update_rider/2) + with {:ok, flag} <- fetch_flag(Rider.Flags, flag) do + rollout(Riders.list_riders(), flag, opt_in, &Riders.update_rider/2) + end end - defp rollout(records, flag, opt_in, update) do - flag = String.to_atom(flag) - - case opt_in do - "0" -> - for r <- records, do: update.(r, %{flags: %{flag => false}}) + # Only flags defined on the schema can be set; anything else would be silently dropped. + defp fetch_flag(schema, name) do + flags = schema.__schema__(:fields) - "100" -> - for r <- records, do: update.(r, %{flags: %{flag => true}}) + case Enum.find(flags, &(Atom.to_string(&1) == name)) do + nil -> + Mix.shell().error("Unknown flag #{inspect(name)}. Known flags: #{Enum.join(flags, ", ")}") + :error - _ -> - percentage = String.to_integer(opt_in) / 100 + flag -> + {:ok, flag} + end + end - for r <- records, - opt_in(flag, r.id, percentage), - do: update.(r, %{flags: %{flag => true}}) + defp rollout(records, flag, opt_in, update) do + updates = + case opt_in do + "0" -> + for r <- records, do: {r, %{flags: %{flag => false}}} + + "100" -> + for r <- records, do: {r, %{flags: %{flag => true}}} + + _ -> + percentage = String.to_integer(opt_in) / 100 + for r <- records, opt_in(flag, r.id, percentage), do: {r, %{flags: %{flag => true}}} + end + + for {record, attrs} <- updates do + with {:error, changeset} <- update.(record, attrs) do + Mix.shell().error("Could not update #{record.id}: #{inspect(changeset.errors)}") + end end + + Mix.shell().info("Updated #{flag} for #{length(updates)} of #{length(records)}") end def opt_in(flag, id, percentage) do diff --git a/priv/repo/migrations/20261001000001_add_unified_messaging.exs b/priv/repo/migrations/20261001000001_add_unified_messaging.exs new file mode 100644 index 00000000..2d00b9f1 --- /dev/null +++ b/priv/repo/migrations/20261001000001_add_unified_messaging.exs @@ -0,0 +1,23 @@ +defmodule BikeBrigade.Repo.Migrations.AddUnifiedMessaging do + use Ecto.Migration + + def change do + create table(:bulk_message_groups) do + add :name, :text, null: false + add :body, :text, null: false + add :creator_id, references(:users), null: false + + timestamps() + end + + create index(:bulk_message_groups, [:inserted_at]) + + create table(:bulk_message_group_riders, primary_key: false) do + add :group_id, references(:bulk_message_groups, on_delete: :delete_all), null: false + add :rider_id, references(:riders, on_delete: :delete_all), null: false + end + + create unique_index(:bulk_message_group_riders, [:group_id, :rider_id]) + create index(:bulk_message_group_riders, [:rider_id]) + end +end diff --git a/priv/repo/seeds/messaging_inbox.exs b/priv/repo/seeds/messaging_inbox.exs new file mode 100644 index 00000000..db7165fc --- /dev/null +++ b/priv/repo/seeds/messaging_inbox.exs @@ -0,0 +1,126 @@ +# Seeds campaigns, delivery assignments, and conversations for exploring the +# messaging inbox and its deliveries panel. +# +# mix run priv/repo/seeds/messaging_inbox.exs +# +# Safe to run more than once; each run adds a fresh set of campaigns. Requires the +# base seeds (`mix run priv/repo/seeds.exs`) for riders and the dispatcher. + +alias BikeBrigade.{Accounts, Delivery, LocalizedDateTime, Messaging, Repo, Riders} +alias BikeBrigade.Repo.Seeds.{Seeder, Toronto} + +dispatcher = + Repo.get_by(Accounts.User, email: "dispatcher@example.com") || + raise "Run priv/repo/seeds.exs first to create dispatcher@example.com" + +{:ok, dispatcher} = + Accounts.update_user_as_admin(dispatcher, %{flags: %{unified_messaging: true}}) + +riders = Riders.list_riders() |> Enum.filter(& &1.phone) |> Enum.shuffle() +if length(riders) < 30, do: raise("Run priv/repo/seeds.exs first to create riders") + +program = + Seeder.program() + |> Delivery.Program.changeset(%{lead_id: dispatcher.id}) + |> Repo.update!() + +for _ <- 1..3, do: Seeder.item_for_program(program) + +today = LocalizedDateTime.today() + +# {label, day, start time, number of riders, statuses to cycle through} +campaigns = [ + {"yesterday", Date.add(today, -1), ~T[14:00:00], 4, [:completed, :completed, :pending]}, + {"this morning", today, ~T[10:00:00], 5, [:completed, :pending]}, + {"this afternoon", today, ~T[15:00:00], 5, [:pending]}, + # Outside the deliveries window, so its riders shouldn't get the deliveries bar or panel. + {"in three days", Date.add(today, 3), ~T[12:00:00], 3, [:pending]} +] + +incoming = [ + "On my way to pickup now", + "Nobody's answering at the door, what should I do?", + "Done with my first drop!", + "Is the second address the side entrance?", + "Running about 10 minutes late, sorry", + "All delivered 👍" +] + +outgoing = [ + "Thanks for riding today!", + "Try calling the recipient, number is in your app", + "Leave it with the concierge if they don't answer", + "No worries, thanks for the heads up" +] + +{campaign_riders, _} = + Enum.map_reduce(campaigns, riders, fn {label, day, time, rider_count, statuses}, riders -> + {campaign_riders, riders} = Enum.split(riders, rider_count) + start = LocalizedDateTime.new!(day, time) + + {:ok, campaign} = + Delivery.create_campaign(%{ + program_id: program.id, + delivery_start: start, + delivery_end: DateTime.add(start, 3, :hour), + location: Toronto.random_location() + }) + + for {rider, i} <- Enum.with_index(campaign_riders) do + {:ok, _} = + Delivery.create_campaign_rider(%{ + campaign_id: campaign.id, + rider_id: rider.id, + rider_capacity: 3, + pickup_window: + "#{Calendar.strftime(time, "%-I:%M")}–#{Calendar.strftime(Time.add(time, 30, :minute), "%-I:%M %p")}" + }) + + # 1–3 deliveries per rider, with statuses cycling so each campaign has a mix. + for j <- 0..rem(i, 3) do + task = Seeder.task_for_campaign(campaign) + {:ok, task} = Delivery.assign_task(task, rider.id, dispatcher.id) + + {:ok, _} = + Delivery.update_task(task, %{ + dropoff_phone: "416-555-#{Enum.random(1000..9999)}", + delivery_instructions: + Enum.random(["Buzz 204", "Side door", "Leave with concierge", nil]), + delivery_status: Enum.at(statuses, rem(i + j, length(statuses))) + }) + end + end + + IO.puts("Created campaign #{campaign.id} (#{label}) with #{rider_count} riders") + {Enum.map(campaign_riders, &{&1, start}), riders} + end) + +# Give every campaign rider a short conversation so they show up in the inbox. +for {{rider, start}, i} <- Enum.with_index(List.flatten(campaign_riders)) do + rider_number = Messaging.inbound_number(rider) + + [ + {false, Enum.at(outgoing, rem(i, length(outgoing))), -60}, + {true, Enum.at(incoming, rem(i, length(incoming))), 15} + ] + |> Enum.each(fn {incoming?, body, minutes} -> + {from, to} = if incoming?, do: {rider.phone, rider_number}, else: {rider_number, rider.phone} + + {:ok, _} = + Messaging.create_sms_message(%{ + rider_id: rider.id, + incoming: incoming?, + from: from, + to: to, + body: body, + sent_at: DateTime.add(start, minutes, :minute), + sent_by_user_id: unless(incoming?, do: dispatcher.id) + }) + end) +end + +IO.puts(""" + +Program "#{program.name}" is led by #{dispatcher.email}, who is now opted into the +unified messaging inbox. Open /messages/inbox?tab=campaigns to explore. +""") diff --git a/test/bike_brigade/delivery_test.exs b/test/bike_brigade/delivery_test.exs index 47c52923..114ae7b7 100644 --- a/test/bike_brigade/delivery_test.exs +++ b/test/bike_brigade/delivery_test.exs @@ -503,6 +503,42 @@ defmodule BikeBrigade.DeliveryTest do end end + describe "list_campaigns/1 with :lead_id" do + test "only returns campaigns from programs led by the user" do + lead = fixture(:user, %{is_dispatcher: true}) + program = fixture(:program, %{lead_id: lead.id}) + week = Date.beginning_of_week(LocalizedDateTime.today()) + start = LocalizedDateTime.new!(week, ~T[12:00:00]) + + led = fixture(:campaign, %{program_id: program.id, delivery_start: start}) + other = fixture(:campaign, %{delivery_start: start}) + + opts = [start_date: week, end_date: Date.add(week, 6)] + + assert Enum.map(Delivery.list_campaigns([lead_id: lead.id] ++ opts), & &1.id) == [led.id] + assert other.id in Enum.map(Delivery.list_campaigns(opts), & &1.id) + end + end + + describe "delivery_progress_by_rider/1" do + test "counts assigned and completed tasks per rider" do + campaign = fixture(:campaign) + rider = fixture(:rider) + other_rider = fixture(:rider) + + task = fixture(:task, %{campaign: campaign, rider: rider}) + fixture(:task, %{campaign: campaign, rider: rider}) + fixture(:task, %{campaign: campaign, rider: other_rider}) + fixture(:task, %{campaign: campaign}) + {:ok, _} = Delivery.update_task(task, %{delivery_status: :completed}) + + assert Delivery.delivery_progress_by_rider(campaign.id) == %{ + rider.id => {2, 1}, + other_rider.id => {1, 0} + } + end + end + def item_name(%Task{task_items: [%{item: %{name: item_name}}]}), do: item_name defp get_utc_now(), do: NaiveDateTime.utc_now() diff --git a/test/bike_brigade/messaging_test.exs b/test/bike_brigade/messaging_test.exs index ccb0dc12..d658d502 100644 --- a/test/bike_brigade/messaging_test.exs +++ b/test/bike_brigade/messaging_test.exs @@ -38,4 +38,121 @@ defmodule BikeBrigade.MessagingTest do assert Messaging.maybe_append_signature("Hello", "not_an_id") == "Hello" end end + + describe "bulk message groups" do + test "create_bulk_message_group/3 saves the creator and riders and broadcasts" do + creator = fixture(:user, %{is_dispatcher: true}) + riders = [fixture(:rider), fixture(:rider)] + Messaging.subscribe() + + assert {:ok, group} = + Messaging.create_bulk_message_group(creator, riders, %{ + name: "Backup riders", + body: "Anyone free?" + }) + + assert_receive {:bulk_message_group_created, %{id: id}} + assert id == group.id + + assert [listed] = Messaging.list_bulk_message_groups() + assert listed.creator.id == creator.id + assert Enum.sort(Enum.map(listed.riders, & &1.id)) == Enum.sort(Enum.map(riders, & &1.id)) + end + + test "create_bulk_message_group/3 requires a name" do + creator = fixture(:user, %{is_dispatcher: true}) + Messaging.subscribe() + + assert {:error, %Ecto.Changeset{}} = + Messaging.create_bulk_message_group(creator, [fixture(:rider)], %{body: "Hi"}) + + refute_receive {:bulk_message_group_created, _} + assert Messaging.list_bulk_message_groups() == [] + end + end + + describe "send_bulk_message/3" do + defmodule SelectiveFailureSmsService do + alias BikeBrigade.SmsService + + @behaviour SmsService + + @impl SmsService + def send_sms(payload) do + if payload[:to] == Application.get_env(:bike_brigade, :sms_service)[:test_failed_phone] do + {:error, "Test transport failure"} + else + {:ok, %{status: "queued", sid: "TEST#{Ecto.UUID.generate()}"}} + end + end + + @impl SmsService + def request_valid?(_url, _params, _signature), do: true + end + + setup do + cfg = Application.get_env(:bike_brigade, :sms_service) + on_exit(fn -> Application.put_env(:bike_brigade, :sms_service, cfg) end) + + %{sender: fixture(:user, %{is_dispatcher: true})} + end + + test "returns a changeset describing what's missing", %{sender: sender} do + rider = fixture(:rider) + + assert {:error, changeset} = Messaging.send_bulk_message(sender, %{body: "Hi"}) + assert %{rider_ids: ["Select at least one rider"]} = errors_on(changeset) + + assert {:error, changeset} = + Messaging.send_bulk_message(sender, %{rider_ids: [rider.id], body: " "}) + + assert %{body: ["Enter a message"]} = errors_on(changeset) + + assert {:error, changeset} = + Messaging.send_bulk_message(sender, %{ + rider_ids: [rider.id], + body: String.duplicate("a", 1601) + }) + + assert %{body: ["Messages can be up to 1600 characters"]} = errors_on(changeset) + + assert {:error, changeset} = + Messaging.send_bulk_message(sender, %{ + rider_ids: [rider.id], + body: "Hi", + save_group: true + }) + + assert %{group_name: ["Enter a name for the group"]} = errors_on(changeset) + assert Messaging.latest_messages(rider) == [] + end + + test "the body is optional when there are attachments" do + assert Messaging.change_bulk_message(%{rider_ids: [1]}, has_media: true).valid? + refute Messaging.change_bulk_message(%{rider_ids: [1]}).valid? + end + + test "partial transport failures keep the saved group and report failed riders", %{ + sender: sender + } do + successful = fixture(:rider, %{flags: %{initial_message_sent: true}}) + failed = fixture(:rider) + BikeBrigade.Utils.put_env(:sms_service, :adapter, SelectiveFailureSmsService) + BikeBrigade.Utils.put_env(:sms_service, :test_failed_phone, failed.phone) + + assert {:ok, result} = + Messaging.send_bulk_message(sender, %{ + rider_ids: [successful.id, failed.id], + body: "Test message", + save_group: true, + group_name: "Retry cohort" + }) + + assert result.sent == 1 + assert Enum.map(result.failed, & &1.id) == [failed.id] + assert length(result.group.riders) == 2 + assert Enum.any?(Messaging.latest_messages(successful), &(&1.body =~ "Test message")) + assert Messaging.latest_messages(failed) == [] + end + end end diff --git a/test/bike_brigade_web/live/messaging_inbox_live_test.exs b/test/bike_brigade_web/live/messaging_inbox_live_test.exs new file mode 100644 index 00000000..8ad36dfa --- /dev/null +++ b/test/bike_brigade_web/live/messaging_inbox_live_test.exs @@ -0,0 +1,439 @@ +defmodule BikeBrigadeWeb.MessagingInboxLiveTest do + use BikeBrigadeWeb.ConnCase + import Phoenix.LiveViewTest + alias BikeBrigade.{Accounts, Messaging, Repo} + setup [:login, :opt_in] + + # Messages sent within the same second have no guaranteed order, so look them up by text. + defp message_to(rider, text) do + Enum.find(Messaging.latest_messages(rider), &(&1.body =~ text)) + end + + defp opt_in(%{user: user}) do + {:ok, user} = Accounts.update_user_as_admin(user, %{flags: %{unified_messaging: true}}) + %{user: user} + end + + describe "routing" do + test "opted-in dispatchers are routed to the inbox", %{conn: conn} do + assert {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/messages") + assert to =~ "/messages/inbox" + + rider = fixture(:rider) + assert {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/messages/#{rider}") + assert to =~ "rider=#{rider.id}" + + assert {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/messages/new") + assert to =~ "compose=new" + + assert {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/messages/#{rider}/tasks") + assert to =~ "panel=deliveries" + assert to =~ "rider=#{rider.id}" + + {:ok, view, _} = live(conn, ~p"/messages/inbox") + assert has_element?(view, "#messaging-inbox") + refute has_element?(view, "#new-bulk") + end + + test "dispatchers who are not opted in keep the old page", %{conn: conn, user: user} do + {:ok, _} = Accounts.update_user_as_admin(user, %{flags: %{unified_messaging: false}}) + {:ok, _, html} = live(conn, ~p"/messages") + refute html =~ "messaging-inbox" + + assert {:error, {:live_redirect, %{to: "/messages", flash: flash}}} = + live(conn, ~p"/messages/inbox") + + assert flash["error"] =~ "isn't enabled" + end + + test "malformed params are ignored", %{conn: conn} do + {:ok, view, _} = live(conn, "/messages/inbox?search[]=x&rider[]=1&tab[]=groups") + assert has_element?(view, "#inbox-riders") + end + end + + describe "all conversations" do + test "legacy unnamed riders remain searchable and usable", %{conn: conn} do + import Ecto.Query + rider = fixture(:rider) + fixture(:sms_message_from_rider, rider) + + Repo.update_all(from(r in BikeBrigade.Riders.Rider, where: r.id == ^rider.id), + set: [name: nil] + ) + + {:ok, view, _} = live(conn, ~p"/messages/inbox?rider=#{rider.id}") + assert has_element?(view, "#conversation-form") + assert has_element?(view, "#inbox-riders", rider.phone) + end + + test "incoming messages preserve a composed reply", %{conn: conn} do + rider = fixture(:rider) + fixture(:sms_message_from_rider, rider) + {:ok, view, _} = live(conn, ~p"/messages/inbox?rider=#{rider.id}") + + view + |> form("#conversation-form", %{sms_message: %{body: "Keep my draft"}}) + |> render_change() + + fixture(:sms_message_from_rider, rider, %{body: "New incoming message"}) + assert has_element?(view, "#conversation-form textarea", "Keep my draft") + assert render(view) =~ "New incoming message" + end + end + + describe "campaigns" do + test "campaigns use this week and program lead, without saving a duplicate group", %{ + conn: conn, + user: user + } do + p = fixture(:program, %{lead_id: user.id}) + now = BikeBrigade.LocalizedDateTime.now() + + campaign = + fixture(:campaign, %{ + program_id: p.id, + delivery_start: now, + delivery_end: DateTime.add(now, 3600) + }) + + other = fixture(:campaign, %{delivery_start: now, delivery_end: DateTime.add(now, 3600)}) + rider = fixture(:rider) + BikeBrigade.Delivery.create_campaign_rider(%{campaign_id: campaign.id, rider_id: rider.id}) + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=campaigns") + assert has_element?(view, "#inbox-contexts", BikeBrigadeWeb.CampaignHelpers.name(campaign)) + refute has_element?(view, "#inbox-contexts", BikeBrigadeWeb.CampaignHelpers.name(other)) + task = fixture(:task, %{campaign: campaign, rider: rider}) + fixture(:task, %{campaign: campaign, rider: rider}) + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=campaigns&context=#{campaign.id}") + refute has_element?(view, "#new-bulk") + + assert has_element?( + view, + "#merged-inbox [data-rider-id='#{rider.id}'] [data-deliveries]", + "0/2" + ) + + BikeBrigade.Delivery.update_task(task, %{delivery_status: :completed}) + + assert has_element?( + view, + "#merged-inbox [data-rider-id='#{rider.id}'] [data-deliveries]", + "1/2" + ) + + refute has_element?(view, "#everyone-drawer") + view |> element("#message-everyone") |> render_click() + + view + |> form("#group-message-form", %{everyone: %{body: "Pickup moved"}}) + |> render_submit() + + refute has_element?(view, "#everyone-drawer") + assert Messaging.list_bulk_message_groups() == [] + assert message_to(rider, "Pickup moved").campaign_id == campaign.id + end + + test "Only my campaigns toggles between my and all campaigns", %{conn: conn, user: user} do + mine = fixture(:campaign, %{program_id: fixture(:program, %{lead_id: user.id}).id}) + other = fixture(:campaign) + name = &BikeBrigadeWeb.CampaignHelpers.name/1 + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=campaigns") + assert has_element?(view, "#inbox-mine[role=switch][aria-checked=true]") + refute has_element?(view, "#inbox-contexts", name.(other)) + + view |> element("#inbox-mine") |> render_click() + assert_patch(view, ~p"/messages/inbox?mine=false&tab=campaigns") + assert has_element?(view, "#inbox-contexts", name.(other)) + assert has_element?(view, "#inbox-contexts", name.(mine)) + end + + test "the campaign page's link opens its campaign even outside this week", %{conn: conn} do + next_month = DateTime.add(BikeBrigade.LocalizedDateTime.now(), 30, :day) + + campaign = + fixture(:campaign, %{ + delivery_start: next_month, + delivery_end: DateTime.add(next_month, 3600) + }) + + rider = fixture(:rider) + BikeBrigade.Delivery.create_campaign_rider(%{campaign_id: campaign.id, rider_id: rider.id}) + + {:ok, view, _} = + live(conn, ~p"/messages/inbox?tab=campaigns&context=#{campaign.id}&mine=false") + + assert has_element?(view, "#merged-inbox [data-rider-id='#{rider.id}']") + assert has_element?(view, "#message-everyone") + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=campaigns&context=not-a-campaign") + refute has_element?(view, "#message-everyone") + end + end + + describe "bulk messages" do + test "saved groups persist recipients and creator, and replies remain individual", %{ + conn: conn, + user: user + } do + {:ok, user} = Accounts.update_user_as_admin(user, %{signature_on_messages: "Dispatch"}) + + r1 = fixture(:rider) + r2 = fixture(:rider) + + assert {:ok, result} = + Messaging.send_bulk_message(user, %{ + rider_ids: [r1.id, r2.id], + body: "Backup riders?", + save_group: true, + group_name: "Backup riders" + }) + + assert result.sent == 2 + assert result.failed == [] + [group] = Messaging.list_bulk_message_groups() + assert group.creator.id == user.id + assert Enum.sort(Enum.map(group.riders, & &1.id)) == Enum.sort([r1.id, r2.id]) + + {:ok, view, _} = + live(conn, ~p"/messages/inbox?tab=groups&context=#{group.id}&rider=#{r1.id}") + + assert has_element?(view, "#message-everyone") + assert has_element?(view, "#new-bulk") + view |> form("#conversation-form", %{sms_message: %{body: "Only you"}}) |> render_submit() + assert message_to(r1, "Only you") + refute message_to(r2, "Only you") + render_patch(view, ~p"/messages/inbox?tab=groups&context=#{group.id}&search=#{r1.name}") + view |> element("#message-everyone") |> render_click() + + view + |> form("#group-message-form", %{everyone: %{body: "Everyone again"}}) + |> render_submit() + + assert message_to(r1, "Everyone again") + assert message_to(r2, "Everyone again") + assert length(Messaging.list_bulk_message_groups()) == 1 + assert Repo.get!(BikeBrigade.Messaging.BulkMessageGroup, group.id).body == "Backup riders?" + end + + test "saving a group needs a name, and the drawer starts empty when reopened", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups") + view |> element("#new-bulk") |> render_click() + assert has_element?(view, "#new-bulk-message") + + view |> form("#inbox-bulk-form", %{bulk_message: %{body: "Hello"}}) |> render_change() + refute has_element?(view, "#inbox-bulk-form input[name='bulk_message[group_name]']") + + view |> element("#save-group-toggle") |> render_click() + assert has_element?(view, "#save-group-toggle[aria-checked=true]") + assert has_element?(view, "#inbox-bulk-form input[name='bulk_message[group_name]']") + # Toggling keeps what was already typed. + assert has_element?(view, "#inbox-bulk-form textarea[name='bulk_message[body]']", "Hello") + + view |> form("#inbox-bulk-form", %{bulk_message: %{body: "Hello"}}) |> render_submit() + + assert has_element?(view, "#inbox-bulk-form", "Enter a name for the group") + + view |> element("#new-bulk-message a", "Cancel") |> render_click() + refute has_element?(view, "#new-bulk-message") + + view |> element("#new-bulk") |> render_click() + refute has_element?(view, "#inbox-bulk-form", "Enter a name for the group") + refute has_element?(view, "#inbox-bulk-form input[name='bulk_message[group_name]']") + assert has_element?(view, "#inbox-bulk-form textarea[name='bulk_message[body]']", "") + end + + test "closing a drawer opened from /messages/new drops it from the URL", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups&compose=new") + view |> element("#new-bulk-message a", "Cancel") |> render_click() + assert_patch(view, ~p"/messages/inbox?tab=groups") + refute has_element?(view, "#new-bulk-message") + end + + test "new bulk message requires picking recipients", %{conn: conn} do + rider = fixture(:rider) + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups") + view |> element("#new-bulk") |> render_click() + + view |> form("#inbox-bulk-form", %{bulk_message: %{body: "Hello"}}) |> render_submit() + assert has_element?(view, "#inbox-bulk-form", "Select at least one rider") + + view |> element("#inbox-rider-select-input") |> render_keyup(%{"value" => rider.name}) + view |> element("[id='rider-selection:#{rider.id}'] a") |> render_click() + view |> form("#inbox-bulk-form", %{bulk_message: %{body: "Hello"}}) |> render_submit() + assert message_to(rider, "Hello") + assert Messaging.list_bulk_message_groups() == [] + end + + test "sending from /messages/new closes the drawer for good", %{conn: conn} do + rider = fixture(:rider) + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups&compose=new") + + view |> element("#inbox-rider-select-input") |> render_keyup(%{"value" => rider.name}) + view |> element("[id='rider-selection:#{rider.id}'] a") |> render_click() + view |> form("#inbox-bulk-form", %{bulk_message: %{body: "Hello"}}) |> render_submit() + + assert message_to(rider, "Hello") + assert_patch(view, ~p"/messages/inbox?tab=groups") + refute has_element?(view, "#new-bulk-message") + + # Navigating elsewhere doesn't bring it back. + render_patch(view, ~p"/messages/inbox?tab=campaigns") + refute has_element?(view, "#new-bulk-message") + end + + test "photos that can't be attached block sending instead of crashing", %{conn: conn} do + rider = fixture(:rider) + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups&compose=new") + + upload = + file_input(view, "#inbox-bulk-form", :media, [ + %{name: "notes.txt", content: "not a photo", type: "text/plain"} + ]) + + assert {:error, _} = render_upload(upload, "notes.txt") + + view |> element("#inbox-rider-select-input") |> render_keyup(%{"value" => rider.name}) + view |> element("[id='rider-selection:#{rider.id}'] a") |> render_click() + html = view |> form("#inbox-bulk-form", %{bulk_message: %{body: ""}}) |> render_submit() + + assert html =~ "Remove the photos that can't be attached" + assert has_element?(view, "#new-bulk-message") + refute message_to(rider, "") + end + + test "an unknown bulk message selects nothing", %{conn: conn} do + group = fixture(:bulk_message_group, %{name: "Evening crew"}) + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups") + assert has_element?(view, "#merged-inbox h2", "Evening crew") + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups&context=#{group.id + 1}") + refute has_element?(view, "#merged-inbox h2", "Evening crew") + refute has_element?(view, "#message-everyone") + end + end + + describe "deliveries panel" do + test "deliveries panel shows today's tasks and toggles them delivered", %{conn: conn} do + rider = fixture(:rider) + task = fixture(:task, %{rider: rider, dropoff_name: "Jordan"}) + fixture(:sms_message_from_rider, rider) + + {:ok, _} = + BikeBrigade.Delivery.create_campaign_rider(%{ + campaign_id: task.campaign_id, + rider_id: rider.id, + pickup_window: "4:15–4:30 PM" + }) + + {:ok, view, _} = live(conn, ~p"/messages/inbox?rider=#{rider.id}") + + # Always beside the chat on xl screens; narrower screens get a summary bar instead. + assert has_element?(view, "#rider-deliveries.hidden.xl\\:flex") + assert has_element?(view, "#rider-delivery-#{task.id}", "Jordan") + assert has_element?(view, "#rider-deliveries", "4:15–4:30 PM") + assert has_element?(view, "#deliveries-bar.xl\\:hidden", "0 of 1 done") + + view |> element("#deliveries-bar") |> render_click() + assert_patch(view, ~p"/messages/inbox?panel=deliveries&rider=#{rider.id}") + refute has_element?(view, "#rider-deliveries.hidden") + + toggle = element(view, "#rider-delivery-#{task.id} [role=checkbox]") + render_click(toggle) + assert BikeBrigade.Delivery.get_task(task.id).delivery_status == :completed + assert has_element?(view, "#deliveries-bar", "1 of 1 done") + assert has_element?(view, "#rider-delivery-#{task.id} [aria-checked=true]") + + render_click(toggle) + assert BikeBrigade.Delivery.get_task(task.id).delivery_status == :pending + + view |> element("#rider-deliveries a[aria-label='Back to conversation']") |> render_click() + assert_patch(view, ~p"/messages/inbox?rider=#{rider.id}") + assert has_element?(view, "#rider-deliveries.hidden") + end + + test "riders without recent deliveries have no deliveries bar or panel", %{conn: conn} do + rider = fixture(:rider) + fixture(:sms_message_from_rider, rider) + + for panel <- ["", "deliveries"] do + {:ok, view, _} = live(conn, ~p"/messages/inbox?rider=#{rider.id}&panel=#{panel}") + refute has_element?(view, "#deliveries-bar") + refute has_element?(view, "#rider-deliveries") + end + end + end + + describe "live updates" do + test "groups saved by another dispatcher appear in Bulk messages", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=groups") + refute has_element?(view, "#inbox-contexts", "Evening crew") + + other_dispatcher = fixture(:user, %{is_dispatcher: true}) + + fixture(:bulk_message_group, %{creator: other_dispatcher, name: "Evening crew"}) + + assert has_element?(view, "#inbox-contexts", "Evening crew") + assert has_element?(view, "#inbox-tab-groups", "1") + end + + test "new messages update the All conversations list", %{conn: conn} do + quiet = fixture(:rider) + fixture(:sms_message_from_rider, quiet, %{body: "Earlier"}) + newcomer = fixture(:rider) + + {:ok, view, _} = live(conn, ~p"/messages/inbox?rider=#{quiet.id}") + refute has_element?(view, "#conversation-list-item\\:#{newcomer.id}") + + fixture(:sms_message_from_rider, newcomer, %{body: "First text"}) + assert has_element?(view, "#conversation-list-item\\:#{newcomer.id}", "First text") + + fixture(:sms_message_from_rider, quiet, %{body: "Following up"}) + assert has_element?(view, "#conversation-list-item\\:#{quiet.id}", "Following up") + end + + test "new messages update the campaign roster, task changes update its delivery chips", + %{conn: conn, user: user} do + campaign = fixture(:campaign, %{program_id: fixture(:program, %{lead_id: user.id}).id}) + other_campaign = fixture(:campaign) + rider = fixture(:rider) + task = fixture(:task, %{campaign: campaign, rider: rider}) + other_task = fixture(:task, %{campaign: other_campaign, rider: rider}) + row = "#merged-inbox [data-rider-id='#{rider.id}']" + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=campaigns&context=#{campaign.id}") + assert has_element?(view, "#{row} [data-deliveries]", "0/1") + + fixture(:sms_message_from_rider, rider, %{body: "At the door"}) + assert has_element?(view, row, "At the door") + + # Another campaign's tasks don't affect this roster. + BikeBrigade.Delivery.update_task(other_task, %{delivery_status: :completed}) + assert has_element?(view, "#{row} [data-deliveries]", "0/1") + + BikeBrigade.Delivery.update_task(task, %{delivery_status: :completed}) + assert has_element?(view, "#{row} [data-deliveries]", "1/1") + end + + test "riders added to the selected campaign join its roster", %{conn: conn, user: user} do + campaign = fixture(:campaign, %{program_id: fixture(:program, %{lead_id: user.id}).id}) + rider = fixture(:rider) + + {:ok, view, _} = live(conn, ~p"/messages/inbox?tab=campaigns&context=#{campaign.id}") + refute has_element?(view, "#merged-inbox [data-rider-id='#{rider.id}']") + + BikeBrigade.Delivery.create_campaign_rider(%{campaign_id: campaign.id, rider_id: rider.id}) + assert has_element?(view, "#merged-inbox [data-rider-id='#{rider.id}']") + end + + test "unrelated messages are ignored", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/messages/inbox") + send(view.pid, :something_else) + send(view.pid, {:opportunity_updated, %{}}) + assert has_element?(view, "#messaging-inbox") + end + end +end diff --git a/test/bike_brigade_web/live/sms_message_live.exs b/test/bike_brigade_web/live/sms_message_live.exs deleted file mode 100644 index fe7e6697..00000000 --- a/test/bike_brigade_web/live/sms_message_live.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule BikeBrigadeWeb.OpportunityLiveTest do - use BikeBrigadeWeb.ConnCase - import Phoenix.LiveViewTest - - describe "Messages :index" do - setup [:login, :create_rider] - - test "Adding new rider to message works", ctx do - {:ok, view, _index} = live(ctx.conn, ~p"/messages/") - view |> element("a", "New") |> render_click() - assert_patch(view, ~p"/messages/new") - - view - |> form("#sms_message-form") - |> render_submit(%{ - "rider_ids" => [ctx.rider.id], - "search" => "f", - "sms_message" => %{"body" => "this is a test message"} - }) - - assert_redirected(view, ~p"/messages/#{ctx.rider.id}") - - {:ok, _view, html} = live(ctx.conn, ~p"/messages/") - assert html =~ "this is a test message" - end - end -end diff --git a/test/mix/tasks/feature_flags_test.exs b/test/mix/tasks/feature_flags_test.exs index d50e6f46..aad59f4b 100644 --- a/test/mix/tasks/feature_flags_test.exs +++ b/test/mix/tasks/feature_flags_test.exs @@ -3,6 +3,11 @@ defmodule Mix.Tasks.FeatureFlagsTest do alias BikeBrigade.Accounts + setup do + Mix.shell(Mix.Shell.Process) + on_exit(fn -> Mix.shell(Mix.Shell.IO) end) + end + test "rolls a flag out to users" do user = fixture(:user) @@ -19,4 +24,18 @@ defmodule Mix.Tasks.FeatureFlagsTest do Mix.Tasks.FeatureFlags.run(["opt_in_to_new_number", "100"]) assert BikeBrigade.Riders.get_rider(rider.id).flags.opt_in_to_new_number end + + test "rejects flags that aren't defined" do + user = fixture(:user) + + Mix.Tasks.FeatureFlags.run(["users", "unified_mesaging", "100"]) + + assert_received {:mix_shell, :error, [message]} + assert message =~ ~s(Unknown flag "unified_mesaging") + assert message =~ "unified_messaging" + refute Accounts.get_user(user.id).flags.unified_messaging + + Mix.Tasks.FeatureFlags.run(["not_a_rider_flag", "100"]) + assert_received {:mix_shell, :error, ["Unknown flag \"not_a_rider_flag\"" <> _]} + end end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index e392f86e..cad98809 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -280,6 +280,22 @@ defmodule BikeBrigade.Fixtures do banner end + def fixture(:bulk_message_group, attrs) do + {creator, attrs} = + Map.pop_lazy(attrs, :creator, fn -> fixture(:user, %{is_dispatcher: true}) end) + + {riders, attrs} = Map.pop_lazy(attrs, :riders, fn -> [fixture(:rider)] end) + + {:ok, group} = + Messaging.create_bulk_message_group( + creator, + riders, + Map.merge(%{name: "#{Faker.Lorem.word()} riders", body: Faker.Lorem.sentence()}, attrs) + ) + + group + end + def fixture(:tag, attrs) do {:ok, tag} = %{