Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

Merged
merged 18 commits into from
Mar 17, 2025
Merged

Conversation

defelmnq
Copy link
Contributor

This PR is part of the inbox notifications topic, and rely on previous PRs merged - it adds :

  • Endpoints to :
    • WS : watch new inbox notifications
    • REST : list inbox notifications
    • REST : update the read status of a notification

Also, this PR acts as a follow-up PR from previous work and :

  • fix DB query issues
  • fix DBMem logic to match DB

@@ -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
Copy link
Contributor Author

@defelmnq defelmnq Mar 12, 2025

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.

@defelmnq defelmnq marked this pull request as ready for review March 12, 2025 14:05
Comment on lines 49 to 94
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
}
}
Copy link
Member

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.

Comment on lines 112 to 148
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
},
Copy link
Member

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.

Copy link
Contributor Author

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 !

}
}

notificationCh <- payload.InboxNotification
Copy link
Member

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?

Copy link
Contributor Author

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'.

Copy link
Member

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:
}

Copy link
Contributor Author

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.

Comment on lines +170 to +176
if err := encoder.Encode(codersdk.GetInboxNotificationResponse{
Notification: notif,
UnreadCount: int(unreadCount),
}); err != nil {
api.Logger.Error(ctx, "encode notification", slog.Error(err))
return
}
Copy link
Member

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?

Copy link
Contributor Author

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 ?

Comment on lines 295 to 327
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 &notification.ReadAt.Time
}(),
CreatedAt: notification.CreatedAt,
})
}
return notificationsList
}(),
UnreadCount: int(unreadCount),
})
Copy link
Member

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.

Copy link
Contributor Author

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.

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{
Copy link
Member

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?)

Copy link
Contributor Author

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.

Comment on lines +363 to +371
ReadAt: func() sql.NullTime {
if body.IsRead {
return sql.NullTime{
Time: dbtime.Now(),
Valid: true,
}
}

return sql.NullTime{}
Copy link
Member

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'?

Copy link
Contributor Author

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.

Comment on lines 67 to 78
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)
}
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Contributor

@dannykopping dannykopping left a 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
Copy link
Contributor

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
Copy link
Contributor

@dannykopping dannykopping Mar 13, 2025

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?

Copy link
Member

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.

Copy link
Contributor Author

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
Copy link
Contributor

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?

Copy link
Member

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:

  1. Rename wspubsub to pubsub, and park everything there.
  2. Just add leave this code in coderd/notifications

Copy link
Contributor Author

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.

Copy link
Member

@mtojek mtojek left a 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 👍

)

// 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) {
Copy link
Member

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
Copy link
Member

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.

}
}

notificationCh <- payload.InboxNotification
Copy link
Member

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
Copy link
Member

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:

  1. Rename wspubsub to pubsub, and park everything there.
  2. 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),
Copy link
Member

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.

Copy link
Contributor Author

@defelmnq defelmnq left a 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
Copy link
Contributor Author

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
Copy link
Contributor Author

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.

}
}

notificationCh <- payload.InboxNotification
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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. ✅

Copy link
Contributor

@dannykopping dannykopping left a 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.

select {
case notificationCh <- payload.InboxNotification:
default:
api.Logger.Error(ctx, "unable to push notification in channel")
Copy link
Contributor

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.

Copy link
Member

@mtojek mtojek left a 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, &notif)
require.NoError(t, err)

require.Equal(t, 1, notif.UnreadCount)
Copy link
Member

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).

Copy link
Contributor Author

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.

@defelmnq defelmnq merged commit 3ae55bb into main Mar 17, 2025
33 of 35 checks passed
@defelmnq defelmnq deleted the notif-inbox/internal-335 branch March 17, 2025 23:02
@github-actions github-actions bot locked and limited conversation to collaborators Mar 17, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants