Files
coder/coderd/inboxnotifications.go
T
Spike Curtis bddb808b25 chore: arrange imports in a standard way (#21452)
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example:

```
import (
	"context"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"golang.org/x/xerrors"
	"gopkg.in/natefinch/lumberjack.v2"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/codersdk/agentsdk"
	"github.com/coder/serpent"
)
```

3 groups: standard library, 3rd partly libs, Coder libs.

This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
2026-01-08 15:24:11 +04:00

451 lines
16 KiB
Go

package coderd
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"slices"
"time"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"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/httpmw/loggermw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/pubsub"
markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
const (
notificationFormatMarkdown = "markdown"
notificationFormatPlaintext = "plaintext"
)
var fallbackIcons = map[uuid.UUID]string{
// workspace related notifications
notifications.TemplateWorkspaceCreated: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceManuallyUpdated: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceDeleted: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceAutobuildFailed: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceDormant: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceAutoUpdated: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceMarkedForDeletion: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceManualBuildFailed: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceOutOfMemory: codersdk.InboxNotificationFallbackIconWorkspace,
notifications.TemplateWorkspaceOutOfDisk: codersdk.InboxNotificationFallbackIconWorkspace,
// account related notifications
notifications.TemplateUserAccountCreated: codersdk.InboxNotificationFallbackIconAccount,
notifications.TemplateUserAccountDeleted: codersdk.InboxNotificationFallbackIconAccount,
notifications.TemplateUserAccountSuspended: codersdk.InboxNotificationFallbackIconAccount,
notifications.TemplateUserAccountActivated: codersdk.InboxNotificationFallbackIconAccount,
notifications.TemplateYourAccountSuspended: codersdk.InboxNotificationFallbackIconAccount,
notifications.TemplateYourAccountActivated: codersdk.InboxNotificationFallbackIconAccount,
notifications.TemplateUserRequestedOneTimePasscode: codersdk.InboxNotificationFallbackIconAccount,
// template related notifications
notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
}
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
if notif.Icon != "" {
return notif
}
fallbackIcon, ok := fallbackIcons[notif.TemplateID]
if !ok {
fallbackIcon = codersdk.InboxNotificationFallbackIconOther
}
notif.Icon = fallbackIcon
return notif
}
// 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 {
convertedNotif := codersdk.InboxNotification{
ID: notif.ID,
UserID: notif.UserID,
TemplateID: notif.TemplateID,
Targets: notif.Targets,
Title: notif.Title,
Content: notif.Content,
Icon: notif.Icon,
Actions: func() []codersdk.InboxNotificationAction {
var actionsList []codersdk.InboxNotificationAction
err := json.Unmarshal([]byte(notif.Actions), &actionsList)
if err != nil {
logger.Error(ctx, "unmarshal inbox notification actions", slog.Error(err))
}
return actionsList
}(),
ReadAt: func() *time.Time {
if !notif.ReadAt.Valid {
return nil
}
return &notif.ReadAt.Time
}(),
CreatedAt: notif.CreatedAt,
}
return ensureNotificationIcon(convertedNotif)
}
// 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"
// @Param format query string false "Define the output format for notifications title and body." enums(plaintext,markdown)
// @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")
format = p.String(vals, notificationFormatMarkdown, "format")
)
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
}
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 <- ensureNotificationIcon(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()
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")
encoder := wsjson.NewEncoder[codersdk.GetInboxNotificationResponse](conn, websocket.MessageText)
defer encoder.Close(websocket.StatusNormalClosure)
// Log the request immediately instead of after it completes.
if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil {
rl.WriteLog(ctx, http.StatusAccepted)
}
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
}
// By default, notifications are stored as markdown
// We can change the format based on parameter if required
if format == notificationFormatPlaintext {
notif.Title, err = markdown.PlaintextFromMarkdown(notif.Title)
if err != nil {
api.Logger.Error(ctx, "failed to convert notification title to plain text", slog.Error(err))
return
}
notif.Content, err = markdown.PlaintextFromMarkdown(notif.Content)
if err != nil {
api.Logger.Error(ctx, "failed to convert notification content to plain text", 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"
// @Param starting_before query string false "ID of the last notification from the current page. Notifications returned will be older than the associated one" format(uuid)
// @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),
})
}
// markAllInboxNotificationsAsRead marks as read all unread notifications for authenticated user.
// @Summary Mark all unread notifications as read
// @ID mark-all-unread-notifications-as-read
// @Security CoderSessionToken
// @Tags Notifications
// @Success 204
// @Router /notifications/inbox/mark-all-as-read [put]
func (api *API) markAllInboxNotificationsAsRead(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apikey = httpmw.APIKey(r)
)
err := api.Database.MarkAllInboxNotificationsAsRead(ctx, database.MarkAllInboxNotificationsAsReadParams{
UserID: apikey.UserID,
ReadAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
})
if err != nil {
api.Logger.Error(ctx, "failed to mark all unread notifications as read", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to mark all unread notifications as read.",
})
return
}
rw.WriteHeader(http.StatusNoContent)
}