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
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
work on PR comments
  • Loading branch information
defelmnq committed Mar 13, 2025
commit d72d1f260099609e4dbc5e2f563c176b1c196b32
4 changes: 2 additions & 2 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/dbmem/dbmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -3296,6 +3296,7 @@ func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, a
defer q.mutex.RUnlock()

notifications := make([]database.InboxNotification, 0)
// TODO : after using go version >= 1.23 , we can change this one to https://pkg.go.dev/slices#Backward
for idx := len(q.inboxNotifications) - 1; idx >= 0; idx-- {
notification := q.inboxNotifications[idx]

Expand Down
2 changes: 1 addition & 1 deletion coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion coderd/database/queries/notificationsinbox.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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[]) OR @templates IS NULL) AND
(@templates::UUID[] IS NULL OR template_id = ANY(@templates::UUID[])) AND
(@targets::UUID[] IS NULL OR targets @> @targets::UUID[]) AND
(@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
(@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
Expand Down
14 changes: 11 additions & 3 deletions coderd/inboxnotifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
go httpapi.Heartbeat(ctx, conn)
defer conn.Close(websocket.StatusNormalClosure, "connection closed")

notificationCh := make(chan codersdk.InboxNotification, 1)
notificationCh := make(chan codersdk.InboxNotification, 10)

closeInboxNotificationsSubscriber, err := api.Pubsub.SubscribeWithErr(pubsub.InboxNotificationForOwnerEventChannel(apikey.UserID),
pubsub.HandleInboxNotificationEvent(
Expand All @@ -150,6 +150,10 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
return
}

// HandleInboxNotificationEvent cb receives all the inbox notifications - without any filters excepted the user_id.
// Based on query parameters defined above and filters defined by the client - we then filter out the
// notifications we do not want to forward and discard it.

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

if len(targets) > 0 {
for _, target := range targets {
Expand Down Expand Up @@ -179,7 +183,11 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
}
}

notificationCh <- payload.InboxNotification
// keep a safe guard in case of latency to push notifications through websocket
select {
case notificationCh <- payload.InboxNotification:
default:
}
},
))
if err != nil {
Expand Down Expand Up @@ -306,7 +314,7 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request)
// @Produce json
// @Tags Notifications
// @Param id path string true "id of the notification"
// @Success 201 {object} codersdk.Response
// @Success 200 {object} codersdk.Response
// @Router /notifications/inbox/{id}/read-status [put]
func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
Expand Down
26 changes: 18 additions & 8 deletions coderd/inboxnotifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import (
"github.com/coder/coder/v2/testutil"
)

const (
inboxNotificationsPageSize = 25
)

func TestInboxNotifications_List(t *testing.T) {
t.Parallel()

Expand All @@ -33,7 +37,8 @@ func TestInboxNotifications_List(t *testing.T) {
t.Parallel()

client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
firstUser := coderdtest.CreateFirstUser(t, client)
client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -51,6 +56,7 @@ func TestInboxNotifications_List(t *testing.T) {

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -64,7 +70,7 @@ func TestInboxNotifications_List(t *testing.T) {
for i := range 40 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: firstUser.UserID,
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Expand All @@ -77,12 +83,12 @@ func TestInboxNotifications_List(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 40, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 25)
require.Len(t, notifs.Notifications, inboxNotificationsPageSize)

require.Equal(t, "Notification 39", notifs.Notifications[0].Title)

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
StartingBefore: notifs.Notifications[24].ID,
StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID,
})
require.NoError(t, err)
require.NotNil(t, notifs)
Expand All @@ -97,6 +103,7 @@ func TestInboxNotifications_List(t *testing.T) {

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -110,7 +117,7 @@ func TestInboxNotifications_List(t *testing.T) {
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: firstUser.UserID,
UserID: member.ID,
TemplateID: func() uuid.UUID {
if i%2 == 0 {
return notifications.TemplateWorkspaceOutOfMemory
Expand Down Expand Up @@ -141,6 +148,7 @@ func TestInboxNotifications_List(t *testing.T) {

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -156,7 +164,7 @@ func TestInboxNotifications_List(t *testing.T) {
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: firstUser.UserID,
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Targets: func() []uuid.UUID {
if i%2 == 0 {
Expand Down Expand Up @@ -188,6 +196,7 @@ func TestInboxNotifications_List(t *testing.T) {

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -203,7 +212,7 @@ func TestInboxNotifications_List(t *testing.T) {
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: firstUser.UserID,
UserID: member.ID,
TemplateID: func() uuid.UUID {
if i < 5 {
return notifications.TemplateWorkspaceOutOfMemory
Expand Down Expand Up @@ -250,6 +259,7 @@ func TestInboxNotifications_ReadStatus(t *testing.T) {

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -263,7 +273,7 @@ func TestInboxNotifications_ReadStatus(t *testing.T) {
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: firstUser.UserID,
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Expand Down
2 changes: 1 addition & 1 deletion coderd/notifications/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func WithTestClock(clock quartz.Clock) ManagerOption {
//
// helpers is a map of template helpers which are used to customize notification messages to use global settings like
// access URL etc.
func NewManager(cfg codersdk.NotificationsConfig, store Store, ps pubsub.Pubsub, helpers template.FuncMap, metrics *Metrics, log slog.Logger, opts ...ManagerOption) (*Manager, error) { // TODO(dannyk): add the ability to use multiple notification methods.
func NewManager(cfg codersdk.NotificationsConfig, store Store, ps pubsub.Pubsub, helpers template.FuncMap, metrics *Metrics, log slog.Logger, opts ...ManagerOption) (*Manager, error) {
var method database.NotificationMethod
if err := method.Scan(cfg.Method.String()); err != nil {
return nil, xerrors.Errorf("notification method %q is invalid", cfg.Method)
Expand Down
3 changes: 1 addition & 2 deletions coderd/pubsub/inboxnotification.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,5 @@ type InboxNotificationEvent struct {
type InboxNotificationEventKind string

const (
InboxNotificationEventKindNew InboxNotificationEventKind = "new"
InboxNotificationEventKindRead InboxNotificationEventKind = "read"
InboxNotificationEventKindNew InboxNotificationEventKind = "new"
)
17 changes: 17 additions & 0 deletions coderd/util/uuid/uuid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package uuid

import (
"strings"

"github.com/google/uuid"
)

func FromSliceToString(uuids []uuid.UUID, separator string) string {
uuidStrings := make([]string, 0, len(uuids))

for _, u := range uuids {
uuidStrings = append(uuidStrings, u.String())
}

return strings.Join(uuidStrings, separator)
}
18 changes: 4 additions & 14 deletions codersdk/inboxnotification.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"time"

"github.com/google/uuid"

utiluuid "github.com/coder/coder/v2/coderd/util/uuid"
)

type InboxNotification struct {
Expand All @@ -33,18 +35,6 @@ type GetInboxNotificationResponse struct {
UnreadCount int `json:"unread_count"`
}

func uuidSliceToString(s []uuid.UUID) string {
resp := ""
for idx, v := range s {
resp += v.String()
if idx < len(s)-1 {
resp += ","
}
}

return resp
}

type ListInboxNotificationsRequest struct {
Targets []uuid.UUID
Templates []uuid.UUID
Expand All @@ -60,10 +50,10 @@ type ListInboxNotificationsResponse struct {
func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsRequest) []RequestOption {
var opts []RequestOption
if len(req.Targets) > 0 {
opts = append(opts, WithQueryParam("targets", uuidSliceToString(req.Targets)))
opts = append(opts, WithQueryParam("targets", utiluuid.FromSliceToString(req.Targets, ",")))
}
if len(req.Templates) > 0 {
opts = append(opts, WithQueryParam("templates", uuidSliceToString(req.Templates)))
opts = append(opts, WithQueryParam("templates", utiluuid.FromSliceToString(req.Templates, ",")))
}
if req.ReadStatus != "" {
opts = append(opts, WithQueryParam("read_status", req.ReadStatus))
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/api/notifications.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading