From 3c6512b51a110a45df176906cc3727a96514b47f Mon Sep 17 00:00:00 2001 From: defelmnq Date: Wed, 12 Mar 2025 11:52:15 +0000 Subject: [PATCH 01/18] add endpoints for inbox notifications --- cli/server.go | 2 +- coderd/apidoc/docs.go | 206 +++++++++ coderd/apidoc/swagger.json | 194 ++++++++ coderd/coderd.go | 5 + coderd/database/dbmem/dbmem.go | 39 +- coderd/database/queries.sql.go | 4 +- .../database/queries/notificationsinbox.sql | 4 +- coderd/inboxnotifications.go | 427 ++++++++++++++++++ coderd/inboxnotifications_test.go | 308 +++++++++++++ coderd/notifications/dispatch/inbox.go | 46 +- coderd/notifications/dispatch/inbox_test.go | 4 +- coderd/notifications/manager.go | 10 +- coderd/notifications/manager_test.go | 8 +- coderd/notifications/metrics_test.go | 16 +- coderd/notifications/notifications_test.go | 51 ++- coderd/pubsub/inboxnotification.go | 44 ++ codersdk/inboxnotification.go | 123 +++++ docs/reference/api/notifications.md | 162 +++++++ docs/reference/api/schemas.md | 125 +++++ site/src/api/typesGenerated.ts | 51 +++ 20 files changed, 1766 insertions(+), 63 deletions(-) create mode 100644 coderd/inboxnotifications.go create mode 100644 coderd/inboxnotifications_test.go create mode 100644 coderd/pubsub/inboxnotification.go create mode 100644 codersdk/inboxnotification.go diff --git a/cli/server.go b/cli/server.go index 745794a236200..0b64cd8aa6899 100644 --- a/cli/server.go +++ b/cli/server.go @@ -934,7 +934,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // The notification manager is responsible for: // - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications) // - keeping the store updated with status updates - notificationsManager, err = notifications.NewManager(notificationsCfg, options.Database, helpers, metrics, logger.Named("notifications.manager")) + notificationsManager, err = notifications.NewManager(notificationsCfg, options.Database, options.Pubsub, helpers, metrics, logger.Named("notifications.manager")) if err != nil { return xerrors.Errorf("failed to instantiate notification manager: %w", err) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0fd3d1165ed8e..6965cb9662b93 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1660,6 +1660,130 @@ const docTemplate = `{ } } }, + "/notifications/inbox": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List inbox notifications", + "operationId": "list-inbox-notifications", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of target IDs to filter notifications", + "name": "targets", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of template IDs to filter notifications", + "name": "templates", + "in": "query" + }, + { + "type": "string", + "description": "Filter notifications by read status. Possible values: read, unread, all", + "name": "read_status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ListInboxNotificationsResponse" + } + } + } + } + }, + "/notifications/inbox/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Watch for new inbox notifications", + "operationId": "watch-for-new-inbox-notifications", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of target IDs to filter notifications", + "name": "targets", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of template IDs to filter notifications", + "name": "templates", + "in": "query" + }, + { + "type": "string", + "description": "Filter notifications by read status. Possible values: read, unread, all", + "name": "read_status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GetInboxNotificationResponse" + } + } + } + } + }, + "/notifications/inbox/{id}/read-status": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Update read status of a notification", + "operationId": "update-read-status-of-a-notification", + "parameters": [ + { + "type": "string", + "description": "id of the notification", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/settings": { "get": { "security": [ @@ -11890,6 +12014,17 @@ const docTemplate = `{ } } }, + "codersdk.GetInboxNotificationResponse": { + "type": "object", + "properties": { + "notification": { + "$ref": "#/definitions/codersdk.InboxNotification" + }, + "unread_count": { + "type": "integer" + } + } + }, "codersdk.GetUserStatusCountsResponse": { "type": "object", "properties": { @@ -12071,6 +12206,63 @@ const docTemplate = `{ } } }, + "codersdk.InboxNotification": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.InboxNotificationAction" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "read_at": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "template_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.InboxNotificationAction": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "codersdk.InsightsReportInterval": { "type": "string", "enum": [ @@ -12181,6 +12373,20 @@ const docTemplate = `{ } } }, + "codersdk.ListInboxNotificationsResponse": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.InboxNotification" + } + }, + "unread_count": { + "type": "integer" + } + } + }, "codersdk.LogLevel": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 21546acb32ab3..a3bb102f5819e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1445,6 +1445,118 @@ } } }, + "/notifications/inbox": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "List inbox notifications", + "operationId": "list-inbox-notifications", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of target IDs to filter notifications", + "name": "targets", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of template IDs to filter notifications", + "name": "templates", + "in": "query" + }, + { + "type": "string", + "description": "Filter notifications by read status. Possible values: read, unread, all", + "name": "read_status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ListInboxNotificationsResponse" + } + } + } + } + }, + "/notifications/inbox/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Watch for new inbox notifications", + "operationId": "watch-for-new-inbox-notifications", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of target IDs to filter notifications", + "name": "targets", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of template IDs to filter notifications", + "name": "templates", + "in": "query" + }, + { + "type": "string", + "description": "Filter notifications by read status. Possible values: read, unread, all", + "name": "read_status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GetInboxNotificationResponse" + } + } + } + } + }, + "/notifications/inbox/{id}/read-status": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Update read status of a notification", + "operationId": "update-read-status-of-a-notification", + "parameters": [ + { + "type": "string", + "description": "id of the notification", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/settings": { "get": { "security": [ @@ -10667,6 +10779,17 @@ } } }, + "codersdk.GetInboxNotificationResponse": { + "type": "object", + "properties": { + "notification": { + "$ref": "#/definitions/codersdk.InboxNotification" + }, + "unread_count": { + "type": "integer" + } + } + }, "codersdk.GetUserStatusCountsResponse": { "type": "object", "properties": { @@ -10842,6 +10965,63 @@ } } }, + "codersdk.InboxNotification": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.InboxNotificationAction" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "read_at": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "template_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.InboxNotificationAction": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "codersdk.InsightsReportInterval": { "type": "string", "enum": ["day", "week"], @@ -10938,6 +11118,20 @@ } } }, + "codersdk.ListInboxNotificationsResponse": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.InboxNotification" + } + }, + "unread_count": { + "type": "integer" + } + } + }, "codersdk.LogLevel": { "type": "string", "enum": ["trace", "debug", "info", "warn", "error"], diff --git a/coderd/coderd.go b/coderd/coderd.go index da4e281dbe506..f5956d7457fe8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1387,6 +1387,11 @@ func New(options *Options) *API { }) r.Route("/notifications", func(r chi.Router) { r.Use(apiKeyMiddleware) + r.Route("/inbox", func(r chi.Router) { + r.Get("/", api.listInboxNotifications) + r.Get("/watch", api.watchInboxNotifications) + r.Put("/{id}/read-status", api.updateInboxNotificationReadStatus) + }) r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) r.Route("/templates", func(r chi.Router) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 63ee1d0bd95e7..b8df93ecb12b5 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3296,34 +3296,51 @@ func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, a defer q.mutex.RUnlock() notifications := make([]database.InboxNotification, 0) - for _, notification := range q.inboxNotifications { + for idx := len(q.inboxNotifications) - 1; idx >= 0; idx-- { + notification := q.inboxNotifications[idx] + if notification.UserID == arg.UserID { + if !arg.CreatedAtOpt.IsZero() && !notification.CreatedAt.Before(arg.CreatedAtOpt) { + continue + } + + templateFound := false for _, template := range arg.Templates { - templateFound := false if notification.TemplateID == template { templateFound = true } + } - if !templateFound { - continue - } + if len(arg.Templates) > 0 && !templateFound { + continue } + targetsFound := true for _, target := range arg.Targets { - isFound := false + targetFound := false for _, insertedTarget := range notification.Targets { if insertedTarget == target { - isFound = true + targetFound = true break } } - if !isFound { - continue + if !targetFound { + targetsFound = false + break } + } - notifications = append(notifications, notification) + if !targetsFound { + continue } + + if (arg.LimitOpt == 0 && len(notifications) == 25) || + (arg.LimitOpt != 0 && len(notifications) == int(arg.LimitOpt)) { + break + } + + notifications = append(notifications, notification) } } @@ -8223,7 +8240,7 @@ func (q *FakeQuerier) InsertInboxNotification(_ context.Context, arg database.In Content: arg.Content, Icon: arg.Icon, Actions: arg.Actions, - CreatedAt: time.Now(), + CreatedAt: arg.CreatedAt, } q.inboxNotifications = append(q.inboxNotifications, notification) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b394a0b0121ec..94d4987c73a88 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4310,8 +4310,8 @@ func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context, const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE user_id = $1 AND - template_id = ANY($2::UUID[]) AND - targets @> COALESCE($3, ARRAY[]::UUID[]) AND + (template_id = ANY($2::UUID[]) OR $2 IS NULL) AND + ($3::UUID[] IS NULL OR targets @> $3::UUID[]) AND ($4::inbox_notification_read_status = 'all' OR ($4::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($4::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND ($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ) ORDER BY created_at DESC diff --git a/coderd/database/queries/notificationsinbox.sql b/coderd/database/queries/notificationsinbox.sql index cdaf1cf78cb7f..1d8dccd347b85 100644 --- a/coderd/database/queries/notificationsinbox.sql +++ b/coderd/database/queries/notificationsinbox.sql @@ -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 - targets @> COALESCE(@targets, ARRAY[]::UUID[]) AND + (template_id = ANY(@templates::UUID[]) OR @templates IS NULL) 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) ORDER BY created_at DESC diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go new file mode 100644 index 0000000000000..228cfb1b5a8b1 --- /dev/null +++ b/coderd/inboxnotifications.go @@ -0,0 +1,427 @@ +package coderd + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/wsjson" + "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") + ) + + 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 + } + } + + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to upgrade connection to websocket.", + Detail: err.Error(), + }) + return + } + + go httpapi.Heartbeat(ctx, conn) + defer conn.Close(websocket.StatusNormalClosure, "connection closed") + + notificationCh := make(chan codersdk.InboxNotification, 1) + + closeInboxNotificationsSubscriber, err := api.Pubsub.SubscribeWithErr(pubsub.InboxNotificationForOwnerEventChannel(apikey.UserID), + pubsub.HandleInboxNotificationEvent( + 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 + }, + )) + if err != nil { + api.Logger.Error(ctx, "subscribe to inbox notification event", slog.Error(err)) + return + } + + defer closeInboxNotificationsSubscriber() + + encoder := wsjson.NewEncoder[codersdk.GetInboxNotificationResponse](conn, websocket.MessageText) + defer encoder.Close(websocket.StatusNormalClosure) + + for { + select { + case <-ctx.Done(): + return + case notif := <-notificationCh: + unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) + if err != nil { + api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + return + } + if err := encoder.Encode(codersdk.GetInboxNotificationResponse{ + Notification: notif, + UnreadCount: int(unreadCount), + }); err != nil { + api.Logger.Error(ctx, "encode notification", slog.Error(err)) + return + } + } + } +} + +// listInboxNotifications lists the notifications for the user. +// @Summary List inbox notifications +// @ID list-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.ListInboxNotificationsResponse +// @Router /notifications/inbox [get] +func (api *API) listInboxNotifications(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") + 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) + } + + startingBefore := dbtime.Now() + if startingBeforeParam != "" { + lastNotifID, err := uuid.Parse(startingBeforeParam) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid starting before.", + }) + return + } + lastNotif, err := api.Database.GetInboxNotificationByID(ctx, lastNotifID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid starting before.", + }) + return + } + startingBefore = lastNotif.CreatedAt + } + + notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{ + UserID: apikey.UserID, + Templates: templates, + Targets: targets, + ReadStatus: readStatus, + CreatedAtOpt: startingBefore, + }) + if err != nil { + api.Logger.Error(ctx, "get filtered inbox notifications", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get inbox notifications.", + }) + return + } + + unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) + if err != nil { + api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to count unread inbox notifications.", + }) + return + } + + 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), + }) +} + +// updateInboxNotificationReadStatus changes the read status of a notification. +// @Summary Update read status of a notification +// @ID update-read-status-of-a-notification +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Param id path string true "id of the notification" +// @Success 201 {object} codersdk.Response +// @Router /notifications/inbox/{id}/read-status [put] +func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var ( + apikey = httpmw.APIKey(r) + notifID = chi.URLParam(r, "id") + ) + + var body codersdk.UpdateInboxNotificationReadStatusRequest + if !httpapi.Read(ctx, rw, r, &body) { + return + } + + parsedNotifID, err := uuid.Parse(notifID) + if err != nil { + api.Logger.Error(ctx, "failed to parse uuid", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to parse notification uuid.", + }) + return + } + + err = api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{ + ID: parsedNotifID, + ReadAt: func() sql.NullTime { + if body.IsRead { + return sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + } + } + + return sql.NullTime{} + }(), + }) + if err != nil { + api.Logger.Error(ctx, "get filtered inbox notifications", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get inbox notifications.", + }) + return + } + + unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) + if err != nil { + api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to count unread inbox notifications.", + }) + return + } + + updatedNotification, err := api.Database.GetInboxNotificationByID(ctx, parsedNotifID) + if err != nil { + api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to count unread inbox notifications.", + }) + return + } + + 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), + }) +} diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go new file mode 100644 index 0000000000000..c3458e3b5a660 --- /dev/null +++ b/coderd/inboxnotifications_test.go @@ -0,0 +1,308 @@ +package coderd_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestInboxNotifications_List(t *testing.T) { + t.Parallel() + + t.Run("OK empty", func(t *testing.T) { + t.Parallel() + + client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + }) + + t.Run("OK with pagination", func(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // nolint:gocritic // used only to seed database + notifierCtx := dbauthz.AsNotifier(ctx) + + api.Database.InTx(func(tx database.Store) error { + 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) + } + return nil + }, &database.TxOptions{ + Isolation: sql.LevelReadCommitted, + }) + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 40, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 25) + + require.Equal(t, "Notification 39", notifs.Notifications[0].Title) + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + StartingBefore: notifs.Notifications[24].ID, + }) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 40, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 15) + + require.Equal(t, "Notification 14", notifs.Notifications[0].Title) + }) + + t.Run("OK with template filter", func(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // nolint:gocritic // used only to seed database + notifierCtx := dbauthz.AsNotifier(ctx) + + for i := range 10 { + _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: firstUser.UserID, + TemplateID: func() uuid.UUID { + if i%2 == 0 { + return notifications.TemplateWorkspaceOutOfMemory + } + + return notifications.TemplateWorkspaceOutOfDisk + }(), + 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) + } + + time.Sleep(5 * time.Second) + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Templates: []uuid.UUID{notifications.TemplateWorkspaceOutOfMemory}, + }) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 10, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 5) + + require.Equal(t, "Notification 8", notifs.Notifications[0].Title) + }) + + t.Run("OK with target filter", func(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // nolint:gocritic // used only to seed database + notifierCtx := dbauthz.AsNotifier(ctx) + + filteredTarget := uuid.New() + + for i := range 10 { + _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: firstUser.UserID, + TemplateID: notifications.TemplateWorkspaceOutOfMemory, + Targets: func() []uuid.UUID { + if i%2 == 0 { + return []uuid.UUID{filteredTarget} + } + + return []uuid.UUID{} + }(), + 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) + } + + time.Sleep(5 * time.Second) + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Targets: []uuid.UUID{filteredTarget}, + }) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 10, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 5) + + require.Equal(t, "Notification 8", notifs.Notifications[0].Title) + }) + + t.Run("OK with multiple filters", func(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // nolint:gocritic // used only to seed database + notifierCtx := dbauthz.AsNotifier(ctx) + + filteredTarget := uuid.New() + + for i := range 10 { + _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: firstUser.UserID, + TemplateID: func() uuid.UUID { + if i < 5 { + return notifications.TemplateWorkspaceOutOfMemory + } + + return notifications.TemplateWorkspaceOutOfDisk + }(), + Targets: func() []uuid.UUID { + if i%2 == 0 { + return []uuid.UUID{filteredTarget} + } + + return []uuid.UUID{} + }(), + 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) + } + + time.Sleep(5 * time.Second) + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Targets: []uuid.UUID{filteredTarget}, + Templates: []uuid.UUID{notifications.TemplateWorkspaceOutOfDisk}, + }) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 10, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 2) + + require.Equal(t, "Notification 8", notifs.Notifications[0].Title) + }) +} + +func TestInboxNotifications_ReadStatus(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // nolint:gocritic // used only to seed database + notifierCtx := dbauthz.AsNotifier(ctx) + + for i := range 20 { + _, 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) + } + + time.Sleep(5 * time.Second) + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 20, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 20) + + updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID, codersdk.UpdateInboxNotificationReadStatusRequest{ + IsRead: true, + }) + require.NoError(t, err) + require.NotNil(t, updatedNotif) + require.NotZero(t, updatedNotif.Notification.ReadAt) + require.Equal(t, 19, updatedNotif.UnreadCount) +} diff --git a/coderd/notifications/dispatch/inbox.go b/coderd/notifications/dispatch/inbox.go index 036424decf3c7..9383e89afec3e 100644 --- a/coderd/notifications/dispatch/inbox.go +++ b/coderd/notifications/dispatch/inbox.go @@ -13,8 +13,11 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/notifications/types" + coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" markdown "github.com/coder/coder/v2/coderd/render" + "github.com/coder/coder/v2/codersdk" ) type InboxStore interface { @@ -23,12 +26,13 @@ type InboxStore interface { // InboxHandler is responsible for dispatching notification messages to the Coder Inbox. type InboxHandler struct { - log slog.Logger - store InboxStore + log slog.Logger + store InboxStore + pubsub pubsub.Pubsub } -func NewInboxHandler(log slog.Logger, store InboxStore) *InboxHandler { - return &InboxHandler{log: log, store: store} +func NewInboxHandler(log slog.Logger, store InboxStore, ps pubsub.Pubsub) *InboxHandler { + return &InboxHandler{log: log, store: store, pubsub: ps} } func (s *InboxHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string, _ template.FuncMap) (DeliveryFunc, error) { @@ -62,7 +66,7 @@ func (s *InboxHandler) dispatch(payload types.MessagePayload, title, body string } // nolint:exhaustruct - _, err = s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{ + insertedNotif, err := s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{ ID: msgID, UserID: userID, TemplateID: templateID, @@ -76,6 +80,38 @@ func (s *InboxHandler) dispatch(payload types.MessagePayload, title, body string return false, xerrors.Errorf("insert inbox notification: %w", err) } + event := coderdpubsub.InboxNotificationEvent{ + Kind: coderdpubsub.InboxNotificationEventKindNew, + InboxNotification: codersdk.InboxNotification{ + ID: msgID, + UserID: userID, + TemplateID: templateID, + Targets: payload.Targets, + Title: title, + Content: body, + Actions: func() []codersdk.InboxNotificationAction { + var actions []codersdk.InboxNotificationAction + err := json.Unmarshal(insertedNotif.Actions, &actions) + if err != nil { + return actions + } + return actions + }(), + ReadAt: nil, // notification just has been inserted + CreatedAt: insertedNotif.CreatedAt, + }, + } + + payload, err := json.Marshal(event) + if err != nil { + return false, xerrors.Errorf("marshal event: %w", err) + } + + err = s.pubsub.Publish(coderdpubsub.InboxNotificationForOwnerEventChannel(userID), payload) + if err != nil { + return false, xerrors.Errorf("publish event: %w", err) + } + return false, nil } } diff --git a/coderd/notifications/dispatch/inbox_test.go b/coderd/notifications/dispatch/inbox_test.go index 72547122b2e01..a06b698e9769a 100644 --- a/coderd/notifications/dispatch/inbox_test.go +++ b/coderd/notifications/dispatch/inbox_test.go @@ -73,7 +73,7 @@ func TestInbox(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) + db, pubsub := dbtestutil.NewDB(t) if tc.payload.UserID == "valid" { user := dbgen.User(t, db, database.User{}) @@ -82,7 +82,7 @@ func TestInbox(t *testing.T) { ctx := context.Background() - handler := dispatch.NewInboxHandler(logger.Named("smtp"), db) + handler := dispatch.NewInboxHandler(logger.Named("smtp"), db, pubsub) dispatcherFunc, err := handler.Dispatcher(tc.payload, "", "", nil) require.NoError(t, err) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 02b4893981abf..5d66c3d2ead72 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -14,6 +14,7 @@ import ( "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/notifications/dispatch" "github.com/coder/coder/v2/codersdk" ) @@ -75,8 +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, 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) { // TODO(dannyk): add the ability to use multiple notification methods. var method database.NotificationMethod if err := method.Scan(cfg.Method.String()); err != nil { return nil, xerrors.Errorf("notification method %q is invalid", cfg.Method) @@ -109,7 +109,7 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template. stop: make(chan any), done: make(chan any), - handlers: defaultHandlers(cfg, log, store), + handlers: defaultHandlers(cfg, log, store, ps), helpers: helpers, clock: quartz.NewReal(), @@ -121,11 +121,11 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template. } // defaultHandlers builds a set of known handlers; panics if any error occurs as these handlers should be valid at compile time. -func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger, store Store) map[database.NotificationMethod]Handler { +func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger, store Store, ps pubsub.Pubsub) map[database.NotificationMethod]Handler { return map[database.NotificationMethod]Handler{ database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")), database.NotificationMethodWebhook: dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")), - database.NotificationMethodInbox: dispatch.NewInboxHandler(log.Named("dispatcher.inbox"), store), + database.NotificationMethodInbox: dispatch.NewInboxHandler(log.Named("dispatcher.inbox"), store, ps), } } diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index f9f8920143e3c..0e6890ae0cef4 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -33,7 +33,7 @@ func TestBufferedUpdates(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) logger := testutil.Logger(t) interceptor := &syncInterceptor{Store: store} @@ -44,7 +44,7 @@ func TestBufferedUpdates(t *testing.T) { cfg.StoreSyncInterval = serpent.Duration(time.Hour) // Ensure we don't sync the store automatically. // GIVEN: a manager which will pass or fail notifications based on their "nice" labels - mgr, err := notifications.NewManager(cfg, interceptor, defaultHelpers(), createMetrics(), logger.Named("notifications-manager")) + mgr, err := notifications.NewManager(cfg, interceptor, ps, defaultHelpers(), createMetrics(), logger.Named("notifications-manager")) require.NoError(t, err) handlers := map[database.NotificationMethod]notifications.Handler{ @@ -168,11 +168,11 @@ func TestStopBeforeRun(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) logger := testutil.Logger(t) // GIVEN: a standard manager - mgr, err := notifications.NewManager(defaultNotificationsConfig(database.NotificationMethodSmtp), store, defaultHelpers(), createMetrics(), logger.Named("notifications-manager")) + mgr, err := notifications.NewManager(defaultNotificationsConfig(database.NotificationMethodSmtp), store, ps, defaultHelpers(), createMetrics(), logger.Named("notifications-manager")) require.NoError(t, err) // THEN: validate that the manager can be stopped safely without Run() having been called yet diff --git a/coderd/notifications/metrics_test.go b/coderd/notifications/metrics_test.go index 2780596fb2c66..052d52873b153 100644 --- a/coderd/notifications/metrics_test.go +++ b/coderd/notifications/metrics_test.go @@ -39,7 +39,7 @@ func TestMetrics(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) reg := prometheus.NewRegistry() @@ -60,7 +60,7 @@ func TestMetrics(t *testing.T) { cfg.RetryInterval = serpent.Duration(time.Millisecond * 50) cfg.StoreSyncInterval = serpent.Duration(time.Millisecond * 100) // Twice as long as fetch interval to ensure we catch pending updates. - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), metrics, logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), metrics, logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) @@ -228,7 +228,7 @@ func TestPendingUpdatesMetric(t *testing.T) { // SETUP // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) reg := prometheus.NewRegistry() @@ -250,7 +250,7 @@ func TestPendingUpdatesMetric(t *testing.T) { defer trap.Close() fetchTrap := mClock.Trap().TickerFunc("notifier", "fetchInterval") defer fetchTrap.Close() - mgr, err := notifications.NewManager(cfg, interceptor, defaultHelpers(), metrics, logger.Named("manager"), + mgr, err := notifications.NewManager(cfg, interceptor, pubsub, defaultHelpers(), metrics, logger.Named("manager"), notifications.WithTestClock(mClock)) require.NoError(t, err) t.Cleanup(func() { @@ -322,7 +322,7 @@ func TestInflightDispatchesMetric(t *testing.T) { // SETUP // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) reg := prometheus.NewRegistry() @@ -338,7 +338,7 @@ func TestInflightDispatchesMetric(t *testing.T) { cfg.RetryInterval = serpent.Duration(time.Hour) // Delay retries so they don't interfere. cfg.StoreSyncInterval = serpent.Duration(time.Millisecond * 100) - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), metrics, logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), metrics, logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) @@ -402,7 +402,7 @@ func TestCustomMethodMetricCollection(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) var ( @@ -427,7 +427,7 @@ func TestCustomMethodMetricCollection(t *testing.T) { // WHEN: two notifications (each with different templates) are enqueued. cfg := defaultNotificationsConfig(defaultMethod) - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), metrics, logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), metrics, logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 3ef8f59228093..e567465211a4e 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -71,7 +71,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) method := database.NotificationMethodSmtp @@ -80,7 +80,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) { interceptor := &syncInterceptor{Store: store} cfg := defaultNotificationsConfig(method) cfg.RetryInterval = serpent.Duration(time.Hour) // Ensure retries don't interfere with the test - mgr, err := notifications.NewManager(cfg, interceptor, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, interceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ method: handler, @@ -138,7 +138,7 @@ func TestSMTPDispatch(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) // start mock SMTP server @@ -161,7 +161,7 @@ func TestSMTPDispatch(t *testing.T) { Hello: "localhost", } handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger.Named("smtp"))) - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ method: handler, @@ -204,7 +204,7 @@ func TestWebhookDispatch(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) sent := make(chan dispatch.WebhookPayload, 1) @@ -230,7 +230,7 @@ func TestWebhookDispatch(t *testing.T) { cfg.Webhook = codersdk.NotificationsWebhookConfig{ Endpoint: *serpent.URLOf(endpoint), } - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) @@ -284,7 +284,7 @@ func TestBackpressure(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitShort)) @@ -319,7 +319,7 @@ func TestBackpressure(t *testing.T) { defer fetchTrap.Close() // GIVEN: a notification manager whose updates will be intercepted - mgr, err := notifications.NewManager(cfg, storeInterceptor, defaultHelpers(), createMetrics(), + mgr, err := notifications.NewManager(cfg, storeInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"), notifications.WithTestClock(mClock)) require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ @@ -417,7 +417,7 @@ func TestRetries(t *testing.T) { const maxAttempts = 3 // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) // GIVEN: a mock HTTP server which will receive webhooksand a map to track the dispatch attempts @@ -468,7 +468,7 @@ func TestRetries(t *testing.T) { // Intercept calls to submit the buffered updates to the store. storeInterceptor := &syncInterceptor{Store: store} - mgr, err := notifications.NewManager(cfg, storeInterceptor, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, storeInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) @@ -517,7 +517,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) // GIVEN: a manager which has its updates intercepted and paused until measurements can be taken @@ -539,7 +539,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) { mgrCtx, cancelManagerCtx := context.WithCancel(dbauthz.AsNotifier(context.Background())) t.Cleanup(cancelManagerCtx) - mgr, err := notifications.NewManager(cfg, noopInterceptor, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, noopInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal()) require.NoError(t, err) @@ -588,7 +588,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) { // Intercept calls to submit the buffered updates to the store. storeInterceptor := &syncInterceptor{Store: store} handler := newDispatchInterceptor(&fakeHandler{}) - mgr, err = notifications.NewManager(cfg, storeInterceptor, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err = notifications.NewManager(cfg, storeInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ method: handler, @@ -620,7 +620,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) { func TestInvalidConfig(t *testing.T) { t.Parallel() - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) // GIVEN: invalid config with dispatch period <= lease period @@ -633,7 +633,7 @@ func TestInvalidConfig(t *testing.T) { cfg.DispatchTimeout = serpent.Duration(leasePeriod) // WHEN: the manager is created with invalid config - _, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + _, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) // THEN: the manager will fail to be created, citing invalid config as error require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout) @@ -646,7 +646,7 @@ func TestNotifierPaused(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) // Prepare the test. @@ -657,7 +657,7 @@ func TestNotifierPaused(t *testing.T) { const fetchInterval = time.Millisecond * 100 cfg := defaultNotificationsConfig(method) cfg.FetchInterval = serpent.Duration(fetchInterval) - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ method: handler, @@ -1229,6 +1229,8 @@ func TestNotificationTemplates_Golden(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) + _, pubsub := dbtestutil.NewDB(t) + // smtp config shared between client and server smtpConfig := codersdk.NotificationsEmailConfig{ Hello: hello, @@ -1296,6 +1298,7 @@ func TestNotificationTemplates_Golden(t *testing.T) { smtpManager, err := notifications.NewManager( smtpCfg, *db, + pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"), @@ -1410,6 +1413,7 @@ func TestNotificationTemplates_Golden(t *testing.T) { return &db, &api.Logger, &user }() + _, pubsub := dbtestutil.NewDB(t) // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) @@ -1437,6 +1441,7 @@ func TestNotificationTemplates_Golden(t *testing.T) { webhookManager, err := notifications.NewManager( webhookCfg, *db, + pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"), @@ -1613,13 +1618,13 @@ func TestDisabledAfterEnqueue(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) method := database.NotificationMethodSmtp cfg := defaultNotificationsConfig(method) - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) @@ -1670,7 +1675,7 @@ func TestCustomNotificationMethod(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) received := make(chan uuid.UUID, 1) @@ -1728,7 +1733,7 @@ func TestCustomNotificationMethod(t *testing.T) { Endpoint: *serpent.URLOf(endpoint), } - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { _ = mgr.Stop(ctx) @@ -1811,13 +1816,13 @@ func TestNotificationDuplicates(t *testing.T) { // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) - store, _ := dbtestutil.NewDB(t) + store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) method := database.NotificationMethodSmtp cfg := defaultNotificationsConfig(method) - mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager")) + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) diff --git a/coderd/pubsub/inboxnotification.go b/coderd/pubsub/inboxnotification.go new file mode 100644 index 0000000000000..7236e1353929c --- /dev/null +++ b/coderd/pubsub/inboxnotification.go @@ -0,0 +1,44 @@ +package pubsub + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +func InboxNotificationForOwnerEventChannel(ownerID uuid.UUID) string { + return fmt.Sprintf("inbox_notification:owner:%s", ownerID) +} + +func HandleInboxNotificationEvent(cb func(ctx context.Context, payload InboxNotificationEvent, err error)) func(ctx context.Context, message []byte, err error) { + return func(ctx context.Context, message []byte, err error) { + if err != nil { + cb(ctx, InboxNotificationEvent{}, xerrors.Errorf("inbox notification event pubsub: %w", err)) + return + } + var payload InboxNotificationEvent + if err := json.Unmarshal(message, &payload); err != nil { + cb(ctx, InboxNotificationEvent{}, xerrors.Errorf("unmarshal inbox notification event")) + return + } + + cb(ctx, payload, err) + } +} + +type InboxNotificationEvent struct { + Kind InboxNotificationEventKind `json:"kind"` + InboxNotification codersdk.InboxNotification `json:"inbox_notification"` +} + +type InboxNotificationEventKind string + +const ( + InboxNotificationEventKindNew InboxNotificationEventKind = "new" + InboxNotificationEventKindRead InboxNotificationEventKind = "read" +) diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go new file mode 100644 index 0000000000000..b234d36d2128d --- /dev/null +++ b/codersdk/inboxnotification.go @@ -0,0 +1,123 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" +) + +type InboxNotification struct { + ID uuid.UUID `json:"id" format:"uuid"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + TemplateID uuid.UUID `json:"template_id" format:"uuid"` + Targets []uuid.UUID `json:"targets" format:"uuid"` + Title string `json:"title"` + Content string `json:"content"` + Icon string `json:"icon"` + Actions []InboxNotificationAction `json:"actions"` + ReadAt *time.Time `json:"read_at"` + CreatedAt time.Time `json:"created_at" format:"date-time"` +} + +type InboxNotificationAction struct { + Label string `json:"label"` + URL string `json:"url"` +} + +type GetInboxNotificationResponse struct { + Notification InboxNotification `json:"notification"` + 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 + ReadStatus string + StartingBefore uuid.UUID +} + +type ListInboxNotificationsResponse struct { + Notifications []InboxNotification `json:"notifications"` + UnreadCount int `json:"unread_count"` +} + +func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsRequest) []RequestOption { + var opts []RequestOption + if len(req.Targets) > 0 { + opts = append(opts, WithQueryParam("targets", uuidSliceToString(req.Targets))) + } + if len(req.Templates) > 0 { + opts = append(opts, WithQueryParam("templates", uuidSliceToString(req.Templates))) + } + if req.ReadStatus != "" { + opts = append(opts, WithQueryParam("read_status", req.ReadStatus)) + } + if req.StartingBefore != uuid.Nil { + opts = append(opts, WithQueryParam("starting_before", req.StartingBefore.String())) + } + + return opts +} + +func (c *Client) ListInboxNotifications(ctx context.Context, req ListInboxNotificationsRequest) (ListInboxNotificationsResponse, error) { + res, err := c.Request( + ctx, http.MethodGet, + "/api/v2/notifications/inbox", + nil, ListInboxNotificationsRequestToQueryParams(req)..., + ) + if err != nil { + return ListInboxNotificationsResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return ListInboxNotificationsResponse{}, ReadBodyAsError(res) + } + + var listInboxNotificationsResponse ListInboxNotificationsResponse + return listInboxNotificationsResponse, json.NewDecoder(res.Body).Decode(&listInboxNotificationsResponse) +} + +type UpdateInboxNotificationReadStatusRequest struct { + IsRead bool `json:"is_read"` +} + +type UpdateInboxNotificationReadStatusResponse struct { + Notification InboxNotification `json:"notification"` + UnreadCount int `json:"unread_count"` +} + +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), + req, + ) + if err != nil { + return UpdateInboxNotificationReadStatusResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UpdateInboxNotificationReadStatusResponse{}, ReadBodyAsError(res) + } + + var resp UpdateInboxNotificationReadStatusResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index b513786bfcb1e..4f921892677c9 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -46,6 +46,168 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## List inbox notifications + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/inbox \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/inbox` + +### Parameters + +| Name | In | Type | Required | Description | +|---------------|-------|--------|----------|-------------------------------------------------------------------------| +| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications | +| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications | +| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all | + +### Example responses + +> 200 Response + +```json +{ + "notifications": [ + { + "actions": [ + { + "label": "string", + "url": "string" + } + ], + "content": "string", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "read_at": "string", + "targets": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "title": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } + ], + "unread_count": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ListInboxNotificationsResponse](schemas.md#codersdklistinboxnotificationsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Watch for new inbox notifications + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/inbox/watch \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/inbox/watch` + +### Parameters + +| Name | In | Type | Required | Description | +|---------------|-------|--------|----------|-------------------------------------------------------------------------| +| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications | +| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications | +| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all | + +### Example responses + +> 200 Response + +```json +{ + "notification": { + "actions": [ + { + "label": "string", + "url": "string" + } + ], + "content": "string", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "read_at": "string", + "targets": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "title": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + }, + "unread_count": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GetInboxNotificationResponse](schemas.md#codersdkgetinboxnotificationresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update read status of a notification + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/notifications/inbox/{id}/read-status \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /notifications/inbox/{id}/read-status` + +### Parameters + +| Name | In | Type | Required | Description | +|------|------|--------|----------|------------------------| +| `id` | path | string | true | id of the notification | + +### Example responses + +> 201 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------|-------------|--------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get notifications settings ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 42ef8a7ade184..2fa9d0d108488 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3016,6 +3016,40 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-------|--------|----------|--------------|-------------| | `key` | string | false | | | +## codersdk.GetInboxNotificationResponse + +```json +{ + "notification": { + "actions": [ + { + "label": "string", + "url": "string" + } + ], + "content": "string", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "read_at": "string", + "targets": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "title": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + }, + "unread_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|----------------------------------------------------------|----------|--------------|-------------| +| `notification` | [codersdk.InboxNotification](#codersdkinboxnotification) | false | | | +| `unread_count` | integer | false | | | + ## codersdk.GetUserStatusCountsResponse ```json @@ -3251,6 +3285,61 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `refresh` | integer | false | | | | `threshold_database` | integer | false | | | +## codersdk.InboxNotification + +```json +{ + "actions": [ + { + "label": "string", + "url": "string" + } + ], + "content": "string", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "read_at": "string", + "targets": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "title": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|-------------------------------------------------------------------------------|----------|--------------|-------------| +| `actions` | array of [codersdk.InboxNotificationAction](#codersdkinboxnotificationaction) | false | | | +| `content` | string | false | | | +| `created_at` | string | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `read_at` | string | false | | | +| `targets` | array of string | false | | | +| `template_id` | string | false | | | +| `title` | string | false | | | +| `user_id` | string | false | | | + +## codersdk.InboxNotificationAction + +```json +{ + "label": "string", + "url": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|--------|----------|--------------|-------------| +| `label` | string | false | | | +| `url` | string | false | | | + ## codersdk.InsightsReportInterval ```json @@ -3380,6 +3469,42 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `icon` | `chat` | | `icon` | `docs` | +## codersdk.ListInboxNotificationsResponse + +```json +{ + "notifications": [ + { + "actions": [ + { + "label": "string", + "url": "string" + } + ], + "content": "string", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "read_at": "string", + "targets": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "title": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } + ], + "unread_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------------------------------------------------------|----------|--------------|-------------| +| `notifications` | array of [codersdk.InboxNotification](#codersdkinboxnotification) | false | | | +| `unread_count` | integer | false | | | + ## codersdk.LogLevel ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6fdfb5ea9d9a1..adbd0e92248bc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -892,6 +892,12 @@ export interface GenerateAPIKeyResponse { readonly key: string; } +// From codersdk/inboxnotification.go +export interface GetInboxNotificationResponse { + readonly notification: InboxNotification; + readonly unread_count: number; +} + // From codersdk/insights.go export interface GetUserStatusCountsRequest { readonly offset: string; @@ -1076,6 +1082,26 @@ export interface IDPSyncMapping { readonly Gets: ResourceIdType; } +// From codersdk/inboxnotification.go +export interface InboxNotification { + readonly id: string; + readonly user_id: string; + readonly template_id: string; + readonly targets: readonly string[]; + readonly title: string; + readonly content: string; + readonly icon: string; + readonly actions: readonly InboxNotificationAction[]; + readonly read_at: string | null; + readonly created_at: string; +} + +// From codersdk/inboxnotification.go +export interface InboxNotificationAction { + readonly label: string; + readonly url: string; +} + // From codersdk/insights.go export type InsightsReportInterval = "day" | "week"; @@ -1133,6 +1159,20 @@ export interface LinkConfig { readonly icon: string; } +// From codersdk/inboxnotification.go +export interface ListInboxNotificationsRequest { + readonly Targets: readonly string[]; + readonly Templates: readonly string[]; + readonly ReadStatus: string; + readonly StartingBefore: string; +} + +// From codersdk/inboxnotification.go +export interface ListInboxNotificationsResponse { + readonly notifications: readonly InboxNotification[]; + readonly unread_count: number; +} + // From codersdk/externalauth.go export interface ListUserExternalAuthResponse { readonly providers: readonly ExternalAuthLinkProvider[]; @@ -2654,6 +2694,17 @@ export interface UpdateHealthSettings { readonly dismissed_healthchecks: readonly HealthSection[]; } +// From codersdk/inboxnotification.go +export interface UpdateInboxNotificationReadStatusRequest { + readonly is_read: boolean; +} + +// From codersdk/inboxnotification.go +export interface UpdateInboxNotificationReadStatusResponse { + readonly notification: InboxNotification; + readonly unread_count: number; +} + // From codersdk/notifications.go export interface UpdateNotificationTemplateMethod { readonly method?: string; From c8ccc6098456746426c2b62d6ce01c0ced9ea583 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Wed, 12 Mar 2025 13:00:34 +0000 Subject: [PATCH 02/18] cleanup comments and errors --- coderd/inboxnotifications.go | 24 ++++++++-------- coderd/inboxnotifications_test.go | 47 +++++++++++++------------------ 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 228cfb1b5a8b1..f8f29871b0768 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -164,7 +164,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) case notif := <-notificationCh: unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) if err != nil { - api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err)) return } if err := encoder.Encode(codersdk.GetInboxNotificationResponse{ @@ -261,7 +261,7 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) lastNotif, err := api.Database.GetInboxNotificationByID(ctx, lastNotifID) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid starting before.", + Message: "Failed to get notification by id.", }) return } @@ -276,16 +276,16 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) CreatedAtOpt: startingBefore, }) if err != nil { - api.Logger.Error(ctx, "get filtered inbox notifications", slog.Error(err)) + api.Logger.Error(ctx, "failed to get filtered inbox notifications", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get inbox notifications.", + Message: "Failed to get filtered inbox notifications.", }) return } unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) if err != nil { - api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to count unread inbox notifications.", }) @@ -351,7 +351,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 uuid", slog.Error(err)) + api.Logger.Error(ctx, "failed to parse notification uuid", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse notification uuid.", }) @@ -372,27 +372,27 @@ func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *htt }(), }) if err != nil { - api.Logger.Error(ctx, "get filtered inbox notifications", slog.Error(err)) + api.Logger.Error(ctx, "failed to update inbox notification read status", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get inbox notifications.", + Message: "Failed to update inbox notification read status.", }) return } unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) if err != nil { - api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + api.Logger.Error(ctx, "failed to call count unread inbox notifications", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to count unread inbox notifications.", + Message: "Failed to call count unread inbox notifications.", }) return } updatedNotification, err := api.Database.GetInboxNotificationByID(ctx, parsedNotifID) if err != nil { - api.Logger.Error(ctx, "count unread inbox notifications", slog.Error(err)) + api.Logger.Error(ctx, "failed to get notification by id", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to count unread inbox notifications.", + Message: "Failed to get notification by id.", }) return } diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index c3458e3b5a660..03bef6df2285e 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -2,11 +2,10 @@ package coderd_test import ( "context" - "database/sql" "encoding/json" "fmt" + "runtime" "testing" - "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -43,6 +42,13 @@ func TestInboxNotifications_List(t *testing.T) { t.Run("OK with pagination", func(t *testing.T) { t.Parallel() + // I skip these tests specifically on windows as for now they are flaky - only on Windows. + // For now the idea is that the runner takes too long to insert the entries, could be worth + // investigating a manual Tx. + if runtime.GOOS == "windows" { + t.Skip("our runners are randomly taking too long to insert entries") + } + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) firstUser := coderdtest.CreateFirstUser(t, client) @@ -58,23 +64,18 @@ func TestInboxNotifications_List(t *testing.T) { // nolint:gocritic // used only to seed database notifierCtx := dbauthz.AsNotifier(ctx) - api.Database.InTx(func(tx database.Store) error { - 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) - } - return nil - }, &database.TxOptions{ - Isolation: sql.LevelReadCommitted, - }) + 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) + } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) require.NoError(t, err) @@ -132,8 +133,6 @@ func TestInboxNotifications_List(t *testing.T) { require.NoError(t, err) } - time.Sleep(5 * time.Second) - notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ Templates: []uuid.UUID{notifications.TemplateWorkspaceOutOfMemory}, }) @@ -185,8 +184,6 @@ func TestInboxNotifications_List(t *testing.T) { require.NoError(t, err) } - time.Sleep(5 * time.Second) - notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ Targets: []uuid.UUID{filteredTarget}, }) @@ -244,8 +241,6 @@ func TestInboxNotifications_List(t *testing.T) { require.NoError(t, err) } - time.Sleep(5 * time.Second) - notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ Targets: []uuid.UUID{filteredTarget}, Templates: []uuid.UUID{notifications.TemplateWorkspaceOutOfDisk}, @@ -290,8 +285,6 @@ func TestInboxNotifications_ReadStatus(t *testing.T) { require.NoError(t, err) } - time.Sleep(5 * time.Second) - notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) require.NoError(t, err) require.NotNil(t, notifs) From 18b694bf0298f5ca38f7924cbae1f7eefaf5dcff Mon Sep 17 00:00:00 2001 From: defelmnq Date: Wed, 12 Mar 2025 14:04:50 +0000 Subject: [PATCH 03/18] skip tests on windows --- coderd/inboxnotifications_test.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 03bef6df2285e..d760e9deac01d 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -22,6 +22,13 @@ import ( func TestInboxNotifications_List(t *testing.T) { t.Parallel() + // I skip these tests specifically on windows as for now they are flaky - only on Windows. + // For now the idea is that the runner takes too long to insert the entries, could be worth + // investigating a manual Tx. + if runtime.GOOS == "windows" { + t.Skip("our runners are randomly taking too long to insert entries") + } + t.Run("OK empty", func(t *testing.T) { t.Parallel() @@ -42,13 +49,6 @@ func TestInboxNotifications_List(t *testing.T) { t.Run("OK with pagination", func(t *testing.T) { t.Parallel() - // I skip these tests specifically on windows as for now they are flaky - only on Windows. - // For now the idea is that the runner takes too long to insert the entries, could be worth - // investigating a manual Tx. - if runtime.GOOS == "windows" { - t.Skip("our runners are randomly taking too long to insert entries") - } - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) firstUser := coderdtest.CreateFirstUser(t, client) @@ -257,6 +257,13 @@ func TestInboxNotifications_List(t *testing.T) { func TestInboxNotifications_ReadStatus(t *testing.T) { t.Parallel() + // I skip these tests specifically on windows as for now they are flaky - only on Windows. + // For now the idea is that the runner takes too long to insert the entries, could be worth + // investigating a manual Tx. + if runtime.GOOS == "windows" { + t.Skip("our runners are randomly taking too long to insert entries") + } + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) firstUser := coderdtest.CreateFirstUser(t, client) From 0e8ac4c0823bcc7f3672b2af2bd7143db481ff68 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Thu, 13 Mar 2025 00:21:55 +0000 Subject: [PATCH 04/18] work on pr comments - cleanup endpoints and isolate logic --- coderd/inboxnotifications.go | 217 ++++++++++++------------------ coderd/inboxnotifications_test.go | 32 +---- 2 files changed, 89 insertions(+), 160 deletions(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index f8f29871b0768..0ec866055cfaa 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" @@ -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) { 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) } @@ -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) } @@ -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 ¬if.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) { + 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{ @@ -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() @@ -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 { @@ -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 ¬ification.ReadAt.Time - }(), - CreatedAt: notification.CreatedAt, - }) + notificationsList = append(notificationsList, convertInboxNotificationResponse(ctx, api.Logger, notification)) } return notificationsList }(), @@ -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 @@ -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), }) } diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index d760e9deac01d..677652abebf30 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -12,7 +12,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/codersdk" @@ -61,11 +61,8 @@ func TestInboxNotifications_List(t *testing.T) { require.Equal(t, 0, notifs.UnreadCount) require.Empty(t, notifs.Notifications) - // nolint:gocritic // used only to seed database - notifierCtx := dbauthz.AsNotifier(ctx) - for i := range 40 { - _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ ID: uuid.New(), UserID: firstUser.UserID, TemplateID: notifications.TemplateWorkspaceOutOfMemory, @@ -74,7 +71,6 @@ func TestInboxNotifications_List(t *testing.T) { Content: fmt.Sprintf("Content of the notif %d", i), CreatedAt: dbtime.Now(), }) - require.NoError(t, err) } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) @@ -111,11 +107,8 @@ func TestInboxNotifications_List(t *testing.T) { require.Equal(t, 0, notifs.UnreadCount) require.Empty(t, notifs.Notifications) - // nolint:gocritic // used only to seed database - notifierCtx := dbauthz.AsNotifier(ctx) - for i := range 10 { - _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ ID: uuid.New(), UserID: firstUser.UserID, TemplateID: func() uuid.UUID { @@ -130,7 +123,6 @@ func TestInboxNotifications_List(t *testing.T) { Content: fmt.Sprintf("Content of the notif %d", i), CreatedAt: dbtime.Now(), }) - require.NoError(t, err) } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ @@ -159,13 +151,10 @@ func TestInboxNotifications_List(t *testing.T) { require.Equal(t, 0, notifs.UnreadCount) require.Empty(t, notifs.Notifications) - // nolint:gocritic // used only to seed database - notifierCtx := dbauthz.AsNotifier(ctx) - filteredTarget := uuid.New() for i := range 10 { - _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ ID: uuid.New(), UserID: firstUser.UserID, TemplateID: notifications.TemplateWorkspaceOutOfMemory, @@ -181,7 +170,6 @@ func TestInboxNotifications_List(t *testing.T) { Content: fmt.Sprintf("Content of the notif %d", i), CreatedAt: dbtime.Now(), }) - require.NoError(t, err) } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ @@ -210,13 +198,10 @@ func TestInboxNotifications_List(t *testing.T) { require.Equal(t, 0, notifs.UnreadCount) require.Empty(t, notifs.Notifications) - // nolint:gocritic // used only to seed database - notifierCtx := dbauthz.AsNotifier(ctx) - filteredTarget := uuid.New() for i := range 10 { - _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ ID: uuid.New(), UserID: firstUser.UserID, TemplateID: func() uuid.UUID { @@ -238,7 +223,6 @@ func TestInboxNotifications_List(t *testing.T) { Content: fmt.Sprintf("Content of the notif %d", i), CreatedAt: dbtime.Now(), }) - require.NoError(t, err) } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ @@ -276,11 +260,8 @@ func TestInboxNotifications_ReadStatus(t *testing.T) { require.Equal(t, 0, notifs.UnreadCount) require.Empty(t, notifs.Notifications) - // nolint:gocritic // used only to seed database - notifierCtx := dbauthz.AsNotifier(ctx) - for i := range 20 { - _, err = api.Database.InsertInboxNotification(notifierCtx, database.InsertInboxNotificationParams{ + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ ID: uuid.New(), UserID: firstUser.UserID, TemplateID: notifications.TemplateWorkspaceOutOfMemory, @@ -289,7 +270,6 @@ func TestInboxNotifications_ReadStatus(t *testing.T) { Content: fmt.Sprintf("Content of the notif %d", i), CreatedAt: dbtime.Now(), }) - require.NoError(t, err) } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) From d72d1f260099609e4dbc5e2f563c176b1c196b32 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Thu, 13 Mar 2025 14:10:13 +0000 Subject: [PATCH 05/18] work on PR comments --- coderd/apidoc/docs.go | 4 +-- coderd/apidoc/swagger.json | 4 +-- coderd/database/dbmem/dbmem.go | 1 + coderd/database/queries.sql.go | 2 +- .../database/queries/notificationsinbox.sql | 2 +- coderd/inboxnotifications.go | 14 +++++++--- coderd/inboxnotifications_test.go | 26 +++++++++++++------ coderd/notifications/manager.go | 2 +- coderd/pubsub/inboxnotification.go | 3 +-- coderd/util/uuid/uuid.go | 17 ++++++++++++ codersdk/inboxnotification.go | 18 +++---------- docs/reference/api/notifications.md | 8 +++--- 12 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 coderd/util/uuid/uuid.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6965cb9662b93..8dbff0fca8274 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1775,8 +1775,8 @@ const docTemplate = `{ } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/codersdk.Response" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a3bb102f5819e..3f58bf0d944fd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1548,8 +1548,8 @@ } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/codersdk.Response" } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index b8df93ecb12b5..60f13aa0fa050 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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] diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 94d4987c73a88..ff135aaa8f14e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4310,7 +4310,7 @@ func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context, const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE user_id = $1 AND - (template_id = ANY($2::UUID[]) OR $2 IS NULL) AND + ($2::UUID[] IS NULL OR template_id = ANY($2::UUID[])) AND ($3::UUID[] IS NULL OR targets @> $3::UUID[]) AND ($4::inbox_notification_read_status = 'all' OR ($4::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($4::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND ($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ) diff --git a/coderd/database/queries/notificationsinbox.sql b/coderd/database/queries/notificationsinbox.sql index 1d8dccd347b85..43ab63ae83652 100644 --- a/coderd/database/queries/notificationsinbox.sql +++ b/coderd/database/queries/notificationsinbox.sql @@ -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) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 0ec866055cfaa..938387827dc15 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -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( @@ -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 if len(targets) > 0 { for _, target := range targets { @@ -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 { @@ -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() diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 677652abebf30..ce1b015ed7534 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -19,6 +19,10 @@ import ( "github.com/coder/coder/v2/testutil" ) +const ( + inboxNotificationsPageSize = 25 +) + func TestInboxNotifications_List(t *testing.T) { t.Parallel() @@ -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() @@ -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() @@ -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("[]"), @@ -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) @@ -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() @@ -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 @@ -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() @@ -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 { @@ -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() @@ -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 @@ -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() @@ -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("[]"), diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 5d66c3d2ead72..eb3a3ea01938f 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -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) diff --git a/coderd/pubsub/inboxnotification.go b/coderd/pubsub/inboxnotification.go index 7236e1353929c..5f7eafda0f8d2 100644 --- a/coderd/pubsub/inboxnotification.go +++ b/coderd/pubsub/inboxnotification.go @@ -39,6 +39,5 @@ type InboxNotificationEvent struct { type InboxNotificationEventKind string const ( - InboxNotificationEventKindNew InboxNotificationEventKind = "new" - InboxNotificationEventKindRead InboxNotificationEventKind = "read" + InboxNotificationEventKindNew InboxNotificationEventKind = "new" ) diff --git a/coderd/util/uuid/uuid.go b/coderd/util/uuid/uuid.go new file mode 100644 index 0000000000000..4daeaa6b52902 --- /dev/null +++ b/coderd/util/uuid/uuid.go @@ -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) +} diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go index b234d36d2128d..66aa7975d145c 100644 --- a/codersdk/inboxnotification.go +++ b/codersdk/inboxnotification.go @@ -8,6 +8,8 @@ import ( "time" "github.com/google/uuid" + + utiluuid "github.com/coder/coder/v2/coderd/util/uuid" ) type InboxNotification struct { @@ -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 @@ -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)) diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 4f921892677c9..9a181cc1d69c5 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -185,7 +185,7 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/inbox/{id}/read-status ### Example responses -> 201 Response +> 200 Response ```json { @@ -202,9 +202,9 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/inbox/{id}/read-status ### Responses -| Status | Meaning | Description | Schema | -|--------|--------------------------------------------------------------|-------------|--------------------------------------------------| -| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Response](schemas.md#codersdkresponse) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). From 796bcd07d58f0c955a6d73a44722bd221ca1e51e Mon Sep 17 00:00:00 2001 From: defelmnq Date: Thu, 13 Mar 2025 16:53:57 +0000 Subject: [PATCH 06/18] fix parameters validation --- coderd/inboxnotifications.go | 55 +++++++++++++---------------------- codersdk/inboxnotification.go | 14 ++++++--- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 938387827dc15..61283224093ad 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -53,20 +53,12 @@ func convertInboxNotificationParameters(ctx context.Context, logger slog.Logger, } } + readStatus := string(database.InboxNotificationReadStatusAll) if readStatusParam != "" { - readOptions := []string{ - string(database.InboxNotificationReadStatusRead), - string(database.InboxNotificationReadStatusUnread), - string(database.InboxNotificationReadStatusAll), - } - - if !slices.Contains(readOptions, readStatusParam) { - logger.Error(ctx, "unable to parse read status") - return nil, nil, "", xerrors.New("unable to parse read status") - } + readStatus = readStatusParam } - return targets, templates, readStatusParam, nil + return targets, templates, readStatus, nil } // convertInboxNotificationResponse works as a util function to transform a database.InboxNotification to codersdk.InboxNotification @@ -110,16 +102,18 @@ func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, n // @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") + ctx = r.Context() + apikey = httpmw.APIKey(r) ) - targets, templates, readStatusParam, err := convertInboxNotificationParameters(ctx, api.Logger, targetsParam, templatesParam, readStatusParam) + var req codersdk.WatchInboxNotificationsRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + targets, templates, readStatusParam, err := convertInboxNotificationParameters(ctx, api.Logger, req.Targets, req.Templates, req.Targets) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid query parameter.", @@ -233,17 +227,17 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) // @Success 200 {object} codersdk.ListInboxNotificationsResponse // @Router /notifications/inbox [get] func (api *API) listInboxNotifications(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") - startingBeforeParam = r.URL.Query().Get("starting_before") + ctx = r.Context() + apikey = httpmw.APIKey(r) ) - targets, templates, readStatus, err := convertInboxNotificationParameters(ctx, api.Logger, targetsParam, templatesParam, readStatusParam) + var req codersdk.ListInboxNotificationsRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + targets, templates, readStatus, err := convertInboxNotificationParameters(ctx, api.Logger, req.Targets, req.Templates, req.ReadStatus) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid query parameter.", @@ -253,15 +247,8 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) } startingBefore := dbtime.Now() - if startingBeforeParam != "" { - lastNotifID, err := uuid.Parse(startingBeforeParam) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid starting before.", - }) - return - } - lastNotif, err := api.Database.GetInboxNotificationByID(ctx, lastNotifID) + if req.StartingBefore != uuid.Nil { + lastNotif, err := api.Database.GetInboxNotificationByID(ctx, req.StartingBefore) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to get notification by id.", diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go index 66aa7975d145c..788d3f53cf129 100644 --- a/codersdk/inboxnotification.go +++ b/codersdk/inboxnotification.go @@ -30,16 +30,22 @@ type InboxNotificationAction struct { URL string `json:"url"` } +type WatchInboxNotificationsRequest struct { + Targets string `json:"targets,omitempty"` + Templates string `json:"templates,omitempty"` + ReadStatus string `json:"read_status,omitempty" validate:"omitempty,oneof=read unread all"` +} + type GetInboxNotificationResponse struct { Notification InboxNotification `json:"notification"` UnreadCount int `json:"unread_count"` } type ListInboxNotificationsRequest struct { - Targets []uuid.UUID - Templates []uuid.UUID - ReadStatus string - StartingBefore uuid.UUID + Targets string `json:"targets,omitempty"` + Templates string `json:"templates,omitempty"` + ReadStatus string `json:"read_status,omitempty" validate:"omitempty,oneof=read unread all"` + StartingBefore uuid.UUID `json:"starting_before,omitempty" validate:"omitempty" format:"uuid"` } type ListInboxNotificationsResponse struct { From 75c310d4d74740d752bf3216e2cf09add57570bf Mon Sep 17 00:00:00 2001 From: defelmnq Date: Thu, 13 Mar 2025 22:41:57 +0000 Subject: [PATCH 07/18] improve parameters validation --- coderd/inboxnotifications.go | 133 +++++++++++++----------------- coderd/inboxnotifications_test.go | 8 +- coderd/util/uuid/uuid.go | 17 ---- codersdk/inboxnotification.go | 12 +-- 4 files changed, 62 insertions(+), 108 deletions(-) delete mode 100644 coderd/util/uuid/uuid.go diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 61283224093ad..9682966fe870f 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -6,12 +6,9 @@ import ( "encoding/json" "net/http" "slices" - "strings" "time" - "github.com/go-chi/chi/v5" "github.com/google/uuid" - "golang.org/x/xerrors" "cdr.dev/slog" @@ -25,42 +22,6 @@ import ( "github.com/coder/websocket" ) -// 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) { - var targets []uuid.UUID - if targetsParam != "" { - splitTargets := strings.Split(targetsParam, ",") - for _, target := range splitTargets { - id, err := uuid.Parse(target) - if err != nil { - 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) - } - } - - var templates []uuid.UUID - if templatesParam != "" { - splitTemplates := strings.Split(templatesParam, ",") - for _, template := range splitTemplates { - id, err := uuid.Parse(template) - if err != nil { - 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) - } - } - - readStatus := string(database.InboxNotificationReadStatusAll) - if readStatusParam != "" { - readStatus = readStatusParam - } - - return targets, templates, readStatus, 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{ @@ -102,22 +63,33 @@ func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, n // @Success 200 {object} codersdk.GetInboxNotificationResponse // @Router /notifications/inbox/watch [get] func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) { + p := httpapi.NewQueryParamParser() + vals := r.URL.Query() var ( ctx = r.Context() apikey = httpmw.APIKey(r) - ) - var req codersdk.WatchInboxNotificationsRequest - if !httpapi.Read(ctx, rw, r, &req) { + targets = p.UUIDs(vals, []uuid.UUID{}, "targets") + templates = p.UUIDs(vals, []uuid.UUID{}, "templates") + readStatus = p.String(vals, "all", "read_status") + ) + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) return } - targets, templates, readStatusParam, err := convertInboxNotificationParameters(ctx, api.Logger, req.Targets, req.Templates, req.Targets) - if err != nil { + if !slices.Contains([]string{ + string(database.InboxNotificationReadStatusAll), + string(database.InboxNotificationReadStatusRead), + string(database.InboxNotificationReadStatusUnread), + }, readStatus) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid query parameter.", - Detail: err.Error(), + Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.", }) return } @@ -165,12 +137,12 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) } // filter out notifications that don't match the read status - if readStatusParam != "" { - if readStatusParam == string(database.InboxNotificationReadStatusRead) { + if readStatus != "" { + if readStatus == string(database.InboxNotificationReadStatusRead) { if payload.InboxNotification.ReadAt == nil { return } - } else if readStatusParam == string(database.InboxNotificationReadStatusUnread) { + } else if readStatus == string(database.InboxNotificationReadStatusUnread) { if payload.InboxNotification.ReadAt != nil { return } @@ -227,35 +199,48 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) // @Success 200 {object} codersdk.ListInboxNotificationsResponse // @Router /notifications/inbox [get] func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) { + p := httpapi.NewQueryParamParser() + vals := r.URL.Query() + var ( ctx = r.Context() apikey = httpmw.APIKey(r) - ) - var req codersdk.ListInboxNotificationsRequest - if !httpapi.Read(ctx, rw, r, &req) { + targets = p.UUIDs(vals, []uuid.UUID{}, "targets") + templates = p.UUIDs(vals, []uuid.UUID{}, "templates") + readStatus = p.String(vals, "all", "read_status") + startingBefore = p.UUID(vals, uuid.Nil, "starting_before") + ) + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) return } - targets, templates, readStatus, err := convertInboxNotificationParameters(ctx, api.Logger, req.Targets, req.Templates, req.ReadStatus) - if err != nil { + if !slices.Contains([]string{ + string(database.InboxNotificationReadStatusAll), + string(database.InboxNotificationReadStatusRead), + string(database.InboxNotificationReadStatusUnread), + }, readStatus) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid query parameter.", - Detail: err.Error(), + Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.", }) return } - startingBefore := dbtime.Now() - if req.StartingBefore != uuid.Nil { - lastNotif, err := api.Database.GetInboxNotificationByID(ctx, req.StartingBefore) + createdBefore := dbtime.Now() + if startingBefore != uuid.Nil { + lastNotif, err := api.Database.GetInboxNotificationByID(ctx, startingBefore) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to get notification by id.", }) return } - startingBefore = lastNotif.CreatedAt + createdBefore = lastNotif.CreatedAt } notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{ @@ -263,7 +248,7 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) Templates: templates, Targets: targets, ReadStatus: database.InboxNotificationReadStatus(readStatus), - CreatedAtOpt: startingBefore, + CreatedAtOpt: createdBefore, }) if err != nil { api.Logger.Error(ctx, "failed to get filtered inbox notifications", slog.Error(err)) @@ -304,29 +289,23 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) // @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() - var ( - apikey = httpmw.APIKey(r) - notifID = chi.URLParam(r, "id") + ctx = r.Context() + apikey = httpmw.APIKey(r) ) - var body codersdk.UpdateInboxNotificationReadStatusRequest - if !httpapi.Read(ctx, rw, r, &body) { - return + notificationID, ok := httpmw.ParseUUIDParam(rw, r, "id") + if !ok { + } - 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.StatusBadRequest, codersdk.Response{ - Message: "Failed to parse notification uuid.", - }) + var body codersdk.UpdateInboxNotificationReadStatusRequest + if !httpapi.Read(ctx, rw, r, &body) { return } - err = api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{ - ID: parsedNotifID, + err := api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{ + ID: notificationID, ReadAt: func() sql.NullTime { if body.IsRead { return sql.NullTime{ @@ -355,7 +334,7 @@ func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *htt return } - updatedNotification, err := api.Database.GetInboxNotificationByID(ctx, parsedNotifID) + updatedNotification, err := api.Database.GetInboxNotificationByID(ctx, notificationID) if err != nil { api.Logger.Error(ctx, "failed to get notification by id", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index ce1b015ed7534..b00ab9d1d5201 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -133,7 +133,7 @@ func TestInboxNotifications_List(t *testing.T) { } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ - Templates: []uuid.UUID{notifications.TemplateWorkspaceOutOfMemory}, + Templates: notifications.TemplateWorkspaceOutOfMemory.String(), }) require.NoError(t, err) require.NotNil(t, notifs) @@ -181,7 +181,7 @@ func TestInboxNotifications_List(t *testing.T) { } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ - Targets: []uuid.UUID{filteredTarget}, + Targets: filteredTarget.String(), }) require.NoError(t, err) require.NotNil(t, notifs) @@ -235,8 +235,8 @@ func TestInboxNotifications_List(t *testing.T) { } notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ - Targets: []uuid.UUID{filteredTarget}, - Templates: []uuid.UUID{notifications.TemplateWorkspaceOutOfDisk}, + Targets: filteredTarget.String(), + Templates: notifications.TemplateWorkspaceOutOfDisk.String(), }) require.NoError(t, err) require.NotNil(t, notifs) diff --git a/coderd/util/uuid/uuid.go b/coderd/util/uuid/uuid.go deleted file mode 100644 index 4daeaa6b52902..0000000000000 --- a/coderd/util/uuid/uuid.go +++ /dev/null @@ -1,17 +0,0 @@ -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) -} diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go index 788d3f53cf129..e72c5c158c84c 100644 --- a/codersdk/inboxnotification.go +++ b/codersdk/inboxnotification.go @@ -8,8 +8,6 @@ import ( "time" "github.com/google/uuid" - - utiluuid "github.com/coder/coder/v2/coderd/util/uuid" ) type InboxNotification struct { @@ -30,12 +28,6 @@ type InboxNotificationAction struct { URL string `json:"url"` } -type WatchInboxNotificationsRequest struct { - Targets string `json:"targets,omitempty"` - Templates string `json:"templates,omitempty"` - ReadStatus string `json:"read_status,omitempty" validate:"omitempty,oneof=read unread all"` -} - type GetInboxNotificationResponse struct { Notification InboxNotification `json:"notification"` UnreadCount int `json:"unread_count"` @@ -56,10 +48,10 @@ type ListInboxNotificationsResponse struct { func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsRequest) []RequestOption { var opts []RequestOption if len(req.Targets) > 0 { - opts = append(opts, WithQueryParam("targets", utiluuid.FromSliceToString(req.Targets, ","))) + opts = append(opts, WithQueryParam("targets", req.Targets)) } if len(req.Templates) > 0 { - opts = append(opts, WithQueryParam("templates", utiluuid.FromSliceToString(req.Templates, ","))) + opts = append(opts, WithQueryParam("templates", req.Templates)) } if req.ReadStatus != "" { opts = append(opts, WithQueryParam("read_status", req.ReadStatus)) From 07ab7c4064ed4ee2b429faa801335e2d4a165363 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Thu, 13 Mar 2025 23:19:37 +0000 Subject: [PATCH 08/18] fmt and lint --- coderd/inboxnotifications.go | 6 +++--- codersdk/inboxnotification.go | 4 ++-- site/src/api/typesGenerated.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 9682966fe870f..96976cf64942b 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -206,8 +206,8 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) ctx = r.Context() apikey = httpmw.APIKey(r) - targets = p.UUIDs(vals, []uuid.UUID{}, "targets") - templates = p.UUIDs(vals, []uuid.UUID{}, "templates") + targets = p.UUIDs(vals, nil, "targets") + templates = p.UUIDs(vals, nil, "templates") readStatus = p.String(vals, "all", "read_status") startingBefore = p.UUID(vals, uuid.Nil, "starting_before") ) @@ -296,7 +296,7 @@ func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *htt notificationID, ok := httpmw.ParseUUIDParam(rw, r, "id") if !ok { - + return } var body codersdk.UpdateInboxNotificationReadStatusRequest diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go index e72c5c158c84c..6d21aead5ac79 100644 --- a/codersdk/inboxnotification.go +++ b/codersdk/inboxnotification.go @@ -47,10 +47,10 @@ type ListInboxNotificationsResponse struct { func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsRequest) []RequestOption { var opts []RequestOption - if len(req.Targets) > 0 { + if req.Targets != "" { opts = append(opts, WithQueryParam("targets", req.Targets)) } - if len(req.Templates) > 0 { + if req.Templates != "" { opts = append(opts, WithQueryParam("templates", req.Templates)) } if req.ReadStatus != "" { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index adbd0e92248bc..236e48e093265 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1161,10 +1161,10 @@ export interface LinkConfig { // From codersdk/inboxnotification.go export interface ListInboxNotificationsRequest { - readonly Targets: readonly string[]; - readonly Templates: readonly string[]; - readonly ReadStatus: string; - readonly StartingBefore: string; + readonly targets?: string; + readonly templates?: string; + readonly read_status?: string; + readonly starting_before?: string; } // From codersdk/inboxnotification.go From cb41d1a200ee47b0e4b6615f733886d3f1ff9f58 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Fri, 14 Mar 2025 13:44:38 +0000 Subject: [PATCH 09/18] websocket testing wip --- coderd/inboxnotifications.go | 1 + coderd/inboxnotifications_test.go | 290 ++++++++++++++++++++++++++---- codersdk/inboxnotification.go | 29 ++- 3 files changed, 278 insertions(+), 42 deletions(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 96976cf64942b..e325503358899 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -153,6 +153,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) select { case notificationCh <- payload.InboxNotification: default: + api.Logger.Error(ctx, "unable to push notification in channel") } }, )) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index b00ab9d1d5201..c0f80029af42f 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "runtime" "testing" @@ -13,16 +15,82 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "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/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" + "github.com/coder/websocket" ) const ( inboxNotificationsPageSize = 25 ) +var ( + failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16") +) + +func TestInboxNotification_Watch(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitLong) + + db, ps := dbtestutil.NewDB(t) + db.DisableForeignKeysAndTriggers(ctx) + + firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) + + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + member.WatchInboxNotificationx(ctx) + })) + defer srv.Close() + + u, err := member.URL.Parse("/api/v2/notifications/inbox/watch") + require.NoError(t, err) + + // nolint: bodyclose + wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Coder-Session-Token": []string{member.SessionToken()}, + }, + }) + if err != nil { + if resp.StatusCode != http.StatusSwitchingProtocols { + err = codersdk.ReadBodyAsError(resp) + } + require.NoError(t, err) + } + defer wsConn.Close(websocket.StatusNormalClosure, "done") + _, cnc := codersdk.WebsocketNetConn(ctx, wsConn, websocket.MessageBinary) + + inboxHandler := dispatch.NewInboxHandler(logger, db, ps) + dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), + }, "notification title", "notification content", nil) + require.NoError(t, err) + + msgID := uuid.New() + _, err = dispatchFunc(ctx, msgID) + require.NoError(t, err) + + op := make([]byte, 1024) + mt, err := cnc.Read(op) + require.NoError(t, err) + require.Equal(t, websocket.MessageText, mt) + }) +} + func TestInboxNotifications_List(t *testing.T) { t.Parallel() @@ -33,6 +101,65 @@ func TestInboxNotifications_List(t *testing.T) { t.Skip("our runners are randomly taking too long to insert entries") } + // create table-based tests for errors and repeting use cases + tests := []struct { + name string + expectedError string + listTemplate string + listTarget string + listReadStatus string + listStartingBefore string + }{ + {"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""}, + {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, + {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, + {"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"}, + {"nok - not found starting before", `Failed to get notification by id`, "", "", "", failingPaginationUUID.String()}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + 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() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // create a new notifications to fill the database with data + for i := range 20 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + TemplateID: notifications.TemplateWorkspaceOutOfMemory, + Title: fmt.Sprintf("Notification %d", i), + Actions: json.RawMessage("[]"), + Content: fmt.Sprintf("Content of the notif %d", i), + CreatedAt: dbtime.Now(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Templates: tt.listTemplate, + Targets: tt.listTarget, + ReadStatus: tt.listReadStatus, + StartingBefore: tt.listStartingBefore, + }) + require.ErrorContains(t, err, tt.expectedError) + require.Empty(t, notifs.Notifications) + require.Zero(t, notifs.UnreadCount) + }) + } + t.Run("OK empty", func(t *testing.T) { t.Parallel() @@ -88,7 +215,7 @@ func TestInboxNotifications_List(t *testing.T) { require.Equal(t, "Notification 39", notifs.Notifications[0].Title) notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ - StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID, + StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID.String(), }) require.NoError(t, err) require.NotNil(t, notifs) @@ -257,42 +384,135 @@ func TestInboxNotifications_ReadStatus(t *testing.T) { t.Skip("our runners are randomly taking too long to insert entries") } - 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() - - notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) - require.NoError(t, err) - require.NotNil(t, notifs) - require.Equal(t, 0, notifs.UnreadCount) - require.Empty(t, notifs.Notifications) - - for i := range 20 { - dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ - ID: uuid.New(), - UserID: member.ID, - TemplateID: notifications.TemplateWorkspaceOutOfMemory, - Title: fmt.Sprintf("Notification %d", i), - Actions: json.RawMessage("[]"), - Content: fmt.Sprintf("Content of the notif %d", i), - CreatedAt: dbtime.Now(), + t.Run("ok", func(t *testing.T) { + t.Parallel() + 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() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + for i := range 20 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + TemplateID: notifications.TemplateWorkspaceOutOfMemory, + Title: fmt.Sprintf("Notification %d", i), + Actions: json.RawMessage("[]"), + Content: fmt.Sprintf("Content of the notif %d", i), + CreatedAt: dbtime.Now(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 20, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 20) + + updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{ + IsRead: true, }) - } + require.NoError(t, err) + require.NotNil(t, updatedNotif) + require.NotZero(t, updatedNotif.Notification.ReadAt) + require.Equal(t, 19, updatedNotif.UnreadCount) + + updatedNotif, err = client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{ + IsRead: false, + }) + require.NoError(t, err) + require.NotNil(t, updatedNotif) + require.Nil(t, updatedNotif.Notification.ReadAt) + require.Equal(t, 20, updatedNotif.UnreadCount) - notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) - require.NoError(t, err) - require.NotNil(t, notifs) - require.Equal(t, 20, notifs.UnreadCount) - require.Len(t, notifs.Notifications, 20) + }) + + t.Run("NOK - wrong id", func(t *testing.T) { + t.Parallel() + 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() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + for i := range 20 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + TemplateID: notifications.TemplateWorkspaceOutOfMemory, + Title: fmt.Sprintf("Notification %d", i), + Actions: json.RawMessage("[]"), + Content: fmt.Sprintf("Content of the notif %d", i), + CreatedAt: dbtime.Now(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 20, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 20) + + updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, "xxx-xxx-xxx", codersdk.UpdateInboxNotificationReadStatusRequest{ + IsRead: true, + }) + require.ErrorContains(t, err, `Invalid UUID "xxx-xxx-xxx"`) + require.Equal(t, 0, updatedNotif.UnreadCount) + require.Empty(t, updatedNotif.Notification) + }) + t.Run("NOK - unknown id", func(t *testing.T) { + t.Parallel() + 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() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) - updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID, codersdk.UpdateInboxNotificationReadStatusRequest{ - IsRead: true, + for i := range 20 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + TemplateID: notifications.TemplateWorkspaceOutOfMemory, + Title: fmt.Sprintf("Notification %d", i), + Actions: json.RawMessage("[]"), + Content: fmt.Sprintf("Content of the notif %d", i), + CreatedAt: dbtime.Now(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 20, notifs.UnreadCount) + require.Len(t, notifs.Notifications, 20) + + updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, failingPaginationUUID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{ + IsRead: true, + }) + require.ErrorContains(t, err, `Failed to update inbox notification read status`) + require.Equal(t, 0, updatedNotif.UnreadCount) + require.Empty(t, updatedNotif.Notification) }) - require.NoError(t, err) - require.NotNil(t, updatedNotif) - require.NotZero(t, updatedNotif.Notification.ReadAt) - require.Equal(t, 19, updatedNotif.UnreadCount) } diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go index 6d21aead5ac79..3438050289940 100644 --- a/codersdk/inboxnotification.go +++ b/codersdk/inboxnotification.go @@ -34,10 +34,10 @@ type GetInboxNotificationResponse struct { } type ListInboxNotificationsRequest struct { - Targets string `json:"targets,omitempty"` - Templates string `json:"templates,omitempty"` - ReadStatus string `json:"read_status,omitempty" validate:"omitempty,oneof=read unread all"` - StartingBefore uuid.UUID `json:"starting_before,omitempty" validate:"omitempty" format:"uuid"` + Targets string `json:"targets,omitempty"` + Templates string `json:"templates,omitempty"` + ReadStatus string `json:"read_status,omitempty"` + StartingBefore string `json:"starting_before,omitempty"` } type ListInboxNotificationsResponse struct { @@ -56,13 +56,28 @@ func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsReques if req.ReadStatus != "" { opts = append(opts, WithQueryParam("read_status", req.ReadStatus)) } - if req.StartingBefore != uuid.Nil { - opts = append(opts, WithQueryParam("starting_before", req.StartingBefore.String())) + if req.StartingBefore != "" { + opts = append(opts, WithQueryParam("starting_before", req.StartingBefore)) } return opts } +func (c *Client) WatchInboxNotificationx(ctx context.Context) error { + res, err := c.Request( + ctx, http.MethodGet, + "/api/v2/notifications/watch", + nil, nil, + ) + if err != nil { + return err + } + + defer res.Body.Close() + + return nil +} + func (c *Client) ListInboxNotifications(ctx context.Context, req ListInboxNotificationsRequest) (ListInboxNotificationsResponse, error) { res, err := c.Request( ctx, http.MethodGet, @@ -91,7 +106,7 @@ type UpdateInboxNotificationReadStatusResponse struct { UnreadCount int `json:"unread_count"` } -func (c *Client) UpdateInboxNotificationReadStatus(ctx context.Context, notifID uuid.UUID, req UpdateInboxNotificationReadStatusRequest) (UpdateInboxNotificationReadStatusResponse, error) { +func (c *Client) UpdateInboxNotificationReadStatus(ctx context.Context, notifID string, req UpdateInboxNotificationReadStatusRequest) (UpdateInboxNotificationReadStatusResponse, error) { res, err := c.Request( ctx, http.MethodPut, fmt.Sprintf("/api/v2/notifications/inbox/%v/read-status", notifID), From 6ff4c7e0f8e314895df7e0be24b62d00b80e3663 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Fri, 14 Mar 2025 16:35:14 +0000 Subject: [PATCH 10/18] improve tests --- coderd/inboxnotifications_test.go | 231 ++++++++++++++++++++++++++++-- codersdk/inboxnotification.go | 15 -- 2 files changed, 216 insertions(+), 30 deletions(-) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index c0f80029af42f..3e7ec90f72d70 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/http/httptest" "runtime" "testing" @@ -37,24 +36,61 @@ var ( func TestInboxNotification_Watch(t *testing.T) { t.Parallel() + tests := []struct { + name string + expectedError string + listTemplate string + listTarget string + listReadStatus string + listStartingBefore string + }{ + {"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""}, + {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, + {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := client.Request(ctx, http.MethodGet, "/api/v2/notifications/inbox/watch", nil, + codersdk.ListInboxNotificationsRequestToQueryParams(codersdk.ListInboxNotificationsRequest{ + Targets: tt.listTarget, + Templates: tt.listTemplate, + ReadStatus: tt.listReadStatus, + StartingBefore: tt.listStartingBefore, + })...) + require.NoError(t, err) + defer resp.Body.Close() + + err = codersdk.ReadBodyAsError(resp) + require.ErrorContains(t, err, tt.expectedError) + }) + } + t.Run("OK", func(t *testing.T) { t.Parallel() - logger := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitLong) + logger := testutil.Logger(t) db, ps := dbtestutil.NewDB(t) - db.DisableForeignKeysAndTriggers(ctx) - firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Pubsub: ps, + Database: db, + }) firstUser := coderdtest.CreateFirstUser(t, firstClient) member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) - srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - member.WatchInboxNotificationx(ctx) - })) - defer srv.Close() - u, err := member.URL.Parse("/api/v2/notifications/inbox/watch") require.NoError(t, err) @@ -71,7 +107,6 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") - _, cnc := codersdk.WebsocketNetConn(ctx, wsConn, websocket.MessageBinary) inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ @@ -80,14 +115,180 @@ func TestInboxNotification_Watch(t *testing.T) { }, "notification title", "notification content", nil) require.NoError(t, err) - msgID := uuid.New() - _, err = dispatchFunc(ctx, msgID) + dispatchFunc(ctx, uuid.New()) + + _, message, err := wsConn.Read(ctx) + require.NoError(t, err) + + var notif codersdk.GetInboxNotificationResponse + err = json.Unmarshal(message, ¬if) require.NoError(t, err) - op := make([]byte, 1024) - mt, err := cnc.Read(op) + require.Equal(t, 1, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + }) + + t.Run("OK - filters on templates", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := testutil.Logger(t) + + db, ps := dbtestutil.NewDB(t) + + firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Pubsub: ps, + Database: db, + }) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) + + u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?templates=%v", notifications.TemplateWorkspaceOutOfMemory)) + require.NoError(t, err) + + // nolint: bodyclose + wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Coder-Session-Token": []string{member.SessionToken()}, + }, + }) + if err != nil { + if resp.StatusCode != http.StatusSwitchingProtocols { + err = codersdk.ReadBodyAsError(resp) + } + require.NoError(t, err) + } + defer wsConn.Close(websocket.StatusNormalClosure, "done") + + inboxHandler := dispatch.NewInboxHandler(logger, db, ps) + dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), + }, "memory related title", "memory related content", nil) + require.NoError(t, err) + + dispatchFunc(ctx, uuid.New()) + + dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfDisk.String(), + }, "disk related title", "disk related title", nil) require.NoError(t, err) - require.Equal(t, websocket.MessageText, mt) + + dispatchFunc(ctx, uuid.New()) + + dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), + }, "second memory related title", "second memory related title", nil) + require.NoError(t, err) + + dispatchFunc(ctx, uuid.New()) + + _, message, err := wsConn.Read(ctx) + require.NoError(t, err) + + var notif codersdk.GetInboxNotificationResponse + err = json.Unmarshal(message, ¬if) + require.NoError(t, err) + + require.Equal(t, 3, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + require.Equal(t, "memory related title", notif.Notification.Title) + + _, message, err = wsConn.Read(ctx) + require.NoError(t, err) + + err = json.Unmarshal(message, ¬if) + require.NoError(t, err) + + require.Equal(t, 3, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + require.Equal(t, "second memory related title", notif.Notification.Title) + }) + + t.Run("OK - filters on targets", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := testutil.Logger(t) + + db, ps := dbtestutil.NewDB(t) + + firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Pubsub: ps, + Database: db, + }) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) + + correctTarget := uuid.New() + + u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?targets=%v", correctTarget.String())) + require.NoError(t, err) + + // nolint: bodyclose + wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Coder-Session-Token": []string{member.SessionToken()}, + }, + }) + if err != nil { + if resp.StatusCode != http.StatusSwitchingProtocols { + err = codersdk.ReadBodyAsError(resp) + } + require.NoError(t, err) + } + defer wsConn.Close(websocket.StatusNormalClosure, "done") + + inboxHandler := dispatch.NewInboxHandler(logger, db, ps) + dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), + Targets: []uuid.UUID{correctTarget}, + }, "memory related title", "memory related content", nil) + require.NoError(t, err) + + dispatchFunc(ctx, uuid.New()) + + dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), + Targets: []uuid.UUID{uuid.New()}, + }, "second memory related title", "second memory related title", nil) + require.NoError(t, err) + + dispatchFunc(ctx, uuid.New()) + + dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{ + UserID: memberClient.ID.String(), + NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), + Targets: []uuid.UUID{correctTarget}, + }, "another memory related title", "another memory related title", nil) + require.NoError(t, err) + + dispatchFunc(ctx, uuid.New()) + + _, message, err := wsConn.Read(ctx) + require.NoError(t, err) + + var notif codersdk.GetInboxNotificationResponse + err = json.Unmarshal(message, ¬if) + require.NoError(t, err) + + require.Equal(t, 3, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + require.Equal(t, "memory related title", notif.Notification.Title) + + _, message, err = wsConn.Read(ctx) + require.NoError(t, err) + + err = json.Unmarshal(message, ¬if) + require.NoError(t, err) + + require.Equal(t, 3, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + require.Equal(t, "another memory related title", notif.Notification.Title) }) } diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go index 3438050289940..845140ea658c7 100644 --- a/codersdk/inboxnotification.go +++ b/codersdk/inboxnotification.go @@ -63,21 +63,6 @@ func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsReques return opts } -func (c *Client) WatchInboxNotificationx(ctx context.Context) error { - res, err := c.Request( - ctx, http.MethodGet, - "/api/v2/notifications/watch", - nil, nil, - ) - if err != nil { - return err - } - - defer res.Body.Close() - - return nil -} - func (c *Client) ListInboxNotifications(ctx context.Context, req ListInboxNotificationsRequest) (ListInboxNotificationsResponse, error) { res, err := c.Request( ctx, http.MethodGet, From 1ebc7f4a28cf84c98918d6c2c6b7bde94ef4dff2 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Fri, 14 Mar 2025 16:39:11 +0000 Subject: [PATCH 11/18] make fmt and lint --- coderd/inboxnotifications_test.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 3e7ec90f72d70..878d966ce49b9 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -29,9 +29,7 @@ const ( inboxNotificationsPageSize = 25 ) -var ( - failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16") -) +var failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16") func TestInboxNotification_Watch(t *testing.T) { t.Parallel() @@ -633,7 +631,6 @@ func TestInboxNotifications_ReadStatus(t *testing.T) { require.NotNil(t, updatedNotif) require.Nil(t, updatedNotif.Notification.ReadAt) require.Equal(t, 20, updatedNotif.UnreadCount) - }) t.Run("NOK - wrong id", func(t *testing.T) { From 736a2d73c839ee412a7ed3705d1f3e05ee5ae4e9 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Fri, 14 Mar 2025 16:56:18 +0000 Subject: [PATCH 12/18] make fmt and lint --- coderd/inboxnotifications_test.go | 44 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 878d966ce49b9..241e1bac6eaed 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -167,6 +167,17 @@ func TestInboxNotification_Watch(t *testing.T) { dispatchFunc(ctx, uuid.New()) + _, message, err := wsConn.Read(ctx) + require.NoError(t, err) + + var notif codersdk.GetInboxNotificationResponse + err = json.Unmarshal(message, ¬if) + require.NoError(t, err) + + require.Equal(t, 1, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + require.Equal(t, "memory related title", notif.Notification.Title) + dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{ UserID: memberClient.ID.String(), NotificationTemplateID: notifications.TemplateWorkspaceOutOfDisk.String(), @@ -183,17 +194,6 @@ func TestInboxNotification_Watch(t *testing.T) { dispatchFunc(ctx, uuid.New()) - _, message, err := wsConn.Read(ctx) - require.NoError(t, err) - - var notif codersdk.GetInboxNotificationResponse - err = json.Unmarshal(message, ¬if) - require.NoError(t, err) - - require.Equal(t, 3, notif.UnreadCount) - require.Equal(t, memberClient.ID, notif.Notification.UserID) - require.Equal(t, "memory related title", notif.Notification.Title) - _, message, err = wsConn.Read(ctx) require.NoError(t, err) @@ -249,6 +249,17 @@ func TestInboxNotification_Watch(t *testing.T) { dispatchFunc(ctx, uuid.New()) + _, message, err := wsConn.Read(ctx) + require.NoError(t, err) + + var notif codersdk.GetInboxNotificationResponse + err = json.Unmarshal(message, ¬if) + require.NoError(t, err) + + require.Equal(t, 1, notif.UnreadCount) + require.Equal(t, memberClient.ID, notif.Notification.UserID) + require.Equal(t, "memory related title", notif.Notification.Title) + dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{ UserID: memberClient.ID.String(), NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(), @@ -267,17 +278,6 @@ func TestInboxNotification_Watch(t *testing.T) { dispatchFunc(ctx, uuid.New()) - _, message, err := wsConn.Read(ctx) - require.NoError(t, err) - - var notif codersdk.GetInboxNotificationResponse - err = json.Unmarshal(message, ¬if) - require.NoError(t, err) - - require.Equal(t, 3, notif.UnreadCount) - require.Equal(t, memberClient.ID, notif.Notification.UserID) - require.Equal(t, "memory related title", notif.Notification.Title) - _, message, err = wsConn.Read(ctx) require.NoError(t, err) From c28002e20928965e3fd276c028325f207be8ed37 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Mon, 17 Mar 2025 02:54:10 +0000 Subject: [PATCH 13/18] remove windows from watch handler tests --- coderd/inboxnotifications_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 241e1bac6eaed..e3555b1e0e600 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -34,6 +34,13 @@ var failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16 func TestInboxNotification_Watch(t *testing.T) { t.Parallel() + // I skip these tests specifically on windows as for now they are flaky - only on Windows. + // For now the idea is that the runner takes too long to insert the entries, could be worth + // investigating a manual Tx. + if runtime.GOOS == "windows" { + t.Skip("our runners are randomly taking too long to insert entries") + } + tests := []struct { name string expectedError string From 2637d86a3f27ec9368eda7d21904123e24a2381e Mon Sep 17 00:00:00 2001 From: defelmnq Date: Mon, 17 Mar 2025 22:10:53 +0000 Subject: [PATCH 14/18] fix comments --- coderd/inboxnotifications.go | 10 +- coderd/inboxnotifications_test.go | 187 +++++++++++++++--------------- 2 files changed, 96 insertions(+), 101 deletions(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index e325503358899..bfee79cfe6cb7 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -153,7 +153,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) select { case notificationCh <- payload.InboxNotification: default: - api.Logger.Error(ctx, "unable to push notification in channel") + api.Logger.Error(ctx, "Failed to push consumed notification into websocket handler, check latency") } }, )) @@ -235,13 +235,9 @@ func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) createdBefore := dbtime.Now() if startingBefore != uuid.Nil { lastNotif, err := api.Database.GetInboxNotificationByID(ctx, startingBefore) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to get notification by id.", - }) - return + if err == nil { + createdBefore = lastNotif.CreatedAt } - createdBefore = lastNotif.CreatedAt } notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{ diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index e3555b1e0e600..9f87b6472e456 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -41,45 +41,47 @@ func TestInboxNotification_Watch(t *testing.T) { t.Skip("our runners are randomly taking too long to insert entries") } - tests := []struct { - name string - expectedError string - listTemplate string - listTarget string - listReadStatus string - listStartingBefore string - }{ - {"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""}, - {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, - {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, - } + t.Run("Failure Modes", func(t *testing.T) { + tests := []struct { + name string + expectedError string + listTemplate string + listTarget string + listReadStatus string + listStartingBefore string + }{ + {"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""}, + {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, + {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, + } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{}) - firstUser := coderdtest.CreateFirstUser(t, client) - client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + firstUser := coderdtest.CreateFirstUser(t, client) + client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/api/v2/notifications/inbox/watch", nil, - codersdk.ListInboxNotificationsRequestToQueryParams(codersdk.ListInboxNotificationsRequest{ - Targets: tt.listTarget, - Templates: tt.listTemplate, - ReadStatus: tt.listReadStatus, - StartingBefore: tt.listStartingBefore, - })...) - require.NoError(t, err) - defer resp.Body.Close() + resp, err := client.Request(ctx, http.MethodGet, "/api/v2/notifications/inbox/watch", nil, + codersdk.ListInboxNotificationsRequestToQueryParams(codersdk.ListInboxNotificationsRequest{ + Targets: tt.listTarget, + Templates: tt.listTemplate, + ReadStatus: tt.listReadStatus, + StartingBefore: tt.listStartingBefore, + })...) + require.NoError(t, err) + defer resp.Body.Close() - err = codersdk.ReadBodyAsError(resp) - require.ErrorContains(t, err, tt.expectedError) - }) - } + err = codersdk.ReadBodyAsError(resp) + require.ErrorContains(t, err, tt.expectedError) + }) + } + }) t.Run("OK", func(t *testing.T) { t.Parallel() @@ -99,7 +101,6 @@ func TestInboxNotification_Watch(t *testing.T) { u, err := member.URL.Parse("/api/v2/notifications/inbox/watch") require.NoError(t, err) - // nolint: bodyclose wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ "Coder-Session-Token": []string{member.SessionToken()}, @@ -142,8 +143,7 @@ func TestInboxNotification_Watch(t *testing.T) { db, ps := dbtestutil.NewDB(t) firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Pubsub: ps, - Database: db, + Pubsub: ps, }) firstUser := coderdtest.CreateFirstUser(t, firstClient) member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) @@ -151,7 +151,6 @@ func TestInboxNotification_Watch(t *testing.T) { u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?templates=%v", notifications.TemplateWorkspaceOutOfMemory)) require.NoError(t, err) - // nolint: bodyclose wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ "Coder-Session-Token": []string{member.SessionToken()}, @@ -232,7 +231,6 @@ func TestInboxNotification_Watch(t *testing.T) { u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?targets=%v", correctTarget.String())) require.NoError(t, err) - // nolint: bodyclose wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ "Coder-Session-Token": []string{member.SessionToken()}, @@ -307,64 +305,65 @@ func TestInboxNotifications_List(t *testing.T) { t.Skip("our runners are randomly taking too long to insert entries") } - // create table-based tests for errors and repeting use cases - tests := []struct { - name string - expectedError string - listTemplate string - listTarget string - listReadStatus string - listStartingBefore string - }{ - {"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""}, - {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, - {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, - {"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"}, - {"nok - not found starting before", `Failed to get notification by id`, "", "", "", failingPaginationUUID.String()}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - 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() + t.Run("Failure Modes", func(t *testing.T) { + tests := []struct { + name string + expectedError string + listTemplate string + listTarget string + listReadStatus string + listStartingBefore string + }{ + {"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""}, + {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, + {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, + {"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"}, + {"nok - not found starting before", `Failed to get notification by id`, "", "", "", failingPaginationUUID.String()}, + } - notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) - require.NoError(t, err) - require.NotNil(t, notifs) - require.Equal(t, 0, notifs.UnreadCount) - require.Empty(t, notifs.Notifications) - - // create a new notifications to fill the database with data - for i := range 20 { - dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ - ID: uuid.New(), - UserID: member.ID, - TemplateID: notifications.TemplateWorkspaceOutOfMemory, - Title: fmt.Sprintf("Notification %d", i), - Actions: json.RawMessage("[]"), - Content: fmt.Sprintf("Content of the notif %d", i), - CreatedAt: dbtime.Now(), + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + 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() + + notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{}) + require.NoError(t, err) + require.NotNil(t, notifs) + require.Equal(t, 0, notifs.UnreadCount) + require.Empty(t, notifs.Notifications) + + // create a new notifications to fill the database with data + for i := range 20 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + TemplateID: notifications.TemplateWorkspaceOutOfMemory, + Title: fmt.Sprintf("Notification %d", i), + Actions: json.RawMessage("[]"), + Content: fmt.Sprintf("Content of the notif %d", i), + CreatedAt: dbtime.Now(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Templates: tt.listTemplate, + Targets: tt.listTarget, + ReadStatus: tt.listReadStatus, + StartingBefore: tt.listStartingBefore, }) - } - - notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ - Templates: tt.listTemplate, - Targets: tt.listTarget, - ReadStatus: tt.listReadStatus, - StartingBefore: tt.listStartingBefore, + require.ErrorContains(t, err, tt.expectedError) + require.Empty(t, notifs.Notifications) + require.Zero(t, notifs.UnreadCount) }) - require.ErrorContains(t, err, tt.expectedError) - require.Empty(t, notifs.Notifications) - require.Zero(t, notifs.UnreadCount) - }) - } + } + }) t.Run("OK empty", func(t *testing.T) { t.Parallel() From 4d0a5619dd46f5300efd6fe292d9742649949ff0 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Mon, 17 Mar 2025 22:26:50 +0000 Subject: [PATCH 15/18] fix tests --- coderd/inboxnotifications_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 9f87b6472e456..08d37a178b46d 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -143,7 +143,8 @@ func TestInboxNotification_Watch(t *testing.T) { db, ps := dbtestutil.NewDB(t) firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Pubsub: ps, + Pubsub: ps, + Database: db, }) firstUser := coderdtest.CreateFirstUser(t, firstClient) member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin()) @@ -318,7 +319,6 @@ func TestInboxNotifications_List(t *testing.T) { {"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""}, {"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""}, {"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"}, - {"nok - not found starting before", `Failed to get notification by id`, "", "", "", failingPaginationUUID.String()}, } for _, tt := range tests { From 1f188680e6efa06c0aeb342c94f9f986898f9cd3 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Mon, 17 Mar 2025 22:31:17 +0000 Subject: [PATCH 16/18] fix ci lint --- coderd/inboxnotifications_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 08d37a178b46d..a9913ea51d9b4 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -113,6 +113,7 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") + defer resp.Body.Close() inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ @@ -164,6 +165,7 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") + defer resp.Body.Close() inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ @@ -244,6 +246,7 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") + defer resp.Body.Close() inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ From 4f01a86f0112cf26352ce2736cf3447fb942fb01 Mon Sep 17 00:00:00 2001 From: defelmnq Date: Mon, 17 Mar 2025 22:33:09 +0000 Subject: [PATCH 17/18] fix ci lint --- coderd/inboxnotifications_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index a9913ea51d9b4..81e119381d281 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -101,6 +101,7 @@ func TestInboxNotification_Watch(t *testing.T) { u, err := member.URL.Parse("/api/v2/notifications/inbox/watch") require.NoError(t, err) + // nolint:bodyclose wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ "Coder-Session-Token": []string{member.SessionToken()}, @@ -113,7 +114,6 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") - defer resp.Body.Close() inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ @@ -153,6 +153,7 @@ func TestInboxNotification_Watch(t *testing.T) { u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?templates=%v", notifications.TemplateWorkspaceOutOfMemory)) require.NoError(t, err) + // nolint:bodyclose wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ "Coder-Session-Token": []string{member.SessionToken()}, @@ -165,7 +166,6 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") - defer resp.Body.Close() inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ @@ -234,6 +234,7 @@ func TestInboxNotification_Watch(t *testing.T) { u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?targets=%v", correctTarget.String())) require.NoError(t, err) + // nolint:bodyclose wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ HTTPHeader: http.Header{ "Coder-Session-Token": []string{member.SessionToken()}, @@ -246,7 +247,6 @@ func TestInboxNotification_Watch(t *testing.T) { require.NoError(t, err) } defer wsConn.Close(websocket.StatusNormalClosure, "done") - defer resp.Body.Close() inboxHandler := dispatch.NewInboxHandler(logger, db, ps) dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{ From cf6af1b3fb1732506d4c942c71db6b2d50bc621d Mon Sep 17 00:00:00 2001 From: defelmnq Date: Mon, 17 Mar 2025 22:48:01 +0000 Subject: [PATCH 18/18] fix ci lint --- coderd/inboxnotifications.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index bfee79cfe6cb7..5437165bb71a6 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -153,7 +153,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) select { case notificationCh <- payload.InboxNotification: default: - api.Logger.Error(ctx, "Failed to push consumed notification into websocket handler, check latency") + api.Logger.Error(ctx, "failed to push consumed notification into websocket handler, check latency") } }, )) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy