diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index b07c5507..af7d88b5 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -181,7 +181,7 @@ func (h *NotificationsHandler) ListSystemNotifications(ctx echo.Context) error { seenDestinations := make(map[string]struct{}) for i := range rows { - name, ok := notification.NormalizeNotificationType(rows[i].NotificationType) + name, ok := notification.NormalizeSystemNotificationName(rows[i].NotificationType) if !ok { err := fmt.Errorf("unsupported notification type %q", rows[i].NotificationType) h.sugar.Errorw("Invalid configured system notification type", "error", err, "notificationType", rows[i].NotificationType) @@ -251,7 +251,7 @@ func (h *NotificationsHandler) ListSystemNotifications(ctx echo.Context) error { // @Router /admin/notifications/{notificationName}/destinations [post] func (h *NotificationsHandler) CreateSystemNotificationDestination(ctx echo.Context) error { notificationName := ctx.Param("notificationName") - canonicalType, ok := notification.NormalizeNotificationType(notificationName) + canonicalType, ok := notification.NormalizeSystemNotificationName(notificationName) if !ok { err := fmt.Errorf("unsupported notification type %q", notificationName) h.sugar.Warnw("Invalid system notification type", "error", err, "notificationName", notificationName) @@ -351,7 +351,7 @@ func (h *NotificationsHandler) CreateSystemNotificationDestination(ctx echo.Cont // @Router /admin/notifications/{notificationName}/destinations [delete] func (h *NotificationsHandler) DeleteSystemNotificationDestination(ctx echo.Context) error { notificationName := ctx.Param("notificationName") - canonicalType, ok := notification.NormalizeNotificationType(notificationName) + canonicalType, ok := notification.NormalizeSystemNotificationName(notificationName) if !ok { err := fmt.Errorf("unsupported notification type %q", notificationName) h.sugar.Warnw("Invalid system notification type", "error", err, "notificationName", notificationName) diff --git a/internal/api/handler/notifications_integration_test.go b/internal/api/handler/notifications_integration_test.go index f57b1ebe..63ce2ec4 100644 --- a/internal/api/handler/notifications_integration_test.go +++ b/internal/api/handler/notifications_integration_test.go @@ -148,7 +148,7 @@ func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviderStatu func (suite *NotificationsApiIntegrationSuite) TestListSystemNotifications() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -159,7 +159,7 @@ func (suite *NotificationsApiIntegrationSuite) TestListSystemNotifications() { }).Error) suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -169,7 +169,7 @@ func (suite *NotificationsApiIntegrationSuite) TestListSystemNotifications() { }).Error) suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -205,7 +205,7 @@ func (suite *NotificationsApiIntegrationSuite) TestListSystemNotifications() { func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsDeduplicatesEquivalentDestinations() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -215,7 +215,7 @@ func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsDedupl }), }).Error) suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -242,7 +242,7 @@ func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsDedupl func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsIncludesConfiguredSupportedTypesOutsideDefaultBaseline() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -267,6 +267,33 @@ func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsInclud }, response.Data[0].ConfiguredDestinations) } +func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsIncludesWorkflowExecutionFailed() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.SystemNotificationNameWorkflowExecutionFailed, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for ListSystemNotifications") + + var response GenericDataListResponse[systemNotificationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notifications response") + suite.Require().Len(response.Data, 1) + + suite.Equal("WORKFLOW_EXECUTION_FAILED", response.Data[0].Name) + suite.Equal([]configuredSystemDestinationResponse{ + {ProviderType: "email", DestinationTarget: "alerts@example.com"}, + }, response.Data[0].ConfiguredDestinations) +} + func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsReturnsEmptyDataWhenNoConfigurationsExist() { rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications") @@ -299,7 +326,7 @@ func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDesti var rows []relational.SystemNotificationDestination suite.Require().NoError(suite.DB.Find(&rows).Error) suite.Require().Len(rows, 1) - suite.Equal(notification.NotificationTypeEvidenceDigest, rows[0].NotificationType) + suite.Equal(notification.SubscriptionGateEvidenceDigest, rows[0].NotificationType) suite.Equal(notification.DeliveryChannelEmail, rows[0].Provider) suite.Equal("alerts@example.com", rows[0].Target.Data().Address[emailprovider.AddressKeyEmail]) } @@ -334,15 +361,32 @@ func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDesti var rows []relational.SystemNotificationDestination suite.Require().NoError(suite.DB.Find(&rows).Error) suite.Require().Len(rows, 1) - suite.Equal(notification.NotificationTypeTaskAvailable, rows[0].NotificationType) + suite.Equal(notification.SubscriptionGateTaskAvailable, rows[0].NotificationType) suite.Equal(notification.DeliveryChannelSlack, rows[0].Provider) suite.Equal("ccf-slack-int", rows[0].Target.Data().Address[slackprovider.AddressKeyChannel]) suite.Equal(slackprovider.TargetTypeChannel, rows[0].Target.Data().Address[slackprovider.AddressKeyTargetType]) } +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationWorkflowExecutionFailed() { + rec, req := suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/WORKFLOW_EXECUTION_FAILED/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "alerts@example.com", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusCreated, rec.Code, "Expected Created response for workflow-execution-failed destination") + + var rows []relational.SystemNotificationDestination + suite.Require().NoError(suite.DB.Find(&rows).Error) + suite.Require().Len(rows, 1) + suite.Equal(notification.SystemNotificationNameWorkflowExecutionFailed, rows[0].NotificationType) + suite.Equal(notification.DeliveryChannelEmail, rows[0].Provider) + suite.Equal("alerts@example.com", rows[0].Target.Data().Address[emailprovider.AddressKeyEmail]) +} + func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationRejectsDuplicateDestination() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -398,7 +442,7 @@ func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDesti func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestination() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -422,7 +466,7 @@ func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDesti func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationAcceptsKebabCasePayload() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -442,7 +486,7 @@ func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDesti func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationRemovesDuplicateRows() { suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -452,7 +496,7 @@ func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDesti }), }).Error) suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -462,7 +506,7 @@ func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDesti }), }).Error) suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index acc8f664..5acd25ef 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -693,7 +693,7 @@ func (h *UserHandler) loadUserNotificationSubscriptions(ctx context.Context, use for i := range rows { channels := make([]string, len(rows[i].Channels)) copy(channels, rows[i].Channels) - wireType, ok := notification.WireNotificationType(rows[i].NotificationType) + wireType, ok := notification.WireSubscriptionGate(rows[i].NotificationType) if !ok { wireType = rows[i].NotificationType } @@ -710,7 +710,7 @@ func normalizeNotificationSubscriptions(notifications map[string][]string) (map[ channelSets := make(map[string]map[string]struct{}, len(notifications)) for notificationType, channels := range notifications { - normalizedType, ok := notification.NormalizeNotificationType(notificationType) + normalizedType, ok := notification.NormalizeSubscriptionGate(notificationType) if !ok { invalidType := strings.ToLower(strings.TrimSpace(notificationType)) return nil, fmt.Errorf("%w: %q", errInvalidNotificationTypes, invalidType) diff --git a/internal/api/handler/users_integration_test.go b/internal/api/handler/users_integration_test.go index 40809fb9..b6b4a770 100644 --- a/internal/api/handler/users_integration_test.go +++ b/internal/api/handler/users_integration_test.go @@ -649,10 +649,10 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() { // Test subscribing to digest notifications. payload := map[string]interface{}{ "notifications": map[string][]string{ - notification.NotificationTypeTaskAvailableWire: {"email", "slack", "email"}, - notification.NotificationTypeEvidenceDigestWire: {"email", "email"}, - notification.NotificationTypeTaskDailyDigestWire: {"email", "email"}, - notification.NotificationTypeRiskNotifications: {"email", "slack", "email"}, + notification.SubscriptionGateTaskAvailableWire: {"email", "slack", "email"}, + notification.SubscriptionGateEvidenceDigestWire: {"email", "email"}, + notification.SubscriptionGateTaskDailyDigestWire: {"email", "email"}, + notification.SubscriptionGateRiskNotifications: {"email", "slack", "email"}, }, } payloadJSON, err := json.Marshal(payload) @@ -674,16 +674,16 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() { err = json.Unmarshal(rec.Body.Bytes(), &response) suite.Require().NoError(err, "Failed to unmarshal UpdateSubscriptions response") - suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.NotificationTypeTaskAvailableWire], "Expected notifications to be normalized and persisted") - suite.Equal([]string{"email"}, response.Data.Notifications[notification.NotificationTypeEvidenceDigestWire], "Expected evidence digest notifications to be normalized and persisted") - suite.Equal([]string{"email"}, response.Data.Notifications[notification.NotificationTypeTaskDailyDigestWire], "Expected task daily digest notifications to be normalized and persisted") - suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.NotificationTypeRiskNotificationsWire], "Expected risk notifications to be normalized and persisted") + suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.SubscriptionGateTaskAvailableWire], "Expected notifications to be normalized and persisted") + suite.Equal([]string{"email"}, response.Data.Notifications[notification.SubscriptionGateEvidenceDigestWire], "Expected evidence digest notifications to be normalized and persisted") + suite.Equal([]string{"email"}, response.Data.Notifications[notification.SubscriptionGateTaskDailyDigestWire], "Expected task daily digest notifications to be normalized and persisted") + suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.SubscriptionGateRiskNotificationsWire], "Expected risk notifications to be normalized and persisted") suite.Equal(int64(4), countStoredSubscriptions(), "Expected exactly one stored row per active notification type") // Test unsubscribing by omitting previously-configured notification types from the map. payload = map[string]interface{}{ "notifications": map[string][]string{ - notification.NotificationTypeTaskAvailableWire: {"email", "slack"}, + notification.SubscriptionGateTaskAvailableWire: {"email", "slack"}, }, } payloadJSON, err = json.Marshal(payload) @@ -705,12 +705,12 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() { err = json.Unmarshal(rec.Body.Bytes(), &response) suite.Require().NoError(err, "Failed to unmarshal unsubscribe response") - suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.NotificationTypeTaskAvailableWire], "Expected task-available notifications to remain configured") - _, hasDigestSubscription := response.Data.Notifications[notification.NotificationTypeEvidenceDigestWire] + suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.SubscriptionGateTaskAvailableWire], "Expected task-available notifications to remain configured") + _, hasDigestSubscription := response.Data.Notifications[notification.SubscriptionGateEvidenceDigestWire] suite.False(hasDigestSubscription, "Expected digest subscription to be removed when evidence_digest is omitted") - _, hasTaskDailyDigestSubscription := response.Data.Notifications[notification.NotificationTypeTaskDailyDigestWire] + _, hasTaskDailyDigestSubscription := response.Data.Notifications[notification.SubscriptionGateTaskDailyDigestWire] suite.False(hasTaskDailyDigestSubscription, "Expected task daily digest subscription to be removed when taskDailyDigest is omitted") - _, hasRiskNotificationSubscription := response.Data.Notifications[notification.NotificationTypeRiskNotificationsWire] + _, hasRiskNotificationSubscription := response.Data.Notifications[notification.SubscriptionGateRiskNotificationsWire] suite.False(hasRiskNotificationSubscription, "Expected risk notification subscription to be removed when risk_notifications is omitted") suite.Equal(int64(1), countStoredSubscriptions(), "Expected old notification rows to be replaced rather than soft-deleted") @@ -737,8 +737,8 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() { err = json.Unmarshal(rec.Body.Bytes(), &response) suite.Require().NoError(err, "Failed to unmarshal response for request without notifications") - suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.NotificationTypeTaskAvailableWire], "Expected notifications to remain unchanged when omitted") - _, hasDigestSubscription = response.Data.Notifications[notification.NotificationTypeEvidenceDigestWire] + suite.Equal([]string{"email", "slack"}, response.Data.Notifications[notification.SubscriptionGateTaskAvailableWire], "Expected notifications to remain unchanged when omitted") + _, hasDigestSubscription = response.Data.Notifications[notification.SubscriptionGateEvidenceDigestWire] suite.False(hasDigestSubscription, "Expected digest notification subscription to remain unchanged when notifications are omitted") suite.Equal(int64(1), countStoredSubscriptions(), "Expected notification row count to remain stable when notifications are omitted") }) @@ -747,7 +747,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() { // Test with malformed notifications payload payload := map[string]interface{}{ "notifications": map[string]interface{}{ - notification.NotificationTypeRiskNotifications: "invalid", + notification.SubscriptionGateRiskNotifications: "invalid", }, } payloadJSON, err := json.Marshal(payload) @@ -764,7 +764,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() { // Test with unsupported notification channel payload2 := map[string]interface{}{ "notifications": map[string][]string{ - notification.NotificationTypeTaskAvailable: {"email", "pagerduty"}, + notification.SubscriptionGateTaskAvailable: {"email", "pagerduty"}, }, } payloadJSON2, err := json.Marshal(payload2) diff --git a/internal/service/digest/notifications.go b/internal/service/digest/notifications.go index 409f7c7e..2741bffe 100644 --- a/internal/service/digest/notifications.go +++ b/internal/service/digest/notifications.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm" ) -const evidenceDigestKind = notification.Kind(notification.NotificationTypeEvidenceDigest) +const evidenceDigestKind = notification.NotificationKindEvidenceDigest type evidenceDigestNotificationModel struct { UserName string @@ -139,7 +139,7 @@ func NewNotificationService( notification.NewGORMUserRepository(db), notification.NewDefinition( evidenceDigestKind, - notification.NotificationTypeEvidenceDigest, + notification.SubscriptionGateEvidenceDigest, emailprovider.TemplateChannel(func(ctx context.Context, model any) (emailprovider.TemplateContent, error) { return renderEvidenceDigestEmail(ctx, model) }), @@ -269,7 +269,7 @@ func (s *Service) configuredDigestTargets(ctx context.Context) ([]notification.T } return notification.NewGORMSystemDestinationRepository(s.db, notificationproviders.NewLookup()). - ListTargetsByNotificationType(ctx, notification.NotificationTypeEvidenceDigest) + ListTargetsBySubscriptionGate(ctx, notification.SubscriptionGateEvidenceDigest) } func audiencesForTargets(targets []notification.Target) []notification.Audience { diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 066e5677..73e3fab9 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -175,7 +175,7 @@ func (s *Service) convertToEvidenceItems(evidences []relational.Evidence) []Evid func (s *Service) GetDigestRecipients(ctx context.Context) ([]DigestRecipient, error) { var subscriptions []relational.UserNotificationSubscription if err := s.db.WithContext(ctx). - Where("notification_type = ?", notification.NotificationTypeEvidenceDigest). + Where("notification_type = ?", notification.SubscriptionGateEvidenceDigest). Find(&subscriptions).Error; err != nil { return nil, fmt.Errorf("failed to fetch evidence digest subscriptions: %w", err) } diff --git a/internal/service/digest/service_test.go b/internal/service/digest/service_test.go index 918dec11..940f9685 100644 --- a/internal/service/digest/service_test.go +++ b/internal/service/digest/service_test.go @@ -122,7 +122,7 @@ func TestHasGlobalDigestDestinations_RequiresConfiguredDestination(t *testing.T) assert.False(t, service.hasGlobalDigestDestinations(context.Background())) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -140,7 +140,7 @@ func TestDispatchEvidenceDigestNotificationsSupportsMultipleConfiguredDestinatio require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -150,7 +150,7 @@ func TestDispatchEvidenceDigestNotificationsSupportsMultipleConfiguredDestinatio }), }).Error) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelEmail, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -161,7 +161,7 @@ func TestDispatchEvidenceDigestNotificationsSupportsMultipleConfiguredDestinatio registry := notification.MustNewRegistry(notification.NewDefinition( evidenceDigestKind, - notification.NotificationTypeEvidenceDigest, + notification.SubscriptionGateEvidenceDigest, notification.BindRenderer(notification.DeliveryChannelEmail, notification.ProviderRenderer(notification.DeliveryChannelEmail, func(context.Context, any) (any, error) { return emailprovider.Content{From: "from@example.com", Subject: "Digest", TextBody: "body"}, nil })), diff --git a/internal/service/email/templates/service_test.go b/internal/service/email/templates/service_test.go index ced383ce..9abc14ac 100644 --- a/internal/service/email/templates/service_test.go +++ b/internal/service/email/templates/service_test.go @@ -199,6 +199,8 @@ func TestTemplateService_WorkflowExecutionFailed_WithData(t *testing.T) { "CompletedSteps": 3, "TotalSteps": 5, "WorkflowURL": "https://app.example.com/workflows/abc", + "MyTasksURL": "https://app.example.com/my-tasks", + "IsSystemAudience": false, } html, text, err := service.Use("workflow-execution-failed", data) @@ -210,6 +212,7 @@ func TestTemplateService_WorkflowExecutionFailed_WithData(t *testing.T) { require.Contains(t, html, "SOC2 2026") require.Contains(t, html, "2 of 5 steps failed") require.Contains(t, html, "exec-abc-123") + require.Contains(t, html, "View all my tasks") require.Contains(t, text, "Alice Smith") require.Contains(t, text, "SOC2 Audit") require.Contains(t, text, "2 of 5 steps failed") @@ -241,6 +244,38 @@ func TestTemplateService_WorkflowExecutionFailed_NoURL(t *testing.T) { require.NotContains(t, html, "View Workflow Instance") } +func TestTemplateService_WorkflowExecutionFailed_SystemAudience(t *testing.T) { + service, err := NewTemplateService() + require.NoError(t, err) + + data := TemplateData{ + "RecipientName": "", + "WorkflowTitle": "Annual Audit", + "WorkflowInstanceName": "Audit 2026", + "ExecutionID": "exec-system-123", + "FailureReason": "step execution failed", + "FailedAt": "Wed, 19 Feb 2026 09:00:00 UTC", + "FailedSteps": 1, + "CompletedSteps": 2, + "TotalSteps": 3, + "WorkflowURL": "https://app.example.com/workflow-executions/exec-system-123", + "MyTasksURL": "", + "IsSystemAudience": true, + } + + html, text, err := service.Use("workflow-execution-failed", data) + require.NoError(t, err) + require.NotEmpty(t, html) + require.NotEmpty(t, text) + require.Contains(t, html, "A workflow execution has failed and may require your attention.") + require.Contains(t, html, "configured for workflow failure alerts") + require.NotContains(t, html, "Hi ,") + require.NotContains(t, html, "View all my tasks") + require.Contains(t, text, "A workflow execution has failed and may require your attention.") + require.Contains(t, text, "configured for workflow failure alerts") + require.NotContains(t, text, "Hi ,") +} + func TestTemplateService_WorkflowTaskDigest_WithTasks(t *testing.T) { service, err := NewTemplateService() require.NoError(t, err) diff --git a/internal/service/email/templates/templates/workflow-execution-failed.html b/internal/service/email/templates/templates/workflow-execution-failed.html index 4dd5c06e..a632b69a 100644 --- a/internal/service/email/templates/templates/workflow-execution-failed.html +++ b/internal/service/email/templates/templates/workflow-execution-failed.html @@ -141,7 +141,11 @@

⚠ Workflow Execution Failed

+ {{if .RecipientName}}

Hi {{.RecipientName}},

+ {{else}} +

A workflow execution has failed and may require your attention.

+ {{end}}
{{.WorkflowInstanceName}} — execution failed
@@ -182,13 +186,17 @@

⚠ Workflow Execution Failed

{{if .WorkflowURL}}View Workflow Instance{{end}} -
+ {{if .MyTasksURL}}
View all my tasks → -
+
{{end}}
diff --git a/internal/service/email/templates/templates/workflow-execution-failed.txt b/internal/service/email/templates/templates/workflow-execution-failed.txt index 1f7cfa72..4b96d43a 100644 --- a/internal/service/email/templates/templates/workflow-execution-failed.txt +++ b/internal/service/email/templates/templates/workflow-execution-failed.txt @@ -1,8 +1,9 @@ WORKFLOW EXECUTION FAILED ========================= -Hi {{.RecipientName}}, +{{if .RecipientName}}Hi {{.RecipientName}}, +{{end}} A workflow execution has failed and may require your attention. WORKFLOW: {{.WorkflowTitle}} @@ -28,5 +29,7 @@ VIEW WORKFLOW INSTANCE {{.WorkflowURL}} {{end}} --- -You are receiving this because you are the owner of this workflow instance. +{{if .IsSystemAudience}}You are receiving this because this destination is configured for workflow failure alerts. +{{else}}You are receiving this because you are the owner of this workflow instance. +{{end}} Compliance Framework diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 87dc16a5..35cca526 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -265,14 +265,14 @@ func migrateLegacySystemNotificationDestinations(db *gorm.DB, cfg *config.Config var existing relational.SystemNotificationDestination if err := db. - Where("notification_type = ? AND provider = ?", notification.NotificationTypeEvidenceDigest, notification.DeliveryChannelSlack). + Where("notification_type = ? AND provider = ?", notification.SubscriptionGateEvidenceDigest, notification.DeliveryChannelSlack). First(&existing).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("failed to query existing system notification destination %q: %w", slackprovider.ConfiguredDestinationDigestChan, err) } row := relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -322,7 +322,7 @@ func migrateLegacyTaskAvailableEmailSubscriptions(db *gorm.DB) error { if err := backfillLegacyNotificationSubscriptions( db, "task_available_email_subscribed", - notification.NotificationTypeTaskAvailable, + notification.SubscriptionGateTaskAvailable, "task-available email", ); err != nil { return err @@ -344,7 +344,7 @@ func migrateLegacyDigestSubscriptions(db *gorm.DB) error { if err := backfillLegacyNotificationSubscriptions( db, "digest_subscribed", - notification.NotificationTypeEvidenceDigest, + notification.SubscriptionGateEvidenceDigest, "evidence digest", ); err != nil { return err @@ -366,7 +366,7 @@ func migrateLegacyTaskDailyDigestSubscriptions(db *gorm.DB) error { if err := backfillLegacyNotificationSubscriptions( db, "task_daily_digest_subscribed", - notification.NotificationTypeTaskDailyDigest, + notification.SubscriptionGateTaskDailyDigest, "task daily digest", ); err != nil { return err @@ -388,7 +388,7 @@ func migrateLegacyRiskNotificationSubscriptions(db *gorm.DB) error { if err := backfillLegacyNotificationSubscriptions( db, "risk_notifications_subscribed", - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, "risk notifications", ); err != nil { return err diff --git a/internal/service/migrator_test.go b/internal/service/migrator_test.go index 285ee4ee..e0cd0b9e 100644 --- a/internal/service/migrator_test.go +++ b/internal/service/migrator_test.go @@ -52,7 +52,7 @@ func TestBackfillLegacyNotificationSubscriptions(t *testing.T) { existing := relational.UserNotificationSubscription{ UserID: userWithExistingSubscription.ID.String(), - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail}, } require.NoError(t, db.Create(&existing).Error) @@ -61,7 +61,7 @@ func TestBackfillLegacyNotificationSubscriptions(t *testing.T) { backfillLegacyNotificationSubscriptions( db, "task_available_email_subscribed", - notification.NotificationTypeTaskAvailable, + notification.SubscriptionGateTaskAvailable, "task-available email", ), ) @@ -75,17 +75,17 @@ func TestBackfillLegacyNotificationSubscriptions(t *testing.T) { rowsByUserID[row.UserID] = row } - assert.Equal(t, notification.NotificationTypeTaskAvailable, rowsByUserID[userWithLegacySubscription.ID.String()].NotificationType) + assert.Equal(t, notification.SubscriptionGateTaskAvailable, rowsByUserID[userWithLegacySubscription.ID.String()].NotificationType) assert.Equal(t, []string{notification.DeliveryChannelEmail}, []string(rowsByUserID[userWithLegacySubscription.ID.String()].Channels)) - assert.Equal(t, notification.NotificationTypeTaskAvailable, rowsByUserID[userWithExistingSubscription.ID.String()].NotificationType) + assert.Equal(t, notification.SubscriptionGateTaskAvailable, rowsByUserID[userWithExistingSubscription.ID.String()].NotificationType) assert.Equal(t, []string{notification.DeliveryChannelEmail}, []string(rowsByUserID[userWithExistingSubscription.ID.String()].Channels)) require.NoError(t, backfillLegacyNotificationSubscriptions( db, "task_available_email_subscribed", - notification.NotificationTypeTaskAvailable, + notification.SubscriptionGateTaskAvailable, "task-available email", ), ) @@ -131,7 +131,7 @@ func TestBackfillLegacyRiskNotificationSubscriptions(t *testing.T) { var rows []relational.UserNotificationSubscription require.NoError(t, db.Find(&rows).Error) require.Len(t, rows, 1) - assert.Equal(t, notification.NotificationTypeRiskNotifications, rows[0].NotificationType) + assert.Equal(t, notification.SubscriptionGateRiskNotifications, rows[0].NotificationType) assert.Equal(t, []string{notification.DeliveryChannelEmail}, []string(rows[0].Channels)) assert.Equal(t, user.ID.String(), rows[0].UserID) @@ -158,7 +158,7 @@ func TestMigrateLegacySystemNotificationDestinationsBackfillsSlackDigestChannel( var rows []relational.SystemNotificationDestination require.NoError(t, db.Find(&rows).Error) require.Len(t, rows, 1) - assert.Equal(t, notification.NotificationTypeEvidenceDigest, rows[0].NotificationType) + assert.Equal(t, notification.SubscriptionGateEvidenceDigest, rows[0].NotificationType) assert.Equal(t, notification.DeliveryChannelSlack, rows[0].Provider) target := rows[0].Target.Data() assert.Equal(t, "ccf-alerts", target.Address[slackprovider.AddressKeyChannel]) @@ -171,7 +171,7 @@ func TestMigrateLegacySystemNotificationDestinationsDoesNotOverwriteExistingRow( require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) existing := relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ diff --git a/internal/service/notification/constants.go b/internal/service/notification/constants.go index b3d64999..db793e11 100644 --- a/internal/service/notification/constants.go +++ b/internal/service/notification/constants.go @@ -5,58 +5,89 @@ import ( ) const ( - NotificationTypeUngated = "" - - NotificationTypeEvidenceDigest = "evidence_digest" - NotificationTypeTaskAvailable = "task_available" - NotificationTypeTaskDailyDigest = "task_daily_digest" - NotificationTypeRiskNotifications = "risk_notifications" - - NotificationTypeEvidenceDigestWire = "evidenceDigest" - NotificationTypeTaskAvailableWire = "taskAvailable" - NotificationTypeTaskDailyDigestWire = "taskDailyDigest" - NotificationTypeRiskNotificationsWire = "riskNotifications" - - notificationTypeEvidenceDigestWireNormalized = "evidencedigest" - notificationTypeTaskAvailableWireNormalized = "taskavailable" - notificationTypeTaskDailyDigestWireNormalized = "taskdailydigest" - notificationTypeRiskNotificationsWireNormalized = "risknotifications" + notificationNameEvidenceDigest = "evidence_digest" + notificationNameTaskAvailable = "task_available" + notificationNameTaskDailyDigest = "task_daily_digest" + notificationNameRiskNotifications = "risk_notifications" + notificationNameWorkflowExecutionFailed = "workflow_execution_failed" + + NotificationKindEvidenceDigest = Kind(notificationNameEvidenceDigest) + NotificationKindWorkflowExecutionFailed = Kind(notificationNameWorkflowExecutionFailed) + + SystemNotificationNameEvidenceDigest = notificationNameEvidenceDigest + SystemNotificationNameTaskAvailable = notificationNameTaskAvailable + SystemNotificationNameTaskDailyDigest = notificationNameTaskDailyDigest + SystemNotificationNameRiskNotifications = notificationNameRiskNotifications + SystemNotificationNameWorkflowExecutionFailed = notificationNameWorkflowExecutionFailed + + SubscriptionGateUngated = "" + + SubscriptionGateEvidenceDigest = notificationNameEvidenceDigest + SubscriptionGateTaskAvailable = notificationNameTaskAvailable + SubscriptionGateTaskDailyDigest = notificationNameTaskDailyDigest + SubscriptionGateRiskNotifications = notificationNameRiskNotifications + + SubscriptionGateEvidenceDigestWire = "evidenceDigest" + SubscriptionGateTaskAvailableWire = "taskAvailable" + SubscriptionGateTaskDailyDigestWire = "taskDailyDigest" + SubscriptionGateRiskNotificationsWire = "riskNotifications" + + subscriptionGateEvidenceDigestWireNormalized = "evidencedigest" + subscriptionGateTaskAvailableWireNormalized = "taskavailable" + subscriptionGateTaskDailyDigestWireNormalized = "taskdailydigest" + subscriptionGateRiskNotificationsWireNormalized = "risknotifications" + + systemNotificationWorkflowExecutionFailedWireNormalized = "workflowexecutionfailed" ) -var notificationTypeInputAliases = map[string]string{ - NotificationTypeEvidenceDigest: NotificationTypeEvidenceDigest, - NotificationTypeTaskAvailable: NotificationTypeTaskAvailable, - NotificationTypeTaskDailyDigest: NotificationTypeTaskDailyDigest, - NotificationTypeRiskNotifications: NotificationTypeRiskNotifications, - notificationTypeEvidenceDigestWireNormalized: NotificationTypeEvidenceDigest, - notificationTypeTaskAvailableWireNormalized: NotificationTypeTaskAvailable, - notificationTypeTaskDailyDigestWireNormalized: NotificationTypeTaskDailyDigest, - notificationTypeRiskNotificationsWireNormalized: NotificationTypeRiskNotifications, +var subscriptionGateInputAliases = map[string]string{ + SubscriptionGateEvidenceDigest: SubscriptionGateEvidenceDigest, + SubscriptionGateTaskAvailable: SubscriptionGateTaskAvailable, + SubscriptionGateTaskDailyDigest: SubscriptionGateTaskDailyDigest, + SubscriptionGateRiskNotifications: SubscriptionGateRiskNotifications, + subscriptionGateEvidenceDigestWireNormalized: SubscriptionGateEvidenceDigest, + subscriptionGateTaskAvailableWireNormalized: SubscriptionGateTaskAvailable, + subscriptionGateTaskDailyDigestWireNormalized: SubscriptionGateTaskDailyDigest, + subscriptionGateRiskNotificationsWireNormalized: SubscriptionGateRiskNotifications, } -var notificationTypeWireValues = map[string]string{ - NotificationTypeEvidenceDigest: NotificationTypeEvidenceDigestWire, - NotificationTypeTaskAvailable: NotificationTypeTaskAvailableWire, - NotificationTypeTaskDailyDigest: NotificationTypeTaskDailyDigestWire, - NotificationTypeRiskNotifications: NotificationTypeRiskNotificationsWire, +var subscriptionGateWireValues = map[string]string{ + SubscriptionGateEvidenceDigest: SubscriptionGateEvidenceDigestWire, + SubscriptionGateTaskAvailable: SubscriptionGateTaskAvailableWire, + SubscriptionGateTaskDailyDigest: SubscriptionGateTaskDailyDigestWire, + SubscriptionGateRiskNotifications: SubscriptionGateRiskNotificationsWire, } -var systemNotificationTypes = []string{ - NotificationTypeEvidenceDigest, +var systemNotificationInputAliases = map[string]string{ + SubscriptionGateEvidenceDigest: SystemNotificationNameEvidenceDigest, + SubscriptionGateTaskAvailable: SystemNotificationNameTaskAvailable, + SubscriptionGateTaskDailyDigest: SystemNotificationNameTaskDailyDigest, + SubscriptionGateRiskNotifications: SystemNotificationNameRiskNotifications, + notificationNameWorkflowExecutionFailed: SystemNotificationNameWorkflowExecutionFailed, + subscriptionGateEvidenceDigestWireNormalized: SystemNotificationNameEvidenceDigest, + subscriptionGateTaskAvailableWireNormalized: SystemNotificationNameTaskAvailable, + subscriptionGateTaskDailyDigestWireNormalized: SystemNotificationNameTaskDailyDigest, + subscriptionGateRiskNotificationsWireNormalized: SystemNotificationNameRiskNotifications, + systemNotificationWorkflowExecutionFailedWireNormalized: SystemNotificationNameWorkflowExecutionFailed, +} + +var systemNotificationNames = []string{ + SystemNotificationNameEvidenceDigest, + SystemNotificationNameWorkflowExecutionFailed, } func normalizeToken(value string) string { return strings.ToLower(strings.TrimSpace(value)) } -// NormalizeNotificationType canonicalizes a notification type and verifies support. -func NormalizeNotificationType(notificationType string) (string, bool) { - normalized := normalizeToken(notificationType) +// NormalizeSubscriptionGate canonicalizes a subscription gate and verifies support. +func NormalizeSubscriptionGate(subscriptionGate string) (string, bool) { + normalized := normalizeToken(subscriptionGate) if normalized == "" { return "", false } - canonical, ok := notificationTypeInputAliases[normalized] + canonical, ok := subscriptionGateInputAliases[normalized] if !ok { return "", false } @@ -64,14 +95,14 @@ func NormalizeNotificationType(notificationType string) (string, bool) { return canonical, true } -// WireNotificationType returns camelCase for a supported notification type. -func WireNotificationType(notificationType string) (string, bool) { - canonical, ok := NormalizeNotificationType(notificationType) +// WireSubscriptionGate returns camelCase for a supported subscription gate. +func WireSubscriptionGate(subscriptionGate string) (string, bool) { + canonical, ok := NormalizeSubscriptionGate(subscriptionGate) if !ok { return "", false } - wireValue, ok := notificationTypeWireValues[canonical] + wireValue, ok := subscriptionGateWireValues[canonical] if !ok { return "", false } @@ -79,6 +110,21 @@ func WireNotificationType(notificationType string) (string, bool) { return wireValue, true } -func SystemNotificationTypes() []string { - return append([]string(nil), systemNotificationTypes...) +// NormalizeSystemNotificationName canonicalizes a system notification name and verifies support. +func NormalizeSystemNotificationName(notificationName string) (string, bool) { + normalized := normalizeToken(notificationName) + if normalized == "" { + return "", false + } + + canonical, ok := systemNotificationInputAliases[normalized] + if !ok { + return "", false + } + + return canonical, true +} + +func SystemNotificationNames() []string { + return append([]string(nil), systemNotificationNames...) } diff --git a/internal/service/notification/constants_test.go b/internal/service/notification/constants_test.go index cc3a3717..1f89aab1 100644 --- a/internal/service/notification/constants_test.go +++ b/internal/service/notification/constants_test.go @@ -24,72 +24,91 @@ func TestNormalizeDeliveryChannels_EmptyIsInvalid(t *testing.T) { assert.Equal(t, 1, len(invalid)) } -func TestNormalizeNotificationType(t *testing.T) { - normalized, ok := NormalizeNotificationType(" Task_Available ") +func TestNormalizeSubscriptionGate(t *testing.T) { + normalized, ok := NormalizeSubscriptionGate(" Task_Available ") assert.True(t, ok) - assert.Equal(t, NotificationTypeTaskAvailable, normalized) + assert.Equal(t, SubscriptionGateTaskAvailable, normalized) } -func TestNormalizeNotificationType_EvidenceDigest(t *testing.T) { - normalized, ok := NormalizeNotificationType(" Evidence_Digest ") +func TestNormalizeSubscriptionGate_EvidenceDigest(t *testing.T) { + normalized, ok := NormalizeSubscriptionGate(" Evidence_Digest ") assert.True(t, ok) - assert.Equal(t, NotificationTypeEvidenceDigest, normalized) + assert.Equal(t, SubscriptionGateEvidenceDigest, normalized) } -func TestNormalizeNotificationType_TaskDailyDigest(t *testing.T) { - normalized, ok := NormalizeNotificationType(" Task_Daily_Digest ") +func TestNormalizeSubscriptionGate_TaskDailyDigest(t *testing.T) { + normalized, ok := NormalizeSubscriptionGate(" Task_Daily_Digest ") assert.True(t, ok) - assert.Equal(t, NotificationTypeTaskDailyDigest, normalized) + assert.Equal(t, SubscriptionGateTaskDailyDigest, normalized) } -func TestNormalizeNotificationType_RiskNotifications(t *testing.T) { - normalized, ok := NormalizeNotificationType(" Risk_Notifications ") +func TestNormalizeSubscriptionGate_RiskNotifications(t *testing.T) { + normalized, ok := NormalizeSubscriptionGate(" Risk_Notifications ") assert.True(t, ok) - assert.Equal(t, NotificationTypeRiskNotifications, normalized) + assert.Equal(t, SubscriptionGateRiskNotifications, normalized) } -func TestNormalizeNotificationType_CamelCaseAliases(t *testing.T) { - normalized, ok := NormalizeNotificationType(" taskAvailable ") +func TestNormalizeSubscriptionGate_CamelCaseAliases(t *testing.T) { + normalized, ok := NormalizeSubscriptionGate(" taskAvailable ") assert.True(t, ok) - assert.Equal(t, NotificationTypeTaskAvailable, normalized) + assert.Equal(t, SubscriptionGateTaskAvailable, normalized) - normalized, ok = NormalizeNotificationType(" evidenceDigest ") + normalized, ok = NormalizeSubscriptionGate(" evidenceDigest ") assert.True(t, ok) - assert.Equal(t, NotificationTypeEvidenceDigest, normalized) + assert.Equal(t, SubscriptionGateEvidenceDigest, normalized) - normalized, ok = NormalizeNotificationType(" taskDailyDigest ") + normalized, ok = NormalizeSubscriptionGate(" taskDailyDigest ") assert.True(t, ok) - assert.Equal(t, NotificationTypeTaskDailyDigest, normalized) + assert.Equal(t, SubscriptionGateTaskDailyDigest, normalized) - normalized, ok = NormalizeNotificationType(" riskNotifications ") + normalized, ok = NormalizeSubscriptionGate(" riskNotifications ") assert.True(t, ok) - assert.Equal(t, NotificationTypeRiskNotifications, normalized) + assert.Equal(t, SubscriptionGateRiskNotifications, normalized) } -func TestWireNotificationType(t *testing.T) { - wireType, ok := WireNotificationType(" task_available ") +func TestWireSubscriptionGate(t *testing.T) { + wireType, ok := WireSubscriptionGate(" task_available ") assert.True(t, ok) - assert.Equal(t, NotificationTypeTaskAvailableWire, wireType) + assert.Equal(t, SubscriptionGateTaskAvailableWire, wireType) - wireType, ok = WireNotificationType(" evidenceDigest ") + wireType, ok = WireSubscriptionGate(" evidenceDigest ") assert.True(t, ok) - assert.Equal(t, NotificationTypeEvidenceDigestWire, wireType) + assert.Equal(t, SubscriptionGateEvidenceDigestWire, wireType) - wireType, ok = WireNotificationType(" taskDailyDigest ") + wireType, ok = WireSubscriptionGate(" taskDailyDigest ") assert.True(t, ok) - assert.Equal(t, NotificationTypeTaskDailyDigestWire, wireType) + assert.Equal(t, SubscriptionGateTaskDailyDigestWire, wireType) - wireType, ok = WireNotificationType(" risk_notifications ") + wireType, ok = WireSubscriptionGate(" risk_notifications ") assert.True(t, ok) - assert.Equal(t, NotificationTypeRiskNotificationsWire, wireType) + assert.Equal(t, SubscriptionGateRiskNotificationsWire, wireType) } -func TestNormalizeNotificationType_Invalid(t *testing.T) { - normalized, ok := NormalizeNotificationType("risk_opened") +func TestNormalizeSubscriptionGate_Invalid(t *testing.T) { + normalized, ok := NormalizeSubscriptionGate("risk_opened") assert.False(t, ok) assert.Equal(t, "", normalized) } -func TestSystemNotificationTypes(t *testing.T) { - assert.Equal(t, []string{NotificationTypeEvidenceDigest}, SystemNotificationTypes()) +func TestNormalizeSystemNotificationName(t *testing.T) { + normalized, ok := NormalizeSystemNotificationName(" workflowExecutionFailed ") + assert.True(t, ok) + assert.Equal(t, SystemNotificationNameWorkflowExecutionFailed, normalized) + + normalized, ok = NormalizeSystemNotificationName(" TASK_AVAILABLE ") + assert.True(t, ok) + assert.Equal(t, SystemNotificationNameTaskAvailable, normalized) +} + +func TestNormalizeSystemNotificationNameRejectsUnsupportedName(t *testing.T) { + normalized, ok := NormalizeSystemNotificationName("risk_opened") + assert.False(t, ok) + assert.Equal(t, "", normalized) +} + +func TestSystemNotificationNames(t *testing.T) { + assert.Equal(t, []string{ + SystemNotificationNameEvidenceDigest, + SystemNotificationNameWorkflowExecutionFailed, + }, SystemNotificationNames()) } diff --git a/internal/service/notification/definitions.go b/internal/service/notification/definitions.go index cdb775fe..08ed3610 100644 --- a/internal/service/notification/definitions.go +++ b/internal/service/notification/definitions.go @@ -30,7 +30,7 @@ func BindRenderer(provider string, renderer ChannelRenderer) RendererBinding { } } -func NewDefinition(kind Kind, subscriptionType string, bindings ...RendererBinding) Definition { +func NewDefinition(kind Kind, subscriptionGate string, bindings ...RendererBinding) Definition { supportedChannels := make([]string, 0, len(bindings)) renderers := make(map[string]ChannelRenderer, len(bindings)) @@ -42,7 +42,7 @@ func NewDefinition(kind Kind, subscriptionType string, bindings ...RendererBindi return Definition{ Kind: kind, - SubscriptionType: subscriptionType, + SubscriptionGate: subscriptionGate, SupportedChannels: supportedChannels, Renderers: renderers, } @@ -50,7 +50,7 @@ func NewDefinition(kind Kind, subscriptionType string, bindings ...RendererBindi type Definition struct { Kind Kind - SubscriptionType string + SubscriptionGate string SupportedChannels []string Renderers map[string]ChannelRenderer } @@ -95,7 +95,7 @@ func (d Definition) normalized() (Definition, error) { normalized := d normalized.Kind = Kind(strings.TrimSpace(string(d.Kind))) normalized.SupportedChannels = append([]string(nil), channels...) - normalized.SubscriptionType = canonicalSubscriptionType(d.SubscriptionType) + normalized.SubscriptionGate = canonicalSubscriptionGate(d.SubscriptionGate) normalized.Renderers = make(map[string]ChannelRenderer, len(d.Renderers)) for key, renderer := range d.Renderers { channel, ok := NormalizeDeliveryChannel(key) @@ -114,12 +114,12 @@ func (d Definition) normalized() (Definition, error) { return normalized, nil } -func canonicalSubscriptionType(subscriptionType string) string { - trimmed := strings.TrimSpace(subscriptionType) +func canonicalSubscriptionGate(subscriptionGate string) string { + trimmed := strings.TrimSpace(subscriptionGate) if trimmed == "" { return "" } - if canonical, ok := NormalizeNotificationType(trimmed); ok { + if canonical, ok := NormalizeSubscriptionGate(trimmed); ok { return canonical } return trimmed diff --git a/internal/service/notification/definitions_test.go b/internal/service/notification/definitions_test.go index 86f98f7b..e51d87b1 100644 --- a/internal/service/notification/definitions_test.go +++ b/internal/service/notification/definitions_test.go @@ -24,5 +24,5 @@ func TestNewDefinitionBuildsSupportedChannelsAndRenderers(t *testing.T) { assert.Equal(t, []string{"email", "slack"}, definition.SupportedChannels) assert.Contains(t, definition.Renderers, "email") assert.Contains(t, definition.Renderers, "slack") - assert.Equal(t, " taskAvailable ", definition.SubscriptionType) + assert.Equal(t, " taskAvailable ", definition.SubscriptionGate) } diff --git a/internal/service/notification/gorm_configured_destination_resolver.go b/internal/service/notification/gorm_configured_destination_resolver.go index 8dc06e1f..56b627b7 100644 --- a/internal/service/notification/gorm_configured_destination_resolver.go +++ b/internal/service/notification/gorm_configured_destination_resolver.go @@ -77,7 +77,7 @@ func configuredDestinationLookup(key string) (configuredDestinationRecordLookup, switch strings.TrimSpace(key) { case configuredDestinationKeySlackDigest: return configuredDestinationRecordLookup{ - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Provider: DeliveryChannelSlack, }, true default: diff --git a/internal/service/notification/gorm_configured_destination_resolver_test.go b/internal/service/notification/gorm_configured_destination_resolver_test.go index c9f122d9..c1d6de83 100644 --- a/internal/service/notification/gorm_configured_destination_resolver_test.go +++ b/internal/service/notification/gorm_configured_destination_resolver_test.go @@ -26,7 +26,7 @@ func TestGORMConfiguredDestinationResolverResolveConfiguredDestination(t *testin require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) record := relational.SystemNotificationDestination{ - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Provider: DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -57,7 +57,7 @@ func TestGORMConfiguredDestinationResolverResolveConfiguredDestinationUsesNewest require.NoError(t, db.Create(&relational.SystemNotificationDestination{ CreatedAt: older, UpdatedAt: older, - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Provider: DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -69,7 +69,7 @@ func TestGORMConfiguredDestinationResolverResolveConfiguredDestinationUsesNewest require.NoError(t, db.Create(&relational.SystemNotificationDestination{ CreatedAt: newer, UpdatedAt: newer, - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Provider: DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ diff --git a/internal/service/notification/gorm_user_repository_test.go b/internal/service/notification/gorm_user_repository_test.go index 23a62106..cf52b99b 100644 --- a/internal/service/notification/gorm_user_repository_test.go +++ b/internal/service/notification/gorm_user_repository_test.go @@ -26,7 +26,7 @@ func TestGORMUserRepositoryFindUserByIDIncludesSlackIdentity(t *testing.T) { }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: userID.String(), - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Channels: datatypes.JSONSlice[string]{DeliveryChannelEmail, DeliveryChannelSlack}, }).Error) require.NoError(t, db.Create(&relational.SlackUserLink{ @@ -45,7 +45,7 @@ func TestGORMUserRepositoryFindUserByIDIncludesSlackIdentity(t *testing.T) { assert.Equal(t, "direct_message", identity["target_type"]) } -func TestGORMUserRepositoryListActiveUsersByNotificationTypeIncludesSlackIdentity(t *testing.T) { +func TestGORMUserRepositoryListActiveUsersBySubscriptionGateIncludesSlackIdentity(t *testing.T) { db := newNotificationTestDB(t) userID := uuid.New() @@ -57,7 +57,7 @@ func TestGORMUserRepositoryListActiveUsersByNotificationTypeIncludesSlackIdentit }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: userID.String(), - NotificationType: NotificationTypeTaskDailyDigest, + NotificationType: SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{DeliveryChannelSlack}, }).Error) require.NoError(t, db.Create(&relational.SlackUserLink{ @@ -67,7 +67,7 @@ func TestGORMUserRepositoryListActiveUsersByNotificationTypeIncludesSlackIdentit }).Error) repo := NewGORMUserRepository(db) - users, err := repo.ListActiveUsersByNotificationType(context.Background(), NotificationTypeTaskDailyDigest) + users, err := repo.ListActiveUsersBySubscriptionGate(context.Background(), SubscriptionGateTaskDailyDigest) require.NoError(t, err) require.Len(t, users, 1) @@ -77,7 +77,7 @@ func TestGORMUserRepositoryListActiveUsersByNotificationTypeIncludesSlackIdentit assert.Equal(t, "direct_message", identity["target_type"]) } -func TestGORMUserRepositoryListActiveUserIDsByNotificationType(t *testing.T) { +func TestGORMUserRepositoryListActiveUserIDsBySubscriptionGate(t *testing.T) { db := newNotificationTestDB(t) activeID := uuid.New() inactiveID := uuid.New() @@ -115,27 +115,27 @@ func TestGORMUserRepositoryListActiveUserIDsByNotificationType(t *testing.T) { require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: activeID.String(), - NotificationType: NotificationTypeTaskDailyDigest, + NotificationType: SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{DeliveryChannelEmail}, }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: inactiveID.String(), - NotificationType: NotificationTypeTaskDailyDigest, + NotificationType: SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{DeliveryChannelEmail}, }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: lockedID.String(), - NotificationType: NotificationTypeTaskDailyDigest, + NotificationType: SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{DeliveryChannelSlack}, }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: invalidChannelID.String(), - NotificationType: NotificationTypeTaskDailyDigest, + NotificationType: SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{"invalid-channel"}, }).Error) repo := NewGORMUserRepository(db) - userIDs, err := repo.ListActiveUserIDsByNotificationType(context.Background(), NotificationTypeTaskDailyDigest) + userIDs, err := repo.ListActiveUserIDsBySubscriptionGate(context.Background(), SubscriptionGateTaskDailyDigest) require.NoError(t, err) require.Len(t, userIDs, 1) assert.Equal(t, activeID.String(), userIDs[0]) diff --git a/internal/service/notification/registry_test.go b/internal/service/notification/registry_test.go index e51cf11b..a431f6b9 100644 --- a/internal/service/notification/registry_test.go +++ b/internal/service/notification/registry_test.go @@ -11,7 +11,7 @@ import ( func TestRegistryRegisterNormalizesDefinition(t *testing.T) { registry, err := NewRegistry(Definition{ Kind: Kind("risk_review_due"), - SubscriptionType: " riskNotifications ", + SubscriptionGate: " riskNotifications ", SupportedChannels: []string{" Slack ", "email", "slack"}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(context.Context, any) (any, error) { @@ -26,7 +26,7 @@ func TestRegistryRegisterNormalizesDefinition(t *testing.T) { definition, ok := registry.Definition(Kind("risk_review_due")) require.True(t, ok) - assert.Equal(t, NotificationTypeRiskNotifications, definition.SubscriptionType) + assert.Equal(t, SubscriptionGateRiskNotifications, definition.SubscriptionGate) assert.Equal(t, []string{DeliveryChannelEmail, DeliveryChannelSlack}, definition.SupportedChannels) } diff --git a/internal/service/notification/resolver.go b/internal/service/notification/resolver.go index a96df91a..efb98cd9 100644 --- a/internal/service/notification/resolver.go +++ b/internal/service/notification/resolver.go @@ -34,8 +34,8 @@ func (u User) FullName() string { return strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName) } -func (u User) NotificationChannels(notificationType string) []string { - normalizedType, ok := NormalizeNotificationType(notificationType) +func (u User) NotificationChannels(subscriptionGate string) []string { + normalizedGate, ok := NormalizeSubscriptionGate(subscriptionGate) if !ok { return nil } @@ -43,8 +43,8 @@ func (u User) NotificationChannels(notificationType string) []string { seen := make(map[string]struct{}) channels := make([]string, 0) for _, subscription := range u.Subscriptions { - currentType, typeOK := NormalizeNotificationType(subscription.NotificationType) - if !typeOK || currentType != normalizedType { + currentGate, gateOK := NormalizeSubscriptionGate(subscription.NotificationType) + if !gateOK || currentGate != normalizedGate { continue } @@ -66,7 +66,7 @@ func (u User) NotificationChannels(notificationType string) []string { type UserRepository interface { FindUserByID(ctx context.Context, userID string) (User, error) - ListActiveUsersByNotificationType(ctx context.Context, notificationType string) ([]User, error) + ListActiveUsersBySubscriptionGate(ctx context.Context, subscriptionGate string) ([]User, error) } type ActiveUserRepository interface { @@ -128,17 +128,17 @@ func (r *GORMUserRepository) FindUserByID(ctx context.Context, userID string) (U }, nil } -func (r *GORMUserRepository) ListActiveUsersByNotificationType(ctx context.Context, notificationType string) ([]User, error) { - canonicalType, ok := NormalizeNotificationType(notificationType) +func (r *GORMUserRepository) ListActiveUsersBySubscriptionGate(ctx context.Context, subscriptionGate string) ([]User, error) { + canonicalGate, ok := NormalizeSubscriptionGate(subscriptionGate) if !ok { return []User{}, nil } var subscriptions []relational.UserNotificationSubscription if err := r.db.WithContext(ctx). - Where("notification_type = ?", canonicalType). + Where("notification_type = ?", canonicalGate). Find(&subscriptions).Error; err != nil { - return nil, fmt.Errorf("failed to fetch notification subscriptions for type %s: %w", canonicalType, err) + return nil, fmt.Errorf("failed to fetch notification subscriptions for gate %s: %w", canonicalGate, err) } userIDSet := make(map[string]struct{}, len(subscriptions)) @@ -170,12 +170,12 @@ func (r *GORMUserRepository) ListActiveUsersByNotificationType(ctx context.Conte Where("id IN ?", userIDs). Where("is_active = ? AND is_locked = ?", true, false). Find(&records).Error; err != nil { - return nil, fmt.Errorf("failed to fetch subscribed users for type %s: %w", canonicalType, err) + return nil, fmt.Errorf("failed to fetch subscribed users for gate %s: %w", canonicalGate, err) } identitiesByUserID, err := r.loadProviderIdentitiesByUserID(ctx, userIDs) if err != nil { - return nil, fmt.Errorf("failed to fetch notification identities for type %s: %w", canonicalType, err) + return nil, fmt.Errorf("failed to fetch notification identities for gate %s: %w", canonicalGate, err) } subscriptionsByUserID := make(map[string][]UserSubscription, len(subscriptions)) @@ -214,19 +214,19 @@ func (r *GORMUserRepository) ListActiveUsersByNotificationType(ctx context.Conte return users, nil } -// ListActiveUserIDsByNotificationType returns IDs for active, unlocked users -// subscribed to the given notification type on at least one valid channel. -func (r *GORMUserRepository) ListActiveUserIDsByNotificationType(ctx context.Context, notificationType string) ([]string, error) { - canonicalType, ok := NormalizeNotificationType(notificationType) +// ListActiveUserIDsBySubscriptionGate returns IDs for active, unlocked users +// subscribed to the given subscription gate on at least one valid channel. +func (r *GORMUserRepository) ListActiveUserIDsBySubscriptionGate(ctx context.Context, subscriptionGate string) ([]string, error) { + canonicalGate, ok := NormalizeSubscriptionGate(subscriptionGate) if !ok { return []string{}, nil } var subscriptions []relational.UserNotificationSubscription if err := r.db.WithContext(ctx). - Where("notification_type = ?", canonicalType). + Where("notification_type = ?", canonicalGate). Find(&subscriptions).Error; err != nil { - return nil, fmt.Errorf("failed to fetch notification subscriptions for type %s: %w", canonicalType, err) + return nil, fmt.Errorf("failed to fetch notification subscriptions for gate %s: %w", canonicalGate, err) } userIDSet := make(map[string]struct{}, len(subscriptions)) @@ -260,7 +260,7 @@ func (r *GORMUserRepository) ListActiveUserIDsByNotificationType(ctx context.Con Where("id IN ?", userIDs). Where("is_active = ? AND is_locked = ?", true, false). Pluck("id", &activeUserIDs).Error; err != nil { - return nil, fmt.Errorf("failed to fetch subscribed users for type %s: %w", canonicalType, err) + return nil, fmt.Errorf("failed to fetch subscribed users for gate %s: %w", canonicalGate, err) } sort.Strings(activeUserIDs) @@ -420,11 +420,11 @@ func (r *Resolver) ListSubscribedUsers(ctx context.Context, definition Definitio if err != nil { return nil, err } - if normalizedDefinition.SubscriptionType == "" { + if normalizedDefinition.SubscriptionGate == "" { return r.listActiveUsers(ctx) } - return r.users.ListActiveUsersByNotificationType(ctx, normalizedDefinition.SubscriptionType) + return r.users.ListActiveUsersBySubscriptionGate(ctx, normalizedDefinition.SubscriptionGate) } func (r *Resolver) listActiveUsers(ctx context.Context) ([]User, error) { @@ -474,12 +474,12 @@ func (r *Resolver) ResolveUser(user User, options DispatchOptions, definition De func (r *Resolver) resolveUser(user User, options DispatchOptions, definition Definition) ([]Target, error) { channels := append([]string(nil), definition.SupportedChannels...) - if definition.SubscriptionType != NotificationTypeUngated { - if definition.SubscriptionType == "" { - return nil, ErrMissingSubscriptionType + if definition.SubscriptionGate != SubscriptionGateUngated { + if definition.SubscriptionGate == "" { + return nil, ErrMissingSubscriptionGate } - channels = user.NotificationChannels(definition.SubscriptionType) + channels = user.NotificationChannels(definition.SubscriptionGate) } channels = applyRequestedChannelFilter(channels, options.RequestedChannel) if len(channels) == 0 { diff --git a/internal/service/notification/resolver_test.go b/internal/service/notification/resolver_test.go index 0b490e1d..5eb082a3 100644 --- a/internal/service/notification/resolver_test.go +++ b/internal/service/notification/resolver_test.go @@ -21,12 +21,12 @@ func (r stubUserRepository) FindUserByID(_ context.Context, userID string) (User return r.users[userID], nil } -func (r stubUserRepository) ListActiveUsersByNotificationType(_ context.Context, notificationType string) ([]User, error) { +func (r stubUserRepository) ListActiveUsersBySubscriptionGate(_ context.Context, notificationType string) ([]User, error) { if r.err != nil { return nil, r.err } - canonicalType, ok := NormalizeNotificationType(notificationType) + canonicalType, ok := NormalizeSubscriptionGate(notificationType) if !ok { return []User{}, nil } @@ -86,7 +86,7 @@ func TestResolverResolveUserAudienceUsesSubscriptionsAndSkipsMissingSlackLink(t }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeRiskNotifications, + NotificationType: SubscriptionGateRiskNotifications, Channels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, }, }, @@ -101,7 +101,7 @@ func TestResolverResolveUserAudienceUsesSubscriptionsAndSkipsMissingSlackLink(t }, }, Definition{ Kind: Kind("risk_review_due"), - SubscriptionType: NotificationTypeRiskNotifications, + SubscriptionGate: SubscriptionGateRiskNotifications, SupportedChannels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(context.Context, any) (any, error) { @@ -153,7 +153,7 @@ func TestResolverResolveUserAudienceSupportsUngatedDefinitions(t *testing.T) { }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeTaskAvailable, + NotificationType: SubscriptionGateTaskAvailable, Channels: []string{DeliveryChannelSlack}, }, }, @@ -168,7 +168,7 @@ func TestResolverResolveUserAudienceSupportsUngatedDefinitions(t *testing.T) { }, }, Definition{ Kind: Kind("workflow_execution_failed"), - SubscriptionType: NotificationTypeUngated, + SubscriptionGate: SubscriptionGateUngated, SupportedChannels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(context.Context, any) (any, error) { @@ -258,7 +258,7 @@ func TestResolverListSubscribedUsers(t *testing.T) { }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Channels: []string{DeliveryChannelEmail}, }, }, @@ -268,7 +268,7 @@ func TestResolverListSubscribedUsers(t *testing.T) { Email: "bob@example.com", Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeRiskNotifications, + NotificationType: SubscriptionGateRiskNotifications, Channels: []string{DeliveryChannelEmail}, }, }, @@ -278,7 +278,7 @@ func TestResolverListSubscribedUsers(t *testing.T) { users, err := resolver.ListSubscribedUsers(context.Background(), Definition{ Kind: Kind("evidence_digest"), - SubscriptionType: NotificationTypeEvidenceDigest, + SubscriptionGate: SubscriptionGateEvidenceDigest, SupportedChannels: []string{DeliveryChannelEmail}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(context.Context, any) (any, error) { @@ -303,7 +303,7 @@ func TestResolverListSubscribedUsersSupportsUngatedDefinitions(t *testing.T) { Email: "bob@example.com", Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeRiskNotifications, + NotificationType: SubscriptionGateRiskNotifications, Channels: []string{DeliveryChannelEmail}, }, }, @@ -313,7 +313,7 @@ func TestResolverListSubscribedUsersSupportsUngatedDefinitions(t *testing.T) { users, err := resolver.ListSubscribedUsers(context.Background(), Definition{ Kind: Kind("workflow_execution_failed"), - SubscriptionType: NotificationTypeUngated, + SubscriptionGate: SubscriptionGateUngated, SupportedChannels: []string{DeliveryChannelEmail}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(context.Context, any) (any, error) { diff --git a/internal/service/notification/service_test.go b/internal/service/notification/service_test.go index e001b40a..e32758de 100644 --- a/internal/service/notification/service_test.go +++ b/internal/service/notification/service_test.go @@ -30,7 +30,7 @@ func (t *stubTransport) byChannel(channel string) []Delivery { func TestServiceDispatchEnqueuesProviderReadyDeliveries(t *testing.T) { registry := MustNewRegistry(Definition{ Kind: Kind("risk_review_due"), - SubscriptionType: NotificationTypeRiskNotifications, + SubscriptionGate: SubscriptionGateRiskNotifications, SupportedChannels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(context.Context, any) (any, error) { @@ -53,7 +53,7 @@ func TestServiceDispatchEnqueuesProviderReadyDeliveries(t *testing.T) { }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeRiskNotifications, + NotificationType: SubscriptionGateRiskNotifications, Channels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, }, }, @@ -106,7 +106,7 @@ func TestServiceDispatchEnqueuesProviderReadyDeliveries(t *testing.T) { func TestServiceDispatchNoTargetsBecomesNoop(t *testing.T) { registry := MustNewRegistry(Definition{ Kind: Kind("risk_review_due"), - SubscriptionType: NotificationTypeRiskNotifications, + SubscriptionGate: SubscriptionGateRiskNotifications, SupportedChannels: []string{DeliveryChannelSlack}, Renderers: map[string]ChannelRenderer{ DeliveryChannelSlack: ProviderRenderer(DeliveryChannelSlack, func(context.Context, any) (any, error) { @@ -122,7 +122,7 @@ func TestServiceDispatchNoTargetsBecomesNoop(t *testing.T) { Email: "user@example.com", Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeRiskNotifications, + NotificationType: SubscriptionGateRiskNotifications, Channels: []string{DeliveryChannelSlack}, }, }, @@ -161,7 +161,7 @@ func TestServiceDispatchReturnsDefinitionErrorForUnknownKind(t *testing.T) { func TestServiceDispatchFanoutSubscribedUsersBuildsPerUserDeliveries(t *testing.T) { registry := MustNewRegistry(Definition{ Kind: Kind("evidence_digest"), - SubscriptionType: NotificationTypeEvidenceDigest, + SubscriptionGate: SubscriptionGateEvidenceDigest, SupportedChannels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(_ context.Context, model any) (any, error) { @@ -187,7 +187,7 @@ func TestServiceDispatchFanoutSubscribedUsersBuildsPerUserDeliveries(t *testing. }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Channels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, }, }, @@ -201,7 +201,7 @@ func TestServiceDispatchFanoutSubscribedUsersBuildsPerUserDeliveries(t *testing. }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Channels: []string{DeliveryChannelEmail}, }, }, @@ -251,7 +251,7 @@ func TestServiceDispatchFanoutSubscribedUsersBuildsPerUserDeliveries(t *testing. func TestServiceDispatchFanoutSupportsSharedAndSubscribedRequests(t *testing.T) { registry := MustNewRegistry(Definition{ Kind: Kind("evidence_digest"), - SubscriptionType: NotificationTypeEvidenceDigest, + SubscriptionGate: SubscriptionGateEvidenceDigest, SupportedChannels: []string{DeliveryChannelEmail, DeliveryChannelSlack}, Renderers: map[string]ChannelRenderer{ DeliveryChannelEmail: ProviderRenderer(DeliveryChannelEmail, func(_ context.Context, model any) (any, error) { @@ -274,7 +274,7 @@ func TestServiceDispatchFanoutSupportsSharedAndSubscribedRequests(t *testing.T) }, Subscriptions: []UserSubscription{ { - NotificationType: NotificationTypeEvidenceDigest, + NotificationType: SubscriptionGateEvidenceDigest, Channels: []string{DeliveryChannelEmail}, }, }, diff --git a/internal/service/notification/system_destination_repository.go b/internal/service/notification/system_destination_repository.go index 9dd25713..bd992b99 100644 --- a/internal/service/notification/system_destination_repository.go +++ b/internal/service/notification/system_destination_repository.go @@ -9,7 +9,8 @@ import ( ) type SystemDestinationRepository interface { - ListTargetsByNotificationType(ctx context.Context, notificationType string) ([]Target, error) + ListTargetsBySubscriptionGate(ctx context.Context, subscriptionGate string) ([]Target, error) + ListTargetsBySystemNotificationName(ctx context.Context, notificationName string) ([]Target, error) } type GORMSystemDestinationRepository struct { @@ -24,21 +25,38 @@ func NewGORMSystemDestinationRepository(db *gorm.DB, providers ProviderLookup) * } } -func (r *GORMSystemDestinationRepository) ListTargetsByNotificationType(ctx context.Context, notificationType string) ([]Target, error) { - if r == nil || r.db == nil || r.providers == nil { +func (r *GORMSystemDestinationRepository) ListTargetsBySubscriptionGate(ctx context.Context, subscriptionGate string) ([]Target, error) { + if r.db == nil || r.providers == nil { return nil, fmt.Errorf("system notification destination repository is not configured") } - canonicalType, ok := NormalizeNotificationType(notificationType) + canonicalGate, ok := NormalizeSubscriptionGate(subscriptionGate) if !ok { return []Target{}, nil } + return r.listTargetsBySystemNotificationName(ctx, canonicalGate) +} + +func (r *GORMSystemDestinationRepository) ListTargetsBySystemNotificationName(ctx context.Context, notificationName string) ([]Target, error) { + if r.db == nil || r.providers == nil { + return nil, fmt.Errorf("system notification destination repository is not configured") + } + + canonicalName, ok := NormalizeSystemNotificationName(notificationName) + if !ok { + return []Target{}, nil + } + + return r.listTargetsBySystemNotificationName(ctx, canonicalName) +} + +func (r *GORMSystemDestinationRepository) listTargetsBySystemNotificationName(ctx context.Context, notificationName string) ([]Target, error) { var records []relational.SystemNotificationDestination if err := r.db.WithContext(ctx). - Where("notification_type = ?", canonicalType). + Where("notification_type = ?", notificationName). Find(&records).Error; err != nil { - return nil, fmt.Errorf("failed to fetch system notification destinations for type %s: %w", canonicalType, err) + return nil, fmt.Errorf("failed to fetch system notification destinations for %s: %w", notificationName, err) } targets := make([]Target, 0, len(records)) @@ -75,18 +93,18 @@ func (r *GORMSystemDestinationRepository) ListTargetsByNotificationType(ctx cont }) if err != nil { return nil, fmt.Errorf( - "invalid system notification destination %s for type %s provider %s: %w", + "invalid system notification destination %s for %s provider %s: %w", recordID, - canonicalType, + notificationName, provider, err, ) } if err := target.Validate(); err != nil { return nil, fmt.Errorf( - "invalid system notification destination %s for type %s provider %s: %w", + "invalid system notification destination %s for %s provider %s: %w", recordID, - canonicalType, + notificationName, provider, err, ) diff --git a/internal/service/notification/system_destination_repository_test.go b/internal/service/notification/system_destination_repository_test.go index be1efc31..dfc83f96 100644 --- a/internal/service/notification/system_destination_repository_test.go +++ b/internal/service/notification/system_destination_repository_test.go @@ -14,11 +14,11 @@ import ( "gorm.io/gorm" ) -func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsTargets(t *testing.T) { +func TestGORMSystemDestinationRepositoryListTargetsBySubscriptionGateExpandsTargets(t *testing.T) { db := newNotificationSystemDestinationTestDB(t) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -29,7 +29,7 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsTarg }).Error) repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) - targets, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + targets, err := repo.ListTargetsBySubscriptionGate(context.Background(), notification.SubscriptionGateEvidenceDigest) require.NoError(t, err) require.Len(t, targets, 1) @@ -38,11 +38,33 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsTarg assert.Equal(t, "channel", targets[0].Address["target_type"]) } -func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsMultipleRowsForSameProvider(t *testing.T) { +func TestGORMSystemDestinationRepositoryListTargetsBySystemNotificationNameExpandsWorkflowFailureTargets(t *testing.T) { db := newNotificationSystemDestinationTestDB(t) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SystemNotificationNameWorkflowExecutionFailed, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "email": " alerts@example.com ", + }, + }), + }).Error) + + repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) + targets, err := repo.ListTargetsBySystemNotificationName(context.Background(), " workflowExecutionFailed ") + require.NoError(t, err) + require.Len(t, targets, 1) + + assert.Equal(t, notification.DeliveryChannelEmail, targets[0].Provider) + assert.Equal(t, "alerts@example.com", targets[0].Address["email"]) +} + +func TestGORMSystemDestinationRepositoryListTargetsBySubscriptionGateExpandsMultipleRowsForSameProvider(t *testing.T) { + db := newNotificationSystemDestinationTestDB(t) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -52,7 +74,7 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsMult }), }).Error) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -63,7 +85,7 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsMult }).Error) repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) - targets, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + targets, err := repo.ListTargetsBySubscriptionGate(context.Background(), notification.SubscriptionGateEvidenceDigest) require.NoError(t, err) require.Len(t, targets, 2) @@ -71,11 +93,11 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsMult assert.ElementsMatch(t, []string{"C-PRIMARY", "C-SECONDARY"}, channels) } -func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeDeduplicatesTargets(t *testing.T) { +func TestGORMSystemDestinationRepositoryListTargetsBySubscriptionGateDeduplicatesTargets(t *testing.T) { db := newNotificationSystemDestinationTestDB(t) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -85,7 +107,7 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeDeduplicate }), }).Error) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -96,17 +118,17 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeDeduplicate }).Error) repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) - targets, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + targets, err := repo.ListTargetsBySubscriptionGate(context.Background(), notification.SubscriptionGateEvidenceDigest) require.NoError(t, err) require.Len(t, targets, 1) assert.Equal(t, "C-DIGEST", targets[0].Address["channel"]) } -func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeRejectsInvalidTargets(t *testing.T) { +func TestGORMSystemDestinationRepositoryListTargetsBySubscriptionGateRejectsInvalidTargets(t *testing.T) { db := newNotificationSystemDestinationTestDB(t) require.NoError(t, db.Create(&relational.SystemNotificationDestination{ - NotificationType: notification.NotificationTypeEvidenceDigest, + NotificationType: notification.SubscriptionGateEvidenceDigest, Provider: notification.DeliveryChannelSlack, Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ Address: map[string]string{ @@ -116,7 +138,7 @@ func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeRejectsInva }).Error) repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) - _, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + _, err := repo.ListTargetsBySubscriptionGate(context.Background(), notification.SubscriptionGateEvidenceDigest) require.Error(t, err) assert.ErrorContains(t, err, "invalid system notification destination") } diff --git a/internal/service/notification/types.go b/internal/service/notification/types.go index a3619e4b..9102f62f 100644 --- a/internal/service/notification/types.go +++ b/internal/service/notification/types.go @@ -20,7 +20,7 @@ var ( ErrRegistryNotConfigured = errors.New("notification registry is not configured") ErrTransportNotConfigured = errors.New("notification transport is not configured") ErrConfiguredDestinationNotFound = errors.New("configured notification destination not found") - ErrMissingSubscriptionType = errors.New("notification definition requires a subscription type for user audiences") + ErrMissingSubscriptionGate = errors.New("notification definition requires a subscription gate for user audiences") ErrUngatedUserListingNotSupported = errors.New("notification user repository does not support ungated audience listing") ) diff --git a/internal/service/slack/formatters/workflow_execution_failed_test.go b/internal/service/slack/formatters/workflow_execution_failed_test.go index 6865ceae..cdc533bf 100644 --- a/internal/service/slack/formatters/workflow_execution_failed_test.go +++ b/internal/service/slack/formatters/workflow_execution_failed_test.go @@ -43,3 +43,41 @@ func TestFormatWorkflowExecutionFailedMessage_DefaultsMissingFields(t *testing.T assert.Contains(t, msg.Text, "Workflow execution failed: Workflow") require.NotEmpty(t, msg.Blocks) } + +func TestFormatWorkflowExecutionFailedMessage_GenericAudienceOmitsMyTasksLink(t *testing.T) { + msg, err := FormatWorkflowExecutionFailedMessage( + "", + "Annual Audit", + "Audit 2026", + "exec-123", + "step execution failed", + "17 Apr 2026", + 1, + 3, + 4, + "https://app.example.com/workflow-executions/exec-123", + "", + ) + require.NoError(t, err) + assert.Contains(t, msg.Text, "Workflow execution failed") + require.NotEmpty(t, msg.Blocks) + + blockText := workflowExecutionFailedSectionText(msg.Blocks) + assert.Contains(t, blockText, "A workflow execution has failed and may require your attention.") + assert.Contains(t, blockText, "View Workflow Instance") + assert.NotContains(t, blockText, "Hi ") + assert.NotContains(t, blockText, "View My Tasks") +} + +func workflowExecutionFailedSectionText(blocks []slack.Block) string { + parts := []string{} + for _, block := range blocks { + section, ok := block.(*slack.SectionBlock) + if !ok || section.Text == nil { + continue + } + parts = append(parts, section.Text.Text) + } + + return strings.Join(parts, "\n") +} diff --git a/internal/service/worker/jobs.go b/internal/service/worker/jobs.go index 095bf692..dcb2f9a4 100644 --- a/internal/service/worker/jobs.go +++ b/internal/service/worker/jobs.go @@ -168,8 +168,8 @@ func (u NotificationUser) FullName() string { return u.FirstName + " " + u.LastName } -func (u NotificationUser) NotificationChannels(notificationType string) []string { - normalizedType, ok := notification.NormalizeNotificationType(notificationType) +func (u NotificationUser) NotificationChannels(subscriptionGate string) []string { + normalizedGate, ok := notification.NormalizeSubscriptionGate(subscriptionGate) if !ok { return nil } @@ -177,8 +177,8 @@ func (u NotificationUser) NotificationChannels(notificationType string) []string seen := map[string]struct{}{} channels := make([]string, 0) for _, subscription := range u.NotificationSubscriptions { - currentType, currentTypeOK := notification.NormalizeNotificationType(subscription.NotificationType) - if !currentTypeOK || currentType != normalizedType { + currentGate, currentGateOK := notification.NormalizeSubscriptionGate(subscription.NotificationType) + if !currentGateOK || currentGate != normalizedGate { continue } diff --git a/internal/service/worker/jobs_notification_channels_test.go b/internal/service/worker/jobs_notification_channels_test.go index 17c02126..be8a9ff5 100644 --- a/internal/service/worker/jobs_notification_channels_test.go +++ b/internal/service/worker/jobs_notification_channels_test.go @@ -15,7 +15,7 @@ func TestNotificationUserNotificationChannels_NormalizesAndDeduplicates(t *testi Channels: []string{" Email ", "slack", "EMAIL", "pagerduty"}, }, { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"SLACK", "email"}, }, { @@ -25,7 +25,7 @@ func TestNotificationUserNotificationChannels_NormalizesAndDeduplicates(t *testi }, } - channels := user.NotificationChannels(notification.NotificationTypeTaskAvailable) + channels := user.NotificationChannels(notification.SubscriptionGateTaskAvailable) assert.Equal(t, []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack}, channels) } @@ -33,7 +33,7 @@ func TestNotificationUserNotificationChannels_InvalidRequestedType(t *testing.T) user := NotificationUser{ NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail}, }, }, @@ -46,13 +46,13 @@ func TestSelectUserNotificationChannels_ReturnsRequestedChannelOnly(t *testing.T user := NotificationUser{ NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack}, }, }, } - channels, ok := selectUserNotificationChannels(user, notification.NotificationTypeTaskAvailable, notification.DeliveryChannelSlack) + channels, ok := selectUserNotificationChannels(user, notification.SubscriptionGateTaskAvailable, notification.DeliveryChannelSlack) assert.True(t, ok) assert.Equal(t, []string{notification.DeliveryChannelSlack}, channels) } @@ -61,13 +61,13 @@ func TestSelectUserNotificationChannels_EmptyRequestedChannelReturnsAllSubscribe user := NotificationUser{ NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack}, }, }, } - channels, ok := selectUserNotificationChannels(user, notification.NotificationTypeTaskAvailable, "") + channels, ok := selectUserNotificationChannels(user, notification.SubscriptionGateTaskAvailable, "") assert.True(t, ok) assert.Equal(t, []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack}, channels) } @@ -76,13 +76,13 @@ func TestSelectUserNotificationChannels_UnsubscribedRequestedChannelSkips(t *tes user := NotificationUser{ NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail}, }, }, } - channels, ok := selectUserNotificationChannels(user, notification.NotificationTypeTaskAvailable, notification.DeliveryChannelSlack) + channels, ok := selectUserNotificationChannels(user, notification.SubscriptionGateTaskAvailable, notification.DeliveryChannelSlack) assert.True(t, ok) assert.Empty(t, channels) } @@ -91,13 +91,13 @@ func TestSelectUserNotificationChannels_InvalidRequestedChannelReturnsFalse(t *t user := NotificationUser{ NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail}, }, }, } - channels, ok := selectUserNotificationChannels(user, notification.NotificationTypeTaskAvailable, "pagerduty") + channels, ok := selectUserNotificationChannels(user, notification.SubscriptionGateTaskAvailable, "pagerduty") assert.False(t, ok) assert.Nil(t, channels) } diff --git a/internal/service/worker/notification_definition_helpers.go b/internal/service/worker/notification_definition_helpers.go index 730abe83..94d720b4 100644 --- a/internal/service/worker/notification_definition_helpers.go +++ b/internal/service/worker/notification_definition_helpers.go @@ -43,7 +43,7 @@ func decodeNotificationModel[T any](model any, modelName string) (T, error) { func newTypedNotificationDefinition[T any]( kind notification.Kind, - subscriptionType string, + subscriptionGate string, decode notificationModelDecoder[T], emailRender emailTemplateNotificationRenderer[T], slackRender slackMessageNotificationRenderer[T], @@ -70,16 +70,16 @@ func newTypedNotificationDefinition[T any]( })) } - return notification.NewDefinition(kind, subscriptionType, bindings...) + return notification.NewDefinition(kind, subscriptionGate, bindings...) } func newTypedEmailOnlyNotificationDefinition[T any]( kind notification.Kind, - subscriptionType string, + subscriptionGate string, decode notificationModelDecoder[T], emailRender emailTemplateNotificationRenderer[T], ) notification.Definition { - return newTypedNotificationDefinition(kind, subscriptionType, decode, emailRender, nil) + return newTypedNotificationDefinition(kind, subscriptionGate, decode, emailRender, nil) } func newNotificationRequest( diff --git a/internal/service/worker/poam_deadline_reminder_worker_test.go b/internal/service/worker/poam_deadline_reminder_worker_test.go index 59632ba2..be6fcfa0 100644 --- a/internal/service/worker/poam_deadline_reminder_worker_test.go +++ b/internal/service/worker/poam_deadline_reminder_worker_test.go @@ -28,7 +28,7 @@ func TestPoamDeadlineReminderWorker_SlackSubscribedUser_SendsSlack(t *testing.T) LastName: "Person", SlackUserID: "USLACKPOAM1", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeRiskNotifications, Channels: []string{"slack"}}, + {NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{"slack"}}, }, } mockRepo.On("FindUserByID", ctx, recipientUserID).Return(user, nil) @@ -78,7 +78,7 @@ func TestPoamDeadlineReminderWorker_EmailAndSlackSubscribedUser_SendsBoth(t *tes LastName: "Person", SlackUserID: "USLACKPOAM2", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeRiskNotifications, Channels: []string{"email", "slack"}}, + {NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{"email", "slack"}}, }, } mockRepo.On("FindUserByID", ctx, recipientUserID).Return(user, nil) diff --git a/internal/service/worker/poam_milestone_overdue_worker_test.go b/internal/service/worker/poam_milestone_overdue_worker_test.go index 22aabc7e..77793f3b 100644 --- a/internal/service/worker/poam_milestone_overdue_worker_test.go +++ b/internal/service/worker/poam_milestone_overdue_worker_test.go @@ -28,7 +28,7 @@ func TestMilestoneOverdueReminderWorker_SlackSubscribedUser_SendsSlack(t *testin LastName: "Person", SlackUserID: "USLACKPOAM5", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeRiskNotifications, Channels: []string{"slack"}}, + {NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{"slack"}}, }, } mockRepo.On("FindUserByID", ctx, recipientUserID).Return(user, nil) @@ -78,7 +78,7 @@ func TestMilestoneOverdueReminderWorker_EmailAndSlackSubscribedUser_SendsBoth(t LastName: "Person", SlackUserID: "USLACKPOAM6", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeRiskNotifications, Channels: []string{"email", "slack"}}, + {NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{"email", "slack"}}, }, } mockRepo.On("FindUserByID", ctx, recipientUserID).Return(user, nil) diff --git a/internal/service/worker/poam_notifications.go b/internal/service/worker/poam_notifications.go index dc94ff0b..f1d19b61 100644 --- a/internal/service/worker/poam_notifications.go +++ b/internal/service/worker/poam_notifications.go @@ -74,28 +74,28 @@ func NewPoamNotificationServiceFactory( definitions: []notification.Definition{ newTypedNotificationDefinition( poamDeadlineReminderNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[poamDeadlineReminderNotificationModel]("poam deadline reminder model"), renderPoamDeadlineReminderEmail, renderPoamDeadlineReminderSlack, ), newTypedNotificationDefinition( poamMilestoneOverdueNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[poamMilestoneOverdueNotificationModel]("poam milestone overdue notification model"), renderPoamMilestoneOverdueNotificationEmail, renderPoamMilestoneOverdueNotificationSlack, ), newTypedNotificationDefinition( poamOverdueNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[poamOverdueNotificationModel]("poam overdue notification model"), renderPoamOverdueNotificationEmail, renderPoamOverdueNotificationSlack, ), newTypedEmailOnlyNotificationDefinition( poamOpenDigestNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[poamOpenDigestNotificationModel]("poam open digest model"), renderPoamOpenDigestEmail, ), diff --git a/internal/service/worker/poam_overdue_notification_worker_test.go b/internal/service/worker/poam_overdue_notification_worker_test.go index fb85a81c..f46a7278 100644 --- a/internal/service/worker/poam_overdue_notification_worker_test.go +++ b/internal/service/worker/poam_overdue_notification_worker_test.go @@ -28,7 +28,7 @@ func TestPoamOverdueNotificationWorker_SlackSubscribedUser_SendsSlack(t *testing LastName: "Person", SlackUserID: "USLACKPOAM3", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeRiskNotifications, Channels: []string{"slack"}}, + {NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{"slack"}}, }, } mockRepo.On("FindUserByID", ctx, recipientUserID).Return(user, nil) @@ -76,7 +76,7 @@ func TestPoamOverdueNotificationWorker_EmailAndSlackSubscribedUser_SendsBoth(t * LastName: "Person", SlackUserID: "USLACKPOAM4", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeRiskNotifications, Channels: []string{"email", "slack"}}, + {NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{"email", "slack"}}, }, } mockRepo.On("FindUserByID", ctx, recipientUserID).Return(user, nil) diff --git a/internal/service/worker/poam_workers_integration_test.go b/internal/service/worker/poam_workers_integration_test.go index e2c4809c..c3b927f9 100644 --- a/internal/service/worker/poam_workers_integration_test.go +++ b/internal/service/worker/poam_workers_integration_test.go @@ -72,7 +72,7 @@ func (suite *PoamWorkersIntegrationSuite) seedUser(email string) uuid.UUID { }).Error) suite.Require().NoError(suite.DB.Create(&relational.UserNotificationSubscription{ UserID: id.String(), - NotificationType: notification.NotificationTypeRiskNotifications, + NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{notification.DeliveryChannelEmail}, }).Error) return id diff --git a/internal/service/worker/risk_digest_worker_integration_test.go b/internal/service/worker/risk_digest_worker_integration_test.go index cae34f9a..72ce33af 100644 --- a/internal/service/worker/risk_digest_worker_integration_test.go +++ b/internal/service/worker/risk_digest_worker_integration_test.go @@ -54,7 +54,7 @@ func (suite *RiskOpenDigestIntegrationSuite) TestRiskOpenDigestSchedulerAndWorke }).Error) suite.Require().NoError(suite.DB.Create(&relational.UserNotificationSubscription{ UserID: recipientID.String(), - NotificationType: notification.NotificationTypeRiskNotifications, + NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{notification.DeliveryChannelEmail}, }).Error) diff --git a/internal/service/worker/risk_digest_worker_test.go b/internal/service/worker/risk_digest_worker_test.go index c16fea82..40a7dcf3 100644 --- a/internal/service/worker/risk_digest_worker_test.go +++ b/internal/service/worker/risk_digest_worker_test.go @@ -282,7 +282,7 @@ func TestRiskOpenDigestWorker_SendsGroupedDigest(t *testing.T) { LastName: "Owner", NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeRiskNotifications, + NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{notification.DeliveryChannelEmail}, }, }, @@ -404,7 +404,7 @@ func TestRiskOpenDigestWorker_SlackSubscribed_SendsSlack(t *testing.T) { SlackUserID: "URISKDIGEST", NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeRiskNotifications, + NotificationType: notification.SubscriptionGateRiskNotifications, Channels: []string{notification.DeliveryChannelSlack}, }, }, diff --git a/internal/service/worker/risk_notifications.go b/internal/service/worker/risk_notifications.go index 676d38ac..241b37f6 100644 --- a/internal/service/worker/risk_notifications.go +++ b/internal/service/worker/risk_notifications.go @@ -58,28 +58,28 @@ func NewRiskNotificationServiceFactory( definitions: []notification.Definition{ newTypedNotificationDefinition( riskReviewDueReminderNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[riskReminderNotificationModel]("risk reminder model"), renderRiskReviewDueReminderEmail, renderRiskReviewDueReminderSlack, ), newTypedNotificationDefinition( riskReviewOverdueEscalationNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[riskReminderNotificationModel]("risk reminder model"), renderRiskReviewOverdueEscalationEmail, renderRiskReviewOverdueEscalationSlack, ), newTypedNotificationDefinition( riskStaleOpenReminderNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[riskReminderNotificationModel]("risk reminder model"), renderRiskStaleOpenReminderEmail, renderRiskStaleOpenReminderSlack, ), newTypedNotificationDefinition( riskOpenDigestNotificationKind, - notification.NotificationTypeRiskNotifications, + notification.SubscriptionGateRiskNotifications, newNotificationModelDecoder[riskOpenDigestNotificationModel]("risk open digest model"), renderRiskOpenDigestEmail, renderRiskOpenDigestSlack, diff --git a/internal/service/worker/risk_workers_test.go b/internal/service/worker/risk_workers_test.go index 9bc13fbd..8f683d9b 100644 --- a/internal/service/worker/risk_workers_test.go +++ b/internal/service/worker/risk_workers_test.go @@ -141,7 +141,7 @@ func createTestUserRiskChannels(t *testing.T, db *gorm.DB, id uuid.UUID, channel t.Helper() require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: id.String(), - NotificationType: notification.NotificationTypeRiskNotifications, + NotificationType: notification.SubscriptionGateRiskNotifications, Channels: channels, }).Error) if slackUserID == "" { diff --git a/internal/service/worker/workflow_execution_failed_worker.go b/internal/service/worker/workflow_execution_failed_worker.go index 20fdc68d..49cfcd3a 100644 --- a/internal/service/worker/workflow_execution_failed_worker.go +++ b/internal/service/worker/workflow_execution_failed_worker.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/compliance-framework/api/internal/service/notification" + notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" "github.com/compliance-framework/api/internal/service/relational/workflows" "github.com/compliance-framework/api/internal/workflow" "github.com/google/uuid" @@ -116,13 +117,25 @@ func (w *WorkflowExecutionFailedWorker) Work(ctx context.Context, job *river.Job FailedSteps: failedSteps, CompletedSteps: completedSteps, TotalSteps: totalSteps, - WorkflowURL: w.webBaseURL + "/my-tasks", + WorkflowURL: w.webBaseURL + "/workflow-executions/" + execution.ID.String(), MyTasksURL: w.webBaseURL + "/my-tasks", } + requests := []notification.Request{ + buildWorkflowExecutionFailedNotificationRequest(args, recipient.ID, model), + } + + targets, err := w.configuredWorkflowExecutionFailedTargets(ctx) + if err != nil { + return fmt.Errorf("resolve workflow-execution-failed system destinations: %w", err) + } + + if systemRequest, ok := buildWorkflowExecutionFailedSystemNotificationRequest(args, targets, model); ok { + requests = append(requests, systemRequest) + } - if err := notifier.Dispatch( + if err := notifier.DispatchFanout( ctx, - buildWorkflowExecutionFailedNotificationRequest(args, recipient.ID, model), + notification.FanoutRequest{Requests: requests}, ); err != nil { return fmt.Errorf("dispatch workflow-execution-failed notification: %w", err) } @@ -130,7 +143,17 @@ func (w *WorkflowExecutionFailedWorker) Work(ctx context.Context, job *river.Job w.logger.Infow("WorkflowExecutionFailedWorker: failure notification sent", "workflow_execution_id", args.WorkflowExecutionID, "recipient", recipient.Email, + "system_target_count", len(targets), ) return nil } + +func (w *WorkflowExecutionFailedWorker) configuredWorkflowExecutionFailedTargets(ctx context.Context) ([]notification.Target, error) { + if w.db == nil { + return []notification.Target{}, nil + } + + return notification.NewGORMSystemDestinationRepository(w.db, notificationproviders.NewLookup()). + ListTargetsBySystemNotificationName(ctx, notification.SystemNotificationNameWorkflowExecutionFailed) +} diff --git a/internal/service/worker/workflow_execution_failed_worker_test.go b/internal/service/worker/workflow_execution_failed_worker_test.go index bb0d067c..945fee66 100644 --- a/internal/service/worker/workflow_execution_failed_worker_test.go +++ b/internal/service/worker/workflow_execution_failed_worker_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.uber.org/zap" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -68,7 +69,7 @@ func TestWorkflowExecutionFailedWorker_SlackSubscribedUser_SendsAllAssociatedCha LastName: "Owner", SlackUserID: "UWFEXEC1", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack"}}, }, } mockRepo.On("FindUserByID", ctx, createdByID.String()).Return(user, nil) @@ -108,7 +109,7 @@ func TestWorkflowExecutionFailedWorker_EmailAndSlackUser_SendsBoth(t *testing.T) LastName: "Owner", SlackUserID: "UWFEXEC2", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"email", "slack"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"email", "slack"}}, }, } mockRepo.On("FindUserByID", ctx, createdByID.String()).Return(user, nil) @@ -131,6 +132,61 @@ func TestWorkflowExecutionFailedWorker_EmailAndSlackUser_SendsBoth(t *testing.T) mockRepo.AssertExpectations(t) } +func TestWorkflowExecutionFailedWorker_ConfiguredSystemEmailDestination_SendsSystemAudience(t *testing.T) { + ctx := context.Background() + db := newWorkflowNotificationJobsTestDB(t) + createdByID := uuid.New() + executionID := seedWorkflowExecutionFailedFixture(t, db, createdByID) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.SystemNotificationNameWorkflowExecutionFailed, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{"email": "alerts@example.com"}, + }), + }).Error) + + mockEmail := &MockEmailService{} + mockRepo := &MockUserRepository{} + mockLog := zap.NewNop().Sugar() + + user := NotificationUser{ + ID: createdByID.String(), + Email: "owner@example.com", + FirstName: "Workflow", + LastName: "Owner", + NotificationSubscriptions: []NotificationSubscription{ + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"email"}}, + }, + } + mockRepo.On("FindUserByID", ctx, createdByID.String()).Return(user, nil) + + mockEmail.On("UseTemplate", "workflow-execution-failed", mock.MatchedBy(func(data map[string]interface{}) bool { + return data["IsSystemAudience"] == false && + data["RecipientName"] == "Workflow Owner" && + data["MyTasksURL"] == "http://localhost:8000/my-tasks" + })).Return("failed", "failed text", nil).Once() + mockEmail.On("UseTemplate", "workflow-execution-failed", mock.MatchedBy(func(data map[string]interface{}) bool { + return data["IsSystemAudience"] == true && + data["RecipientName"] == "" && + data["MyTasksURL"] == "" + })).Return("system failed", "system failed text", nil).Once() + mockEmail.On("GetDefaultFromAddress").Return("noreply@example.com").Twice() + mockEmail.On("Send", ctx, mock.MatchedBy(func(msg *types.Message) bool { + return len(msg.To) == 1 && msg.To[0] == "owner@example.com" + })).Return(&types.SendResult{Success: true, MessageID: "wf-exec-email-user"}, nil).Once() + mockEmail.On("Send", ctx, mock.MatchedBy(func(msg *types.Message) bool { + return len(msg.To) == 1 && msg.To[0] == "alerts@example.com" + })).Return(&types.SendResult{Success: true, MessageID: "wf-exec-email-system"}, nil).Once() + + w := NewWorkflowExecutionFailedWorker(db, mockRepo, "http://localhost:8000", newTestNotificationRuntimeProvider(mockEmail, nil), mockLog) + + err := w.Work(ctx, makeFailedJob(WorkflowExecutionFailedArgs{WorkflowExecutionID: executionID.String()})) + assert.NoError(t, err) + mockEmail.AssertExpectations(t) + mockRepo.AssertExpectations(t) +} + func seedWorkflowExecutionFailedFixture(t *testing.T, db *gorm.DB, createdByID uuid.UUID) uuid.UUID { t.Helper() diff --git a/internal/service/worker/workflow_notification_jobs_test.go b/internal/service/worker/workflow_notification_jobs_test.go index 30ddcbfb..af52af87 100644 --- a/internal/service/worker/workflow_notification_jobs_test.go +++ b/internal/service/worker/workflow_notification_jobs_test.go @@ -99,7 +99,7 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { createWorkflowNotificationUser(t, db, userID, "alice@example.com", "UALICE") require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: userID.String(), - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: datatypes.JSONSlice[string]{ notification.DeliveryChannelEmail, notification.DeliveryChannelSlack, @@ -202,7 +202,7 @@ func TestDueSoonCheckerWorker_CachesFetchedUsersAcrossDispatches(t *testing.T) { LastName: "User", NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskAvailable, + NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{notification.DeliveryChannelEmail}, }, }, @@ -282,7 +282,7 @@ func TestWorkflowTaskDigestCheckerWorker_EnqueuesOneJobPerSubscribedUser(t *test require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: userOneID.String(), - NotificationType: notification.NotificationTypeTaskDailyDigest, + NotificationType: notification.SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{ notification.DeliveryChannelEmail, notification.DeliveryChannelSlack, @@ -290,12 +290,12 @@ func TestWorkflowTaskDigestCheckerWorker_EnqueuesOneJobPerSubscribedUser(t *test }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: userTwoID.String(), - NotificationType: notification.NotificationTypeTaskDailyDigest, + NotificationType: notification.SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{notification.DeliveryChannelEmail}, }).Error) require.NoError(t, db.Create(&relational.UserNotificationSubscription{ UserID: missingUserID.String(), - NotificationType: notification.NotificationTypeTaskDailyDigest, + NotificationType: notification.SubscriptionGateTaskDailyDigest, Channels: datatypes.JSONSlice[string]{notification.DeliveryChannelSlack}, }).Error) @@ -325,6 +325,7 @@ func newWorkflowNotificationJobsTestDB(t *testing.T) *gorm.DB { &relational.User{}, &relational.SlackUserLink{}, &relational.UserNotificationSubscription{}, + &relational.SystemNotificationDestination{}, &relational.SystemSecurityPlan{}, &workflows.WorkflowDefinition{}, &workflows.WorkflowInstance{}, diff --git a/internal/service/worker/workflow_notifications.go b/internal/service/worker/workflow_notifications.go index f6f6de7d..699118ac 100644 --- a/internal/service/worker/workflow_notifications.go +++ b/internal/service/worker/workflow_notifications.go @@ -16,7 +16,7 @@ const ( workflowTaskAssignedNotificationKind = notification.Kind(JobTypeWorkflowTaskAssigned) workflowTaskDueSoonNotificationKind = notification.Kind(JobTypeWorkflowTaskDueSoon) workflowTaskDigestNotificationKind = notification.Kind(JobTypeWorkflowTaskDigest) - workflowExecutionFailedNotificationKind = notification.Kind(JobTypeWorkflowExecutionFailed) + workflowExecutionFailedNotificationKind = notification.NotificationKindWorkflowExecutionFailed ) type workflowTaskAssignedNotificationModel struct { @@ -60,6 +60,7 @@ type workflowExecutionFailedNotificationModel struct { TotalSteps int WorkflowURL string MyTasksURL string + IsSystemAudience bool } type notificationUserRepositoryAdapter struct { @@ -75,28 +76,28 @@ func newWorkflowNotificationServiceFromFactory( users, newTypedNotificationDefinition( workflowTaskAssignedNotificationKind, - notification.NotificationTypeTaskAvailable, + notification.SubscriptionGateTaskAvailable, newNotificationModelDecoder[workflowTaskAssignedNotificationModel]("workflow task assigned model"), renderWorkflowTaskAssignedEmail, renderWorkflowTaskAssignedSlack, ), newTypedNotificationDefinition( workflowTaskDueSoonNotificationKind, - notification.NotificationTypeTaskAvailable, + notification.SubscriptionGateTaskAvailable, newNotificationModelDecoder[workflowTaskDueSoonNotificationModel]("workflow task due soon model"), renderWorkflowTaskDueSoonEmail, renderWorkflowTaskDueSoonSlack, ), newTypedNotificationDefinition( workflowTaskDigestNotificationKind, - notification.NotificationTypeTaskDailyDigest, + notification.SubscriptionGateTaskDailyDigest, newNotificationModelDecoder[workflowTaskDigestNotificationModel]("workflow task digest model"), renderWorkflowTaskDigestEmail, renderWorkflowTaskDigestSlack, ), newTypedNotificationDefinition( workflowExecutionFailedNotificationKind, - notification.NotificationTypeUngated, + notification.SubscriptionGateUngated, newNotificationModelDecoder[workflowExecutionFailedNotificationModel]("workflow execution failed model"), renderWorkflowExecutionFailedEmail, renderWorkflowExecutionFailedSlack, @@ -152,6 +153,7 @@ func newWorkflowExecutionFailedNotificationModel(data workflowExecutionFailedNot TotalSteps: data.TotalSteps, WorkflowURL: strings.TrimSpace(data.WorkflowURL), MyTasksURL: strings.TrimSpace(data.MyTasksURL), + IsSystemAudience: data.IsSystemAudience, } } @@ -210,6 +212,40 @@ func buildWorkflowExecutionFailedNotificationRequest( ) } +func buildWorkflowExecutionFailedSystemNotificationRequest( + args WorkflowExecutionFailedArgs, + targets []notification.Target, + data workflowExecutionFailedNotificationModel, +) (notification.Request, bool) { + audiences := make([]notification.Audience, 0, len(targets)) + for i := range targets { + // Directly assign the map since no mutation is needed + audiences = append(audiences, notification.Audience{ + Direct: ¬ification.DirectAudience{ + Provider: targets[i].Provider, + Address: targets[i].Address, + }, + }) + } + + if len(audiences) == 0 { + return notification.Request{}, false + } + + // Use the provided data directly and only modify necessary fields + systemModel := data + systemModel.RecipientName = "" + systemModel.MyTasksURL = "" + systemModel.IsSystemAudience = true + + return notification.Request{ + Kind: workflowExecutionFailedNotificationKind, + Audiences: audiences, + Model: systemModel, + Options: newJobDispatchOptions(JobTypeWorkflowExecutionFailed, "", args.WorkflowExecutionID), + }, true +} + func (m workflowTaskDigestNotificationModel) templateData() map[string]interface{} { return map[string]interface{}{ "UserName": m.UserName, @@ -233,6 +269,7 @@ func (m workflowExecutionFailedNotificationModel) templateData() map[string]inte "TotalSteps": m.TotalSteps, "WorkflowURL": m.WorkflowURL, "MyTasksURL": m.MyTasksURL, + "IsSystemAudience": m.IsSystemAudience, } } @@ -292,7 +329,7 @@ func (a *notificationUserRepositoryAdapter) cacheUsers(users ...NotificationUser } } -func (a *notificationUserRepositoryAdapter) ListActiveUsersByNotificationType(_ context.Context, notificationType string) ([]notification.User, error) { +func (a *notificationUserRepositoryAdapter) ListActiveUsersBySubscriptionGate(_ context.Context, notificationType string) ([]notification.User, error) { if a == nil || len(a.cached) == 0 { return []notification.User{}, nil } @@ -395,11 +432,18 @@ func renderWorkflowExecutionFailedEmail(failedModel workflowExecutionFailedNotif instanceName = failedModel.WorkflowTitle } + subject := fmt.Sprintf("Workflow execution failed: %s", instanceName) + textBody := fmt.Sprintf("Workflow execution failed for %s. Open: %s", instanceName, failedModel.WorkflowURL) + if failedModel.IsSystemAudience { + subject = fmt.Sprintf("Workflow execution failed: %s", instanceName) + textBody = fmt.Sprintf("Workflow execution failed for %s. Review details: %s", instanceName, failedModel.WorkflowURL) + } + return emailprovider.TemplateContent{ TemplateName: "workflow-execution-failed", TemplateData: failedModel.templateData(), - Subject: fmt.Sprintf("Workflow execution failed: %s", instanceName), - TextBody: fmt.Sprintf("Workflow execution failed for %s. Open: %s", instanceName, failedModel.WorkflowURL), + Subject: subject, + TextBody: textBody, }, nil } @@ -451,6 +495,11 @@ func renderWorkflowTaskDigestSlack(digestModel workflowTaskDigestNotificationMod } func renderWorkflowExecutionFailedSlack(failedModel workflowExecutionFailedNotificationModel) (*slackprovider.Message, error) { + myTasksURL := failedModel.MyTasksURL + if failedModel.IsSystemAudience { + myTasksURL = "" + } + message, err := slackprovider.FormatWorkflowExecutionFailedMessage( failedModel.RecipientName, failedModel.WorkflowTitle, @@ -462,7 +511,7 @@ func renderWorkflowExecutionFailedSlack(failedModel workflowExecutionFailedNotif failedModel.CompletedSteps, failedModel.TotalSteps, failedModel.WorkflowURL, - failedModel.MyTasksURL, + myTasksURL, ) if err != nil { return nil, fmt.Errorf("failed to format workflow-execution-failed slack message: %w", err) diff --git a/internal/service/worker/workflow_task_assigned_worker_test.go b/internal/service/worker/workflow_task_assigned_worker_test.go index d6f86d70..54f4c2d3 100644 --- a/internal/service/worker/workflow_task_assigned_worker_test.go +++ b/internal/service/worker/workflow_task_assigned_worker_test.go @@ -48,7 +48,7 @@ func TestWorkflowTaskAssignedWorker_SubscribedUser_SendsEmail(t *testing.T) { FirstName: "Alice", LastName: "Smith", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-1").Return(user, nil) @@ -93,7 +93,7 @@ func TestWorkflowTaskAssignedWorker_UnsubscribedUser_Skips(t *testing.T) { Email: "bob@example.com", FirstName: "Bob", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{}}, }, } mockRepo.On("FindUserByID", ctx, "user-2").Return(user, nil) @@ -157,7 +157,7 @@ func TestWorkflowTaskAssignedWorker_TemplateError_ReturnsError(t *testing.T) { Email: "carol@example.com", FirstName: "Carol", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-3").Return(user, nil) @@ -198,7 +198,7 @@ func TestWorkflowTaskAssignedWorker_MultiChannel_EmailChannelJob_SendsOnlyEmail( FirstName: "Dora", SlackUserID: "U12345", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack", "email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack", "email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-4").Return(user, nil) @@ -249,7 +249,7 @@ func TestWorkflowTaskAssignedWorker_MultiChannel_SlackChannelJob_SendsOnlySlack( FirstName: "Ella", SlackUserID: "USLACK5", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack", "email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack", "email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-5").Return(user, nil) @@ -336,7 +336,7 @@ func TestWorkflowTaskAssignedWorker_WithNotificationEnqueuer_EnqueuesSubscribedC FirstName: "Grace", SlackUserID: "USLACK7", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack", "email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack", "email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-7").Return(user, nil) diff --git a/internal/service/worker/workflow_task_digest_checker.go b/internal/service/worker/workflow_task_digest_checker.go index d979138c..39911bda 100644 --- a/internal/service/worker/workflow_task_digest_checker.go +++ b/internal/service/worker/workflow_task_digest_checker.go @@ -43,7 +43,7 @@ func (w *WorkflowTaskDigestCheckerWorker) Work(ctx context.Context, job *river.J return fmt.Errorf("WorkflowTaskDigestCheckerWorker: db is nil") } - userIDs, err := notification.NewGORMUserRepository(w.db).ListActiveUserIDsByNotificationType(ctx, notification.NotificationTypeTaskDailyDigest) + userIDs, err := notification.NewGORMUserRepository(w.db).ListActiveUserIDsBySubscriptionGate(ctx, notification.SubscriptionGateTaskDailyDigest) if err != nil { return fmt.Errorf("workflow-task-digest-checker: failed to load subscribed users: %w", err) } diff --git a/internal/service/worker/workflow_task_digest_worker_test.go b/internal/service/worker/workflow_task_digest_worker_test.go index a9c906fb..46cab503 100644 --- a/internal/service/worker/workflow_task_digest_worker_test.go +++ b/internal/service/worker/workflow_task_digest_worker_test.go @@ -29,7 +29,7 @@ func TestWorkflowTaskDigestWorker_DBRequiredAfterUserLookup(t *testing.T) { FirstName: "Alice", NotificationSubscriptions: []NotificationSubscription{ { - NotificationType: notification.NotificationTypeTaskDailyDigest, + NotificationType: notification.SubscriptionGateTaskDailyDigest, Channels: []string{}, }, }, diff --git a/internal/service/worker/workflow_task_due_soon_worker_test.go b/internal/service/worker/workflow_task_due_soon_worker_test.go index a0e40d89..8b87f94a 100644 --- a/internal/service/worker/workflow_task_due_soon_worker_test.go +++ b/internal/service/worker/workflow_task_due_soon_worker_test.go @@ -33,7 +33,7 @@ func TestWorkflowTaskDueSoonWorker_SubscribedUser_SendsEmail(t *testing.T) { FirstName: "Alice", LastName: "Smith", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-1").Return(user, nil) @@ -78,7 +78,7 @@ func TestWorkflowTaskDueSoonWorker_UnsubscribedUser_Skips(t *testing.T) { Email: "bob@example.com", FirstName: "Bob", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{}}, }, } mockRepo.On("FindUserByID", ctx, "user-2").Return(user, nil) @@ -142,7 +142,7 @@ func TestWorkflowTaskDueSoonWorker_TemplateError_ReturnsError(t *testing.T) { Email: "carol@example.com", FirstName: "Carol", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-3").Return(user, nil) @@ -184,7 +184,7 @@ func TestWorkflowTaskDueSoonWorker_MultiChannel_EmailChannelJob_SendsOnlyEmail(t FirstName: "Dora", SlackUserID: "U12345", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack", "email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack", "email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-4").Return(user, nil) @@ -235,7 +235,7 @@ func TestWorkflowTaskDueSoonWorker_MultiChannel_SlackChannelJob_SendsOnlySlack(t FirstName: "Fran", SlackUserID: "USLACK6", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack", "email"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack", "email"}}, }, } mockRepo.On("FindUserByID", ctx, "user-6").Return(user, nil) @@ -285,7 +285,7 @@ func TestWorkflowTaskDueSoonWorker_SlackOnlyUser_SendsSlack(t *testing.T) { FirstName: "Eve", SlackUserID: "USLACK1", NotificationSubscriptions: []NotificationSubscription{ - {NotificationType: notification.NotificationTypeTaskAvailable, Channels: []string{"slack"}}, + {NotificationType: notification.SubscriptionGateTaskAvailable, Channels: []string{"slack"}}, }, } mockRepo.On("FindUserByID", ctx, "user-5").Return(user, nil)