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
6 changes: 5 additions & 1 deletion api-description/web-api.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7198,7 +7198,7 @@ paths:
/v1/notifications/unread_count:
get:
summary: Get Unread Count
description: Get the requesting user's unread notification count, used for the bell badge.
description: Get the requesting user's unread notification count.
operationId: web.v1.notification.unread_count
responses:
"200":
Expand Down Expand Up @@ -14344,6 +14344,10 @@ definitions:
$ref: '#/definitions/notificationNotification'
notificationGetNotificationUnreadCountResponse:
type: object
properties:
count:
type: string
format: int64
notificationListDraftAdminNotificationsRequestOrderBy:
type: string
enum:
Expand Down
16 changes: 15 additions & 1 deletion pkg/notification/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,21 @@ func (s *NotificationService) GetNotificationUnreadCount(
ctx context.Context,
req *proto.GetNotificationUnreadCountRequest,
) (*proto.GetNotificationUnreadCountResponse, error) {
return nil, statusNotImplemented
t, err := s.checkAuthenticated(ctx)
if err != nil {
return nil, err
}
count, err := s.notificationStorage.GetNotificationUnreadCount(ctx, t.Email)
if err != nil {
s.logger.Error(
"Failed to get notification unread count",
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
)
return nil, api.NewGRPCStatus(err).Err()
}
return &proto.GetNotificationUnreadCountResponse{
Count: count,
}, nil
}

func (s *NotificationService) MarkNotificationsAsRead(
Expand Down
60 changes: 60 additions & 0 deletions pkg/notification/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1269,3 +1269,63 @@ func TestNotificationService_MarkNotificationsAsRead(t *testing.T) {
})
}
}

func TestNotificationService_GetNotificationUnreadCount(t *testing.T) {
t.Parallel()
mockController := gomock.NewController(t)
defer mockController.Finish()

viewerCtx := metadata.NewIncomingContext(
createContextWithToken(t, false),
metadata.MD{"accept-language": []string{"en"}},
)

patterns := []struct {
desc string
ctx context.Context
setup func(*NotificationService)
expected int64
expectedErr error
}{
{
desc: "err: unauthenticated",
ctx: context.TODO(),
expectedErr: statusUnauthenticated.Err(),
},
{
desc: "err: internal",
ctx: viewerCtx,
setup: func(s *NotificationService) {
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetNotificationUnreadCount(
gomock.Any(), "email",
).Return(int64(0), errors.New("error"))
},
expectedErr: api.NewGRPCStatus(errors.New("error")).Err(),
},
{
desc: "success",
ctx: viewerCtx,
setup: func(s *NotificationService) {
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetNotificationUnreadCount(
gomock.Any(), "email",
).Return(int64(3), nil)
},
expected: 3,
expectedErr: nil,
},
}
for _, p := range patterns {
t.Run(p.desc, func(t *testing.T) {
s := createNotificationService(mockController)
if p.setup != nil {
p.setup(s)
}
resp, err := s.GetNotificationUnreadCount(p.ctx, &proto.GetNotificationUnreadCountRequest{})
assert.Equal(t, p.expectedErr, err)
if p.expectedErr == nil {
assert.NotNil(t, resp)
assert.Equal(t, p.expected, resp.Count)
}
})
}
}
15 changes: 15 additions & 0 deletions pkg/notification/storage/mock/notification.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions pkg/notification/storage/mysql/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ var (
selectEarliestAccountCreatedAtSQL string
//go:embed sql/insert_notification_read.sql
insertNotificationReadSQL string
//go:embed sql/count_unread_notifications.sql
countUnreadNotificationsSQL string
)

// readNotificationExistsSubquery correlates a viewer's read marker with the
Expand Down Expand Up @@ -558,6 +560,54 @@ func (s *notificationStorage) MarkNotificationsAsRead(
return nil
}

// GetNotificationUnreadCount counts the published notifications the viewer
// has not read, limited to notifications published after the viewer's
// account was created.
func (s *notificationStorage) GetNotificationUnreadCount(
ctx context.Context,
email string,
) (int64, error) {
filters := []*mysqlstorage.FilterV2{
{
Column: "notification.status",
Operator: mysqlstorage.OperatorEqual,
Value: int32(proto.Notification_PUBLISHED),
},
{
Column: "notification.deleted",
Operator: mysqlstorage.OperatorEqual,
Value: false,
},
}
accountCreatedAt, err := s.earliestAccountCreatedAt(ctx, email)
if err != nil {
return 0, err
}
if accountCreatedAt > 0 {
filters = append(filters, &mysqlstorage.FilterV2{
Column: "notification.published_at",
Operator: mysqlstorage.OperatorGreaterThanOrEqual,
Value: accountCreatedAt,
})
}
options := &mysqlstorage.ListOptions{
Filters: filters,
ExistsFilters: []*mysqlstorage.ExistsFilter{
{
Subquery: readNotificationExistsSubquery,
NotExists: true,
Values: []interface{}{email},
},
},
}
query, whereArgs := mysqlstorage.ConstructCountQuery(countUnreadNotificationsSQL, options)
var count int64
if err := s.qe.QueryRowContext(ctx, query, whereArgs...).Scan(&count); err != nil {
return 0, err
}
return count, nil
}

// readNotificationIDs returns which of the given notifications the viewer
// has read; the query is bounded by the page size.
func (s *notificationStorage) readNotificationIDs(
Expand Down
79 changes: 79 additions & 0 deletions pkg/notification/storage/mysql/notification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1065,3 +1065,82 @@ func TestMarkNotificationsAsRead(t *testing.T) {
})
}
}

func TestGetNotificationUnreadCount(t *testing.T) {
t.Parallel()
mockController := gomock.NewController(t)
defer mockController.Finish()

newBoundRow := func(createdAt int64) *mock.MockRow {
row := mock.NewMockRow(mockController)
row.EXPECT().Scan(gomock.Any()).DoAndReturn(func(args ...interface{}) error {
*args[0].(*int64) = createdAt
return nil
})
return row
}

patterns := []struct {
desc string
setup func(*notificationStorage)
expected int64
expectedErr error
}{
{
desc: "Error: account bound query",
setup: func(s *notificationStorage) {
row := mock.NewMockRow(mockController)
row.EXPECT().Scan(gomock.Any()).Return(errors.New("error"))
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
gomock.Any(), selectEarliestAccountCreatedAtSQL, "viewer@example.com",
).Return(row)
},
expected: 0,
expectedErr: errors.New("error"),
},
{
desc: "Error: count query",
setup: func(s *notificationStorage) {
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
gomock.Any(), selectEarliestAccountCreatedAtSQL, "viewer@example.com",
).Return(newBoundRow(5))
row := mock.NewMockRow(mockController)
row.EXPECT().Scan(gomock.Any()).Return(errors.New("error"))
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(row)
},
expected: 0,
expectedErr: errors.New("error"),
},
{
desc: "Success",
setup: func(s *notificationStorage) {
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
gomock.Any(), selectEarliestAccountCreatedAtSQL, "viewer@example.com",
).Return(newBoundRow(5))
row := mock.NewMockRow(mockController)
row.EXPECT().Scan(gomock.Any()).DoAndReturn(func(args ...interface{}) error {
*args[0].(*int64) = int64(3)
return nil
})
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(row)
},
expected: 3,
expectedErr: nil,
},
}
for _, p := range patterns {
t.Run(p.desc, func(t *testing.T) {
storage := &notificationStorage{qe: mock.NewMockQueryExecer(mockController)}
if p.setup != nil {
p.setup(storage)
}
count, err := storage.GetNotificationUnreadCount(context.Background(), "viewer@example.com")
assert.Equal(t, p.expectedErr, err)
assert.Equal(t, p.expected, count)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SELECT
COUNT(notification.id)
FROM
notification
4 changes: 4 additions & 0 deletions pkg/notification/storage/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ type NotificationStorage interface {
// notifications; unknown, draft, and deleted ids are ignored. Idempotent:
// already-read notifications keep their original read_at.
MarkNotificationsAsRead(ctx context.Context, ids []string, email string, readAt int64) error
// GetNotificationUnreadCount counts the published notifications the
// viewer has not read, limited to notifications published after the
// viewer's account was created.
GetNotificationUnreadCount(ctx context.Context, email string) (int64, error)
}

type ListDraftAdminNotificationsParams struct {
Expand Down
50 changes: 50 additions & 0 deletions pkg/notification/storage/postgres/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ var (
selectEarliestAccountCreatedAtSQL string
//go:embed sql/insert_notification_read.sql
insertNotificationReadSQL string
//go:embed sql/count_unread_notifications.sql
countUnreadNotificationsSQL string
)

// readNotificationExistsSubquery correlates a viewer's read marker with the
Expand Down Expand Up @@ -563,6 +565,54 @@ func (s *notificationStorage) MarkNotificationsAsRead(
return nil
}

// GetNotificationUnreadCount counts the published notifications the viewer
// has not read, limited to notifications published after the viewer's
// account was created.
func (s *notificationStorage) GetNotificationUnreadCount(
ctx context.Context,
email string,
) (int64, error) {
filters := []*pgstorage.Filter{
{
Column: "notification.status",
Operator: pgstorage.OperatorEqual,
Value: int32(proto.Notification_PUBLISHED),
},
{
Column: "notification.deleted",
Operator: pgstorage.OperatorEqual,
Value: false,
},
}
accountCreatedAt, err := s.earliestAccountCreatedAt(ctx, email)
if err != nil {
return 0, err
}
if accountCreatedAt > 0 {
filters = append(filters, &pgstorage.Filter{
Column: "notification.published_at",
Operator: pgstorage.OperatorGreaterThanOrEqual,
Value: accountCreatedAt,
})
}
options := &pgstorage.ListOptions{
Filters: filters,
ExistsFilters: []*pgstorage.ExistsFilter{
{
Subquery: readNotificationExistsSubquery,
NotExists: true,
Values: []interface{}{email},
},
},
}
query, whereArgs := pgstorage.ConstructCountQuery(countUnreadNotificationsSQL, options)
var count int64
if err := s.qe.QueryRowContext(ctx, query, whereArgs...).Scan(&count); err != nil {
return 0, err
}
return count, nil
}

// readNotificationIDs returns which of the given notifications the viewer
// has read; the query is bounded by the page size.
func (s *notificationStorage) readNotificationIDs(
Expand Down
Loading
Loading