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 - cleanup endpoints and isolate logic
  • Loading branch information
defelmnq committed Mar 13, 2025
commit 0e8ac4c0823bcc7f3672b2af2bd7143db481ff68
217 changes: 83 additions & 134 deletions coderd/inboxnotifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"

"cdr.dev/slog"

Expand All @@ -24,39 +25,16 @@ import (
"github.com/coder/websocket"
)

// watchInboxNotifications watches for new inbox notifications and sends them to the client.
// The client can specify a list of target IDs to filter the notifications.
// @Summary Watch for new inbox notifications
// @ID watch-for-new-inbox-notifications
// @Security CoderSessionToken
// @Produce json
// @Tags Notifications
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
// @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) {
ctx := r.Context()

var (
apikey = httpmw.APIKey(r)
targetsParam = r.URL.Query().Get("targets")
templatesParam = r.URL.Query().Get("templates")
readStatusParam = r.URL.Query().Get("read_status")
)

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

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
logger.Error(ctx, "unable to parse target id", slog.Error(err))
return nil, nil, "", xerrors.New("unable to parse target id")
}
targets = append(targets, id)
}
Expand All @@ -68,11 +46,8 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
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
logger.Error(ctx, "unable to parse template id", slog.Error(err))
return nil, nil, "", xerrors.New("unable to parse template id")
}
templates = append(templates, id)
}
Expand All @@ -86,13 +61,73 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
}

if !slices.Contains(readOptions, readStatusParam) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid read status.",
})
return
logger.Error(ctx, "unable to parse read status")
return nil, nil, "", xerrors.New("unable to parse read status")
}
}

return targets, templates, readStatusParam, nil
}

// convertInboxNotificationResponse works as a util function to transform a database.InboxNotification to codersdk.InboxNotification
func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, notif database.InboxNotification) codersdk.InboxNotification {
return codersdk.InboxNotification{
ID: notif.ID,
UserID: notif.UserID,
TemplateID: notif.TemplateID,
Targets: notif.Targets,
Title: notif.Title,
Content: notif.Content,
Icon: notif.Icon,
Actions: func() []codersdk.InboxNotificationAction {
var actionsList []codersdk.InboxNotificationAction
err := json.Unmarshal([]byte(notif.Actions), &actionsList)
if err != nil {
logger.Error(ctx, "unmarshal inbox notification actions", slog.Error(err))
}
return actionsList
}(),
ReadAt: func() *time.Time {
if !notif.ReadAt.Valid {
return nil
}
return &notif.ReadAt.Time
}(),
CreatedAt: notif.CreatedAt,
}
}

// watchInboxNotifications watches for new inbox notifications and sends them to the client.
// The client can specify a list of target IDs to filter the notifications.
// @Summary Watch for new inbox notifications
// @ID watch-for-new-inbox-notifications
// @Security CoderSessionToken
// @Produce json
// @Tags Notifications
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
// @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
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I bet you want to write some tests for this API endpoint 👍

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

ctx := r.Context()

var (
apikey = httpmw.APIKey(r)
targetsParam = r.URL.Query().Get("targets")
templatesParam = r.URL.Query().Get("templates")
readStatusParam = r.URL.Query().Get("read_status")
)

targets, templates, readStatusParam, err := convertInboxNotificationParameters(ctx, api.Logger, targetsParam, templatesParam, readStatusParam)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameter.",
Detail: err.Error(),
})
return
}

conn, err := websocket.Accept(rw, r, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Expand Down Expand Up @@ -200,53 +235,13 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request)
startingBeforeParam = r.URL.Query().Get("starting_before")
)

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

readStatus := database.InboxNotificationReadStatusAll
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
}
readStatus = database.InboxNotificationReadStatus(readStatusParam)
targets, templates, readStatus, err := convertInboxNotificationParameters(ctx, api.Logger, targetsParam, templatesParam, readStatusParam)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameter.",
Detail: err.Error(),
})
return
}

startingBefore := dbtime.Now()
Expand All @@ -272,7 +267,7 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request)
UserID: apikey.UserID,
Templates: templates,
Targets: targets,
ReadStatus: readStatus,
ReadStatus: database.InboxNotificationReadStatus(readStatus),
CreatedAtOpt: startingBefore,
})
if err != nil {
Expand All @@ -296,30 +291,7 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request)
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,
})
notificationsList = append(notificationsList, convertInboxNotificationResponse(ctx, api.Logger, notification))
}
return notificationsList
}(),
Expand Down Expand Up @@ -352,7 +324,7 @@ func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *htt
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{
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to parse notification uuid.",
})
return
Expand Down Expand Up @@ -398,30 +370,7 @@ func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *htt
}

httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateInboxNotificationReadStatusResponse{
Notification: codersdk.InboxNotification{
ID: updatedNotification.ID,
UserID: updatedNotification.UserID,
TemplateID: updatedNotification.TemplateID,
Targets: updatedNotification.Targets,
Title: updatedNotification.Title,
Content: updatedNotification.Content,
Icon: updatedNotification.Icon,
Actions: func() []codersdk.InboxNotificationAction {
var actionsList []codersdk.InboxNotificationAction
err := json.Unmarshal([]byte(updatedNotification.Actions), &actionsList)
if err != nil {
api.Logger.Error(ctx, "unmarshal inbox notification actions", slog.Error(err))
}
return actionsList
}(),
ReadAt: func() *time.Time {
if !updatedNotification.ReadAt.Valid {
return nil
}
return &updatedNotification.ReadAt.Time
}(),
CreatedAt: updatedNotification.CreatedAt,
},
UnreadCount: int(unreadCount),
Notification: convertInboxNotificationResponse(ctx, api.Logger, updatedNotification),
UnreadCount: int(unreadCount),
})
}
Loading
Loading