Skip to content

Commit 0516403

Browse files
authored
feat: implement mark all notification as read API (#2762)
1 parent 0caad64 commit 0516403

12 files changed

Lines changed: 252 additions & 9 deletions

File tree

pkg/notification/api/api.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,21 @@ func (s *NotificationService) MarkAllNotificationsAsRead(
302302
ctx context.Context,
303303
req *proto.MarkAllNotificationsAsReadRequest,
304304
) (*proto.MarkAllNotificationsAsReadResponse, error) {
305-
return nil, statusNotImplemented
305+
t, err := s.checkAuthenticated(ctx)
306+
if err != nil {
307+
return nil, err
308+
}
309+
err = s.dbClient.RunInTransactionV2(ctx, func(ctxWithTx context.Context) error {
310+
return s.notificationStorage.MarkAllNotificationsAsRead(ctxWithTx, t.Email, time.Now().Unix())
311+
})
312+
if err != nil {
313+
s.logger.Error(
314+
"Failed to mark all notifications as read",
315+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
316+
)
317+
return nil, api.NewGRPCStatus(err).Err()
318+
}
319+
return &proto.MarkAllNotificationsAsReadResponse{}, nil
306320
}
307321

308322
func (s *NotificationService) ListDraftAdminNotifications(

pkg/notification/api/api_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,3 +1329,65 @@ func TestNotificationService_GetNotificationUnreadCount(t *testing.T) {
13291329
})
13301330
}
13311331
}
1332+
1333+
func TestNotificationService_MarkAllNotificationsAsRead(t *testing.T) {
1334+
t.Parallel()
1335+
mockController := gomock.NewController(t)
1336+
defer mockController.Finish()
1337+
1338+
viewerCtx := metadata.NewIncomingContext(
1339+
createContextWithToken(t, false),
1340+
metadata.MD{"accept-language": []string{"en"}},
1341+
)
1342+
1343+
patterns := []struct {
1344+
desc string
1345+
ctx context.Context
1346+
setup func(*NotificationService)
1347+
expectedErr error
1348+
}{
1349+
{
1350+
desc: "err: unauthenticated",
1351+
ctx: context.TODO(),
1352+
expectedErr: statusUnauthenticated.Err(),
1353+
},
1354+
{
1355+
desc: "err: internal",
1356+
ctx: viewerCtx,
1357+
setup: func(s *NotificationService) {
1358+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
1359+
gomock.Any(), gomock.Any(),
1360+
).Return(errors.New("error"))
1361+
},
1362+
expectedErr: api.NewGRPCStatus(errors.New("error")).Err(),
1363+
},
1364+
{
1365+
desc: "success",
1366+
ctx: viewerCtx,
1367+
setup: func(s *NotificationService) {
1368+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
1369+
gomock.Any(), gomock.Any(),
1370+
).DoAndReturn(func(ctx context.Context, fn func(ctx context.Context) error) error {
1371+
return fn(ctx)
1372+
})
1373+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().MarkAllNotificationsAsRead(
1374+
gomock.Any(), "email", gomock.Any(),
1375+
).Return(nil)
1376+
},
1377+
expectedErr: nil,
1378+
},
1379+
}
1380+
for _, p := range patterns {
1381+
t.Run(p.desc, func(t *testing.T) {
1382+
s := createNotificationService(mockController)
1383+
if p.setup != nil {
1384+
p.setup(s)
1385+
}
1386+
resp, err := s.MarkAllNotificationsAsRead(p.ctx, &proto.MarkAllNotificationsAsReadRequest{})
1387+
assert.Equal(t, p.expectedErr, err)
1388+
if p.expectedErr == nil {
1389+
assert.NotNil(t, resp)
1390+
}
1391+
})
1392+
}
1393+
}

pkg/notification/api/error.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,10 @@
1515
package api
1616

1717
import (
18-
"google.golang.org/grpc/codes"
19-
gstatus "google.golang.org/grpc/status"
20-
2118
"github.com/bucketeer-io/bucketeer/v2/pkg/api/api"
2219
bkterr "github.com/bucketeer-io/bucketeer/v2/pkg/error"
2320
)
2421

25-
var statusNotImplemented = gstatus.Error(codes.Unimplemented, "notification: not implemented")
26-
2722
var (
2823
statusUnauthenticated = api.NewGRPCStatus(
2924
bkterr.NewErrorUnauthenticated(bkterr.NotificationPackageName, "unauthenticated"))

pkg/notification/storage/mock/notification.go

Lines changed: 14 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ var (
5959
insertNotificationReadSQL string
6060
//go:embed sql/count_unread_notifications.sql
6161
countUnreadNotificationsSQL string
62+
//go:embed sql/insert_all_notification_reads.sql
63+
insertAllNotificationReadsSQL string
6264
)
6365

6466
// readNotificationExistsSubquery correlates a viewer's read marker with the
@@ -560,6 +562,23 @@ func (s *notificationStorage) MarkNotificationsAsRead(
560562
return nil
561563
}
562564

565+
// MarkAllNotificationsAsRead upserts read markers for every published
566+
// notification for the viewer.
567+
func (s *notificationStorage) MarkAllNotificationsAsRead(
568+
ctx context.Context,
569+
email string,
570+
readAt int64,
571+
) error {
572+
_, err := s.qe.ExecContext(
573+
ctx,
574+
insertAllNotificationReadsSQL,
575+
email,
576+
readAt,
577+
int32(proto.Notification_PUBLISHED),
578+
)
579+
return err
580+
}
581+
563582
// GetNotificationUnreadCount counts the published notifications the viewer
564583
// has not read, limited to notifications published after the viewer's
565584
// account was created.

pkg/notification/storage/mysql/notification_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,3 +1144,48 @@ func TestGetNotificationUnreadCount(t *testing.T) {
11441144
})
11451145
}
11461146
}
1147+
1148+
func TestMarkAllNotificationsAsRead(t *testing.T) {
1149+
t.Parallel()
1150+
mockController := gomock.NewController(t)
1151+
defer mockController.Finish()
1152+
1153+
patterns := []struct {
1154+
desc string
1155+
setup func(*notificationStorage)
1156+
expectedErr error
1157+
}{
1158+
{
1159+
desc: "Error",
1160+
setup: func(s *notificationStorage) {
1161+
s.qe.(*mock.MockQueryExecer).EXPECT().ExecContext(
1162+
gomock.Any(), gomock.Any(), gomock.Any(),
1163+
).Return(nil, errors.New("error"))
1164+
},
1165+
expectedErr: errors.New("error"),
1166+
},
1167+
{
1168+
desc: "Success",
1169+
setup: func(s *notificationStorage) {
1170+
s.qe.(*mock.MockQueryExecer).EXPECT().ExecContext(
1171+
gomock.Any(),
1172+
insertAllNotificationReadsSQL,
1173+
"viewer@example.com",
1174+
int64(5),
1175+
int32(proto.Notification_PUBLISHED),
1176+
).Return(nil, nil)
1177+
},
1178+
expectedErr: nil,
1179+
},
1180+
}
1181+
for _, p := range patterns {
1182+
t.Run(p.desc, func(t *testing.T) {
1183+
storage := &notificationStorage{qe: mock.NewMockQueryExecer(mockController)}
1184+
if p.setup != nil {
1185+
p.setup(storage)
1186+
}
1187+
err := storage.MarkAllNotificationsAsRead(context.Background(), "viewer@example.com", 5)
1188+
assert.Equal(t, p.expectedErr, err)
1189+
})
1190+
}
1191+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
INSERT IGNORE INTO notification_read (
2+
notification_id,
3+
email,
4+
read_at
5+
)
6+
SELECT
7+
id,
8+
?,
9+
?
10+
FROM
11+
notification
12+
WHERE
13+
status = ? AND
14+
deleted = false

pkg/notification/storage/notification.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ type NotificationStorage interface {
8383
// viewer has not read, limited to notifications published after the
8484
// viewer's account was created.
8585
GetNotificationUnreadCount(ctx context.Context, email string) (int64, error)
86+
// MarkAllNotificationsAsRead upserts read markers for every published
87+
// notification for the viewer. Idempotent: already-read notifications
88+
// keep their original read_at.
89+
MarkAllNotificationsAsRead(ctx context.Context, email string, readAt int64) error
8690
}
8791

8892
type ListDraftAdminNotificationsParams struct {

pkg/notification/storage/postgres/notification.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ var (
6060
insertNotificationReadSQL string
6161
//go:embed sql/count_unread_notifications.sql
6262
countUnreadNotificationsSQL string
63+
//go:embed sql/insert_all_notification_reads.sql
64+
insertAllNotificationReadsSQL string
6365
)
6466

6567
// readNotificationExistsSubquery correlates a viewer's read marker with the
@@ -565,6 +567,23 @@ func (s *notificationStorage) MarkNotificationsAsRead(
565567
return nil
566568
}
567569

570+
// MarkAllNotificationsAsRead upserts read markers for every published
571+
// notification for the viewer.
572+
func (s *notificationStorage) MarkAllNotificationsAsRead(
573+
ctx context.Context,
574+
email string,
575+
readAt int64,
576+
) error {
577+
_, err := s.qe.ExecContext(
578+
ctx,
579+
insertAllNotificationReadsSQL,
580+
email,
581+
readAt,
582+
int32(proto.Notification_PUBLISHED),
583+
)
584+
return err
585+
}
586+
568587
// GetNotificationUnreadCount counts the published notifications the viewer
569588
// has not read, limited to notifications published after the viewer's
570589
// account was created.

pkg/notification/storage/postgres/notification_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,3 +1174,48 @@ func TestGetNotificationUnreadCount(t *testing.T) {
11741174
})
11751175
}
11761176
}
1177+
1178+
func TestMarkAllNotificationsAsRead(t *testing.T) {
1179+
t.Parallel()
1180+
mockController := gomock.NewController(t)
1181+
defer mockController.Finish()
1182+
1183+
patterns := []struct {
1184+
desc string
1185+
setup func(*notificationStorage)
1186+
expectedErr error
1187+
}{
1188+
{
1189+
desc: "Error",
1190+
setup: func(s *notificationStorage) {
1191+
s.qe.(*mock.MockQueryExecer).EXPECT().ExecContext(
1192+
gomock.Any(), gomock.Any(), gomock.Any(),
1193+
).Return(nil, errors.New("error"))
1194+
},
1195+
expectedErr: errors.New("error"),
1196+
},
1197+
{
1198+
desc: "Success",
1199+
setup: func(s *notificationStorage) {
1200+
s.qe.(*mock.MockQueryExecer).EXPECT().ExecContext(
1201+
gomock.Any(),
1202+
insertAllNotificationReadsSQL,
1203+
"viewer@example.com",
1204+
int64(5),
1205+
int32(proto.Notification_PUBLISHED),
1206+
).Return(nil, nil)
1207+
},
1208+
expectedErr: nil,
1209+
},
1210+
}
1211+
for _, p := range patterns {
1212+
t.Run(p.desc, func(t *testing.T) {
1213+
storage := &notificationStorage{qe: mock.NewMockQueryExecer(mockController)}
1214+
if p.setup != nil {
1215+
p.setup(storage)
1216+
}
1217+
err := storage.MarkAllNotificationsAsRead(context.Background(), "viewer@example.com", 5)
1218+
assert.Equal(t, p.expectedErr, err)
1219+
})
1220+
}
1221+
}

0 commit comments

Comments
 (0)