Skip to content

Commit b2d62d1

Browse files
authored
feat(notification): implement CreateNotification API (#2708)
Part of #2215 (RFC: #2680) - Proto: CreateNotificationRequest carries repeated localizations (language, tags, title, Markdown content); response returns the created Notification. - Domain: NewNotification creates a DRAFT with UUID, editor email as created_by/last_edited_by, and timestamps. - Storage: NotificationStorage interface with MySQL and PostgreSQL implementations inserting the notification row and one localization row per language (tags as JSON). - API: system-admin-only handler with request validation, running the inserts in a transaction; wired with storage in the web server for both database backends. - Unit tests for domain, both storage implementations, and the API handler.
1 parent 463f502 commit b2d62d1

21 files changed

Lines changed: 1790 additions & 538 deletions

api-description/web-api.swagger.yaml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14182,8 +14182,18 @@ definitions:
1418214182
title: Sorted by yearmonth ascending
1418314183
notificationCreateNotificationRequest:
1418414184
type: object
14185+
properties:
14186+
localizations:
14187+
type: array
14188+
items:
14189+
type: object
14190+
$ref: '#/definitions/notificationNotificationLocalization'
14191+
description: At least one localization is required; languages must be unique.
1418514192
notificationCreateNotificationResponse:
1418614193
type: object
14194+
properties:
14195+
notification:
14196+
$ref: '#/definitions/notificationNotification'
1418714197
notificationDeleteNotificationResponse:
1418814198
type: object
1418914199
notificationGetNotificationResponse:
@@ -14202,6 +14212,72 @@ definitions:
1420214212
type: object
1420314213
notificationMarkNotificationsAsReadResponse:
1420414214
type: object
14215+
notificationNotification:
14216+
type: object
14217+
properties:
14218+
id:
14219+
type: string
14220+
status:
14221+
$ref: '#/definitions/notificationNotificationStatus'
14222+
createdBy:
14223+
type: string
14224+
lastEditedBy:
14225+
type: string
14226+
publishedBy:
14227+
type: string
14228+
publishedAt:
14229+
type: string
14230+
format: int64
14231+
description: Epoch seconds; 0 while draft.
14232+
createdAt:
14233+
type: string
14234+
format: int64
14235+
updatedAt:
14236+
type: string
14237+
format: int64
14238+
localization:
14239+
$ref: '#/definitions/notificationNotificationLocalization'
14240+
description: |-
14241+
Localization resolved to the requested language, falling back to
14242+
English, then to whichever localization exists.
14243+
localizations:
14244+
type: array
14245+
items:
14246+
type: object
14247+
$ref: '#/definitions/notificationNotificationLocalization'
14248+
description: All localizations; populated only for system admins (editor).
14249+
read:
14250+
type: boolean
14251+
description: Whether the requesting user has read this notification.
14252+
notificationNotificationLocalization:
14253+
type: object
14254+
properties:
14255+
language:
14256+
type: string
14257+
description: BCP 47 language code, e.g. "en", "ja".
14258+
tags:
14259+
type: array
14260+
items:
14261+
type: object
14262+
$ref: '#/definitions/notificationNotificationTag'
14263+
title:
14264+
type: string
14265+
content:
14266+
type: string
14267+
description: Markdown source.
14268+
notificationNotificationStatus:
14269+
type: string
14270+
enum:
14271+
- DRAFT
14272+
- PUBLISHED
14273+
default: DRAFT
14274+
notificationNotificationTag:
14275+
type: object
14276+
properties:
14277+
name:
14278+
type: string
14279+
color:
14280+
type: string
1420514281
notificationPublishNotificationRequest:
1420614282
type: object
1420714283
notificationPublishNotificationResponse:

pkg/error/error.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
AccountPackageName = "account"
2626
FeaturePackageName = "feature"
2727
SubscriptionPackageName = "subscription"
28+
NotificationPackageName = "notification"
2829
PushPackageName = "push"
2930
TagPackageName = "tag"
3031
EventCounterPackageName = "eventcounter"

pkg/notification/api/api.go

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,24 @@ package api
1616

1717
import (
1818
"context"
19+
"errors"
20+
"strings"
1921

2022
"go.uber.org/zap"
2123
"google.golang.org/grpc"
2224
"google.golang.org/grpc/codes"
2325
gstatus "google.golang.org/grpc/status"
2426

27+
"github.com/bucketeer-io/bucketeer/v2/pkg/api/api"
28+
"github.com/bucketeer-io/bucketeer/v2/pkg/log"
29+
"github.com/bucketeer-io/bucketeer/v2/pkg/notification/domain"
30+
"github.com/bucketeer-io/bucketeer/v2/pkg/notification/storage"
31+
"github.com/bucketeer-io/bucketeer/v2/pkg/role"
32+
"github.com/bucketeer-io/bucketeer/v2/pkg/storage/v2/database"
33+
eventproto "github.com/bucketeer-io/bucketeer/v2/proto/event/domain"
2534
proto "github.com/bucketeer-io/bucketeer/v2/proto/notification"
2635
)
2736

28-
var statusNotImplemented = gstatus.Error(codes.Unimplemented, "notification: not implemented")
29-
3037
type options struct {
3138
logger *zap.Logger
3239
}
@@ -40,27 +47,64 @@ func WithLogger(l *zap.Logger) Option {
4047
}
4148

4249
type NotificationService struct {
43-
opts *options
44-
logger *zap.Logger
50+
dbClient database.Client
51+
notificationStorage storage.NotificationStorage
52+
opts *options
53+
logger *zap.Logger
4554
}
4655

47-
func NewNotificationService(opts ...Option) *NotificationService {
56+
func NewNotificationService(
57+
dbClient database.Client,
58+
notificationStorage storage.NotificationStorage,
59+
opts ...Option,
60+
) *NotificationService {
4861
dopts := &options{
4962
logger: zap.NewNop(),
5063
}
5164
for _, opt := range opts {
5265
opt(dopts)
5366
}
5467
return &NotificationService{
55-
opts: dopts,
56-
logger: dopts.logger.Named("api"),
68+
dbClient: dbClient,
69+
notificationStorage: notificationStorage,
70+
opts: dopts,
71+
logger: dopts.logger.Named("api"),
5772
}
5873
}
5974

6075
func (s *NotificationService) Register(server *grpc.Server) {
6176
proto.RegisterNotificationServiceServer(server, s)
6277
}
6378

79+
func (s *NotificationService) checkSystemAdminRole(
80+
ctx context.Context,
81+
) (*eventproto.Editor, error) {
82+
editor, err := role.CheckSystemAdminRole(ctx)
83+
if err != nil {
84+
switch gstatus.Code(err) {
85+
case codes.Unauthenticated:
86+
s.logger.Error(
87+
"Unauthenticated",
88+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
89+
)
90+
return nil, statusUnauthenticated.Err()
91+
case codes.PermissionDenied:
92+
s.logger.Error(
93+
"Permission denied",
94+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
95+
)
96+
return nil, statusPermissionDenied.Err()
97+
default:
98+
s.logger.Error(
99+
"Failed to check role",
100+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
101+
)
102+
return nil, api.NewGRPCStatus(err).Err()
103+
}
104+
}
105+
return editor, nil
106+
}
107+
64108
func (s *NotificationService) ListNotifications(
65109
ctx context.Context,
66110
req *proto.ListNotificationsRequest,
@@ -107,7 +151,69 @@ func (s *NotificationService) CreateNotification(
107151
ctx context.Context,
108152
req *proto.CreateNotificationRequest,
109153
) (*proto.CreateNotificationResponse, error) {
110-
return nil, statusNotImplemented
154+
editor, err := s.checkSystemAdminRole(ctx)
155+
if err != nil {
156+
return nil, err
157+
}
158+
if err := validateCreateNotificationRequest(req); err != nil {
159+
s.logger.Error(
160+
"Failed to validate create notification request",
161+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
162+
)
163+
return nil, err
164+
}
165+
notification, err := domain.NewNotification(editor.Email, req.Localizations)
166+
if err != nil {
167+
s.logger.Error(
168+
"Failed to create new notification",
169+
log.FieldsFromIncomingContext(ctx).AddFields(zap.Error(err))...,
170+
)
171+
return nil, api.NewGRPCStatus(err).Err()
172+
}
173+
err = s.dbClient.RunInTransactionV2(ctx, func(ctxWithTx context.Context) error {
174+
return s.notificationStorage.CreateNotification(ctxWithTx, notification)
175+
})
176+
if err != nil {
177+
if errors.Is(err, storage.ErrNotificationAlreadyExists) {
178+
return nil, statusNotificationAlreadyExists.Err()
179+
}
180+
s.logger.Error(
181+
"Failed to create notification",
182+
log.FieldsFromIncomingContext(ctx).AddFields(
183+
zap.Error(err),
184+
zap.String("notificationId", notification.Id),
185+
)...,
186+
)
187+
return nil, api.NewGRPCStatus(err).Err()
188+
}
189+
return &proto.CreateNotificationResponse{
190+
Notification: notification.Notification,
191+
}, nil
192+
}
193+
194+
func validateCreateNotificationRequest(req *proto.CreateNotificationRequest) error {
195+
if len(req.Localizations) == 0 {
196+
return statusLocalizationRequired.Err()
197+
}
198+
languages := make(map[string]struct{}, len(req.Localizations))
199+
for _, l := range req.Localizations {
200+
l.Language = strings.TrimSpace(l.Language)
201+
l.Title = strings.TrimSpace(l.Title)
202+
if l.Language == "" {
203+
return statusLanguageRequired.Err()
204+
}
205+
if _, ok := languages[l.Language]; ok {
206+
return statusDuplicatedLanguage.Err()
207+
}
208+
languages[l.Language] = struct{}{}
209+
if l.Title == "" {
210+
return statusTitleRequired.Err()
211+
}
212+
if strings.TrimSpace(l.Content) == "" {
213+
return statusContentRequired.Err()
214+
}
215+
}
216+
return nil
111217
}
112218

113219
func (s *NotificationService) UpdateNotification(

0 commit comments

Comments
 (0)