Skip to content

Commit cf0886d

Browse files
hvn2k1claude
andcommitted
feat(notification): implement GetNotificationUnreadCount API
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c97e28b commit cf0886d

14 files changed

Lines changed: 870 additions & 489 deletions

File tree

api-description/web-api.swagger.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7198,7 +7198,7 @@ paths:
71987198
/v1/notifications/unread_count:
71997199
get:
72007200
summary: Get Unread Count
7201-
description: Get the requesting user's unread notification count, used for the bell badge.
7201+
description: Get the requesting user's unread notification count.
72027202
operationId: web.v1.notification.unread_count
72037203
responses:
72047204
"200":
@@ -14344,6 +14344,10 @@ definitions:
1434414344
$ref: '#/definitions/notificationNotification'
1434514345
notificationGetNotificationUnreadCountResponse:
1434614346
type: object
14347+
properties:
14348+
count:
14349+
type: string
14350+
format: int64
1434714351
notificationListDraftAdminNotificationsRequestOrderBy:
1434814352
type: string
1434914353
enum:

pkg/notification/api/api.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,21 @@ func (s *NotificationService) GetNotificationUnreadCount(
241241
ctx context.Context,
242242
req *proto.GetNotificationUnreadCountRequest,
243243
) (*proto.GetNotificationUnreadCountResponse, error) {
244-
return nil, statusNotImplemented
244+
t, err := s.checkAuthenticated(ctx)
245+
if err != nil {
246+
return nil, err
247+
}
248+
count, err := s.notificationStorage.GetNotificationUnreadCount(ctx, t.Email)
249+
if err != nil {
250+
s.logger.Error(
251+
"Failed to get notification unread count",
252+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
253+
)
254+
return nil, api.NewGRPCStatus(err).Err()
255+
}
256+
return &proto.GetNotificationUnreadCountResponse{
257+
Count: count,
258+
}, nil
245259
}
246260

247261
func (s *NotificationService) MarkNotificationsAsRead(

pkg/notification/api/api_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,3 +1269,63 @@ func TestNotificationService_MarkNotificationsAsRead(t *testing.T) {
12691269
})
12701270
}
12711271
}
1272+
1273+
func TestNotificationService_GetNotificationUnreadCount(t *testing.T) {
1274+
t.Parallel()
1275+
mockController := gomock.NewController(t)
1276+
defer mockController.Finish()
1277+
1278+
viewerCtx := metadata.NewIncomingContext(
1279+
createContextWithToken(t, false),
1280+
metadata.MD{"accept-language": []string{"en"}},
1281+
)
1282+
1283+
patterns := []struct {
1284+
desc string
1285+
ctx context.Context
1286+
setup func(*NotificationService)
1287+
expected int64
1288+
expectedErr error
1289+
}{
1290+
{
1291+
desc: "err: unauthenticated",
1292+
ctx: context.TODO(),
1293+
expectedErr: statusUnauthenticated.Err(),
1294+
},
1295+
{
1296+
desc: "err: internal",
1297+
ctx: viewerCtx,
1298+
setup: func(s *NotificationService) {
1299+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetNotificationUnreadCount(
1300+
gomock.Any(), "email",
1301+
).Return(int64(0), errors.New("error"))
1302+
},
1303+
expectedErr: api.NewGRPCStatus(errors.New("error")).Err(),
1304+
},
1305+
{
1306+
desc: "success",
1307+
ctx: viewerCtx,
1308+
setup: func(s *NotificationService) {
1309+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetNotificationUnreadCount(
1310+
gomock.Any(), "email",
1311+
).Return(int64(3), nil)
1312+
},
1313+
expected: 3,
1314+
expectedErr: nil,
1315+
},
1316+
}
1317+
for _, p := range patterns {
1318+
t.Run(p.desc, func(t *testing.T) {
1319+
s := createNotificationService(mockController)
1320+
if p.setup != nil {
1321+
p.setup(s)
1322+
}
1323+
resp, err := s.GetNotificationUnreadCount(p.ctx, &proto.GetNotificationUnreadCountRequest{})
1324+
assert.Equal(t, p.expectedErr, err)
1325+
if p.expectedErr == nil {
1326+
assert.NotNil(t, resp)
1327+
assert.Equal(t, p.expected, resp.Count)
1328+
}
1329+
})
1330+
}
1331+
}

pkg/notification/storage/mock/notification.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/notification/storage/mysql/notification.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ var (
5757
selectEarliestAccountCreatedAtSQL string
5858
//go:embed sql/insert_notification_read.sql
5959
insertNotificationReadSQL string
60+
//go:embed sql/count_unread_notifications.sql
61+
countUnreadNotificationsSQL string
6062
)
6163

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

563+
// GetNotificationUnreadCount counts the published notifications the viewer
564+
// has not read, limited to notifications published after the viewer's
565+
// account was created.
566+
func (s *notificationStorage) GetNotificationUnreadCount(
567+
ctx context.Context,
568+
email string,
569+
) (int64, error) {
570+
filters := []*mysqlstorage.FilterV2{
571+
{
572+
Column: "notification.status",
573+
Operator: mysqlstorage.OperatorEqual,
574+
Value: int32(proto.Notification_PUBLISHED),
575+
},
576+
{
577+
Column: "notification.deleted",
578+
Operator: mysqlstorage.OperatorEqual,
579+
Value: false,
580+
},
581+
}
582+
accountCreatedAt, err := s.earliestAccountCreatedAt(ctx, email)
583+
if err != nil {
584+
return 0, err
585+
}
586+
if accountCreatedAt > 0 {
587+
filters = append(filters, &mysqlstorage.FilterV2{
588+
Column: "notification.published_at",
589+
Operator: mysqlstorage.OperatorGreaterThanOrEqual,
590+
Value: accountCreatedAt,
591+
})
592+
}
593+
options := &mysqlstorage.ListOptions{
594+
Filters: filters,
595+
ExistsFilters: []*mysqlstorage.ExistsFilter{
596+
{
597+
Subquery: readNotificationExistsSubquery,
598+
NotExists: true,
599+
Values: []interface{}{email},
600+
},
601+
},
602+
}
603+
query, whereArgs := mysqlstorage.ConstructCountQuery(countUnreadNotificationsSQL, options)
604+
var count int64
605+
if err := s.qe.QueryRowContext(ctx, query, whereArgs...).Scan(&count); err != nil {
606+
return 0, err
607+
}
608+
return count, nil
609+
}
610+
561611
// readNotificationIDs returns which of the given notifications the viewer
562612
// has read; the query is bounded by the page size.
563613
func (s *notificationStorage) readNotificationIDs(

pkg/notification/storage/mysql/notification_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,3 +1065,82 @@ func TestMarkNotificationsAsRead(t *testing.T) {
10651065
})
10661066
}
10671067
}
1068+
1069+
func TestGetNotificationUnreadCount(t *testing.T) {
1070+
t.Parallel()
1071+
mockController := gomock.NewController(t)
1072+
defer mockController.Finish()
1073+
1074+
newBoundRow := func(createdAt int64) *mock.MockRow {
1075+
row := mock.NewMockRow(mockController)
1076+
row.EXPECT().Scan(gomock.Any()).DoAndReturn(func(args ...interface{}) error {
1077+
*args[0].(*int64) = createdAt
1078+
return nil
1079+
})
1080+
return row
1081+
}
1082+
1083+
patterns := []struct {
1084+
desc string
1085+
setup func(*notificationStorage)
1086+
expected int64
1087+
expectedErr error
1088+
}{
1089+
{
1090+
desc: "Error: account bound query",
1091+
setup: func(s *notificationStorage) {
1092+
row := mock.NewMockRow(mockController)
1093+
row.EXPECT().Scan(gomock.Any()).Return(errors.New("error"))
1094+
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
1095+
gomock.Any(), selectEarliestAccountCreatedAtSQL, "viewer@example.com",
1096+
).Return(row)
1097+
},
1098+
expected: 0,
1099+
expectedErr: errors.New("error"),
1100+
},
1101+
{
1102+
desc: "Error: count query",
1103+
setup: func(s *notificationStorage) {
1104+
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
1105+
gomock.Any(), selectEarliestAccountCreatedAtSQL, "viewer@example.com",
1106+
).Return(newBoundRow(5))
1107+
row := mock.NewMockRow(mockController)
1108+
row.EXPECT().Scan(gomock.Any()).Return(errors.New("error"))
1109+
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
1110+
gomock.Any(), gomock.Any(), gomock.Any(),
1111+
).Return(row)
1112+
},
1113+
expected: 0,
1114+
expectedErr: errors.New("error"),
1115+
},
1116+
{
1117+
desc: "Success",
1118+
setup: func(s *notificationStorage) {
1119+
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
1120+
gomock.Any(), selectEarliestAccountCreatedAtSQL, "viewer@example.com",
1121+
).Return(newBoundRow(5))
1122+
row := mock.NewMockRow(mockController)
1123+
row.EXPECT().Scan(gomock.Any()).DoAndReturn(func(args ...interface{}) error {
1124+
*args[0].(*int64) = int64(3)
1125+
return nil
1126+
})
1127+
s.qe.(*mock.MockQueryExecer).EXPECT().QueryRowContext(
1128+
gomock.Any(), gomock.Any(), gomock.Any(),
1129+
).Return(row)
1130+
},
1131+
expected: 3,
1132+
expectedErr: nil,
1133+
},
1134+
}
1135+
for _, p := range patterns {
1136+
t.Run(p.desc, func(t *testing.T) {
1137+
storage := &notificationStorage{qe: mock.NewMockQueryExecer(mockController)}
1138+
if p.setup != nil {
1139+
p.setup(storage)
1140+
}
1141+
count, err := storage.GetNotificationUnreadCount(context.Background(), "viewer@example.com")
1142+
assert.Equal(t, p.expectedErr, err)
1143+
assert.Equal(t, p.expected, count)
1144+
})
1145+
}
1146+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SELECT
2+
COUNT(notification.id)
3+
FROM
4+
notification

pkg/notification/storage/notification.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ type NotificationStorage interface {
7979
// notifications; unknown, draft, and deleted ids are ignored. Idempotent:
8080
// already-read notifications keep their original read_at.
8181
MarkNotificationsAsRead(ctx context.Context, ids []string, email string, readAt int64) error
82+
// GetNotificationUnreadCount counts the published notifications the
83+
// viewer has not read, limited to notifications published after the
84+
// viewer's account was created.
85+
GetNotificationUnreadCount(ctx context.Context, email string) (int64, error)
8286
}
8387

8488
type ListDraftAdminNotificationsParams struct {

pkg/notification/storage/postgres/notification.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ var (
5858
selectEarliestAccountCreatedAtSQL string
5959
//go:embed sql/insert_notification_read.sql
6060
insertNotificationReadSQL string
61+
//go:embed sql/count_unread_notifications.sql
62+
countUnreadNotificationsSQL string
6163
)
6264

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

568+
// GetNotificationUnreadCount counts the published notifications the viewer
569+
// has not read, limited to notifications published after the viewer's
570+
// account was created.
571+
func (s *notificationStorage) GetNotificationUnreadCount(
572+
ctx context.Context,
573+
email string,
574+
) (int64, error) {
575+
filters := []*pgstorage.Filter{
576+
{
577+
Column: "notification.status",
578+
Operator: pgstorage.OperatorEqual,
579+
Value: int32(proto.Notification_PUBLISHED),
580+
},
581+
{
582+
Column: "notification.deleted",
583+
Operator: pgstorage.OperatorEqual,
584+
Value: false,
585+
},
586+
}
587+
accountCreatedAt, err := s.earliestAccountCreatedAt(ctx, email)
588+
if err != nil {
589+
return 0, err
590+
}
591+
if accountCreatedAt > 0 {
592+
filters = append(filters, &pgstorage.Filter{
593+
Column: "notification.published_at",
594+
Operator: pgstorage.OperatorGreaterThanOrEqual,
595+
Value: accountCreatedAt,
596+
})
597+
}
598+
options := &pgstorage.ListOptions{
599+
Filters: filters,
600+
ExistsFilters: []*pgstorage.ExistsFilter{
601+
{
602+
Subquery: readNotificationExistsSubquery,
603+
NotExists: true,
604+
Values: []interface{}{email},
605+
},
606+
},
607+
}
608+
query, whereArgs := pgstorage.ConstructCountQuery(countUnreadNotificationsSQL, options)
609+
var count int64
610+
if err := s.qe.QueryRowContext(ctx, query, whereArgs...).Scan(&count); err != nil {
611+
return 0, err
612+
}
613+
return count, nil
614+
}
615+
566616
// readNotificationIDs returns which of the given notifications the viewer
567617
// has read; the query is bounded by the page size.
568618
func (s *notificationStorage) readNotificationIDs(

0 commit comments

Comments
 (0)