mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+1
-1
@@ -934,7 +934,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
// The notification manager is responsible for:
|
||||
// - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications)
|
||||
// - keeping the store updated with status updates
|
||||
notificationsManager, err = notifications.NewManager(notificationsCfg, options.Database, helpers, metrics, logger.Named("notifications.manager"))
|
||||
notificationsManager, err = notifications.NewManager(notificationsCfg, options.Database, options.Pubsub, helpers, metrics, logger.Named("notifications.manager"))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to instantiate notification manager: %w", err)
|
||||
}
|
||||
|
||||
Generated
+206
@@ -1660,6 +1660,130 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/inbox": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "List inbox notifications",
|
||||
"operationId": "list-inbox-notifications",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of target IDs to filter notifications",
|
||||
"name": "targets",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of template IDs to filter notifications",
|
||||
"name": "templates",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter notifications by read status. Possible values: read, unread, all",
|
||||
"name": "read_status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ListInboxNotificationsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/inbox/watch": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Watch for new inbox notifications",
|
||||
"operationId": "watch-for-new-inbox-notifications",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of target IDs to filter notifications",
|
||||
"name": "targets",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of template IDs to filter notifications",
|
||||
"name": "templates",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter notifications by read status. Possible values: read, unread, all",
|
||||
"name": "read_status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.GetInboxNotificationResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/inbox/{id}/read-status": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Update read status of a notification",
|
||||
"operationId": "update-read-status-of-a-notification",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "id of the notification",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/settings": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -11890,6 +12014,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GetInboxNotificationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notification": {
|
||||
"$ref": "#/definitions/codersdk.InboxNotification"
|
||||
},
|
||||
"unread_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GetUserStatusCountsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12071,6 +12206,63 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InboxNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.InboxNotificationAction"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"read_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"template_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InboxNotificationAction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InsightsReportInterval": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -12181,6 +12373,20 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ListInboxNotificationsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notifications": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.InboxNotification"
|
||||
}
|
||||
},
|
||||
"unread_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.LogLevel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
Generated
+194
@@ -1445,6 +1445,118 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/inbox": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Notifications"],
|
||||
"summary": "List inbox notifications",
|
||||
"operationId": "list-inbox-notifications",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of target IDs to filter notifications",
|
||||
"name": "targets",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of template IDs to filter notifications",
|
||||
"name": "templates",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter notifications by read status. Possible values: read, unread, all",
|
||||
"name": "read_status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ListInboxNotificationsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/inbox/watch": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Notifications"],
|
||||
"summary": "Watch for new inbox notifications",
|
||||
"operationId": "watch-for-new-inbox-notifications",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of target IDs to filter notifications",
|
||||
"name": "targets",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of template IDs to filter notifications",
|
||||
"name": "templates",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter notifications by read status. Possible values: read, unread, all",
|
||||
"name": "read_status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.GetInboxNotificationResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/inbox/{id}/read-status": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Notifications"],
|
||||
"summary": "Update read status of a notification",
|
||||
"operationId": "update-read-status-of-a-notification",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "id of the notification",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notifications/settings": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -10667,6 +10779,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GetInboxNotificationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notification": {
|
||||
"$ref": "#/definitions/codersdk.InboxNotification"
|
||||
},
|
||||
"unread_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GetUserStatusCountsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10842,6 +10965,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InboxNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.InboxNotificationAction"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"read_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"template_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InboxNotificationAction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InsightsReportInterval": {
|
||||
"type": "string",
|
||||
"enum": ["day", "week"],
|
||||
@@ -10938,6 +11118,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ListInboxNotificationsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notifications": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.InboxNotification"
|
||||
}
|
||||
},
|
||||
"unread_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.LogLevel": {
|
||||
"type": "string",
|
||||
"enum": ["trace", "debug", "info", "warn", "error"],
|
||||
|
||||
@@ -1387,6 +1387,11 @@ func New(options *Options) *API {
|
||||
})
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Route("/inbox", func(r chi.Router) {
|
||||
r.Get("/", api.listInboxNotifications)
|
||||
r.Get("/watch", api.watchInboxNotifications)
|
||||
r.Put("/{id}/read-status", api.updateInboxNotificationReadStatus)
|
||||
})
|
||||
r.Get("/settings", api.notificationsSettings)
|
||||
r.Put("/settings", api.putNotificationsSettings)
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
|
||||
@@ -3296,34 +3296,52 @@ func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, a
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
notifications := make([]database.InboxNotification, 0)
|
||||
for _, notification := range q.inboxNotifications {
|
||||
// TODO : after using go version >= 1.23 , we can change this one to https://pkg.go.dev/slices#Backward
|
||||
for idx := len(q.inboxNotifications) - 1; idx >= 0; idx-- {
|
||||
notification := q.inboxNotifications[idx]
|
||||
|
||||
if notification.UserID == arg.UserID {
|
||||
if !arg.CreatedAtOpt.IsZero() && !notification.CreatedAt.Before(arg.CreatedAtOpt) {
|
||||
continue
|
||||
}
|
||||
|
||||
templateFound := false
|
||||
for _, template := range arg.Templates {
|
||||
templateFound := false
|
||||
if notification.TemplateID == template {
|
||||
templateFound = true
|
||||
}
|
||||
|
||||
if !templateFound {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(arg.Templates) > 0 && !templateFound {
|
||||
continue
|
||||
}
|
||||
|
||||
targetsFound := true
|
||||
for _, target := range arg.Targets {
|
||||
isFound := false
|
||||
targetFound := false
|
||||
for _, insertedTarget := range notification.Targets {
|
||||
if insertedTarget == target {
|
||||
isFound = true
|
||||
targetFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isFound {
|
||||
continue
|
||||
if !targetFound {
|
||||
targetsFound = false
|
||||
break
|
||||
}
|
||||
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
|
||||
if !targetsFound {
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.LimitOpt == 0 && len(notifications) == 25) ||
|
||||
(arg.LimitOpt != 0 && len(notifications) == int(arg.LimitOpt)) {
|
||||
break
|
||||
}
|
||||
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8223,7 +8241,7 @@ func (q *FakeQuerier) InsertInboxNotification(_ context.Context, arg database.In
|
||||
Content: arg.Content,
|
||||
Icon: arg.Icon,
|
||||
Actions: arg.Actions,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: arg.CreatedAt,
|
||||
}
|
||||
|
||||
q.inboxNotifications = append(q.inboxNotifications, notification)
|
||||
|
||||
@@ -4310,8 +4310,8 @@ func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context,
|
||||
const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many
|
||||
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE
|
||||
user_id = $1 AND
|
||||
template_id = ANY($2::UUID[]) AND
|
||||
targets @> COALESCE($3, ARRAY[]::UUID[]) AND
|
||||
($2::UUID[] IS NULL OR template_id = ANY($2::UUID[])) AND
|
||||
($3::UUID[] IS NULL OR targets @> $3::UUID[]) AND
|
||||
($4::inbox_notification_read_status = 'all' OR ($4::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($4::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
|
||||
($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ)
|
||||
ORDER BY created_at DESC
|
||||
|
||||
@@ -21,8 +21,8 @@ SELECT * FROM inbox_notifications WHERE
|
||||
-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
SELECT * FROM inbox_notifications WHERE
|
||||
user_id = @user_id AND
|
||||
template_id = ANY(@templates::UUID[]) AND
|
||||
targets @> COALESCE(@targets, ARRAY[]::UUID[]) AND
|
||||
(@templates::UUID[] IS NULL OR template_id = ANY(@templates::UUID[])) AND
|
||||
(@targets::UUID[] IS NULL OR targets @> @targets::UUID[]) AND
|
||||
(@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
|
||||
(@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
|
||||
ORDER BY created_at DESC
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// convertInboxNotificationResponse works as a util function to transform a database.InboxNotification to codersdk.InboxNotification
|
||||
func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, notif database.InboxNotification) codersdk.InboxNotification {
|
||||
return codersdk.InboxNotification{
|
||||
ID: notif.ID,
|
||||
UserID: notif.UserID,
|
||||
TemplateID: notif.TemplateID,
|
||||
Targets: notif.Targets,
|
||||
Title: notif.Title,
|
||||
Content: notif.Content,
|
||||
Icon: notif.Icon,
|
||||
Actions: func() []codersdk.InboxNotificationAction {
|
||||
var actionsList []codersdk.InboxNotificationAction
|
||||
err := json.Unmarshal([]byte(notif.Actions), &actionsList)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "unmarshal inbox notification actions", slog.Error(err))
|
||||
}
|
||||
return actionsList
|
||||
}(),
|
||||
ReadAt: func() *time.Time {
|
||||
if !notif.ReadAt.Valid {
|
||||
return nil
|
||||
}
|
||||
return ¬if.ReadAt.Time
|
||||
}(),
|
||||
CreatedAt: notif.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// watchInboxNotifications watches for new inbox notifications and sends them to the client.
|
||||
// The client can specify a list of target IDs to filter the notifications.
|
||||
// @Summary Watch for new inbox notifications
|
||||
// @ID watch-for-new-inbox-notifications
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Notifications
|
||||
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
|
||||
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
|
||||
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
|
||||
// @Success 200 {object} codersdk.GetInboxNotificationResponse
|
||||
// @Router /notifications/inbox/watch [get]
|
||||
func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) {
|
||||
p := httpapi.NewQueryParamParser()
|
||||
vals := r.URL.Query()
|
||||
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apikey = httpmw.APIKey(r)
|
||||
|
||||
targets = p.UUIDs(vals, []uuid.UUID{}, "targets")
|
||||
templates = p.UUIDs(vals, []uuid.UUID{}, "templates")
|
||||
readStatus = p.String(vals, "all", "read_status")
|
||||
)
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameters have invalid values.",
|
||||
Validations: p.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !slices.Contains([]string{
|
||||
string(database.InboxNotificationReadStatusAll),
|
||||
string(database.InboxNotificationReadStatusRead),
|
||||
string(database.InboxNotificationReadStatusUnread),
|
||||
}, readStatus) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(rw, r, nil)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to upgrade connection to websocket.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
go httpapi.Heartbeat(ctx, conn)
|
||||
defer conn.Close(websocket.StatusNormalClosure, "connection closed")
|
||||
|
||||
notificationCh := make(chan codersdk.InboxNotification, 10)
|
||||
|
||||
closeInboxNotificationsSubscriber, err := api.Pubsub.SubscribeWithErr(pubsub.InboxNotificationForOwnerEventChannel(apikey.UserID),
|
||||
pubsub.HandleInboxNotificationEvent(
|
||||
func(ctx context.Context, payload pubsub.InboxNotificationEvent, err error) {
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "inbox notification event", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// HandleInboxNotificationEvent cb receives all the inbox notifications - without any filters excepted the user_id.
|
||||
// Based on query parameters defined above and filters defined by the client - we then filter out the
|
||||
// notifications we do not want to forward and discard it.
|
||||
|
||||
// filter out notifications that don't match the targets
|
||||
if len(targets) > 0 {
|
||||
for _, target := range targets {
|
||||
if isFound := slices.Contains(payload.InboxNotification.Targets, target); !isFound {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filter out notifications that don't match the templates
|
||||
if len(templates) > 0 {
|
||||
if isFound := slices.Contains(templates, payload.InboxNotification.TemplateID); !isFound {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// filter out notifications that don't match the read status
|
||||
if readStatus != "" {
|
||||
if readStatus == string(database.InboxNotificationReadStatusRead) {
|
||||
if payload.InboxNotification.ReadAt == nil {
|
||||
return
|
||||
}
|
||||
} else if readStatus == string(database.InboxNotificationReadStatusUnread) {
|
||||
if payload.InboxNotification.ReadAt != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keep a safe guard in case of latency to push notifications through websocket
|
||||
select {
|
||||
case notificationCh <- payload.InboxNotification:
|
||||
default:
|
||||
api.Logger.Error(ctx, "failed to push consumed notification into websocket handler, check latency")
|
||||
}
|
||||
},
|
||||
))
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "subscribe to inbox notification event", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
defer closeInboxNotificationsSubscriber()
|
||||
|
||||
encoder := wsjson.NewEncoder[codersdk.GetInboxNotificationResponse](conn, websocket.MessageText)
|
||||
defer encoder.Close(websocket.StatusNormalClosure)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case notif := <-notificationCh:
|
||||
unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err))
|
||||
return
|
||||
}
|
||||
if err := encoder.Encode(codersdk.GetInboxNotificationResponse{
|
||||
Notification: notif,
|
||||
UnreadCount: int(unreadCount),
|
||||
}); err != nil {
|
||||
api.Logger.Error(ctx, "encode notification", slog.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listInboxNotifications lists the notifications for the user.
|
||||
// @Summary List inbox notifications
|
||||
// @ID list-inbox-notifications
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Notifications
|
||||
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
|
||||
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
|
||||
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
|
||||
// @Success 200 {object} codersdk.ListInboxNotificationsResponse
|
||||
// @Router /notifications/inbox [get]
|
||||
func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) {
|
||||
p := httpapi.NewQueryParamParser()
|
||||
vals := r.URL.Query()
|
||||
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apikey = httpmw.APIKey(r)
|
||||
|
||||
targets = p.UUIDs(vals, nil, "targets")
|
||||
templates = p.UUIDs(vals, nil, "templates")
|
||||
readStatus = p.String(vals, "all", "read_status")
|
||||
startingBefore = p.UUID(vals, uuid.Nil, "starting_before")
|
||||
)
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameters have invalid values.",
|
||||
Validations: p.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !slices.Contains([]string{
|
||||
string(database.InboxNotificationReadStatusAll),
|
||||
string(database.InboxNotificationReadStatusRead),
|
||||
string(database.InboxNotificationReadStatusUnread),
|
||||
}, readStatus) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
createdBefore := dbtime.Now()
|
||||
if startingBefore != uuid.Nil {
|
||||
lastNotif, err := api.Database.GetInboxNotificationByID(ctx, startingBefore)
|
||||
if err == nil {
|
||||
createdBefore = lastNotif.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{
|
||||
UserID: apikey.UserID,
|
||||
Templates: templates,
|
||||
Targets: targets,
|
||||
ReadStatus: database.InboxNotificationReadStatus(readStatus),
|
||||
CreatedAtOpt: createdBefore,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to get filtered inbox notifications", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get filtered inbox notifications.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to count unread inbox notifications.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListInboxNotificationsResponse{
|
||||
Notifications: func() []codersdk.InboxNotification {
|
||||
notificationsList := make([]codersdk.InboxNotification, 0, len(notifs))
|
||||
for _, notification := range notifs {
|
||||
notificationsList = append(notificationsList, convertInboxNotificationResponse(ctx, api.Logger, notification))
|
||||
}
|
||||
return notificationsList
|
||||
}(),
|
||||
UnreadCount: int(unreadCount),
|
||||
})
|
||||
}
|
||||
|
||||
// updateInboxNotificationReadStatus changes the read status of a notification.
|
||||
// @Summary Update read status of a notification
|
||||
// @ID update-read-status-of-a-notification
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Notifications
|
||||
// @Param id path string true "id of the notification"
|
||||
// @Success 200 {object} codersdk.Response
|
||||
// @Router /notifications/inbox/{id}/read-status [put]
|
||||
func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apikey = httpmw.APIKey(r)
|
||||
)
|
||||
|
||||
notificationID, ok := httpmw.ParseUUIDParam(rw, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body codersdk.UpdateInboxNotificationReadStatusRequest
|
||||
if !httpapi.Read(ctx, rw, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
err := api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{
|
||||
ID: notificationID,
|
||||
ReadAt: func() sql.NullTime {
|
||||
if body.IsRead {
|
||||
return sql.NullTime{
|
||||
Time: dbtime.Now(),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
return sql.NullTime{}
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to update inbox notification read status", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update inbox notification read status.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to call count unread inbox notifications", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to call count unread inbox notifications.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedNotification, err := api.Database.GetInboxNotificationByID(ctx, notificationID)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to get notification by id", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get notification by id.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateInboxNotificationReadStatusResponse{
|
||||
Notification: convertInboxNotificationResponse(ctx, api.Logger, updatedNotification),
|
||||
UnreadCount: int(unreadCount),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/notifications/dispatch"
|
||||
"github.com/coder/coder/v2/coderd/notifications/types"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
inboxNotificationsPageSize = 25
|
||||
)
|
||||
|
||||
var failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16")
|
||||
|
||||
func TestInboxNotification_Watch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
|
||||
// For now the idea is that the runner takes too long to insert the entries, could be worth
|
||||
// investigating a manual Tx.
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("our runners are randomly taking too long to insert entries")
|
||||
}
|
||||
|
||||
t.Run("Failure Modes", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedError string
|
||||
listTemplate string
|
||||
listTarget string
|
||||
listReadStatus string
|
||||
listStartingBefore string
|
||||
}{
|
||||
{"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""},
|
||||
{"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""},
|
||||
{"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/notifications/inbox/watch", nil,
|
||||
codersdk.ListInboxNotificationsRequestToQueryParams(codersdk.ListInboxNotificationsRequest{
|
||||
Targets: tt.listTarget,
|
||||
Templates: tt.listTemplate,
|
||||
ReadStatus: tt.listReadStatus,
|
||||
StartingBefore: tt.listStartingBefore,
|
||||
})...)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = codersdk.ReadBodyAsError(resp)
|
||||
require.ErrorContains(t, err, tt.expectedError)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
|
||||
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Pubsub: ps,
|
||||
Database: db,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, firstClient)
|
||||
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
u, err := member.URL.Parse("/api/v2/notifications/inbox/watch")
|
||||
require.NoError(t, err)
|
||||
|
||||
// nolint:bodyclose
|
||||
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Coder-Session-Token": []string{member.SessionToken()},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
err = codersdk.ReadBodyAsError(resp)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
defer wsConn.Close(websocket.StatusNormalClosure, "done")
|
||||
|
||||
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
|
||||
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
}, "notification title", "notification content", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
_, message, err := wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var notif codersdk.GetInboxNotificationResponse
|
||||
err = json.Unmarshal(message, ¬if)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, notif.UnreadCount)
|
||||
require.Equal(t, memberClient.ID, notif.Notification.UserID)
|
||||
})
|
||||
|
||||
t.Run("OK - filters on templates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
|
||||
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Pubsub: ps,
|
||||
Database: db,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, firstClient)
|
||||
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?templates=%v", notifications.TemplateWorkspaceOutOfMemory))
|
||||
require.NoError(t, err)
|
||||
|
||||
// nolint:bodyclose
|
||||
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Coder-Session-Token": []string{member.SessionToken()},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
err = codersdk.ReadBodyAsError(resp)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
defer wsConn.Close(websocket.StatusNormalClosure, "done")
|
||||
|
||||
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
|
||||
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
}, "memory related title", "memory related content", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
_, message, err := wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var notif codersdk.GetInboxNotificationResponse
|
||||
err = json.Unmarshal(message, ¬if)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, notif.UnreadCount)
|
||||
require.Equal(t, memberClient.ID, notif.Notification.UserID)
|
||||
require.Equal(t, "memory related title", notif.Notification.Title)
|
||||
|
||||
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfDisk.String(),
|
||||
}, "disk related title", "disk related title", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
}, "second memory related title", "second memory related title", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
_, message, err = wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = json.Unmarshal(message, ¬if)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 3, notif.UnreadCount)
|
||||
require.Equal(t, memberClient.ID, notif.Notification.UserID)
|
||||
require.Equal(t, "second memory related title", notif.Notification.Title)
|
||||
})
|
||||
|
||||
t.Run("OK - filters on targets", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
|
||||
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Pubsub: ps,
|
||||
Database: db,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, firstClient)
|
||||
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
correctTarget := uuid.New()
|
||||
|
||||
u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?targets=%v", correctTarget.String()))
|
||||
require.NoError(t, err)
|
||||
|
||||
// nolint:bodyclose
|
||||
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Coder-Session-Token": []string{member.SessionToken()},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
err = codersdk.ReadBodyAsError(resp)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
defer wsConn.Close(websocket.StatusNormalClosure, "done")
|
||||
|
||||
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
|
||||
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
Targets: []uuid.UUID{correctTarget},
|
||||
}, "memory related title", "memory related content", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
_, message, err := wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var notif codersdk.GetInboxNotificationResponse
|
||||
err = json.Unmarshal(message, ¬if)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, notif.UnreadCount)
|
||||
require.Equal(t, memberClient.ID, notif.Notification.UserID)
|
||||
require.Equal(t, "memory related title", notif.Notification.Title)
|
||||
|
||||
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
Targets: []uuid.UUID{uuid.New()},
|
||||
}, "second memory related title", "second memory related title", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
|
||||
UserID: memberClient.ID.String(),
|
||||
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
Targets: []uuid.UUID{correctTarget},
|
||||
}, "another memory related title", "another memory related title", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dispatchFunc(ctx, uuid.New())
|
||||
|
||||
_, message, err = wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = json.Unmarshal(message, ¬if)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 3, notif.UnreadCount)
|
||||
require.Equal(t, memberClient.ID, notif.Notification.UserID)
|
||||
require.Equal(t, "another memory related title", notif.Notification.Title)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInboxNotifications_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
|
||||
// For now the idea is that the runner takes too long to insert the entries, could be worth
|
||||
// investigating a manual Tx.
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("our runners are randomly taking too long to insert entries")
|
||||
}
|
||||
|
||||
t.Run("Failure Modes", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedError string
|
||||
listTemplate string
|
||||
listTarget string
|
||||
listReadStatus string
|
||||
listStartingBefore string
|
||||
}{
|
||||
{"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""},
|
||||
{"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""},
|
||||
{"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""},
|
||||
{"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
// create a new notifications to fill the database with data
|
||||
for i := range 20 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
|
||||
Templates: tt.listTemplate,
|
||||
Targets: tt.listTarget,
|
||||
ReadStatus: tt.listReadStatus,
|
||||
StartingBefore: tt.listStartingBefore,
|
||||
})
|
||||
require.ErrorContains(t, err, tt.expectedError)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
require.Zero(t, notifs.UnreadCount)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OK empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
})
|
||||
|
||||
t.Run("OK with pagination", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
for i := range 40 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 40, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, inboxNotificationsPageSize)
|
||||
|
||||
require.Equal(t, "Notification 39", notifs.Notifications[0].Title)
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
|
||||
StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 40, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 15)
|
||||
|
||||
require.Equal(t, "Notification 14", notifs.Notifications[0].Title)
|
||||
})
|
||||
|
||||
t.Run("OK with template filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
for i := range 10 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: func() uuid.UUID {
|
||||
if i%2 == 0 {
|
||||
return notifications.TemplateWorkspaceOutOfMemory
|
||||
}
|
||||
|
||||
return notifications.TemplateWorkspaceOutOfDisk
|
||||
}(),
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
|
||||
Templates: notifications.TemplateWorkspaceOutOfMemory.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 10, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 5)
|
||||
|
||||
require.Equal(t, "Notification 8", notifs.Notifications[0].Title)
|
||||
})
|
||||
|
||||
t.Run("OK with target filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
filteredTarget := uuid.New()
|
||||
|
||||
for i := range 10 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
|
||||
Targets: func() []uuid.UUID {
|
||||
if i%2 == 0 {
|
||||
return []uuid.UUID{filteredTarget}
|
||||
}
|
||||
|
||||
return []uuid.UUID{}
|
||||
}(),
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
|
||||
Targets: filteredTarget.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 10, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 5)
|
||||
|
||||
require.Equal(t, "Notification 8", notifs.Notifications[0].Title)
|
||||
})
|
||||
|
||||
t.Run("OK with multiple filters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
filteredTarget := uuid.New()
|
||||
|
||||
for i := range 10 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: func() uuid.UUID {
|
||||
if i < 5 {
|
||||
return notifications.TemplateWorkspaceOutOfMemory
|
||||
}
|
||||
|
||||
return notifications.TemplateWorkspaceOutOfDisk
|
||||
}(),
|
||||
Targets: func() []uuid.UUID {
|
||||
if i%2 == 0 {
|
||||
return []uuid.UUID{filteredTarget}
|
||||
}
|
||||
|
||||
return []uuid.UUID{}
|
||||
}(),
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
|
||||
Targets: filteredTarget.String(),
|
||||
Templates: notifications.TemplateWorkspaceOutOfDisk.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 10, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 2)
|
||||
|
||||
require.Equal(t, "Notification 8", notifs.Notifications[0].Title)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInboxNotifications_ReadStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
|
||||
// For now the idea is that the runner takes too long to insert the entries, could be worth
|
||||
// investigating a manual Tx.
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("our runners are randomly taking too long to insert entries")
|
||||
}
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
for i := range 20 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 20, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 20)
|
||||
|
||||
updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
|
||||
IsRead: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedNotif)
|
||||
require.NotZero(t, updatedNotif.Notification.ReadAt)
|
||||
require.Equal(t, 19, updatedNotif.UnreadCount)
|
||||
|
||||
updatedNotif, err = client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
|
||||
IsRead: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedNotif)
|
||||
require.Nil(t, updatedNotif.Notification.ReadAt)
|
||||
require.Equal(t, 20, updatedNotif.UnreadCount)
|
||||
})
|
||||
|
||||
t.Run("NOK - wrong id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
for i := range 20 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 20, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 20)
|
||||
|
||||
updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, "xxx-xxx-xxx", codersdk.UpdateInboxNotificationReadStatusRequest{
|
||||
IsRead: true,
|
||||
})
|
||||
require.ErrorContains(t, err, `Invalid UUID "xxx-xxx-xxx"`)
|
||||
require.Equal(t, 0, updatedNotif.UnreadCount)
|
||||
require.Empty(t, updatedNotif.Notification)
|
||||
})
|
||||
t.Run("NOK - unknown id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 0, notifs.UnreadCount)
|
||||
require.Empty(t, notifs.Notifications)
|
||||
|
||||
for i := range 20 {
|
||||
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
|
||||
Title: fmt.Sprintf("Notification %d", i),
|
||||
Actions: json.RawMessage("[]"),
|
||||
Content: fmt.Sprintf("Content of the notif %d", i),
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, notifs)
|
||||
require.Equal(t, 20, notifs.UnreadCount)
|
||||
require.Len(t, notifs.Notifications, 20)
|
||||
|
||||
updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, failingPaginationUUID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
|
||||
IsRead: true,
|
||||
})
|
||||
require.ErrorContains(t, err, `Failed to update inbox notification read status`)
|
||||
require.Equal(t, 0, updatedNotif.UnreadCount)
|
||||
require.Empty(t, updatedNotif.Notification)
|
||||
})
|
||||
}
|
||||
@@ -13,8 +13,11 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/notifications/types"
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
markdown "github.com/coder/coder/v2/coderd/render"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type InboxStore interface {
|
||||
@@ -23,12 +26,13 @@ type InboxStore interface {
|
||||
|
||||
// InboxHandler is responsible for dispatching notification messages to the Coder Inbox.
|
||||
type InboxHandler struct {
|
||||
log slog.Logger
|
||||
store InboxStore
|
||||
log slog.Logger
|
||||
store InboxStore
|
||||
pubsub pubsub.Pubsub
|
||||
}
|
||||
|
||||
func NewInboxHandler(log slog.Logger, store InboxStore) *InboxHandler {
|
||||
return &InboxHandler{log: log, store: store}
|
||||
func NewInboxHandler(log slog.Logger, store InboxStore, ps pubsub.Pubsub) *InboxHandler {
|
||||
return &InboxHandler{log: log, store: store, pubsub: ps}
|
||||
}
|
||||
|
||||
func (s *InboxHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string, _ template.FuncMap) (DeliveryFunc, error) {
|
||||
@@ -62,7 +66,7 @@ func (s *InboxHandler) dispatch(payload types.MessagePayload, title, body string
|
||||
}
|
||||
|
||||
// nolint:exhaustruct
|
||||
_, err = s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{
|
||||
insertedNotif, err := s.store.InsertInboxNotification(ctx, database.InsertInboxNotificationParams{
|
||||
ID: msgID,
|
||||
UserID: userID,
|
||||
TemplateID: templateID,
|
||||
@@ -76,6 +80,38 @@ func (s *InboxHandler) dispatch(payload types.MessagePayload, title, body string
|
||||
return false, xerrors.Errorf("insert inbox notification: %w", err)
|
||||
}
|
||||
|
||||
event := coderdpubsub.InboxNotificationEvent{
|
||||
Kind: coderdpubsub.InboxNotificationEventKindNew,
|
||||
InboxNotification: codersdk.InboxNotification{
|
||||
ID: msgID,
|
||||
UserID: userID,
|
||||
TemplateID: templateID,
|
||||
Targets: payload.Targets,
|
||||
Title: title,
|
||||
Content: body,
|
||||
Actions: func() []codersdk.InboxNotificationAction {
|
||||
var actions []codersdk.InboxNotificationAction
|
||||
err := json.Unmarshal(insertedNotif.Actions, &actions)
|
||||
if err != nil {
|
||||
return actions
|
||||
}
|
||||
return actions
|
||||
}(),
|
||||
ReadAt: nil, // notification just has been inserted
|
||||
CreatedAt: insertedNotif.CreatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
err = s.pubsub.Publish(coderdpubsub.InboxNotificationForOwnerEventChannel(userID), payload)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("publish event: %w", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestInbox(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
|
||||
if tc.payload.UserID == "valid" {
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
@@ -82,7 +82,7 @@ func TestInbox(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
handler := dispatch.NewInboxHandler(logger.Named("smtp"), db)
|
||||
handler := dispatch.NewInboxHandler(logger.Named("smtp"), db, pubsub)
|
||||
dispatcherFunc, err := handler.Dispatcher(tc.payload, "", "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/coder/quartz"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/notifications/dispatch"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
@@ -75,8 +76,7 @@ func WithTestClock(clock quartz.Clock) ManagerOption {
|
||||
//
|
||||
// helpers is a map of template helpers which are used to customize notification messages to use global settings like
|
||||
// access URL etc.
|
||||
func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template.FuncMap, metrics *Metrics, log slog.Logger, opts ...ManagerOption) (*Manager, error) {
|
||||
// TODO(dannyk): add the ability to use multiple notification methods.
|
||||
func NewManager(cfg codersdk.NotificationsConfig, store Store, ps pubsub.Pubsub, helpers template.FuncMap, metrics *Metrics, log slog.Logger, opts ...ManagerOption) (*Manager, error) {
|
||||
var method database.NotificationMethod
|
||||
if err := method.Scan(cfg.Method.String()); err != nil {
|
||||
return nil, xerrors.Errorf("notification method %q is invalid", cfg.Method)
|
||||
@@ -109,7 +109,7 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template.
|
||||
stop: make(chan any),
|
||||
done: make(chan any),
|
||||
|
||||
handlers: defaultHandlers(cfg, log, store),
|
||||
handlers: defaultHandlers(cfg, log, store, ps),
|
||||
helpers: helpers,
|
||||
|
||||
clock: quartz.NewReal(),
|
||||
@@ -121,11 +121,11 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template.
|
||||
}
|
||||
|
||||
// defaultHandlers builds a set of known handlers; panics if any error occurs as these handlers should be valid at compile time.
|
||||
func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger, store Store) map[database.NotificationMethod]Handler {
|
||||
func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger, store Store, ps pubsub.Pubsub) map[database.NotificationMethod]Handler {
|
||||
return map[database.NotificationMethod]Handler{
|
||||
database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")),
|
||||
database.NotificationMethodWebhook: dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")),
|
||||
database.NotificationMethodInbox: dispatch.NewInboxHandler(log.Named("dispatcher.inbox"), store),
|
||||
database.NotificationMethodInbox: dispatch.NewInboxHandler(log.Named("dispatcher.inbox"), store, ps),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestBufferedUpdates(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, ps := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
interceptor := &syncInterceptor{Store: store}
|
||||
@@ -44,7 +44,7 @@ func TestBufferedUpdates(t *testing.T) {
|
||||
cfg.StoreSyncInterval = serpent.Duration(time.Hour) // Ensure we don't sync the store automatically.
|
||||
|
||||
// GIVEN: a manager which will pass or fail notifications based on their "nice" labels
|
||||
mgr, err := notifications.NewManager(cfg, interceptor, defaultHelpers(), createMetrics(), logger.Named("notifications-manager"))
|
||||
mgr, err := notifications.NewManager(cfg, interceptor, ps, defaultHelpers(), createMetrics(), logger.Named("notifications-manager"))
|
||||
require.NoError(t, err)
|
||||
|
||||
handlers := map[database.NotificationMethod]notifications.Handler{
|
||||
@@ -168,11 +168,11 @@ func TestStopBeforeRun(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, ps := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
// GIVEN: a standard manager
|
||||
mgr, err := notifications.NewManager(defaultNotificationsConfig(database.NotificationMethodSmtp), store, defaultHelpers(), createMetrics(), logger.Named("notifications-manager"))
|
||||
mgr, err := notifications.NewManager(defaultNotificationsConfig(database.NotificationMethodSmtp), store, ps, defaultHelpers(), createMetrics(), logger.Named("notifications-manager"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: validate that the manager can be stopped safely without Run() having been called yet
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestMetrics(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
@@ -60,7 +60,7 @@ func TestMetrics(t *testing.T) {
|
||||
cfg.RetryInterval = serpent.Duration(time.Millisecond * 50)
|
||||
cfg.StoreSyncInterval = serpent.Duration(time.Millisecond * 100) // Twice as long as fetch interval to ensure we catch pending updates.
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), metrics, logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), metrics, logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
@@ -228,7 +228,7 @@ func TestPendingUpdatesMetric(t *testing.T) {
|
||||
// SETUP
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
@@ -250,7 +250,7 @@ func TestPendingUpdatesMetric(t *testing.T) {
|
||||
defer trap.Close()
|
||||
fetchTrap := mClock.Trap().TickerFunc("notifier", "fetchInterval")
|
||||
defer fetchTrap.Close()
|
||||
mgr, err := notifications.NewManager(cfg, interceptor, defaultHelpers(), metrics, logger.Named("manager"),
|
||||
mgr, err := notifications.NewManager(cfg, interceptor, pubsub, defaultHelpers(), metrics, logger.Named("manager"),
|
||||
notifications.WithTestClock(mClock))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
@@ -322,7 +322,7 @@ func TestInflightDispatchesMetric(t *testing.T) {
|
||||
// SETUP
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
@@ -338,7 +338,7 @@ func TestInflightDispatchesMetric(t *testing.T) {
|
||||
cfg.RetryInterval = serpent.Duration(time.Hour) // Delay retries so they don't interfere.
|
||||
cfg.StoreSyncInterval = serpent.Duration(time.Millisecond * 100)
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), metrics, logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), metrics, logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
@@ -402,7 +402,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
var (
|
||||
@@ -427,7 +427,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
|
||||
|
||||
// WHEN: two notifications (each with different templates) are enqueued.
|
||||
cfg := defaultNotificationsConfig(defaultMethod)
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), metrics, logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), metrics, logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
method := database.NotificationMethodSmtp
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) {
|
||||
interceptor := &syncInterceptor{Store: store}
|
||||
cfg := defaultNotificationsConfig(method)
|
||||
cfg.RetryInterval = serpent.Duration(time.Hour) // Ensure retries don't interfere with the test
|
||||
mgr, err := notifications.NewManager(cfg, interceptor, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, interceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
|
||||
method: handler,
|
||||
@@ -138,7 +138,7 @@ func TestSMTPDispatch(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
// start mock SMTP server
|
||||
@@ -161,7 +161,7 @@ func TestSMTPDispatch(t *testing.T) {
|
||||
Hello: "localhost",
|
||||
}
|
||||
handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger.Named("smtp")))
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
|
||||
method: handler,
|
||||
@@ -204,7 +204,7 @@ func TestWebhookDispatch(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
sent := make(chan dispatch.WebhookPayload, 1)
|
||||
@@ -230,7 +230,7 @@ func TestWebhookDispatch(t *testing.T) {
|
||||
cfg.Webhook = codersdk.NotificationsWebhookConfig{
|
||||
Endpoint: *serpent.URLOf(endpoint),
|
||||
}
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
@@ -284,7 +284,7 @@ func TestBackpressure(t *testing.T) {
|
||||
t.Skip("This test requires postgres; it relies on business-logic only implemented in the database")
|
||||
}
|
||||
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitShort))
|
||||
@@ -319,7 +319,7 @@ func TestBackpressure(t *testing.T) {
|
||||
defer fetchTrap.Close()
|
||||
|
||||
// GIVEN: a notification manager whose updates will be intercepted
|
||||
mgr, err := notifications.NewManager(cfg, storeInterceptor, defaultHelpers(), createMetrics(),
|
||||
mgr, err := notifications.NewManager(cfg, storeInterceptor, pubsub, defaultHelpers(), createMetrics(),
|
||||
logger.Named("manager"), notifications.WithTestClock(mClock))
|
||||
require.NoError(t, err)
|
||||
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
|
||||
@@ -417,7 +417,7 @@ func TestRetries(t *testing.T) {
|
||||
const maxAttempts = 3
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
// GIVEN: a mock HTTP server which will receive webhooksand a map to track the dispatch attempts
|
||||
@@ -468,7 +468,7 @@ func TestRetries(t *testing.T) {
|
||||
// Intercept calls to submit the buffered updates to the store.
|
||||
storeInterceptor := &syncInterceptor{Store: store}
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, storeInterceptor, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, storeInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
@@ -517,7 +517,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
// GIVEN: a manager which has its updates intercepted and paused until measurements can be taken
|
||||
@@ -539,7 +539,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
|
||||
mgrCtx, cancelManagerCtx := context.WithCancel(dbauthz.AsNotifier(context.Background()))
|
||||
t.Cleanup(cancelManagerCtx)
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, noopInterceptor, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, noopInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal())
|
||||
require.NoError(t, err)
|
||||
@@ -588,7 +588,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
|
||||
// Intercept calls to submit the buffered updates to the store.
|
||||
storeInterceptor := &syncInterceptor{Store: store}
|
||||
handler := newDispatchInterceptor(&fakeHandler{})
|
||||
mgr, err = notifications.NewManager(cfg, storeInterceptor, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err = notifications.NewManager(cfg, storeInterceptor, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
|
||||
method: handler,
|
||||
@@ -620,7 +620,7 @@ func TestExpiredLeaseIsRequeued(t *testing.T) {
|
||||
func TestInvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
// GIVEN: invalid config with dispatch period <= lease period
|
||||
@@ -633,7 +633,7 @@ func TestInvalidConfig(t *testing.T) {
|
||||
cfg.DispatchTimeout = serpent.Duration(leasePeriod)
|
||||
|
||||
// WHEN: the manager is created with invalid config
|
||||
_, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
_, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
|
||||
// THEN: the manager will fail to be created, citing invalid config as error
|
||||
require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout)
|
||||
@@ -646,7 +646,7 @@ func TestNotifierPaused(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
// Prepare the test.
|
||||
@@ -657,7 +657,7 @@ func TestNotifierPaused(t *testing.T) {
|
||||
const fetchInterval = time.Millisecond * 100
|
||||
cfg := defaultNotificationsConfig(method)
|
||||
cfg.FetchInterval = serpent.Duration(fetchInterval)
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
|
||||
method: handler,
|
||||
@@ -1229,6 +1229,8 @@ func TestNotificationTemplates_Golden(t *testing.T) {
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
|
||||
_, pubsub := dbtestutil.NewDB(t)
|
||||
|
||||
// smtp config shared between client and server
|
||||
smtpConfig := codersdk.NotificationsEmailConfig{
|
||||
Hello: hello,
|
||||
@@ -1296,6 +1298,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
|
||||
smtpManager, err := notifications.NewManager(
|
||||
smtpCfg,
|
||||
*db,
|
||||
pubsub,
|
||||
defaultHelpers(),
|
||||
createMetrics(),
|
||||
logger.Named("manager"),
|
||||
@@ -1410,6 +1413,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
|
||||
return &db, &api.Logger, &user
|
||||
}()
|
||||
|
||||
_, pubsub := dbtestutil.NewDB(t)
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
|
||||
@@ -1437,6 +1441,7 @@ func TestNotificationTemplates_Golden(t *testing.T) {
|
||||
webhookManager, err := notifications.NewManager(
|
||||
webhookCfg,
|
||||
*db,
|
||||
pubsub,
|
||||
defaultHelpers(),
|
||||
createMetrics(),
|
||||
logger.Named("manager"),
|
||||
@@ -1613,13 +1618,13 @@ func TestDisabledAfterEnqueue(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
method := database.NotificationMethodSmtp
|
||||
cfg := defaultNotificationsConfig(method)
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
@@ -1670,7 +1675,7 @@ func TestCustomNotificationMethod(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
received := make(chan uuid.UUID, 1)
|
||||
@@ -1728,7 +1733,7 @@ func TestCustomNotificationMethod(t *testing.T) {
|
||||
Endpoint: *serpent.URLOf(endpoint),
|
||||
}
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = mgr.Stop(ctx)
|
||||
@@ -1811,13 +1816,13 @@ func TestNotificationDuplicates(t *testing.T) {
|
||||
|
||||
// nolint:gocritic // Unit test.
|
||||
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
store, pubsub := dbtestutil.NewDB(t)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
method := database.NotificationMethodSmtp
|
||||
cfg := defaultNotificationsConfig(method)
|
||||
|
||||
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, mgr.Stop(ctx))
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
Generated
+162
@@ -46,6 +46,168 @@ Status Code **200**
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## List inbox notifications
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/notifications/inbox \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /notifications/inbox`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|---------------|-------|--------|----------|-------------------------------------------------------------------------|
|
||||
| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications |
|
||||
| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications |
|
||||
| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"notifications": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"label": "string",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"content": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"read_at": "string",
|
||||
"targets": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
|
||||
"title": "string",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
}
|
||||
],
|
||||
"unread_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ListInboxNotificationsResponse](schemas.md#codersdklistinboxnotificationsresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Watch for new inbox notifications
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/notifications/inbox/watch \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /notifications/inbox/watch`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|---------------|-------|--------|----------|-------------------------------------------------------------------------|
|
||||
| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications |
|
||||
| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications |
|
||||
| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"notification": {
|
||||
"actions": [
|
||||
{
|
||||
"label": "string",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"content": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"read_at": "string",
|
||||
"targets": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
|
||||
"title": "string",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
},
|
||||
"unread_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GetInboxNotificationResponse](schemas.md#codersdkgetinboxnotificationresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Update read status of a notification
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X PUT http://coder-server:8080/api/v2/notifications/inbox/{id}/read-status \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`PUT /notifications/inbox/{id}/read-status`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|------|--------|----------|------------------------|
|
||||
| `id` | path | string | true | id of the notification |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "string",
|
||||
"message": "string",
|
||||
"validations": [
|
||||
{
|
||||
"detail": "string",
|
||||
"field": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|--------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get notifications settings
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+125
@@ -3016,6 +3016,40 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
|-------|--------|----------|--------------|-------------|
|
||||
| `key` | string | false | | |
|
||||
|
||||
## codersdk.GetInboxNotificationResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"notification": {
|
||||
"actions": [
|
||||
{
|
||||
"label": "string",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"content": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"read_at": "string",
|
||||
"targets": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
|
||||
"title": "string",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
},
|
||||
"unread_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------|----------------------------------------------------------|----------|--------------|-------------|
|
||||
| `notification` | [codersdk.InboxNotification](#codersdkinboxnotification) | false | | |
|
||||
| `unread_count` | integer | false | | |
|
||||
|
||||
## codersdk.GetUserStatusCountsResponse
|
||||
|
||||
```json
|
||||
@@ -3251,6 +3285,61 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
| `refresh` | integer | false | | |
|
||||
| `threshold_database` | integer | false | | |
|
||||
|
||||
## codersdk.InboxNotification
|
||||
|
||||
```json
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"label": "string",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"content": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"read_at": "string",
|
||||
"targets": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
|
||||
"title": "string",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|-------------------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `actions` | array of [codersdk.InboxNotificationAction](#codersdkinboxnotificationaction) | false | | |
|
||||
| `content` | string | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `icon` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `read_at` | string | false | | |
|
||||
| `targets` | array of string | false | | |
|
||||
| `template_id` | string | false | | |
|
||||
| `title` | string | false | | |
|
||||
| `user_id` | string | false | | |
|
||||
|
||||
## codersdk.InboxNotificationAction
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "string",
|
||||
"url": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------|--------|----------|--------------|-------------|
|
||||
| `label` | string | false | | |
|
||||
| `url` | string | false | | |
|
||||
|
||||
## codersdk.InsightsReportInterval
|
||||
|
||||
```json
|
||||
@@ -3380,6 +3469,42 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
| `icon` | `chat` |
|
||||
| `icon` | `docs` |
|
||||
|
||||
## codersdk.ListInboxNotificationsResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"notifications": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"label": "string",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"content": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"read_at": "string",
|
||||
"targets": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
|
||||
"title": "string",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
}
|
||||
],
|
||||
"unread_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------|-------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `notifications` | array of [codersdk.InboxNotification](#codersdkinboxnotification) | false | | |
|
||||
| `unread_count` | integer | false | | |
|
||||
|
||||
## codersdk.LogLevel
|
||||
|
||||
```json
|
||||
|
||||
Generated
+51
@@ -892,6 +892,12 @@ export interface GenerateAPIKeyResponse {
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface GetInboxNotificationResponse {
|
||||
readonly notification: InboxNotification;
|
||||
readonly unread_count: number;
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface GetUserStatusCountsRequest {
|
||||
readonly offset: string;
|
||||
@@ -1076,6 +1082,26 @@ export interface IDPSyncMapping<ResourceIdType extends string | string> {
|
||||
readonly Gets: ResourceIdType;
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface InboxNotification {
|
||||
readonly id: string;
|
||||
readonly user_id: string;
|
||||
readonly template_id: string;
|
||||
readonly targets: readonly string[];
|
||||
readonly title: string;
|
||||
readonly content: string;
|
||||
readonly icon: string;
|
||||
readonly actions: readonly InboxNotificationAction[];
|
||||
readonly read_at: string | null;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface InboxNotificationAction {
|
||||
readonly label: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export type InsightsReportInterval = "day" | "week";
|
||||
|
||||
@@ -1133,6 +1159,20 @@ export interface LinkConfig {
|
||||
readonly icon: string;
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface ListInboxNotificationsRequest {
|
||||
readonly targets?: string;
|
||||
readonly templates?: string;
|
||||
readonly read_status?: string;
|
||||
readonly starting_before?: string;
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface ListInboxNotificationsResponse {
|
||||
readonly notifications: readonly InboxNotification[];
|
||||
readonly unread_count: number;
|
||||
}
|
||||
|
||||
// From codersdk/externalauth.go
|
||||
export interface ListUserExternalAuthResponse {
|
||||
readonly providers: readonly ExternalAuthLinkProvider[];
|
||||
@@ -2653,6 +2693,17 @@ export interface UpdateHealthSettings {
|
||||
readonly dismissed_healthchecks: readonly HealthSection[];
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface UpdateInboxNotificationReadStatusRequest {
|
||||
readonly is_read: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/inboxnotification.go
|
||||
export interface UpdateInboxNotificationReadStatusResponse {
|
||||
readonly notification: InboxNotification;
|
||||
readonly unread_count: number;
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
export interface UpdateNotificationTemplateMethod {
|
||||
readonly method?: string;
|
||||
|
||||
Reference in New Issue
Block a user