diff --git a/docs/docs.go b/docs/docs.go index 0dafb113..28d287c2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -16503,21 +16503,21 @@ const docTemplate = `{ ] } }, - "/users/me/digest-subscription": { + "/users/me/subscriptions": { "get": { - "description": "Gets the current user's digest email subscription status", + "description": "Gets the current user's digest and workflow notification email preferences", "produces": [ "application/json" ], "tags": [ "Users" ], - "summary": "Get digest subscription status", + "summary": "Get notification preferences", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + "$ref": "#/definitions/handler.GenericDataResponse-handler_SubscriptionsResponse" } }, "401": { @@ -16546,7 +16546,7 @@ const docTemplate = `{ ] }, "put": { - "description": "Updates the current user's digest email subscription status", + "description": "Updates the current user's digest and workflow notification email preferences", "consumes": [ "application/json" ], @@ -16556,15 +16556,15 @@ const docTemplate = `{ "tags": [ "Users" ], - "summary": "Update digest subscription status", + "summary": "Update notification preferences", "parameters": [ { - "description": "Subscription status", + "description": "Notification preferences", "name": "subscription", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/handler.UpdateSubscriptionsRequest" } } ], @@ -16572,7 +16572,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + "$ref": "#/definitions/handler.GenericDataResponse-handler_SubscriptionsResponse" } }, "400": { @@ -20654,14 +20654,14 @@ const docTemplate = `{ } } }, - "handler.GenericDataResponse-handler_UserHandler": { + "handler.GenericDataResponse-handler_SubscriptionsResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/handler.SubscriptionsResponse" } ] } @@ -21567,6 +21567,34 @@ const docTemplate = `{ } } }, + "handler.SubscriptionsResponse": { + "type": "object", + "properties": { + "subscribed": { + "type": "boolean" + }, + "taskAvailableEmailSubscribed": { + "type": "boolean" + }, + "taskDailyDigestSubscribed": { + "type": "boolean" + } + } + }, + "handler.UpdateSubscriptionsRequest": { + "type": "object", + "properties": { + "subscribed": { + "type": "boolean" + }, + "taskAvailableEmailSubscribed": { + "type": "boolean" + }, + "taskDailyDigestSubscribed": { + "type": "boolean" + } + } + }, "handler.UserHandler": { "type": "object" }, @@ -29044,6 +29072,14 @@ const docTemplate = `{ "lastName": { "type": "string" }, + "taskAvailableEmailSubscribed": { + "description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available", + "type": "boolean" + }, + "taskDailyDigestSubscribed": { + "description": "TaskDailyDigestSubscribed indicates if the user wants to receive a daily task digest email", + "type": "boolean" + }, "updatedAt": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 99b1c0de..fa017b39 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -16497,21 +16497,21 @@ ] } }, - "/users/me/digest-subscription": { + "/users/me/subscriptions": { "get": { - "description": "Gets the current user's digest email subscription status", + "description": "Gets the current user's digest and workflow notification email preferences", "produces": [ "application/json" ], "tags": [ "Users" ], - "summary": "Get digest subscription status", + "summary": "Get notification preferences", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + "$ref": "#/definitions/handler.GenericDataResponse-handler_SubscriptionsResponse" } }, "401": { @@ -16540,7 +16540,7 @@ ] }, "put": { - "description": "Updates the current user's digest email subscription status", + "description": "Updates the current user's digest and workflow notification email preferences", "consumes": [ "application/json" ], @@ -16550,15 +16550,15 @@ "tags": [ "Users" ], - "summary": "Update digest subscription status", + "summary": "Update notification preferences", "parameters": [ { - "description": "Subscription status", + "description": "Notification preferences", "name": "subscription", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/handler.UpdateSubscriptionsRequest" } } ], @@ -16566,7 +16566,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + "$ref": "#/definitions/handler.GenericDataResponse-handler_SubscriptionsResponse" } }, "400": { @@ -20648,14 +20648,14 @@ } } }, - "handler.GenericDataResponse-handler_UserHandler": { + "handler.GenericDataResponse-handler_SubscriptionsResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/handler.SubscriptionsResponse" } ] } @@ -21561,6 +21561,34 @@ } } }, + "handler.SubscriptionsResponse": { + "type": "object", + "properties": { + "subscribed": { + "type": "boolean" + }, + "taskAvailableEmailSubscribed": { + "type": "boolean" + }, + "taskDailyDigestSubscribed": { + "type": "boolean" + } + } + }, + "handler.UpdateSubscriptionsRequest": { + "type": "object", + "properties": { + "subscribed": { + "type": "boolean" + }, + "taskAvailableEmailSubscribed": { + "type": "boolean" + }, + "taskDailyDigestSubscribed": { + "type": "boolean" + } + } + }, "handler.UserHandler": { "type": "object" }, @@ -29038,6 +29066,14 @@ "lastName": { "type": "string" }, + "taskAvailableEmailSubscribed": { + "description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available", + "type": "boolean" + }, + "taskDailyDigestSubscribed": { + "description": "TaskDailyDigestSubscribed indicates if the user wants to receive a daily task digest email", + "type": "boolean" + }, "updatedAt": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fabc5c31..74240bd6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -785,11 +785,11 @@ definitions: - $ref: '#/definitions/handler.OscalLikeEvidence' description: Items from the list response type: object - handler.GenericDataResponse-handler_UserHandler: + handler.GenericDataResponse-handler_SubscriptionsResponse: properties: data: allOf: - - $ref: '#/definitions/handler.UserHandler' + - $ref: '#/definitions/handler.SubscriptionsResponse' description: Items from the list response type: object handler.GenericDataResponse-oscal_BuildByPropsResponse: @@ -1299,6 +1299,24 @@ definitions: $ref: '#/definitions/handler.StatusCount' type: array type: object + handler.SubscriptionsResponse: + properties: + subscribed: + type: boolean + taskAvailableEmailSubscribed: + type: boolean + taskDailyDigestSubscribed: + type: boolean + type: object + handler.UpdateSubscriptionsRequest: + properties: + subscribed: + type: boolean + taskAvailableEmailSubscribed: + type: boolean + taskDailyDigestSubscribed: + type: boolean + type: object handler.UserHandler: type: object handler.createFilterRequest: @@ -6248,6 +6266,14 @@ definitions: type: string lastName: type: string + taskAvailableEmailSubscribed: + description: TaskAvailableEmailSubscribed indicates if the user wants an email + when tasks become available + type: boolean + taskDailyDigestSubscribed: + description: TaskDailyDigestSubscribed indicates if the user wants to receive + a daily task digest email + type: boolean updatedAt: type: string userAttributes: @@ -17998,16 +18024,17 @@ paths: summary: Change password for logged-in user tags: - Users - /users/me/digest-subscription: + /users/me/subscriptions: get: - description: Gets the current user's digest email subscription status + description: Gets the current user's digest and workflow notification email + preferences produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler' + $ref: '#/definitions/handler.GenericDataResponse-handler_SubscriptionsResponse' "401": description: Unauthorized schema: @@ -18022,27 +18049,28 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Get digest subscription status + summary: Get notification preferences tags: - Users put: consumes: - application/json - description: Updates the current user's digest email subscription status + description: Updates the current user's digest and workflow notification email + preferences parameters: - - description: Subscription status + - description: Notification preferences in: body name: subscription required: true schema: - $ref: '#/definitions/handler.UserHandler' + $ref: '#/definitions/handler.UpdateSubscriptionsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler' + $ref: '#/definitions/handler.GenericDataResponse-handler_SubscriptionsResponse' "400": description: Bad Request schema: @@ -18061,7 +18089,7 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Update digest subscription status + summary: Update notification preferences tags: - Users /workflows/control-relationships: diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index 315afb9c..f95098c8 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -23,6 +23,18 @@ type userResponse struct { AuthProvider *string `json:"authProvider,omitempty"` } +type SubscriptionsResponse struct { + Subscribed bool `json:"subscribed"` + TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"` + TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"` +} + +type UpdateSubscriptionsRequest struct { + Subscribed *bool `json:"subscribed"` + TaskAvailableEmailSubscribed *bool `json:"taskAvailableEmailSubscribed"` + TaskDailyDigestSubscribed *bool `json:"taskDailyDigestSubscribed"` +} + func NewUserHandler(sugar *zap.SugaredLogger, db *gorm.DB) *UserHandler { return &UserHandler{ sugar: sugar, @@ -42,8 +54,8 @@ func (h *UserHandler) Register(api *echo.Group) { func (h *UserHandler) RegisterSelfRoutes(api *echo.Group) { api.GET("/me", h.GetMe) api.POST("/me/change-password", h.ChangeLoggedInUserPassword) - api.GET("/me/digest-subscription", h.GetDigestSubscription) - api.PUT("/me/digest-subscription", h.UpdateDigestSubscription) + api.GET("/me/subscriptions", h.GetSubscriptions) + api.PUT("/me/subscriptions", h.UpdateSubscriptions) } // ListUsers godoc @@ -395,23 +407,19 @@ func (h *UserHandler) ChangeLoggedInUserPassword(ctx echo.Context) error { return ctx.NoContent(204) } -// GetDigestSubscription godoc +// GetSubscriptions godoc // -// @Summary Get digest subscription status -// @Description Gets the current user's digest email subscription status +// @Summary Get notification preferences +// @Description Gets the current user's digest and workflow notification email preferences // @Tags Users // @Produce json -// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.GetDigestSubscription.digestSubscriptionResponse] +// @Success 200 {object} handler.GenericDataResponse[handler.SubscriptionsResponse] // @Failure 401 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password -// @Router /users/me/digest-subscription [get] -func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error { - type digestSubscriptionResponse struct { - Subscribed bool `json:"subscribed"` - } - +// @Router /users/me/subscriptions [get] +func (h *UserHandler) GetSubscriptions(ctx echo.Context) error { userClaims := ctx.Get("user").(*authn.UserClaims) email := userClaims.Subject @@ -424,39 +432,36 @@ func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error { return ctx.JSON(500, api.NewError(err)) } - return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ - Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, + return ctx.JSON(200, GenericDataResponse[SubscriptionsResponse]{ + Data: SubscriptionsResponse{ + Subscribed: user.DigestSubscribed, + TaskAvailableEmailSubscribed: user.TaskAvailableEmailSubscribed, + TaskDailyDigestSubscribed: user.TaskDailyDigestSubscribed, + }, }) } -// UpdateDigestSubscription godoc +// UpdateSubscriptions godoc // -// @Summary Update digest subscription status -// @Description Updates the current user's digest email subscription status +// @Summary Update notification preferences +// @Description Updates the current user's digest and workflow notification email preferences // @Tags Users // @Accept json // @Produce json -// @Param subscription body handler.UserHandler.UpdateDigestSubscription.updateDigestSubscriptionRequest true "Subscription status" -// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.UpdateDigestSubscription.digestSubscriptionResponse] +// @Param subscription body handler.UpdateSubscriptionsRequest true "Notification preferences" +// @Success 200 {object} handler.GenericDataResponse[handler.SubscriptionsResponse] // @Failure 400 {object} api.Error // @Failure 401 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password -// @Router /users/me/digest-subscription [put] -func (h *UserHandler) UpdateDigestSubscription(ctx echo.Context) error { - type updateDigestSubscriptionRequest struct { - Subscribed bool `json:"subscribed"` - } - type digestSubscriptionResponse struct { - Subscribed bool `json:"subscribed"` - } - +// @Router /users/me/subscriptions [put] +func (h *UserHandler) UpdateSubscriptions(ctx echo.Context) error { userClaims := ctx.Get("user").(*authn.UserClaims) - var req updateDigestSubscriptionRequest + var req UpdateSubscriptionsRequest if err := ctx.Bind(&req); err != nil { - h.sugar.Errorw("Failed to bind update digest subscription request", "error", err) + h.sugar.Errorw("Failed to bind update subscriptions request", "error", err) return ctx.JSON(400, api.NewError(err)) } @@ -470,16 +475,35 @@ func (h *UserHandler) UpdateDigestSubscription(ctx echo.Context) error { return ctx.JSON(500, api.NewError(err)) } - user.DigestSubscribed = req.Subscribed + if req.Subscribed != nil { + user.DigestSubscribed = *req.Subscribed + } + if req.TaskAvailableEmailSubscribed != nil { + user.TaskAvailableEmailSubscribed = *req.TaskAvailableEmailSubscribed + } + if req.TaskDailyDigestSubscribed != nil { + user.TaskDailyDigestSubscribed = *req.TaskDailyDigestSubscribed + } + if err := h.db.Save(&user).Error; err != nil { - h.sugar.Errorw("Failed to update user digest subscription", "error", err) + h.sugar.Errorw("Failed to update user subscriptions", "error", err) return ctx.JSON(500, api.NewError(err)) } - h.sugar.Debugw("User digest subscription updated", "email", email, "subscribed", req.Subscribed) - - return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ - Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, + h.sugar.Debugw( + "User subscriptions updated", + "email", email, + "subscribed", user.DigestSubscribed, + "taskAvailableEmailSubscribed", user.TaskAvailableEmailSubscribed, + "taskDailyDigestSubscribed", user.TaskDailyDigestSubscribed, + ) + + return ctx.JSON(200, GenericDataResponse[SubscriptionsResponse]{ + Data: SubscriptionsResponse{ + Subscribed: user.DigestSubscribed, + TaskAvailableEmailSubscribed: user.TaskAvailableEmailSubscribed, + TaskDailyDigestSubscribed: user.TaskDailyDigestSubscribed, + }, }) } diff --git a/internal/api/handler/users_integration_test.go b/internal/api/handler/users_integration_test.go index 503287f4..1be7874d 100644 --- a/internal/api/handler/users_integration_test.go +++ b/internal/api/handler/users_integration_test.go @@ -385,61 +385,76 @@ func (suite *UserApiIntegrationSuite) TestChangePassword() { }) } -func (suite *UserApiIntegrationSuite) TestDigestSubscription() { +func (suite *UserApiIntegrationSuite) TestSubscriptions() { token, err := suite.GetAuthToken() suite.Require().NoError(err) - suite.Run("GetDigestSubscription", func() { + suite.Run("GetSubscriptions", func() { rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/users/me/digest-subscription", nil) + req := httptest.NewRequest("GET", "/api/users/me/subscriptions", nil) req.Header.Set("Authorization", "Bearer "+*token) suite.server.E().ServeHTTP(rec, req) - suite.Equal(200, rec.Code, "Expected OK response for GetDigestSubscription") + suite.Equal(200, rec.Code, "Expected OK response for GetSubscriptions") var response struct { Data struct { - Subscribed bool `json:"subscribed"` + Subscribed bool `json:"subscribed"` + TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"` + TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"` } `json:"data"` } err = json.Unmarshal(rec.Body.Bytes(), &response) - suite.Require().NoError(err, "Failed to unmarshal GetDigestSubscription response") + suite.Require().NoError(err, "Failed to unmarshal GetSubscriptions response") // The default should be false for new users suite.False(response.Data.Subscribed, "Expected default digest subscription to be false") + suite.False(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to default to false") + suite.False(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to default to false") }) - suite.Run("UpdateDigestSubscription", func() { + suite.Run("UpdateSubscriptions", func() { // Test subscribing to digest - payload := map[string]bool{"subscribed": true} + payload := map[string]interface{}{ + "subscribed": true, + "taskAvailableEmailSubscribed": true, + "taskDailyDigestSubscribed": true, + } payloadJSON, err := json.Marshal(payload) - suite.Require().NoError(err, "Failed to marshal update digest subscription request") + suite.Require().NoError(err, "Failed to marshal update subscriptions request") rec := httptest.NewRecorder() - req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req := httptest.NewRequest("PUT", "/api/users/me/subscriptions", bytes.NewReader(payloadJSON)) req.Header.Set("Authorization", "Bearer "+*token) req.Header.Set("Content-Type", "application/json") suite.server.E().ServeHTTP(rec, req) - suite.Equal(200, rec.Code, "Expected OK response for UpdateDigestSubscription") + suite.Equal(200, rec.Code, "Expected OK response for UpdateSubscriptions") var response struct { Data struct { - Subscribed bool `json:"subscribed"` + Subscribed bool `json:"subscribed"` + TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"` + TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"` } `json:"data"` } err = json.Unmarshal(rec.Body.Bytes(), &response) - suite.Require().NoError(err, "Failed to unmarshal UpdateDigestSubscription response") + suite.Require().NoError(err, "Failed to unmarshal UpdateSubscriptions response") suite.True(response.Data.Subscribed, "Expected digest subscription to be updated to true") + suite.True(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to be updated to true") + suite.True(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to be updated to true") // Test unsubscribing from digest - payload = map[string]bool{"subscribed": false} + payload = map[string]interface{}{ + "subscribed": false, + "taskDailyDigestSubscribed": false, + } payloadJSON, err = json.Marshal(payload) - suite.Require().NoError(err, "Failed to marshal unsubscribe digest request") + suite.Require().NoError(err, "Failed to marshal unsubscribe request") rec = httptest.NewRecorder() - req = httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req = httptest.NewRequest("PUT", "/api/users/me/subscriptions", bytes.NewReader(payloadJSON)) req.Header.Set("Authorization", "Bearer "+*token) req.Header.Set("Content-Type", "application/json") @@ -447,23 +462,26 @@ func (suite *UserApiIntegrationSuite) TestDigestSubscription() { suite.Equal(200, rec.Code, "Expected OK response for unsubscribe digest") err = json.Unmarshal(rec.Body.Bytes(), &response) - suite.Require().NoError(err, "Failed to unmarshal unsubscribe digest response") + suite.Require().NoError(err, "Failed to unmarshal unsubscribe response") suite.False(response.Data.Subscribed, "Expected digest subscription to be updated to false") + suite.True(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to remain unchanged when omitted") + suite.False(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to be updated to false") }) - suite.Run("UpdateDigestSubscriptionInvalidPayload", func() { + suite.Run("UpdateSubscriptionsInvalidPayload", func() { // Test with invalid payload payload := map[string]string{"subscribed": "invalid"} payloadJSON, err := json.Marshal(payload) - suite.Require().NoError(err, "Failed to marshal invalid digest subscription request") + suite.Require().NoError(err, "Failed to marshal invalid subscriptions request") rec := httptest.NewRecorder() - req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req := httptest.NewRequest("PUT", "/api/users/me/subscriptions", bytes.NewReader(payloadJSON)) req.Header.Set("Authorization", "Bearer "+*token) req.Header.Set("Content-Type", "application/json") suite.server.E().ServeHTTP(rec, req) suite.Equal(400, rec.Code, "Expected Bad Request response for invalid payload") }) + } diff --git a/internal/service/relational/ccf_internal.go b/internal/service/relational/ccf_internal.go index b5cb1b48..4ee7dbf5 100644 --- a/internal/service/relational/ccf_internal.go +++ b/internal/service/relational/ccf_internal.go @@ -30,6 +30,11 @@ type User struct { // DigestSubscribed indicates if the user wants to receive evidence digest emails DigestSubscribed bool `json:"digestSubscribed" gorm:"default:false"` + + // TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available + TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed" gorm:"default:false"` + // TaskDailyDigestSubscribed indicates if the user wants to receive a daily task digest email + TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed" gorm:"default:false"` } func (User) TableName() string {