feat(coderd): add inbox notifications endpoints (#16889)

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

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

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

- fix DB query issues
- fix DBMem logic to match DB
This commit is contained in:
Vincent Vielle
2025-03-18 00:02:47 +01:00
committed by GitHub
parent e85c92e7d5
commit 3ae55bbbf4
20 changed files with 2093 additions and 65 deletions
+1 -1
View File
@@ -934,7 +934,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// The notification manager is responsible for: // The notification manager is responsible for:
// - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications) // - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications)
// - keeping the store updated with status updates // - 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 { if err != nil {
return xerrors.Errorf("failed to instantiate notification manager: %w", err) return xerrors.Errorf("failed to instantiate notification manager: %w", err)
} }
+206
View File
@@ -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": { "/notifications/settings": {
"get": { "get": {
"security": [ "security": [
@@ -11890,6 +12014,17 @@ const docTemplate = `{
} }
} }
}, },
"codersdk.GetInboxNotificationResponse": {
"type": "object",
"properties": {
"notification": {
"$ref": "#/definitions/codersdk.InboxNotification"
},
"unread_count": {
"type": "integer"
}
}
},
"codersdk.GetUserStatusCountsResponse": { "codersdk.GetUserStatusCountsResponse": {
"type": "object", "type": "object",
"properties": { "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": { "codersdk.InsightsReportInterval": {
"type": "string", "type": "string",
"enum": [ "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": { "codersdk.LogLevel": {
"type": "string", "type": "string",
"enum": [ "enum": [
+194
View File
@@ -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": { "/notifications/settings": {
"get": { "get": {
"security": [ "security": [
@@ -10667,6 +10779,17 @@
} }
} }
}, },
"codersdk.GetInboxNotificationResponse": {
"type": "object",
"properties": {
"notification": {
"$ref": "#/definitions/codersdk.InboxNotification"
},
"unread_count": {
"type": "integer"
}
}
},
"codersdk.GetUserStatusCountsResponse": { "codersdk.GetUserStatusCountsResponse": {
"type": "object", "type": "object",
"properties": { "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": { "codersdk.InsightsReportInterval": {
"type": "string", "type": "string",
"enum": ["day", "week"], "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": { "codersdk.LogLevel": {
"type": "string", "type": "string",
"enum": ["trace", "debug", "info", "warn", "error"], "enum": ["trace", "debug", "info", "warn", "error"],
+5
View File
@@ -1387,6 +1387,11 @@ func New(options *Options) *API {
}) })
r.Route("/notifications", func(r chi.Router) { r.Route("/notifications", func(r chi.Router) {
r.Use(apiKeyMiddleware) 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.Get("/settings", api.notificationsSettings)
r.Put("/settings", api.putNotificationsSettings) r.Put("/settings", api.putNotificationsSettings)
r.Route("/templates", func(r chi.Router) { r.Route("/templates", func(r chi.Router) {
+31 -13
View File
@@ -3296,34 +3296,52 @@ func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, a
defer q.mutex.RUnlock() defer q.mutex.RUnlock()
notifications := make([]database.InboxNotification, 0) 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 notification.UserID == arg.UserID {
if !arg.CreatedAtOpt.IsZero() && !notification.CreatedAt.Before(arg.CreatedAtOpt) {
continue
}
templateFound := false
for _, template := range arg.Templates { for _, template := range arg.Templates {
templateFound := false
if notification.TemplateID == template { if notification.TemplateID == template {
templateFound = true templateFound = true
} }
if !templateFound {
continue
}
} }
if len(arg.Templates) > 0 && !templateFound {
continue
}
targetsFound := true
for _, target := range arg.Targets { for _, target := range arg.Targets {
isFound := false targetFound := false
for _, insertedTarget := range notification.Targets { for _, insertedTarget := range notification.Targets {
if insertedTarget == target { if insertedTarget == target {
isFound = true targetFound = true
break break
} }
} }
if !isFound { if !targetFound {
continue 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, Content: arg.Content,
Icon: arg.Icon, Icon: arg.Icon,
Actions: arg.Actions, Actions: arg.Actions,
CreatedAt: time.Now(), CreatedAt: arg.CreatedAt,
} }
q.inboxNotifications = append(q.inboxNotifications, notification) q.inboxNotifications = append(q.inboxNotifications, notification)
+2 -2
View File
@@ -4310,8 +4310,8 @@ func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context,
const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE
user_id = $1 AND user_id = $1 AND
template_id = ANY($2::UUID[]) AND ($2::UUID[] IS NULL OR template_id = ANY($2::UUID[])) AND
targets @> COALESCE($3, ARRAY[]::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 ($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) ($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ)
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -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 -- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
SELECT * FROM inbox_notifications WHERE SELECT * FROM inbox_notifications WHERE
user_id = @user_id AND user_id = @user_id AND
template_id = ANY(@templates::UUID[]) AND (@templates::UUID[] IS NULL OR template_id = ANY(@templates::UUID[])) AND
targets @> COALESCE(@targets, ARRAY[]::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 (@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) (@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
ORDER BY created_at DESC ORDER BY created_at DESC
+347
View File
@@ -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 &notif.ReadAt.Time
}(),
CreatedAt: notif.CreatedAt,
}
}
// watchInboxNotifications watches for new inbox notifications and sends them to the client.
// The client can specify a list of target IDs to filter the notifications.
// @Summary Watch for new inbox notifications
// @ID watch-for-new-inbox-notifications
// @Security CoderSessionToken
// @Produce json
// @Tags Notifications
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
// @Success 200 {object} codersdk.GetInboxNotificationResponse
// @Router /notifications/inbox/watch [get]
func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) {
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),
})
}
+725
View File
@@ -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, &notif)
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, &notif)
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, &notif)
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, &notif)
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, &notif)
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)
})
}
+41 -5
View File
@@ -13,8 +13,11 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/coderd/notifications/types"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
markdown "github.com/coder/coder/v2/coderd/render" markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/codersdk"
) )
type InboxStore interface { type InboxStore interface {
@@ -23,12 +26,13 @@ type InboxStore interface {
// InboxHandler is responsible for dispatching notification messages to the Coder Inbox. // InboxHandler is responsible for dispatching notification messages to the Coder Inbox.
type InboxHandler struct { type InboxHandler struct {
log slog.Logger log slog.Logger
store InboxStore store InboxStore
pubsub pubsub.Pubsub
} }
func NewInboxHandler(log slog.Logger, store InboxStore) *InboxHandler { func NewInboxHandler(log slog.Logger, store InboxStore, ps pubsub.Pubsub) *InboxHandler {
return &InboxHandler{log: log, store: store} return &InboxHandler{log: log, store: store, pubsub: ps}
} }
func (s *InboxHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string, _ template.FuncMap) (DeliveryFunc, error) { 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 // nolint:exhaustruct
_, err = s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{ insertedNotif, err := s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{
ID: msgID, ID: msgID,
UserID: userID, UserID: userID,
TemplateID: templateID, 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) 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 return false, nil
} }
} }
+2 -2
View File
@@ -73,7 +73,7 @@ func TestInbox(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
db, _ := dbtestutil.NewDB(t) db, pubsub := dbtestutil.NewDB(t)
if tc.payload.UserID == "valid" { if tc.payload.UserID == "valid" {
user := dbgen.User(t, db, database.User{}) user := dbgen.User(t, db, database.User{})
@@ -82,7 +82,7 @@ func TestInbox(t *testing.T) {
ctx := context.Background() 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) dispatcherFunc, err := handler.Dispatcher(tc.payload, "", "", nil)
require.NoError(t, err) require.NoError(t, err)
+5 -5
View File
@@ -14,6 +14,7 @@ import (
"github.com/coder/quartz" "github.com/coder/quartz"
"github.com/coder/coder/v2/coderd/database" "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/coderd/notifications/dispatch"
"github.com/coder/coder/v2/codersdk" "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 // helpers is a map of template helpers which are used to customize notification messages to use global settings like
// access URL etc. // access URL etc.
func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template.FuncMap, metrics *Metrics, log slog.Logger, opts ...ManagerOption) (*Manager, error) { 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 var method database.NotificationMethod
if err := method.Scan(cfg.Method.String()); err != nil { if err := method.Scan(cfg.Method.String()); err != nil {
return nil, xerrors.Errorf("notification method %q is invalid", cfg.Method) 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), stop: make(chan any),
done: make(chan any), done: make(chan any),
handlers: defaultHandlers(cfg, log, store), handlers: defaultHandlers(cfg, log, store, ps),
helpers: helpers, helpers: helpers,
clock: quartz.NewReal(), 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. // 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{ return map[database.NotificationMethod]Handler{
database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")), database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")),
database.NotificationMethodWebhook: dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")), 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),
} }
} }
+4 -4
View File
@@ -33,7 +33,7 @@ func TestBufferedUpdates(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, ps := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
interceptor := &syncInterceptor{Store: store} 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. 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 // 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) require.NoError(t, err)
handlers := map[database.NotificationMethod]notifications.Handler{ handlers := map[database.NotificationMethod]notifications.Handler{
@@ -168,11 +168,11 @@ func TestStopBeforeRun(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, ps := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
// GIVEN: a standard manager // 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) require.NoError(t, err)
// THEN: validate that the manager can be stopped safely without Run() having been called yet // THEN: validate that the manager can be stopped safely without Run() having been called yet
+8 -8
View File
@@ -39,7 +39,7 @@ func TestMetrics(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()
@@ -60,7 +60,7 @@ func TestMetrics(t *testing.T) {
cfg.RetryInterval = serpent.Duration(time.Millisecond * 50) 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. 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) assert.NoError(t, mgr.Stop(ctx))
@@ -228,7 +228,7 @@ func TestPendingUpdatesMetric(t *testing.T) {
// SETUP // SETUP
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()
@@ -250,7 +250,7 @@ func TestPendingUpdatesMetric(t *testing.T) {
defer trap.Close() defer trap.Close()
fetchTrap := mClock.Trap().TickerFunc("notifier", "fetchInterval") fetchTrap := mClock.Trap().TickerFunc("notifier", "fetchInterval")
defer fetchTrap.Close() 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)) notifications.WithTestClock(mClock))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
@@ -322,7 +322,7 @@ func TestInflightDispatchesMetric(t *testing.T) {
// SETUP // SETUP
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
reg := prometheus.NewRegistry() 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.RetryInterval = serpent.Duration(time.Hour) // Delay retries so they don't interfere.
cfg.StoreSyncInterval = serpent.Duration(time.Millisecond * 100) 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) assert.NoError(t, mgr.Stop(ctx))
@@ -402,7 +402,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
var ( var (
@@ -427,7 +427,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
// WHEN: two notifications (each with different templates) are enqueued. // WHEN: two notifications (each with different templates) are enqueued.
cfg := defaultNotificationsConfig(defaultMethod) 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) assert.NoError(t, mgr.Stop(ctx))
+28 -23
View File
@@ -71,7 +71,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
method := database.NotificationMethodSmtp method := database.NotificationMethodSmtp
@@ -80,7 +80,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) {
interceptor := &syncInterceptor{Store: store} interceptor := &syncInterceptor{Store: store}
cfg := defaultNotificationsConfig(method) cfg := defaultNotificationsConfig(method)
cfg.RetryInterval = serpent.Duration(time.Hour) // Ensure retries don't interfere with the test 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) require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
method: handler, method: handler,
@@ -138,7 +138,7 @@ func TestSMTPDispatch(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
// start mock SMTP server // start mock SMTP server
@@ -161,7 +161,7 @@ func TestSMTPDispatch(t *testing.T) {
Hello: "localhost", Hello: "localhost",
} }
handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger.Named("smtp"))) 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) require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
method: handler, method: handler,
@@ -204,7 +204,7 @@ func TestWebhookDispatch(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
sent := make(chan dispatch.WebhookPayload, 1) sent := make(chan dispatch.WebhookPayload, 1)
@@ -230,7 +230,7 @@ func TestWebhookDispatch(t *testing.T) {
cfg.Webhook = codersdk.NotificationsWebhookConfig{ cfg.Webhook = codersdk.NotificationsWebhookConfig{
Endpoint: *serpent.URLOf(endpoint), 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) 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") 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) logger := testutil.Logger(t)
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitShort)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitShort))
@@ -319,7 +319,7 @@ func TestBackpressure(t *testing.T) {
defer fetchTrap.Close() defer fetchTrap.Close()
// GIVEN: a notification manager whose updates will be intercepted // 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)) logger.Named("manager"), notifications.WithTestClock(mClock))
require.NoError(t, err) require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
@@ -417,7 +417,7 @@ func TestRetries(t *testing.T) {
const maxAttempts = 3 const maxAttempts = 3
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
// GIVEN: a mock HTTP server which will receive webhooksand a map to track the dispatch attempts // 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. // Intercept calls to submit the buffered updates to the store.
storeInterceptor := &syncInterceptor{Store: 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) assert.NoError(t, mgr.Stop(ctx))
@@ -517,7 +517,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
// GIVEN: a manager which has its updates intercepted and paused until measurements can be taken // 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())) mgrCtx, cancelManagerCtx := context.WithCancel(dbauthz.AsNotifier(context.Background()))
t.Cleanup(cancelManagerCtx) 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) require.NoError(t, err)
enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal()) enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal())
require.NoError(t, err) require.NoError(t, err)
@@ -588,7 +588,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
// Intercept calls to submit the buffered updates to the store. // Intercept calls to submit the buffered updates to the store.
storeInterceptor := &syncInterceptor{Store: store} storeInterceptor := &syncInterceptor{Store: store}
handler := newDispatchInterceptor(&fakeHandler{}) 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) require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
method: handler, method: handler,
@@ -620,7 +620,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
func TestInvalidConfig(t *testing.T) { func TestInvalidConfig(t *testing.T) {
t.Parallel() t.Parallel()
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
// GIVEN: invalid config with dispatch period <= lease period // GIVEN: invalid config with dispatch period <= lease period
@@ -633,7 +633,7 @@ func TestInvalidConfig(t *testing.T) {
cfg.DispatchTimeout = serpent.Duration(leasePeriod) cfg.DispatchTimeout = serpent.Duration(leasePeriod)
// WHEN: the manager is created with invalid config // 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 // THEN: the manager will fail to be created, citing invalid config as error
require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout) require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout)
@@ -646,7 +646,7 @@ func TestNotifierPaused(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
// Prepare the test. // Prepare the test.
@@ -657,7 +657,7 @@ func TestNotifierPaused(t *testing.T) {
const fetchInterval = time.Millisecond * 100 const fetchInterval = time.Millisecond * 100
cfg := defaultNotificationsConfig(method) cfg := defaultNotificationsConfig(method)
cfg.FetchInterval = serpent.Duration(fetchInterval) 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) require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
method: handler, method: handler,
@@ -1229,6 +1229,8 @@ func TestNotificationTemplates_Golden(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
_, pubsub := dbtestutil.NewDB(t)
// smtp config shared between client and server // smtp config shared between client and server
smtpConfig := codersdk.NotificationsEmailConfig{ smtpConfig := codersdk.NotificationsEmailConfig{
Hello: hello, Hello: hello,
@@ -1296,6 +1298,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
smtpManager, err := notifications.NewManager( smtpManager, err := notifications.NewManager(
smtpCfg, smtpCfg,
*db, *db,
pubsub,
defaultHelpers(), defaultHelpers(),
createMetrics(), createMetrics(),
logger.Named("manager"), logger.Named("manager"),
@@ -1410,6 +1413,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
return &db, &api.Logger, &user return &db, &api.Logger, &user
}() }()
_, pubsub := dbtestutil.NewDB(t)
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
@@ -1437,6 +1441,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
webhookManager, err := notifications.NewManager( webhookManager, err := notifications.NewManager(
webhookCfg, webhookCfg,
*db, *db,
pubsub,
defaultHelpers(), defaultHelpers(),
createMetrics(), createMetrics(),
logger.Named("manager"), logger.Named("manager"),
@@ -1613,13 +1618,13 @@ func TestDisabledAfterEnqueue(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
method := database.NotificationMethodSmtp method := database.NotificationMethodSmtp
cfg := defaultNotificationsConfig(method) 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) assert.NoError(t, mgr.Stop(ctx))
@@ -1670,7 +1675,7 @@ func TestCustomNotificationMethod(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
received := make(chan uuid.UUID, 1) received := make(chan uuid.UUID, 1)
@@ -1728,7 +1733,7 @@ func TestCustomNotificationMethod(t *testing.T) {
Endpoint: *serpent.URLOf(endpoint), 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
_ = mgr.Stop(ctx) _ = mgr.Stop(ctx)
@@ -1811,13 +1816,13 @@ func TestNotificationDuplicates(t *testing.T) {
// nolint:gocritic // Unit test. // nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t) store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t) logger := testutil.Logger(t)
method := database.NotificationMethodSmtp method := database.NotificationMethodSmtp
cfg := defaultNotificationsConfig(method) 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) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx)) assert.NoError(t, mgr.Stop(ctx))
+43
View File
@@ -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"
)
+111
View File
@@ -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)
}
+162
View File
@@ -46,6 +46,168 @@ Status Code **200**
To perform this operation, you must be authenticated. [Learn more](authentication.md). 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 ## Get notifications settings
### Code samples ### Code samples
+125
View File
@@ -3016,6 +3016,40 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|-------|--------|----------|--------------|-------------| |-------|--------|----------|--------------|-------------|
| `key` | string | false | | | | `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 ## codersdk.GetUserStatusCountsResponse
```json ```json
@@ -3251,6 +3285,61 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `refresh` | integer | false | | | | `refresh` | integer | false | | |
| `threshold_database` | 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 ## codersdk.InsightsReportInterval
```json ```json
@@ -3380,6 +3469,42 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `icon` | `chat` | | `icon` | `chat` |
| `icon` | `docs` | | `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 ## codersdk.LogLevel
```json ```json
+51
View File
@@ -892,6 +892,12 @@ export interface GenerateAPIKeyResponse {
readonly key: string; readonly key: string;
} }
// From codersdk/inboxnotification.go
export interface GetInboxNotificationResponse {
readonly notification: InboxNotification;
readonly unread_count: number;
}
// From codersdk/insights.go // From codersdk/insights.go
export interface GetUserStatusCountsRequest { export interface GetUserStatusCountsRequest {
readonly offset: string; readonly offset: string;
@@ -1076,6 +1082,26 @@ export interface IDPSyncMapping<ResourceIdType extends string | string> {
readonly Gets: ResourceIdType; 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 // From codersdk/insights.go
export type InsightsReportInterval = "day" | "week"; export type InsightsReportInterval = "day" | "week";
@@ -1133,6 +1159,20 @@ export interface LinkConfig {
readonly icon: string; 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 // From codersdk/externalauth.go
export interface ListUserExternalAuthResponse { export interface ListUserExternalAuthResponse {
readonly providers: readonly ExternalAuthLinkProvider[]; readonly providers: readonly ExternalAuthLinkProvider[];
@@ -2653,6 +2693,17 @@ export interface UpdateHealthSettings {
readonly dismissed_healthchecks: readonly HealthSection[]; 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 // From codersdk/notifications.go
export interface UpdateNotificationTemplateMethod { export interface UpdateNotificationTemplateMethod {
readonly method?: string; readonly method?: string;