Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion assets/js/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,5 +27,6 @@ export default {
CheckboxAll,
Frame,
TagsComponent,
LeafletMap
LeafletMap,
MessagingInbox
}
22 changes: 22 additions & 0 deletions assets/js/hooks/messaging_inbox.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion assets/js/hooks/rider_selection_list.js
Original file line number Diff line number Diff line change
@@ -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?
Expand Down
8 changes: 8 additions & 0 deletions lib/bike_brigade/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions lib/bike_brigade/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,29 @@ 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
field :phone, EctoPhoneNumber.Canadian
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
Expand All @@ -31,5 +47,6 @@ defmodule BikeBrigade.Accounts.User do
user
|> changeset(attrs)
|> cast(attrs, [:is_dispatcher, :rider_id])
|> cast_embed(:flags)
end
end
27 changes: 26 additions & 1 deletion lib/bike_brigade/delivery.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -613,14 +621,31 @@ 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]

Repo.all(query)
|> 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)
Expand Down
1 change: 1 addition & 0 deletions lib/bike_brigade/delivery/campaign.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
96 changes: 94 additions & 2 deletions lib/bike_brigade/messaging.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
49 changes: 49 additions & 0 deletions lib/bike_brigade/messaging/bulk_message.ex
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions lib/bike_brigade/messaging/bulk_message_group.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading