package notifications import ( "context" "encoding/json" "strings" "text/template" "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/codersdk" ) var ErrCannotEnqueueDisabledNotification = xerrors.New("user has disabled this notification") type StoreEnqueuer struct { store Store log slog.Logger defaultMethod database.NotificationMethod // helpers holds a map of template funcs which are used when rendering templates. These need to be passed in because // the template funcs will return values which are inappropriately encapsulated in this struct. helpers template.FuncMap } // NewStoreEnqueuer creates an Enqueuer implementation which can persist notification messages in the store. func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers template.FuncMap, log slog.Logger) (*StoreEnqueuer, error) { var method database.NotificationMethod if err := method.Scan(cfg.Method.String()); err != nil { return nil, xerrors.Errorf("given notification method %q is invalid", cfg.Method) } return &StoreEnqueuer{ store: store, log: log, defaultMethod: method, helpers: helpers, }, nil } // Enqueue queues a notification message for later delivery. // Messages will be dequeued by a notifier later and dispatched. func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ UserID: userID, NotificationTemplateID: templateID, }) if err != nil { s.log.Warn(ctx, "failed to fetch message metadata", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) return nil, xerrors.Errorf("new message metadata: %w", err) } dispatchMethod := s.defaultMethod if metadata.CustomMethod.Valid { dispatchMethod = metadata.CustomMethod.NotificationMethod } payload, err := s.buildPayload(metadata, labels) if err != nil { s.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) return nil, xerrors.Errorf("enqueue notification (payload build): %w", err) } input, err := json.Marshal(payload) if err != nil { return nil, xerrors.Errorf("failed encoding input labels: %w", err) } id := uuid.New() err = s.store.EnqueueNotificationMessage(ctx, database.EnqueueNotificationMessageParams{ ID: id, UserID: userID, NotificationTemplateID: templateID, Method: dispatchMethod, Payload: input, Targets: targets, CreatedBy: createdBy, }) if err != nil { // We have a trigger on the notification_messages table named `inhibit_enqueue_if_disabled` which prevents messages // from being enqueued if the user has disabled them via notification_preferences. The trigger will fail the insertion // with the message "cannot enqueue message: user has disabled this notification". // // This is more efficient than fetching the user's preferences for each enqueue, and centralizes the business logic. if strings.Contains(err.Error(), ErrCannotEnqueueDisabledNotification.Error()) { return nil, ErrCannotEnqueueDisabledNotification } s.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err)) return nil, xerrors.Errorf("enqueue notification: %w", err) } s.log.Debug(ctx, "enqueued notification", slog.F("msg_id", id)) return &id, nil } // buildPayload creates the payload that the notification will for variable substitution and/or routing. // The payload contains information about the recipient, the event that triggered the notification, and any subsequent // actions which can be taken by the recipient. func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) { payload := types.MessagePayload{ Version: "1.0", NotificationName: metadata.NotificationName, UserID: metadata.UserID.String(), UserEmail: metadata.UserEmail, UserName: metadata.UserName, UserUsername: metadata.UserUsername, Labels: labels, // No actions yet } // Execute any templates in actions. out, err := render.GoTemplate(string(metadata.Actions), payload, s.helpers) if err != nil { return nil, xerrors.Errorf("render actions: %w", err) } metadata.Actions = []byte(out) var actions []types.TemplateAction if err = json.Unmarshal(metadata.Actions, &actions); err != nil { return nil, xerrors.Errorf("new message metadata: parse template actions: %w", err) } payload.Actions = actions return &payload, nil } // NoopEnqueuer implements the Enqueuer interface but performs a noop. type NoopEnqueuer struct{} // NewNoopEnqueuer builds a NoopEnqueuer which is used to fulfill the contract for enqueuing notifications, if ExperimentNotifications is not set. func NewNoopEnqueuer() *NoopEnqueuer { return &NoopEnqueuer{} } func (*NoopEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, map[string]string, string, ...uuid.UUID) (*uuid.UUID, error) { // nolint:nilnil // irrelevant. return nil, nil }