Skip to content

Commit a494af2

Browse files
authored
Merge pull request #1296 from code-corps/1288-conversation-index
Conversation index endpoint with supporting infrastructure
2 parents d64bab9 + 21293b0 commit a494af2

File tree

12 files changed

+433
-8
lines changed

12 files changed

+433
-8
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defmodule CodeCorps.Messages.ConversationQuery do
2+
@moduledoc ~S"""
3+
Holds helpers to query `CodeCorps.Conversation` records using a map of params.
4+
"""
5+
6+
import Ecto.Query
7+
8+
alias CodeCorps.{Conversation, ConversationPart, Message, Repo}
9+
alias Ecto.Queryable
10+
11+
@doc ~S"""
12+
Narrows down a `CodeCorps.Conversation` query by `project_id` of the parent
13+
`CodeCorps.Message`, if specified in a params map
14+
"""
15+
@spec project_filter(Queryable.t, map) :: Queryable.t
16+
def project_filter(queryable, %{"project_id" => project_id}) do
17+
queryable
18+
|> join(:left, [c], m in Message, c.message_id == m.id)
19+
|> where([_c, m], m.project_id == ^project_id)
20+
end
21+
def project_filter(queryable, %{}), do: queryable
22+
23+
24+
@doc ~S"""
25+
Narrows down a `CodeCorps.Conversation` query to return only those records
26+
considered to have a specific status.
27+
28+
The status of `active` means that only those records are included which either
29+
- belong to a `CodeCorps.Message` initiated by user
30+
- belong to a `CodeCorps.Message` initiated by admin, with at least a single
31+
reply in the form of a `CodeCorps.ConversationPart`
32+
"""
33+
@spec status_filter(Queryable.t, map) :: Queryable.t
34+
def status_filter(queryable, %{"status" => "active"}) do
35+
prefiltered_ids = queryable |> select([c], c.id) |> Repo.all
36+
37+
Conversation
38+
|> where([c], c.id in ^prefiltered_ids)
39+
|> join(:left, [c], m in Message, c.message_id == m.id)
40+
|> join(:left, [c, _m], cp in ConversationPart, c.id == cp.conversation_id)
41+
|> group_by([c, m, _cp], [c.id, m.initiated_by])
42+
|> having([_c, m, _cp], m.initiated_by == "user")
43+
|> or_having([c, m, cp], m.initiated_by == "admin" and count(cp.id) > 0)
44+
end
45+
def status_filter(query, %{}), do: query
46+
end

lib/code_corps/messages/messages.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule CodeCorps.Messages do
33
Main context for work with the Messaging feature.
44
"""
55

6-
alias CodeCorps.{Helpers.Query, Message, Messages, Repo}
6+
alias CodeCorps.{Conversation, Helpers.Query, Message, Messages, Repo}
77
alias Ecto.{Changeset, Queryable}
88

99
@doc ~S"""
@@ -18,6 +18,17 @@ defmodule CodeCorps.Messages do
1818
|> Repo.all()
1919
end
2020

21+
@doc ~S"""
22+
Lists pre-scoped `CodeCorps.Conversation` records filtered by parameters
23+
"""
24+
@spec list_conversations(Queryable.t, map) :: list(Conversation.t)
25+
def list_conversations(scope, %{} = params) do
26+
scope
27+
|> Messages.ConversationQuery.project_filter(params)
28+
|> Messages.ConversationQuery.status_filter(params)
29+
|> Repo.all()
30+
end
31+
2132
@doc ~S"""
2233
Creates a `CodeCorps.Message` from a set of parameters.
2334
"""

lib/code_corps/model/conversation.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ defmodule CodeCorps.Conversation do
1616
@type t :: %__MODULE__{}
1717

1818
schema "conversations" do
19-
field :status, :string, null: false, default: "open"
2019
field :read_at, :utc_datetime, null: true
20+
field :status, :string, null: false, default: "open"
2121

2222
belongs_to :message, CodeCorps.Message
2323
belongs_to :user, CodeCorps.User

lib/code_corps/policy/conversation.ex

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
defmodule CodeCorps.Policy.Conversation do
2+
@moduledoc ~S"""
3+
Handles `CodeCorps.User` authorization of actions on `CodeCorps.Conversation`
4+
records.
5+
"""
6+
7+
import Ecto.Query
8+
9+
alias CodeCorps.{Message, Policy, Repo, User}
10+
11+
@spec scope(Ecto.Queryable.t, User.t) :: Ecto.Queryable.t
12+
def scope(queryable, %User{admin: true}), do: queryable
13+
def scope(queryable, %User{id: id} = current_user) do
14+
scoped_message_ids =
15+
Message
16+
|> Policy.Message.scope(current_user)
17+
|> select([m], m.id)
18+
|> Repo.all
19+
20+
queryable
21+
|> where(user_id: ^id)
22+
|> or_where([c], c.message_id in ^scoped_message_ids)
23+
end
24+
end

lib/code_corps/policy/policy.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule CodeCorps.Policy do
33
Handles authorization for various API actions performed on objects in the database.
44
"""
55

6-
alias CodeCorps.{Category, Comment, DonationGoal, GithubAppInstallation, GithubEvent, GithubRepo, Message, Organization, OrganizationInvite, OrganizationGithubAppInstallation, Preview, Project, ProjectCategory, ProjectSkill, ProjectUser, Role, RoleSkill, Skill, StripeConnectAccount, StripeConnectPlan, StripeConnectSubscription, StripePlatformCard, StripePlatformCustomer, Task, TaskSkill, User, UserCategory, UserRole, UserSkill, UserTask}
6+
alias CodeCorps.{Category, Comment, Conversation, DonationGoal, GithubAppInstallation, GithubEvent, GithubRepo, Message, Organization, OrganizationInvite, OrganizationGithubAppInstallation, Preview, Project, ProjectCategory, ProjectSkill, ProjectUser, Role, RoleSkill, Skill, StripeConnectAccount, StripeConnectPlan, StripeConnectSubscription, StripePlatformCard, StripePlatformCustomer, Task, TaskSkill, User, UserCategory, UserRole, UserSkill, UserTask}
77

88
alias CodeCorps.Policy
99

@@ -28,6 +28,7 @@ defmodule CodeCorps.Policy do
2828
"""
2929
@spec scope(module, User.t) :: Ecto.Queryable.t
3030
def scope(Message, %User{} = current_user), do: Message |> Policy.Message.scope(current_user)
31+
def scope(Conversation, %User{} = current_user), do: Conversation |> Policy.Conversation.scope(current_user)
3132

3233
@spec can?(User.t, atom, struct, map) :: boolean
3334

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
defmodule CodeCorpsWeb.ConversationController do
2+
@moduledoc false
3+
use CodeCorpsWeb, :controller
4+
5+
alias CodeCorps.{
6+
Conversation,
7+
Messages,
8+
User
9+
}
10+
11+
action_fallback CodeCorpsWeb.FallbackController
12+
plug CodeCorpsWeb.Plug.DataToAttributes
13+
plug CodeCorpsWeb.Plug.IdsToIntegers
14+
15+
@spec index(Conn.t, map) :: Conn.t
16+
def index(%Conn{} = conn, %{} = params) do
17+
with %User{} = current_user <- conn |> CodeCorps.Guardian.Plug.current_resource,
18+
conversations <- Conversation |> Policy.scope(current_user) |> Messages.list_conversations(params) do
19+
conn |> render("index.json-api", data: conversations)
20+
end
21+
end
22+
end

lib/code_corps_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ defmodule CodeCorpsWeb.Router do
7171

7272
resources "/categories", CategoryController, only: [:create, :update]
7373
resources "/comments", CommentController, only: [:create, :update]
74+
resources "/conversations", ConversationController, only: [:index]
7475
resources "/donation-goals", DonationGoalController, only: [:create, :update, :delete]
7576
post "/oauth/github", UserController, :github_oauth
7677
resources "/github-app-installations", GithubAppInstallationController, only: [:create]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule CodeCorpsWeb.ConversationView do
2+
@moduledoc false
3+
use CodeCorpsWeb, :view
4+
use JaSerializer.PhoenixView
5+
6+
attributes [:read_at, :status, :inserted_at, :updated_at]
7+
8+
has_one :user, type: "user", field: :user_id
9+
has_one :message, type: "message", field: :message_id
10+
end

test/lib/code_corps/messages/messages_test.exs

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ defmodule CodeCorps.MessagesTest do
55

66
import Ecto.Query, only: [where: 2]
77

8-
alias CodeCorps.{Message, Messages}
8+
alias CodeCorps.{Conversation, Message, Messages}
99

10-
describe "list" do
11-
defp get_and_sort_ids(records) do
12-
records |> Enum.map(&Map.get(&1, :id)) |> Enum.sort
13-
end
10+
defp get_and_sort_ids(records) do
11+
records |> Enum.map(&Map.get(&1, :id)) |> Enum.sort
12+
end
1413

14+
describe "list" do
1515
test "returns all records by default" do
1616
insert_list(3, :message)
1717
assert Message |> Messages.list(%{}) |> Enum.count == 3
@@ -121,4 +121,165 @@ defmodule CodeCorps.MessagesTest do
121121
refute message_p2_a2.id in result_ids
122122
end
123123
end
124+
125+
describe "list_conversations/2" do
126+
test "returns all records by default" do
127+
insert_list(3, :conversation)
128+
assert Conversation |> Messages.list_conversations(%{}) |> Enum.count == 3
129+
end
130+
131+
test "can filter by project" do
132+
[%{project: project_1} = message_1, %{project: project_2} = message_2] =
133+
insert_pair(:message)
134+
135+
conversation_1 = insert(:conversation, message: message_1)
136+
conversation_2 = insert(:conversation, message: message_2)
137+
138+
result_ids =
139+
Conversation
140+
|> Messages.list_conversations(%{"project_id" => project_1.id})
141+
|> get_and_sort_ids()
142+
143+
assert result_ids |> Enum.count == 1
144+
assert conversation_1.id in result_ids
145+
refute conversation_2.id in result_ids
146+
147+
result_ids =
148+
Conversation
149+
|> Messages.list_conversations(%{"project_id" => project_2.id})
150+
|> get_and_sort_ids()
151+
152+
assert result_ids |> Enum.count == 1
153+
refute conversation_1.id in result_ids
154+
assert conversation_2.id in result_ids
155+
end
156+
157+
test "can filter by status" do
158+
message_started_by_admin = insert(:message, initiated_by: "admin")
159+
message_started_by_user = insert(:message, initiated_by: "user")
160+
161+
conversation_started_by_admin_without_reply =
162+
insert(:conversation, message: message_started_by_admin)
163+
conversation_started_by_admin_with_reply =
164+
insert(:conversation, message: message_started_by_admin)
165+
insert(
166+
:conversation_part,
167+
conversation: conversation_started_by_admin_with_reply
168+
)
169+
170+
conversation_started_by_user_without_reply =
171+
insert(:conversation, message: message_started_by_user)
172+
conversation_started_by_user_with_reply =
173+
insert(:conversation, message: message_started_by_user)
174+
insert(
175+
:conversation_part,
176+
conversation: conversation_started_by_user_with_reply
177+
)
178+
179+
result_ids =
180+
Conversation
181+
|> Messages.list_conversations(%{"status" => "active"})
182+
|> get_and_sort_ids()
183+
184+
refute conversation_started_by_admin_without_reply.id in result_ids
185+
assert conversation_started_by_admin_with_reply.id in result_ids
186+
assert conversation_started_by_user_without_reply.id in result_ids
187+
assert conversation_started_by_user_with_reply.id in result_ids
188+
189+
result_ids =
190+
Conversation
191+
|> Messages.list_conversations(%{"status" => "any"})
192+
|> get_and_sort_ids()
193+
194+
assert conversation_started_by_admin_without_reply.id in result_ids
195+
assert conversation_started_by_admin_with_reply.id in result_ids
196+
assert conversation_started_by_user_without_reply.id in result_ids
197+
assert conversation_started_by_user_with_reply.id in result_ids
198+
end
199+
200+
test "builds upon the provided scope" do
201+
[project_1, project_2] = insert_pair(:project)
202+
[user_1, user_2] = insert_pair(:user)
203+
204+
message_p1 = insert(:message, project: project_1)
205+
message_p2 = insert(:message, project: project_2)
206+
207+
conversation_u1_p1 =
208+
insert(:conversation, user: user_1, message: message_p1)
209+
conversation_u1_p2 =
210+
insert(:conversation, user: user_1, message: message_p2)
211+
conversation_u2_p1 =
212+
insert(:conversation, user: user_2, message: message_p1)
213+
conversation_u2_p2 =
214+
insert(:conversation, user: user_2, message: message_p2)
215+
216+
params = %{"project_id" => project_1.id}
217+
result_ids =
218+
Conversation
219+
|> where(user_id: ^user_1.id)
220+
|> Messages.list_conversations(params)
221+
|> get_and_sort_ids()
222+
223+
assert conversation_u1_p1.id in result_ids
224+
refute conversation_u1_p2.id in result_ids
225+
refute conversation_u2_p1.id in result_ids
226+
refute conversation_u2_p2.id in result_ids
227+
end
228+
229+
test "supports multiple filters at once" do
230+
## we create two messages started by admin, each on a different project
231+
%{project: project_1} = message_1_started_by_admin =
232+
insert(:message, initiated_by: "admin")
233+
%{project: project_2} = message_2_started_by_admin =
234+
insert(:message, initiated_by: "admin")
235+
236+
# we create one conversation without a reply, to test the "status" filter
237+
238+
conversation_started_by_admin_without_reply =
239+
insert(:conversation, message: message_1_started_by_admin)
240+
241+
# we create two conversations with replies, on on each message
242+
# since the messages are on different projects, this allows us to
243+
# test the project filter
244+
245+
conversation_started_by_admin_with_reply =
246+
insert(:conversation, message: message_1_started_by_admin)
247+
insert(
248+
:conversation_part,
249+
conversation: conversation_started_by_admin_with_reply
250+
)
251+
other_conversation_started_by_admin_with_reply =
252+
insert(:conversation, message: message_2_started_by_admin)
253+
insert(
254+
:conversation_part,
255+
conversation: other_conversation_started_by_admin_with_reply
256+
)
257+
258+
params = %{"status" => "active", "project_id" => project_1.id}
259+
result_ids =
260+
Conversation
261+
|> Messages.list_conversations(params)
262+
|> get_and_sort_ids()
263+
264+
# this means the status filter worked, because the first conv. belongs to
265+
# the message with the correct project
266+
refute conversation_started_by_admin_without_reply.id in result_ids
267+
# this conversation is active and belongs to the message with the
268+
# correct project
269+
assert conversation_started_by_admin_with_reply.id in result_ids
270+
# this conversation is active, but belongs to a message with a different
271+
# project
272+
refute other_conversation_started_by_admin_with_reply.id in result_ids
273+
274+
params = %{"status" => "active", "project_id" => project_2.id}
275+
result_ids =
276+
Conversation
277+
|> Messages.list_conversations(params)
278+
|> get_and_sort_ids()
279+
280+
refute conversation_started_by_admin_without_reply.id in result_ids
281+
refute conversation_started_by_admin_with_reply.id in result_ids
282+
assert other_conversation_started_by_admin_with_reply.id in result_ids
283+
end
284+
end
124285
end

0 commit comments

Comments
 (0)