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
11 changes: 10 additions & 1 deletion lib/sanbase/api_call_limit/api_call_limit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,16 @@ defmodule Sanbase.ApiCallLimit do
def reset(%User{} = user) do
if struct = Repo.get_by(__MODULE__, user_id: user.id), do: Repo.delete!(struct)

create(:user, user)
result = create(:user, user)

case result do
{:ok, _acl} ->
__MODULE__.ETS.clear_data(:user, user)
result

_ ->
result
end
end

# Private functions
Expand Down
25 changes: 16 additions & 9 deletions lib/sanbase/api_call_limit/api_call_limit_ets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,22 @@ defmodule Sanbase.ApiCallLimit.ETS do

case :ets.lookup(@ets_table, entity_key) do
[] ->
update_usage_get_quota_from_db_and_update_ets(
entity_type,
entity,
entity_key,
count,
result_byte_size
)

:ok
# ETS has no entry (cold start or after ETS was cleared). Refresh from DB and
# apply only this request's usage in ETS; do not write to the DB. Writing to the
# DB here would let a late or out-of-order update overwrite the authoritative DB
# state (e.g. after a reset). The DB is updated only when the in-memory quota is
# exhausted and we flush via update_usage_get_quota_from_db_and_update_ets.
case get_quota_from_db_and_update_ets(entity_type, entity, entity_key) do
{:ok, %{quota: :infinity}} ->
:ok

{:ok, %{quota: quota} = metadata} ->
do_upate_ets_usage(entity_key, quota, count, result_byte_size, metadata)
:ok

{:error, _} ->
:ok
end

[
{^entity_key, _quota = :infinity, _api_calls_remaining = :infinity, _result_size,
Expand Down
29 changes: 27 additions & 2 deletions lib/sanbase/app_notifications/app_notifications.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ defmodule Sanbase.AppNotifications do
def list_notifications_for_user(user_id, opts \\ []) when is_integer(user_id) do
limit = Keyword.get(opts, :limit, @default_limit)
cursor = Keyword.get(opts, :cursor)
types = Keyword.get(opts, :types)

from(nrs in NotificationReadStatus,
join: n in Notification,
Expand All @@ -118,12 +119,29 @@ defmodule Sanbase.AppNotifications do
limit: ^limit
)
|> maybe_apply_cursor(cursor)
|> maybe_filter_by_types(types)
|> Repo.all()
# Two-phase load to preload the user and their roles.
# We select from NotificationReadStatus, but preload the user from Notification
# Ecto is not happy if you don't select the binding from `from` when there's
# a preload
|> Repo.preload(user: [:roles, roles: :role])
|> Repo.preload(user: [:user_settings, :roles, [roles: :role]])
end

@doc """
Returns all distinct notification types that exist for the given user.
Useful for building filter UIs that show only types the user actually has.
"""
@spec list_available_notification_types_for_user(pos_integer()) :: [String.t()]
def list_available_notification_types_for_user(user_id) when is_integer(user_id) do
from(nrs in NotificationReadStatus,
join: n in Notification,
on: n.id == nrs.notification_id and n.is_deleted == false,
where: nrs.user_id == ^user_id,
select: n.type,
distinct: true
)
|> Repo.all()
end

@doc """
Expand All @@ -139,7 +157,7 @@ defmodule Sanbase.AppNotifications do
join: nrs in NotificationReadStatus,
on: nrs.notification_id == n.id and nrs.user_id == ^user_id,
where: n.id == ^notification_id and n.is_deleted == false,
preload: [user: [:roles, roles: :role]]
preload: [user: [:user_settings, :roles, [roles: :role]]]
)
|> select_merge([_n, nrs], %{read_at: nrs.read_at})
|> Repo.one()
Expand Down Expand Up @@ -226,6 +244,13 @@ defmodule Sanbase.AppNotifications do
|> where([_notification_read_status, notification], notification.inserted_at > ^datetime)
end

defp maybe_filter_by_types(query, nil), do: query
defp maybe_filter_by_types(query, []), do: query

defp maybe_filter_by_types(query, types) when is_list(types) do
where(query, [_notification_read_status, notification], notification.type in ^types)
end

defp fetch_notification_for_user(user_id, notification_id) do
from(n in Notification,
join: nrs in NotificationReadStatus,
Expand Down
21 changes: 16 additions & 5 deletions lib/sanbase_web/graphql/resolvers/app_notification_resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ defmodule SanbaseWeb.Graphql.Resolvers.AppNotificationResolver do
def get_notifications(_root, args, %{context: %{auth: %{current_user: user}}}) do
opts = build_opts(args)

user.id
|> AppNotifications.list_notifications_for_user(opts)
|> AppNotifications.wrap_with_cursor()
with {:ok, paginated} <-
user.id
|> AppNotifications.list_notifications_for_user(opts)
|> AppNotifications.wrap_with_cursor() do
available_types = AppNotifications.list_available_notification_types_for_user(user.id)
{:ok, Map.put(paginated, :available_notification_types, available_types)}
end
end

@doc """
Expand All @@ -35,9 +39,16 @@ defmodule SanbaseWeb.Graphql.Resolvers.AppNotificationResolver do
defp build_opts(args) do
opts = [limit: Map.get(args, :limit, 20)]

case Map.get(args, :cursor) do
opts =
case Map.get(args, :cursor) do
nil -> opts
cursor -> Keyword.put(opts, :cursor, cursor)
end

case Map.get(args, :types) do
nil -> opts
cursor -> Keyword.put(opts, :cursor, cursor)
[] -> opts
types -> Keyword.put(opts, :types, types)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule SanbaseWeb.Graphql.Schema.AppNotificationQueries do

arg(:limit, :integer, default_value: 20)
arg(:cursor, :cursor_input)
arg(:types, list_of(:string))

middleware(JWTAuth)
resolve(&AppNotificationResolver.get_notifications/3)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ defmodule SanbaseWeb.Graphql.AppNotificationTypes do
object :app_notifications_paginated do
field(:notifications, list_of(:app_notification))
field(:cursor, :cursor)
field(:available_notification_types, list_of(:string))
end
end
115 changes: 115 additions & 0 deletions test/sanbase/app_notifications/app_notifications_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,121 @@ defmodule Sanbase.AppNotificationsTest do
end
end

describe "list_notifications_for_user/2 with types filter" do
setup do
user = insert(:user)
author = insert(:user)

for {type, entity_type, entity_id} <- [
{"create_watchlist", "watchlist", 1},
{"publish_insight", "insight", 2},
{"create_comment", "insight", 2}
] do
{:ok, notification} =
AppNotifications.create_notification(%{
type: type,
user_id: author.id,
entity_type: entity_type,
entity_id: entity_id
})

{:ok, _} =
AppNotifications.create_notification_read_status(%{
notification_id: notification.id,
user_id: user.id
})
end

[user: user]
end

test "filters by a single type", %{user: user} do
result = AppNotifications.list_notifications_for_user(user.id, types: ["create_watchlist"])
assert length(result) == 1
assert hd(result).type == "create_watchlist"
end

test "filters by multiple types", %{user: user} do
result =
AppNotifications.list_notifications_for_user(user.id,
types: ["create_watchlist", "publish_insight"]
)

assert length(result) == 2
returned_types = result |> Enum.map(& &1.type) |> MapSet.new()
assert returned_types == MapSet.new(["create_watchlist", "publish_insight"])
end

test "returns all notifications when no types filter given", %{user: user} do
result = AppNotifications.list_notifications_for_user(user.id)
assert length(result) == 3
end

test "returns empty list when no notifications match the given types", %{user: user} do
result =
AppNotifications.list_notifications_for_user(user.id, types: ["alert_triggered"])

assert result == []
end
end

describe "list_available_notification_types_for_user/1" do
test "returns distinct notification types for the user" do
user = insert(:user)
author = insert(:user)

for {type, entity_id} <- [
{"create_watchlist", 1},
{"publish_insight", 2},
{"create_watchlist", 3}
] do
{:ok, notification} =
AppNotifications.create_notification(%{
type: type,
user_id: author.id,
entity_type: "watchlist",
entity_id: entity_id
})

{:ok, _} =
AppNotifications.create_notification_read_status(%{
notification_id: notification.id,
user_id: user.id
})
end

types = AppNotifications.list_available_notification_types_for_user(user.id)
assert Enum.sort(types) == ["create_watchlist", "publish_insight"]
end

test "returns empty list when user has no notifications" do
user = insert(:user)
assert AppNotifications.list_available_notification_types_for_user(user.id) == []
end

test "does not include types from deleted notifications" do
user = insert(:user)
author = insert(:user)

{:ok, notification} =
AppNotifications.create_notification(%{
type: "create_watchlist",
user_id: author.id,
entity_type: "watchlist",
entity_id: 1,
is_deleted: true
})

{:ok, _} =
AppNotifications.create_notification_read_status(%{
notification_id: notification.id,
user_id: user.id
})

assert AppNotifications.list_available_notification_types_for_user(user.id) == []
end
end

describe "get_notification_for_user/2" do
setup do
follower = insert(:user)
Expand Down
6 changes: 4 additions & 2 deletions test/sanbase_web/api_call_limit/api_call_limit_api_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -650,20 +650,22 @@ defmodule SanbaseWeb.ApiCallLimitTest do

# This works if the number of api calls is less than
# quota_size + quota_size_max_offset
for _ <- 1..12, do: make_api_call(context.apikey_conn, [])
for _ <- 1..30, do: make_api_call(context.apikey_conn, [])

{:ok, quota2} = Sanbase.ApiCallLimit.get_quota_db(:user, context.user)

assert quota2.api_calls_remaining.month < quota.api_calls_remaining.month

Process.sleep(250)
Process.sleep(50)

result =
self_reset_api_calls(context.apikey_conn)
|> get_in(["data", "selfResetApiRateLimits"])

assert result["id"] |> String.to_integer() == context.user.id

Process.sleep(50)

{:ok, quota3} = Sanbase.ApiCallLimit.get_quota_db(:user, context.user)

assert quota3.api_calls_remaining.month == quota.api_calls_remaining.month
Expand Down