-
Notifications
You must be signed in to change notification settings - Fork 881
feat(coderd): add inbox notifications endpoints #16889
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 our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
@@ -21,8 +21,8 @@ SELECT * FROM inbox_notifications WHERE | |||
-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 | |||
SELECT * FROM inbox_notifications WHERE | |||
user_id = @user_id AND | |||
template_id = ANY(@templates::UUID[]) AND |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The previous query was not working as we want for empty values -
I've done a lot of tests and both template and targets logic were culprits here.
To clarify :
Templates
- Either you can give an array of template_id as parameter and if the
template_id
of the entry match any of those, it will match. - Either you can give an empty (NULL) value and all the entries will match.
Targets
- Either you can give an array of targets as parameter and if all the targets match, then the entry will match.
- either you can give an empty (NULL) value and all the entries will match.
coderd/inboxnotifications.go
Outdated
var targets []uuid.UUID | ||
if targetsParam != "" { | ||
splitTargets := strings.Split(targetsParam, ",") | ||
for _, target := range splitTargets { | ||
id, err := uuid.Parse(target) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
Message: "Invalid target ID.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
targets = append(targets, id) | ||
} | ||
} | ||
|
||
var templates []uuid.UUID | ||
if templatesParam != "" { | ||
splitTemplates := strings.Split(templatesParam, ",") | ||
for _, template := range splitTemplates { | ||
id, err := uuid.Parse(template) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
Message: "Invalid template ID.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
templates = append(templates, id) | ||
} | ||
} | ||
|
||
if readStatusParam != "" { | ||
readOptions := []string{ | ||
string(database.InboxNotificationReadStatusRead), | ||
string(database.InboxNotificationReadStatusUnread), | ||
string(database.InboxNotificationReadStatusAll), | ||
} | ||
|
||
if !slices.Contains(readOptions, readStatusParam) { | ||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
Message: "Invalid read status.", | ||
}) | ||
return | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Take a look at httpapi.NewQueryParamParser()
as it should help you with this.
coderd/inboxnotifications.go
Outdated
func(ctx context.Context, payload pubsub.InboxNotificationEvent, err error) { | ||
if err != nil { | ||
api.Logger.Error(ctx, "inbox notification event", slog.Error(err)) | ||
return | ||
} | ||
|
||
// filter out notifications that don't match the targets | ||
if len(targets) > 0 { | ||
for _, target := range targets { | ||
if isFound := slices.Contains(payload.InboxNotification.Targets, target); !isFound { | ||
return | ||
} | ||
} | ||
} | ||
|
||
// filter out notifications that don't match the templates | ||
if len(templates) > 0 { | ||
if isFound := slices.Contains(templates, payload.InboxNotification.TemplateID); !isFound { | ||
return | ||
} | ||
} | ||
|
||
// filter out notifications that don't match the read status | ||
if readStatusParam != "" { | ||
if readStatusParam == string(database.InboxNotificationReadStatusRead) { | ||
if payload.InboxNotification.ReadAt == nil { | ||
return | ||
} | ||
} else if readStatusParam == string(database.InboxNotificationReadStatusUnread) { | ||
if payload.InboxNotification.ReadAt != nil { | ||
return | ||
} | ||
} | ||
} | ||
|
||
notificationCh <- payload.InboxNotification | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
review: Could this be extracted to its own function? This would probably enhance readability and allow independently unit-testing this filtering functionality.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have isolated - on both the websocket and list endpoints - the query parameters parsing. Indeed clean up the endpoints and isolate this part , thanks !
coderd/inboxnotifications.go
Outdated
} | ||
} | ||
|
||
notificationCh <- payload.InboxNotification |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if notificationCh
already has an item in it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So as of now, if there's already an item it will 'block' trying to push the second one, until the first one has been pushed into the ws. It will just delay the new messages but not fail per itself.
We could increase the size of the chan to a bigger value - and am up to any proposal I just would like to avoid using a huge value just for the matter of 'having a big value'.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never say never my friend. It may happen that due to bug in a different components, the subscriber collapses, and this will hang forever.
something like this can save oncall
select {
case notificationCh <- payload.InboxNotification:
default:
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed ✅
I also increased the channel size just a bit, just to have a buffer and avoid skipping notifications too fast.
if err := encoder.Encode(codersdk.GetInboxNotificationResponse{ | ||
Notification: notif, | ||
UnreadCount: int(unreadCount), | ||
}); err != nil { | ||
api.Logger.Error(ctx, "encode notification", slog.Error(err)) | ||
return | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to set some kind of write deadline here to ensure we either write the response to the websocket in a reasonable amount of time, or fail and log the error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you here. I checked a bit more the code and feel like the best option would be to change the Encoder function to accept a context.Context - we could then use deadline, cancel, etc...
Here I'd have to handle it manually in the endpoints which feels a bit less clean.
Would you agree if I create a follow-up ticket - not related to notifs but more this function ?
coderd/inboxnotifications.go
Outdated
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListInboxNotificationsResponse{ | ||
Notifications: func() []codersdk.InboxNotification { | ||
notificationsList := make([]codersdk.InboxNotification, 0, len(notifs)) | ||
for _, notification := range notifs { | ||
notificationsList = append(notificationsList, codersdk.InboxNotification{ | ||
ID: notification.ID, | ||
UserID: notification.UserID, | ||
TemplateID: notification.TemplateID, | ||
Targets: notification.Targets, | ||
Title: notification.Title, | ||
Content: notification.Content, | ||
Icon: notification.Icon, | ||
Actions: func() []codersdk.InboxNotificationAction { | ||
var actionsList []codersdk.InboxNotificationAction | ||
err := json.Unmarshal([]byte(notification.Actions), &actionsList) | ||
if err != nil { | ||
api.Logger.Error(ctx, "unmarshal inbox notification actions", slog.Error(err)) | ||
} | ||
return actionsList | ||
}(), | ||
ReadAt: func() *time.Time { | ||
if !notification.ReadAt.Valid { | ||
return nil | ||
} | ||
return ¬ification.ReadAt.Time | ||
}(), | ||
CreatedAt: notification.CreatedAt, | ||
}) | ||
} | ||
return notificationsList | ||
}(), | ||
UnreadCount: int(unreadCount), | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this could use some kind of convertNotificationsResponse
helper function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense, I tried first to put one in codersdk but we have some linter rules to avoid any coderd/database
import into it.
I created one in the coderd
package, next to the handlers - please tell me if that's good for you - makes it way more readable anyway.
coderd/inboxnotifications.go
Outdated
parsedNotifID, err := uuid.Parse(notifID) | ||
if err != nil { | ||
api.Logger.Error(ctx, "failed to parse notification uuid", slog.Error(err)) | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably more of a 4xx error (bad request?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah you're right ✅
Changed to 400 Bad Request to align with the other endpoints.
ReadAt: func() sql.NullTime { | ||
if body.IsRead { | ||
return sql.NullTime{ | ||
Time: dbtime.Now(), | ||
Valid: true, | ||
} | ||
} | ||
|
||
return sql.NullTime{} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this allow you to mark a notification as 'unread'?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, for now as a product requirement we want to be able to set a notification as 'unread'. It can be used if :
- You missclicked and set a notification as read.
- You want to keep some kind of reminder for a notification that you are interested by - so you set it as unread.
coderd/inboxnotifications_test.go
Outdated
for i := range 40 { | ||
_, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ | ||
ID: uuid.New(), | ||
UserID: firstUser.UserID, | ||
TemplateID: notifications.TemplateWorkspaceOutOfMemory, | ||
Title: fmt.Sprintf("Notification %d", i), | ||
Actions: json.RawMessage("[]"), | ||
Content: fmt.Sprintf("Content of the notif %d", i), | ||
CreatedAt: dbtime.Now(), | ||
}) | ||
require.NoError(t, err) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be a good candidate for a dbgen
helper function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch - even more that the function was already there for other testing.. :')
I migrated all tests to it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally fine with the changes, but the lack of testing around the meat of this PR (the websocket aspect) is why I'm withholding my ✅ for now.
@@ -0,0 +1,288 @@ | |||
package coderd_test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The coverage of inboxnotifications.go
is only about 41%. The two major testing blindspots right now are:
- error conditions in general
- all of
watchInboxNotifications
return | ||
} | ||
|
||
// filter out notifications that don't match the targets |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious why we need this here when the SQL query allows a list of targets to match on?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1
unless this is intended, then please drop a comment on why this is mandatory.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is indeed intended - I added a comment to clarify the situation but also here :
The inbox notifications received on the HandleInboxNotificationEvent
are all the notifications pushed into the inbox table, without any filtering logic.
This is why here - for each websocket connection and based on the filters set as query params, we filters the events we want to process or not.
@@ -0,0 +1,44 @@ | |||
package pubsub |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Howcome you're creating this package inside coderd
and not coderd/notifications
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I may have know the answer. It looks awkward but it seems ~consistent with coderd/wspubsub
. You have 2 options:
- Rename
wspubsub
topubsub
, and park everything there. - Just add leave this code in
coderd/notifications
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is indeed what Marcin said. As we already had a wspubsub
handling pubsub but only for workspace - I was willing to create a whole pubsub
package on the which we could migrate the events handler.
Both solutions make sense to me, and both require anyway to move wspubsub
to stay consistent. I tried to do it in this PR but it was generating a lot of new files changes and LoC so I preferred keep it for later.
If we agree on one or the other solution , I can create the follow-up PR for workspaces later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The first review round is done on my end. We're quite not there yet, but the direction is fine :)
Please remember to link to the Github issue 👍
coderd/inboxnotifications.go
Outdated
) | ||
|
||
// convertInboxNotificationParameters parses and validates the common parameters used in get and list endpoints for inbox notifications | ||
func convertInboxNotificationParameters(ctx context.Context, logger slog.Logger, targetsParam string, templatesParam string, readStatusParam string) ([]uuid.UUID, []uuid.UUID, string, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a general comment - if we want to use inbox just for notifications, we should adjust filenames and function names to indicate the relationship: notificationsinbox.go
or convertNotificationsInboxParameters
.
return | ||
} | ||
|
||
// filter out notifications that don't match the targets |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1
unless this is intended, then please drop a comment on why this is mandatory.
coderd/inboxnotifications.go
Outdated
} | ||
} | ||
|
||
notificationCh <- payload.InboxNotification |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never say never my friend. It may happen that due to bug in a different components, the subscriber collapses, and this will hang forever.
something like this can save oncall
select {
case notificationCh <- payload.InboxNotification:
default:
}
@@ -0,0 +1,44 @@ | |||
package pubsub |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I may have know the answer. It looks awkward but it seems ~consistent with coderd/wspubsub
. You have 2 options:
- Rename
wspubsub
topubsub
, and park everything there. - Just add leave this code in
coderd/notifications
func (c *Client) UpdateInboxNotificationReadStatus(ctx context.Context, notifID uuid.UUID, req UpdateInboxNotificationReadStatusRequest) (UpdateInboxNotificationReadStatusResponse, error) { | ||
res, err := c.Request( | ||
ctx, http.MethodPut, | ||
fmt.Sprintf("/api/v2/notifications/inbox/%v/read-status", notifID), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
%v
in the URL suggests, that it should be possible to access a single notification via REST endpoint /api/v2/notifications/inbox/%v
which is not true.
Alternatively, we can change this to /api/v2/notifications/inbox/update
: { "notification_ids": [ ... ], "action": "MARK_READ" }
.
PS. Ignore this if it is too late to change the API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks everyone for the first review - I tried to answer everything but please do not hesitate to ping me on a topic if you feel like I missed it.
Globally, the biggest changes are :
- Testing coverage on the handlers.
- Improving parameters validation on the handlers using existing functions.
- Cleaning up some code, adding comments and smaller points...
@@ -0,0 +1,44 @@ | |||
package pubsub |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is indeed what Marcin said. As we already had a wspubsub
handling pubsub but only for workspace - I was willing to create a whole pubsub
package on the which we could migrate the events handler.
Both solutions make sense to me, and both require anyway to move wspubsub
to stay consistent. I tried to do it in this PR but it was generating a lot of new files changes and LoC so I preferred keep it for later.
If we agree on one or the other solution , I can create the follow-up PR for workspaces later.
return | ||
} | ||
|
||
// filter out notifications that don't match the targets |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is indeed intended - I added a comment to clarify the situation but also here :
The inbox notifications received on the HandleInboxNotificationEvent
are all the notifications pushed into the inbox table, without any filtering logic.
This is why here - for each websocket connection and based on the filters set as query params, we filters the events we want to process or not.
coderd/inboxnotifications.go
Outdated
} | ||
} | ||
|
||
notificationCh <- payload.InboxNotification |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed ✅
I also increased the channel size just a bit, just to have a buffer and avoid skipping notifications too fast.
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all" | ||
// @Success 200 {object} codersdk.GetInboxNotificationResponse | ||
// @Router /notifications/inbox/watch [get] | ||
func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added some tests to cover most of the endpoint. ✅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the improvements @defelmnq 👍
A few things left but nothing major.
coderd/inboxnotifications.go
Outdated
select { | ||
case notificationCh <- payload.InboxNotification: | ||
default: | ||
api.Logger.Error(ctx, "unable to push notification in channel") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whenever you log a message, I want you to think to yourself: "If I were an operator, how would I know what happened here, why I should care, and what to do next". This doesn't really satisfy any of those 3 clauses.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good 👍 once you're confident with other comments, feel free to merge it!
err = json.Unmarshal(message, ¬if) | ||
require.NoError(t, err) | ||
|
||
require.Equal(t, 1, notif.UnreadCount) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a chance that notifs.UnreadCount
will accidentally return 2 unread notifications? I haven't analyzed the code too deeply, but we have dealt with similar flakiness around insights tests (expected 1, returned 2).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Funny that you raised that point as it was the case at first - I changed the logic to make sure we do not depend on potential latency and create flakiness - Here it should be all good.
This PR is part of the inbox notifications topic, and rely on previous PRs merged - it adds :
Also, this PR acts as a follow-up PR from previous work and :