Skip to content

Commit 7850fac

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

17 files changed

Lines changed: 1049 additions & 481 deletions

api-description/web-api.swagger.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14368,8 +14368,16 @@ definitions:
1436814368
type: string
1436914369
notificationPublishAdminNotificationRequest:
1437014370
type: object
14371+
properties:
14372+
id:
14373+
type: string
14374+
required:
14375+
- id
1437114376
notificationPublishAdminNotificationResponse:
1437214377
type: object
14378+
properties:
14379+
notification:
14380+
$ref: '#/definitions/notificationNotification'
1437314381
notificationUpdateAdminNotificationRequest:
1437414382
type: object
1437514383
properties:

pkg/notification/api/api.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,51 @@ func (s *NotificationService) PublishAdminNotification(
321321
ctx context.Context,
322322
req *proto.PublishAdminNotificationRequest,
323323
) (*proto.PublishAdminNotificationResponse, error) {
324-
return nil, statusNotImplemented
324+
editor, err := s.checkSystemAdminRole(ctx)
325+
if err != nil {
326+
return nil, err
327+
}
328+
if len(strings.TrimSpace(req.Id)) == 0 {
329+
return nil, statusNotificationIDRequired.Err()
330+
}
331+
var notification *domain.Notification
332+
err = s.dbClient.RunInTransactionV2(ctx, func(ctxWithTx context.Context) error {
333+
var err error
334+
notification, err = s.notificationStorage.GetAdminNotification(ctxWithTx, req.Id)
335+
if err != nil {
336+
return err
337+
}
338+
if notification.Status != proto.Notification_DRAFT {
339+
return statusNotificationAlreadyPublished.Err()
340+
}
341+
if len(notification.Localizations) == 0 {
342+
return statusLocalizationRequired.Err()
343+
}
344+
notification.Publish(editor.Email)
345+
return s.notificationStorage.PublishAdminNotification(ctxWithTx, notification)
346+
})
347+
if err != nil {
348+
if errors.Is(err, storage.ErrNotificationNotFound) {
349+
return nil, statusNotificationNotFound.Err()
350+
}
351+
if errors.Is(err, statusNotificationAlreadyPublished.Err()) {
352+
return nil, statusNotificationAlreadyPublished.Err()
353+
}
354+
if errors.Is(err, statusLocalizationRequired.Err()) {
355+
return nil, statusLocalizationRequired.Err()
356+
}
357+
s.logger.Error(
358+
"Failed to publish notification",
359+
log.FieldsFromIncomingContext(ctx).AddFields(
360+
zap.Error(err),
361+
zap.String("notificationId", req.Id),
362+
)...,
363+
)
364+
return nil, api.NewGRPCStatus(err).Err()
365+
}
366+
return &proto.PublishAdminNotificationResponse{
367+
Notification: notification.Notification,
368+
}, nil
325369
}
326370

327371
func (s *NotificationService) DeleteAdminNotification(

pkg/notification/api/api_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,175 @@ func TestNotificationService_DeleteAdminNotification(t *testing.T) {
693693
})
694694
}
695695
}
696+
697+
func TestNotificationService_PublishAdminNotification(t *testing.T) {
698+
t.Parallel()
699+
mockController := gomock.NewController(t)
700+
defer mockController.Finish()
701+
702+
adminCtx := metadata.NewIncomingContext(
703+
createContextWithToken(t, true),
704+
metadata.MD{"accept-language": []string{"en"}},
705+
)
706+
memberCtx := metadata.NewIncomingContext(
707+
createContextWithToken(t, false),
708+
metadata.MD{"accept-language": []string{"en"}},
709+
)
710+
711+
draft := func() *domain.Notification {
712+
return &domain.Notification{
713+
Notification: &proto.Notification{
714+
Id: "notification-id-0",
715+
Status: proto.Notification_DRAFT,
716+
CreatedBy: "admin@example.com",
717+
LastEditedBy: "admin@example.com",
718+
CreatedAt: 1,
719+
UpdatedAt: 1,
720+
Localizations: []*proto.NotificationLocalization{
721+
{Language: "en", Title: "New feature", Content: "# New feature"},
722+
},
723+
},
724+
}
725+
}
726+
727+
patterns := []struct {
728+
desc string
729+
ctx context.Context
730+
setup func(*NotificationService)
731+
req *proto.PublishAdminNotificationRequest
732+
expectedErr error
733+
}{
734+
{
735+
desc: "err: unauthenticated",
736+
ctx: context.TODO(),
737+
req: &proto.PublishAdminNotificationRequest{
738+
Id: "notification-id-0",
739+
},
740+
expectedErr: statusUnauthenticated.Err(),
741+
},
742+
{
743+
desc: "err: permission denied",
744+
ctx: memberCtx,
745+
req: &proto.PublishAdminNotificationRequest{
746+
Id: "notification-id-0",
747+
},
748+
expectedErr: statusPermissionDenied.Err(),
749+
},
750+
{
751+
desc: "err: id required",
752+
ctx: adminCtx,
753+
req: &proto.PublishAdminNotificationRequest{Id: " "},
754+
expectedErr: statusNotificationIDRequired.Err(),
755+
},
756+
{
757+
desc: "err: not found",
758+
ctx: adminCtx,
759+
setup: func(s *NotificationService) {
760+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
761+
gomock.Any(), gomock.Any(),
762+
).DoAndReturn(func(ctx context.Context, fn func(ctx context.Context) error) error {
763+
return fn(ctx)
764+
})
765+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetAdminNotification(
766+
gomock.Any(), "notification-id-0",
767+
).Return(nil, storage.ErrNotificationNotFound)
768+
},
769+
req: &proto.PublishAdminNotificationRequest{
770+
Id: "notification-id-0",
771+
},
772+
expectedErr: statusNotificationNotFound.Err(),
773+
},
774+
{
775+
desc: "err: already published",
776+
ctx: adminCtx,
777+
setup: func(s *NotificationService) {
778+
published := draft()
779+
published.Status = proto.Notification_PUBLISHED
780+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
781+
gomock.Any(), gomock.Any(),
782+
).DoAndReturn(func(ctx context.Context, fn func(ctx context.Context) error) error {
783+
return fn(ctx)
784+
})
785+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetAdminNotification(
786+
gomock.Any(), "notification-id-0",
787+
).Return(published, nil)
788+
},
789+
req: &proto.PublishAdminNotificationRequest{
790+
Id: "notification-id-0",
791+
},
792+
expectedErr: statusNotificationAlreadyPublished.Err(),
793+
},
794+
{
795+
desc: "err: localization required",
796+
ctx: adminCtx,
797+
setup: func(s *NotificationService) {
798+
empty := draft()
799+
empty.Localizations = nil
800+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
801+
gomock.Any(), gomock.Any(),
802+
).DoAndReturn(func(ctx context.Context, fn func(ctx context.Context) error) error {
803+
return fn(ctx)
804+
})
805+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetAdminNotification(
806+
gomock.Any(), "notification-id-0",
807+
).Return(empty, nil)
808+
},
809+
req: &proto.PublishAdminNotificationRequest{
810+
Id: "notification-id-0",
811+
},
812+
expectedErr: statusLocalizationRequired.Err(),
813+
},
814+
{
815+
desc: "err: internal",
816+
ctx: adminCtx,
817+
setup: func(s *NotificationService) {
818+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
819+
gomock.Any(), gomock.Any(),
820+
).Return(errors.New("error"))
821+
},
822+
req: &proto.PublishAdminNotificationRequest{
823+
Id: "notification-id-0",
824+
},
825+
expectedErr: api.NewGRPCStatus(errors.New("error")).Err(),
826+
},
827+
{
828+
desc: "success",
829+
ctx: adminCtx,
830+
setup: func(s *NotificationService) {
831+
s.dbClient.(*databasemock.MockClient).EXPECT().RunInTransactionV2(
832+
gomock.Any(), gomock.Any(),
833+
).DoAndReturn(func(ctx context.Context, fn func(ctx context.Context) error) error {
834+
return fn(ctx)
835+
})
836+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().GetAdminNotification(
837+
gomock.Any(), "notification-id-0",
838+
).Return(draft(), nil)
839+
s.notificationStorage.(*notificationstoragemock.MockNotificationStorage).EXPECT().PublishAdminNotification(
840+
gomock.Any(), gomock.Any(),
841+
).Return(nil)
842+
},
843+
req: &proto.PublishAdminNotificationRequest{
844+
Id: "notification-id-0",
845+
},
846+
expectedErr: nil,
847+
},
848+
}
849+
for _, p := range patterns {
850+
t.Run(p.desc, func(t *testing.T) {
851+
s := createNotificationService(mockController)
852+
if p.setup != nil {
853+
p.setup(s)
854+
}
855+
resp, err := s.PublishAdminNotification(p.ctx, p.req)
856+
assert.Equal(t, p.expectedErr, err)
857+
if p.expectedErr == nil {
858+
assert.NotNil(t, resp)
859+
assert.Equal(t, "notification-id-0", resp.Notification.Id)
860+
assert.Equal(t, proto.Notification_PUBLISHED, resp.Notification.Status)
861+
assert.Equal(t, "email", resp.Notification.PublishedBy)
862+
assert.True(t, resp.Notification.PublishedAt > 0)
863+
assert.Equal(t, resp.Notification.PublishedAt, resp.Notification.UpdatedAt)
864+
}
865+
})
866+
}
867+
}

pkg/notification/api/error.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ var (
6666
statusNotificationAlreadyPublished = api.NewGRPCStatus(
6767
bkterr.NewErrorFailedPrecondition(
6868
bkterr.NotificationPackageName,
69-
"published notifications cannot be edited"))
69+
"notification is already published"))
7070
statusInvalidCursor = api.NewGRPCStatus(
7171
bkterr.NewErrorInvalidArgNotMatchFormat(
7272
bkterr.NotificationPackageName,

pkg/notification/domain/notification.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ func (n *Notification) Update(
3535
n.Localizations = localizations
3636
}
3737

38+
// Publish marks a draft as published and stamps the publisher.
39+
func (n *Notification) Publish(publishedBy string) {
40+
now := time.Now().Unix()
41+
n.Status = proto.Notification_PUBLISHED
42+
n.PublishedBy = publishedBy
43+
n.PublishedAt = now
44+
n.UpdatedAt = now
45+
}
46+
3847
func NewNotification(
3948
createdBy string,
4049
localizations []*proto.NotificationLocalization,

pkg/notification/domain/notification_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,20 @@ func TestNewNotification(t *testing.T) {
6868
assert.Equal(t, notification.CreatedAt, notification.UpdatedAt)
6969
assert.Equal(t, localizations, notification.Localizations)
7070
}
71+
72+
func TestPublishNotification(t *testing.T) {
73+
t.Parallel()
74+
notification, err := NewNotification("admin@example.com", []*proto.NotificationLocalization{
75+
{Language: "en", Title: "New feature", Content: "# New feature"},
76+
})
77+
assert.Nil(t, err)
78+
createdAt := notification.CreatedAt
79+
notification.Publish("publisher@example.com")
80+
assert.Equal(t, proto.Notification_PUBLISHED, notification.Status)
81+
assert.Equal(t, "publisher@example.com", notification.PublishedBy)
82+
assert.True(t, notification.PublishedAt >= createdAt)
83+
assert.Equal(t, notification.PublishedAt, notification.UpdatedAt)
84+
assert.Equal(t, "admin@example.com", notification.CreatedBy)
85+
assert.Equal(t, "admin@example.com", notification.LastEditedBy)
86+
assert.Equal(t, createdAt, notification.CreatedAt)
87+
}

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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ var (
3535
selectNotificationSQL string
3636
//go:embed sql/update_notification.sql
3737
updateNotificationSQL string
38+
//go:embed sql/publish_notification.sql
39+
publishNotificationSQL string
3840
//go:embed sql/delete_notification_localizations.sql
3941
deleteNotificationLocalizationsSQL string
4042
//go:embed sql/delete_notification.sql
@@ -173,6 +175,32 @@ func (s *notificationStorage) UpdateAdminNotification(
173175
return nil
174176
}
175177

178+
func (s *notificationStorage) PublishAdminNotification(
179+
ctx context.Context,
180+
notification *domain.Notification,
181+
) error {
182+
result, err := s.qe.ExecContext(
183+
ctx,
184+
publishNotificationSQL,
185+
int32(notification.Status),
186+
notification.PublishedBy,
187+
notification.PublishedAt,
188+
notification.UpdatedAt,
189+
notification.Id,
190+
)
191+
if err != nil {
192+
return err
193+
}
194+
rowsAffected, err := result.RowsAffected()
195+
if err != nil {
196+
return err
197+
}
198+
if rowsAffected == 0 {
199+
return notificationstorage.ErrNotificationNotFound
200+
}
201+
return nil
202+
}
203+
176204
func (s *notificationStorage) DeleteAdminNotification(
177205
ctx context.Context,
178206
id, lastEditedBy string,

0 commit comments

Comments
 (0)