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..8dbff0fca8274 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": { + "200": { + "description": "OK", + "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..3f58bf0d944fd 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": { + "200": { + "description": "OK", + "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..60f13aa0fa050 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3296,34 +3296,52 @@ func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, a defer q.mutex.RUnlock() notifications := make([]database.InboxNotification, 0) - for _, notification := range q.inboxNotifications { + // 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] + 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 +8241,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..ff135aaa8f14e 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 + ($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) ORDER BY created_at DESC diff --git a/coderd/database/queries/notificationsinbox.sql b/coderd/database/queries/notificationsinbox.sql index cdaf1cf78cb7f..43ab63ae83652 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 + (@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) ORDER BY created_at DESC diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go new file mode 100644 index 0000000000000..5437165bb71a6 --- /dev/null +++ b/coderd/inboxnotifications.go @@ -0,0 +1,347 @@ +package coderd + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "slices" + "time" + + "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" +) + +// 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) { + p := httpapi.NewQueryParamParser() + vals := r.URL.Query() + + var ( + ctx = r.Context() + apikey = httpmw.APIKey(r) + + 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 + } + + if !slices.Contains([]string{ + string(database.InboxNotificationReadStatusAll), + string(database.InboxNotificationReadStatusRead), + string(database.InboxNotificationReadStatusUnread), + }, readStatus) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.", + }) + 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, 10) + + 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 + } + + // 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 { + 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 readStatus != "" { + if readStatus == string(database.InboxNotificationReadStatusRead) { + if payload.InboxNotification.ReadAt == nil { + return + } + } else if readStatus == string(database.InboxNotificationReadStatusUnread) { + if payload.InboxNotification.ReadAt != nil { + return + } + } + } + + // keep a safe guard in case of latency to push notifications through websocket + select { + case notificationCh <- payload.InboxNotification: + default: + api.Logger.Error(ctx, "failed to push consumed notification into websocket handler, check latency") + } + }, + )) + 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, "failed to 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) { + p := httpapi.NewQueryParamParser() + vals := r.URL.Query() + + var ( + ctx = r.Context() + apikey = httpmw.APIKey(r) + + 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") + ) + 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 + } + + if !slices.Contains([]string{ + string(database.InboxNotificationReadStatusAll), + string(database.InboxNotificationReadStatusRead), + string(database.InboxNotificationReadStatusUnread), + }, readStatus) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.", + }) + return + } + + createdBefore := dbtime.Now() + if startingBefore != uuid.Nil { + lastNotif, err := api.Database.GetInboxNotificationByID(ctx, startingBefore) + if err == nil { + createdBefore = lastNotif.CreatedAt + } + } + + notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{ + UserID: apikey.UserID, + Templates: templates, + Targets: targets, + ReadStatus: database.InboxNotificationReadStatus(readStatus), + CreatedAtOpt: createdBefore, + }) + if err != nil { + 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 filtered inbox notifications.", + }) + return + } + + unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) + if err != nil { + 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.", + }) + 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, convertInboxNotificationResponse(ctx, api.Logger, notification)) + } + 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 200 {object} codersdk.Response +// @Router /notifications/inbox/{id}/read-status [put] +func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apikey = httpmw.APIKey(r) + ) + + notificationID, ok := httpmw.ParseUUIDParam(rw, r, "id") + if !ok { + return + } + + var body codersdk.UpdateInboxNotificationReadStatusRequest + if !httpapi.Read(ctx, rw, r, &body) { + return + } + + err := api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{ + ID: notificationID, + 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, "failed to update inbox notification read status", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update inbox notification read status.", + }) + return + } + + unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) + if err != nil { + 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 call count unread inbox notifications.", + }) + return + } + + 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{ + Message: "Failed to get notification by id.", + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateInboxNotificationReadStatusResponse{ + Notification: convertInboxNotificationResponse(ctx, api.Logger, updatedNotification), + UnreadCount: int(unreadCount), + }) +} diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go new file mode 100644 index 0000000000000..81e119381d281 --- /dev/null +++ b/coderd/inboxnotifications_test.go @@ -0,0 +1,725 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "runtime" + "testing" + + "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/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() + + // 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("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() + + 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() + + 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("/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") + + 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) + + 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) + }) + + 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()) + + _, 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(), + }, "disk related title", "disk 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(), + }, "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) + + 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()) + + _, 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(), + 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) + + 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) + }) +} + +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("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"}, + } + + 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() + + 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() + + 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) + 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 40 { + 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, 40, notifs.UnreadCount) + 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[inboxNotificationsPageSize-1].ID.String(), + }) + 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) + 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 10 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + 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(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Templates: notifications.TemplateWorkspaceOutOfMemory.String(), + }) + 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) + 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) + + filteredTarget := uuid.New() + + for i := range 10 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + 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(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Targets: filteredTarget.String(), + }) + 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) + 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) + + filteredTarget := uuid.New() + + for i := range 10 { + dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{ + ID: uuid.New(), + UserID: member.ID, + 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(), + }) + } + + notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{ + Targets: filteredTarget.String(), + Templates: notifications.TemplateWorkspaceOutOfDisk.String(), + }) + 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() + + // 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", 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) + }) + + 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) + + 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) + }) +} 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..eb3a3ea01938f 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) { 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..5f7eafda0f8d2 --- /dev/null +++ b/coderd/pubsub/inboxnotification.go @@ -0,0 +1,43 @@ +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" +) diff --git a/codersdk/inboxnotification.go b/codersdk/inboxnotification.go new file mode 100644 index 0000000000000..845140ea658c7 --- /dev/null +++ b/codersdk/inboxnotification.go @@ -0,0 +1,111 @@ +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"` +} + +type ListInboxNotificationsRequest struct { + 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 { + Notifications []InboxNotification `json:"notifications"` + UnreadCount int `json:"unread_count"` +} + +func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsRequest) []RequestOption { + var opts []RequestOption + if req.Targets != "" { + opts = append(opts, WithQueryParam("targets", req.Targets)) + } + if req.Templates != "" { + opts = append(opts, WithQueryParam("templates", req.Templates)) + } + if req.ReadStatus != "" { + opts = append(opts, WithQueryParam("read_status", req.ReadStatus)) + } + if req.StartingBefore != "" { + opts = append(opts, WithQueryParam("starting_before", req.StartingBefore)) + } + + 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 string, 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..9a181cc1d69c5 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 + +> 200 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| 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). + ## 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..236e48e093265 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?: string; + readonly templates?: string; + readonly read_status?: string; + readonly starting_before?: 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;