- Notifications
You must be signed in to change notification settings - Fork907
feat(coderd): add new dispatch logic for coder inbox#16764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
61111c9
10276a5
9573813
4485b59
02113f6
1c2f5d2
9134ea2
9a58bf7
7e088ac
548335b
b995d9d
ab1e9ed
61746c8
e265ea0
e56c155
ac36085
ad59e8b
a11f505
fe58472
15933fe
aae0f98
5a5f2d6
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
-- The migration is about an enum value change | ||
-- As we can not remove a value from an enum, we can let the down migration empty | ||
-- In order to avoid any failure, we use ADD VALUE IF NOT EXISTS to add the value |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
ALTER TYPE notification_method ADD VALUE IF NOT EXISTS 'inbox'; |
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
package dispatch | ||
import ( | ||
"context" | ||
"encoding/json" | ||
"text/template" | ||
"golang.org/x/xerrors" | ||
"cdr.dev/slog" | ||
"github.com/google/uuid" | ||
"github.com/coder/coder/v2/coderd/database" | ||
"github.com/coder/coder/v2/coderd/database/dbtime" | ||
"github.com/coder/coder/v2/coderd/notifications/types" | ||
markdown "github.com/coder/coder/v2/coderd/render" | ||
) | ||
type InboxStore interface { | ||
InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) | ||
} | ||
// InboxHandler is responsible for dispatching notification messages to the Coder Inbox. | ||
type InboxHandler struct { | ||
log slog.Logger | ||
store InboxStore | ||
} | ||
func NewInboxHandler(log slog.Logger, store InboxStore) *InboxHandler { | ||
return &InboxHandler{log: log, store: store} | ||
} | ||
func (s *InboxHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string, _ template.FuncMap) (DeliveryFunc, error) { | ||
subject, err := markdown.PlaintextFromMarkdown(titleTmpl) | ||
if err != nil { | ||
return nil, xerrors.Errorf("render subject: %w", err) | ||
} | ||
htmlBody, err := markdown.PlaintextFromMarkdown(bodyTmpl) | ||
if err != nil { | ||
return nil, xerrors.Errorf("render html body: %w", err) | ||
} | ||
return s.dispatch(payload, subject, htmlBody), nil | ||
} | ||
func (s *InboxHandler) dispatch(payload types.MessagePayload, title, body string) DeliveryFunc { | ||
return func(ctx context.Context, msgID uuid.UUID) (bool, error) { | ||
userID, err := uuid.Parse(payload.UserID) | ||
if err != nil { | ||
return false, xerrors.Errorf("parse user ID: %w", err) | ||
} | ||
templateID, err := uuid.Parse(payload.NotificationTemplateID) | ||
if err != nil { | ||
return false, xerrors.Errorf("parse template ID: %w", err) | ||
} | ||
actions, err := json.Marshal(payload.Actions) | ||
if err != nil { | ||
return false, xerrors.Errorf("marshal actions: %w", err) | ||
} | ||
// nolint:exhaustruct | ||
_, err = s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{ | ||
ID: msgID, | ||
UserID: userID, | ||
TemplateID: templateID, | ||
Targets: payload.Targets, | ||
Title: title, | ||
Content: body, | ||
Actions: actions, | ||
CreatedAt: dbtime.Now(), | ||
}) | ||
if err != nil { | ||
return false, xerrors.Errorf("insert inbox notification: %w", err) | ||
} | ||
return false, nil | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
package dispatch_test | ||
import ( | ||
"context" | ||
"testing" | ||
"cdr.dev/slog" | ||
"cdr.dev/slog/sloggers/slogtest" | ||
"github.com/google/uuid" | ||
"github.com/stretchr/testify/require" | ||
"github.com/coder/coder/v2/coderd/database" | ||
"github.com/coder/coder/v2/coderd/database/dbgen" | ||
"github.com/coder/coder/v2/coderd/database/dbtestutil" | ||
"github.com/coder/coder/v2/coderd/notifications" | ||
"github.com/coder/coder/v2/coderd/notifications/dispatch" | ||
"github.com/coder/coder/v2/coderd/notifications/types" | ||
) | ||
func TestInbox(t *testing.T) { | ||
t.Parallel() | ||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) | ||
tests := []struct { | ||
name string | ||
msgID uuid.UUID | ||
payload types.MessagePayload | ||
expectedErr string | ||
expectedRetry bool | ||
}{ | ||
{ | ||
name: "OK", | ||
msgID: uuid.New(), | ||
payload: types.MessagePayload{ | ||
NotificationName: "test", | ||
NotificationTemplateID: notifications.TemplateWorkspaceDeleted.String(), | ||
UserID: "valid", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. How is | ||
Actions: []types.TemplateAction{ | ||
{ | ||
Label: "View my workspace", | ||
URL: "https://coder.com/workspaces/1", | ||
}, | ||
}, | ||
}, | ||
}, | ||
{ | ||
name: "InvalidUserID", | ||
payload: types.MessagePayload{ | ||
NotificationName: "test", | ||
NotificationTemplateID: notifications.TemplateWorkspaceDeleted.String(), | ||
UserID: "invalid", | ||
Actions: []types.TemplateAction{}, | ||
}, | ||
expectedErr: "parse user ID", | ||
expectedRetry: false, | ||
}, | ||
{ | ||
name: "InvalidTemplateID", | ||
payload: types.MessagePayload{ | ||
NotificationName: "test", | ||
NotificationTemplateID: "invalid", | ||
UserID: "valid", | ||
Actions: []types.TemplateAction{}, | ||
}, | ||
expectedErr: "parse template ID", | ||
expectedRetry: false, | ||
}, | ||
} | ||
for _, tc := range tests { | ||
tc := tc | ||
t.Run(tc.name, func(t *testing.T) { | ||
t.Parallel() | ||
db, _ := dbtestutil.NewDB(t) | ||
if tc.payload.UserID == "valid" { | ||
user := dbgen.User(t, db, database.User{}) | ||
tc.payload.UserID = user.ID.String() | ||
} | ||
ctx := context.Background() | ||
handler := dispatch.NewInboxHandler(logger.Named("smtp"), db) | ||
dispatcherFunc, err := handler.Dispatcher(tc.payload, "", "", nil) | ||
require.NoError(t, err) | ||
retryable, err := dispatcherFunc(ctx, tc.msgID) | ||
if tc.expectedErr != "" { | ||
require.ErrorContains(t, err, tc.expectedErr) | ||
require.Equal(t, tc.expectedRetry, retryable) | ||
} else { | ||
require.NoError(t, err) | ||
require.False(t, retryable) | ||
uid := uuid.MustParse(tc.payload.UserID) | ||
notifs, err := db.GetInboxNotificationsByUserID(ctx, database.GetInboxNotificationsByUserIDParams{ | ||
UserID: uid, | ||
ReadStatus: database.InboxNotificationReadStatusAll, | ||
}) | ||
require.NoError(t, err) | ||
require.Len(t, notifs, 1) | ||
require.Equal(t, tc.msgID, notifs[0].ID) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -53,13 +53,13 @@ func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers tem | ||
} | ||
// Enqueue queues a notification message for later delivery, assumes no structured input data. | ||
func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) { | ||
return s.EnqueueWithData(ctx, userID, templateID, labels, nil, createdBy, targets...) | ||
} | ||
// Enqueue queues a notification message for later delivery. | ||
// Messages will be dequeued by a notifier later and dispatched. | ||
func (s *StoreEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) { | ||
metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ | ||
UserID: userID, | ||
NotificationTemplateID: templateID, | ||
@@ -85,40 +85,48 @@ func (s *StoreEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID | ||
return nil, xerrors.Errorf("failed encoding input labels: %w", err) | ||
} | ||
uuids := make([]uuid.UUID, 0, 2) | ||
// All the enqueued messages are enqueued both on the dispatch method set by the user (or default one) and the inbox. | ||
// As the inbox is not configurable per the user and is always enabled, we always enqueue the message on the inbox. | ||
// The logic is done here in order to have two completely separated processing and retries are handled separately. | ||
for _, method := range []database.NotificationMethod{dispatchMethod, database.NotificationMethodInbox} { | ||
dannykopping marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
id := uuid.New() | ||
err = s.store.EnqueueNotificationMessage(ctx, database.EnqueueNotificationMessageParams{ | ||
ID: id, | ||
UserID: userID, | ||
NotificationTemplateID: templateID, | ||
Method: method, | ||
Payload: input, | ||
Targets: targets, | ||
CreatedBy: createdBy, | ||
CreatedAt: dbtime.Time(s.clock.Now().UTC()), | ||
}) | ||
if err != nil { | ||
// We have a trigger on the notification_messages table named `inhibit_enqueue_if_disabled` which prevents messages | ||
// from being enqueued if the user has disabled them via notification_preferences. The trigger will fail the insertion | ||
// with the message "cannot enqueue message: user has disabled this notification". | ||
// | ||
// This is more efficient than fetching the user's preferences for each enqueue, and centralizes the business logic. | ||
if strings.Contains(err.Error(), ErrCannotEnqueueDisabledNotification.Error()) { | ||
return nil, ErrCannotEnqueueDisabledNotification | ||
} | ||
// If the enqueue fails due to a dedupe hash conflict, this means that a notification has already been enqueued | ||
// today with identical properties. It's far simpler to prevent duplicate sends in this central manner, rather than | ||
// having each notification enqueue handle its own logic. | ||
if database.IsUniqueViolation(err, database.UniqueNotificationMessagesDedupeHashIndex) { | ||
return nil, ErrDuplicate | ||
} | ||
s.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err)) | ||
return nil, xerrors.Errorf("enqueue notification: %w", err) | ||
} | ||
uuids = append(uuids, id) | ||
} | ||
s.log.Debug(ctx, "enqueued notification", slog.F("msg_ids",uuids)) | ||
returnuuids, nil | ||
} | ||
// buildPayload creates the payload that the notification will for variable substitution and/or routing. | ||
@@ -165,12 +173,12 @@ func NewNoopEnqueuer() *NoopEnqueuer { | ||
return &NoopEnqueuer{} | ||
} | ||
func (*NoopEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, map[string]string, string, ...uuid.UUID) ([]uuid.UUID, error) { | ||
// nolint:nilnil // irrelevant. | ||
return nil, nil | ||
} | ||
func (*NoopEnqueuer) EnqueueWithData(context.Context, uuid.UUID, uuid.UUID, map[string]string, map[string]any, string, ...uuid.UUID) ([]uuid.UUID, error) { | ||
// nolint:nilnil // irrelevant. | ||
return nil, nil | ||
} |
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.