diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index f5268493..eb18bffe 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -339,13 +339,7 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) { return } if h.NotifPrefs == nil { - c.JSON(http.StatusOK, gin.H{ - "property_change": true, - "state_change": true, - "comment": true, - "mention": true, - "issue_completed": true, - }) + c.JSON(http.StatusOK, notifPrefResponse(model.DefaultNotificationPreference())) return } p, err := h.NotifPrefs.GetGlobal(c.Request.Context(), user.ID) @@ -354,31 +348,10 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) { return } if p == nil { - c.JSON(http.StatusOK, gin.H{ - "property_change": true, - "state_change": true, - "comment": true, - "mention": true, - "issue_completed": true, - }) + c.JSON(http.StatusOK, notifPrefResponse(model.DefaultNotificationPreference())) return } - c.JSON(http.StatusOK, gin.H{ - "property_change": p.PropertyChange, - "state_change": p.StateChange, - "comment": p.Comment, - "mention": p.Mention, - "issue_completed": p.IssueCompleted, - }) -} - -// UpdateNotificationPreferencesRequest is the body for PUT /api/users/me/notification-preferences/ -type UpdateNotificationPreferencesRequest struct { - PropertyChange *bool `json:"property_change"` - StateChange *bool `json:"state_change"` - Comment *bool `json:"comment"` - Mention *bool `json:"mention"` - IssueCompleted *bool `json:"issue_completed"` + c.JSON(http.StatusOK, notifPrefResponse(*p)) } // UpdateNotificationPreferences updates account-level notification preferences. @@ -393,7 +366,7 @@ func (h *AuthHandler) UpdateNotificationPreferences(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Not configured"}) return } - var req UpdateNotificationPreferencesRequest + var req NotifPrefBody if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) return @@ -404,39 +377,16 @@ func (h *AuthHandler) UpdateNotificationPreferences(c *gin.Context) { return } if p == nil { - p = &model.UserNotificationPreference{UserID: user.ID} - p.PropertyChange = true - p.StateChange = true - p.Comment = true - p.Mention = true - p.IssueCompleted = true - } - if req.PropertyChange != nil { - p.PropertyChange = *req.PropertyChange - } - if req.StateChange != nil { - p.StateChange = *req.StateChange - } - if req.Comment != nil { - p.Comment = *req.Comment - } - if req.Mention != nil { - p.Mention = *req.Mention - } - if req.IssueCompleted != nil { - p.IssueCompleted = *req.IssueCompleted + def := model.DefaultNotificationPreference() + def.UserID = user.ID + p = &def } + applyNotifPrefBody(p, req) if err := h.NotifPrefs.UpsertGlobal(c.Request.Context(), p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}) return } - c.JSON(http.StatusOK, gin.H{ - "property_change": p.PropertyChange, - "state_change": p.StateChange, - "comment": p.Comment, - "mention": p.Mention, - "issue_completed": p.IssueCompleted, - }) + c.JSON(http.StatusOK, notifPrefResponse(*p)) } // ListTokens returns the current user's API tokens (without secret values). diff --git a/apps/api/internal/handler/notification_preference.go b/apps/api/internal/handler/notification_preference.go new file mode 100644 index 00000000..148ff48c --- /dev/null +++ b/apps/api/internal/handler/notification_preference.go @@ -0,0 +1,246 @@ +package handler + +import ( + "net/http" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// NotifPrefBody is the request/patch shape for notification preferences. Every +// field is optional; only the provided ones are changed. The bare names gate +// the in-app channel, the email_* names gate the email channel. +type NotifPrefBody struct { + PropertyChange *bool `json:"property_change"` + StateChange *bool `json:"state_change"` + Comment *bool `json:"comment"` + Mention *bool `json:"mention"` + IssueCompleted *bool `json:"issue_completed"` + EmailPropertyChange *bool `json:"email_property_change"` + EmailStateChange *bool `json:"email_state_change"` + EmailComment *bool `json:"email_comment"` + EmailMention *bool `json:"email_mention"` + EmailIssueCompleted *bool `json:"email_issue_completed"` +} + +// notifPrefResponse renders every channel/type toggle for a preference. +func notifPrefResponse(p model.UserNotificationPreference) gin.H { + return gin.H{ + "property_change": p.PropertyChange, + "state_change": p.StateChange, + "comment": p.Comment, + "mention": p.Mention, + "issue_completed": p.IssueCompleted, + "email_property_change": p.EmailPropertyChange, + "email_state_change": p.EmailStateChange, + "email_comment": p.EmailComment, + "email_mention": p.EmailMention, + "email_issue_completed": p.EmailIssueCompleted, + } +} + +// applyNotifPrefBody overlays the provided fields onto p. +func applyNotifPrefBody(p *model.UserNotificationPreference, b NotifPrefBody) { + if b.PropertyChange != nil { + p.PropertyChange = *b.PropertyChange + } + if b.StateChange != nil { + p.StateChange = *b.StateChange + } + if b.Comment != nil { + p.Comment = *b.Comment + } + if b.Mention != nil { + p.Mention = *b.Mention + } + if b.IssueCompleted != nil { + p.IssueCompleted = *b.IssueCompleted + } + if b.EmailPropertyChange != nil { + p.EmailPropertyChange = *b.EmailPropertyChange + } + if b.EmailStateChange != nil { + p.EmailStateChange = *b.EmailStateChange + } + if b.EmailComment != nil { + p.EmailComment = *b.EmailComment + } + if b.EmailMention != nil { + p.EmailMention = *b.EmailMention + } + if b.EmailIssueCompleted != nil { + p.EmailIssueCompleted = *b.EmailIssueCompleted + } +} + +// NotificationPreferenceHandler serves workspace- and project-scoped +// notification preferences. Account-level ones live on AuthHandler. +type NotificationPreferenceHandler struct { + Prefs *store.UserNotificationPreferenceStore + Ws *store.WorkspaceStore + Projects *service.ProjectService +} + +// baseForScope is the starting preference for a scoped save: the effective +// (resolved) preference so unspecified fields inherit from the parent scope, +// or the all-enabled default when nothing is stored. The ID is cleared so the +// upsert writes a fresh row at the target scope rather than reusing a parent's. +// A Resolve error is returned rather than swallowed, so a transient failure +// can't silently reset inherited (disabled) fields to the all-true default. +func (h *NotificationPreferenceHandler) baseForScope(c *gin.Context, userID uuid.UUID, workspaceID, projectID *uuid.UUID) (model.UserNotificationPreference, error) { + p, err := h.Prefs.Resolve(c.Request.Context(), userID, workspaceID, projectID) + if err != nil { + return model.UserNotificationPreference{}, err + } + if p != nil { + b := *p + b.ID = uuid.Nil + return b, nil + } + return model.DefaultNotificationPreference(), nil +} + +// GetWorkspace returns the effective notification preferences for the caller in +// a workspace. +// GET /api/workspaces/:slug/notification-preferences/ +func (h *NotificationPreferenceHandler) GetWorkspace(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + wrk, ok := h.workspaceForMember(c, user.ID) + if !ok { + return + } + h.respondResolved(c, user.ID, &wrk.ID, nil) +} + +// UpdateWorkspace writes the caller's workspace-scoped notification preferences. +// PUT /api/workspaces/:slug/notification-preferences/ +func (h *NotificationPreferenceHandler) UpdateWorkspace(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + wrk, ok := h.workspaceForMember(c, user.ID) + if !ok { + return + } + var body NotifPrefBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + p, err := h.baseForScope(c, user.ID, &wrk.ID, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load preferences"}) + return + } + p.UserID = user.ID + p.WorkspaceID = &wrk.ID + p.ProjectID = nil + applyNotifPrefBody(&p, body) + if err := h.Prefs.UpsertScoped(c.Request.Context(), &p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}) + return + } + c.JSON(http.StatusOK, notifPrefResponse(p)) +} + +// GetProject returns the effective notification preferences for the caller in a +// project (project → workspace → account). +// GET /api/workspaces/:slug/projects/:projectId/notification-preferences/ +func (h *NotificationPreferenceHandler) GetProject(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + proj, ok := h.projectForMember(c, user.ID) + if !ok { + return + } + h.respondResolved(c, user.ID, &proj.WorkspaceID, &proj.ID) +} + +// UpdateProject writes the caller's project-scoped notification preferences. +// PUT /api/workspaces/:slug/projects/:projectId/notification-preferences/ +func (h *NotificationPreferenceHandler) UpdateProject(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + proj, ok := h.projectForMember(c, user.ID) + if !ok { + return + } + var body NotifPrefBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + p, err := h.baseForScope(c, user.ID, &proj.WorkspaceID, &proj.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load preferences"}) + return + } + p.UserID = user.ID + p.WorkspaceID = &proj.WorkspaceID + p.ProjectID = &proj.ID + applyNotifPrefBody(&p, body) + if err := h.Prefs.UpsertScoped(c.Request.Context(), &p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}) + return + } + c.JSON(http.StatusOK, notifPrefResponse(p)) +} + +func (h *NotificationPreferenceHandler) respondResolved(c *gin.Context, userID uuid.UUID, workspaceID, projectID *uuid.UUID) { + p, err := h.Prefs.Resolve(c.Request.Context(), userID, workspaceID, projectID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load preferences"}) + return + } + if p == nil { + def := model.DefaultNotificationPreference() + p = &def + } + c.JSON(http.StatusOK, notifPrefResponse(*p)) +} + +// workspaceForMember resolves the :slug workspace and confirms the caller is a +// member; it writes the error response and returns false otherwise. +func (h *NotificationPreferenceHandler) workspaceForMember(c *gin.Context, userID uuid.UUID) (*model.Workspace, bool) { + wrk, err := h.Ws.GetBySlug(c.Request.Context(), c.Param("slug")) + if err != nil || wrk == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) + return nil, false + } + if ok, _ := h.Ws.IsMember(c.Request.Context(), wrk.ID, userID); !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) + return nil, false + } + return wrk, true +} + +// projectForMember resolves the :projectId project and confirms the caller can +// access it; it writes the error response and returns false otherwise. +func (h *NotificationPreferenceHandler) projectForMember(c *gin.Context, userID uuid.UUID) (*model.Project, bool) { + pid, ok := projectID(c) + if !ok { + return nil, false + } + proj, err := h.Projects.GetByID(c.Request.Context(), c.Param("slug"), pid, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return nil, false + } + return proj, true +} diff --git a/apps/api/internal/handler/notification_preference_test.go b/apps/api/internal/handler/notification_preference_test.go new file mode 100644 index 00000000..94c994e2 --- /dev/null +++ b/apps/api/internal/handler/notification_preference_test.go @@ -0,0 +1,60 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Scoped notification preferences round-trip, and a project override does not +// leak up to the workspace scope. Also covers inheritance (project reads the +// workspace row until it has its own) and access control. Covers #203. +func TestNotifPref_ScopedRoundTrip(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + wsBase := "/api/workspaces/" + w.Workspace.Slug + "/notification-preferences/" + projBase := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/notification-preferences/" + + // Workspace defaults: everything on. + rr := ts.GET(wsBase, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, true, body["email_comment"]) + assert.Equal(t, true, body["comment"]) + + // Mute workspace email for comments; the in-app channel stays on. + rr = ts.PUT(wsBase, map[string]any{"email_comment": false}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body = testutil.MustJSONMap(t, rr) + assert.Equal(t, false, body["email_comment"]) + assert.Equal(t, true, body["comment"]) + + // The project inherits the workspace row until it has its own. + rr = ts.GET(projBase, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + assert.Equal(t, false, testutil.MustJSONMap(t, rr)["email_comment"], "project inherits workspace") + + // A project override (in-app comment off) does not change the workspace row. + rr = ts.PUT(projBase, map[string]any{"comment": false}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + assert.Equal(t, false, testutil.MustJSONMap(t, rr)["comment"]) + + rr = ts.GET(projBase, w.Session) + assert.Equal(t, false, testutil.MustJSONMap(t, rr)["comment"], "project override persists") + rr = ts.GET(wsBase, w.Session) + assert.Equal(t, true, testutil.MustJSONMap(t, rr)["comment"], "workspace unchanged by project override") +} + +// A non-member cannot read a workspace's notification preferences. +func TestNotifPref_NonMemberForbidden(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + stranger := testutil.CreateUser(t, ts.DB) + strangerSession := testutil.LoginAs(t, ts.DB, stranger) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/notification-preferences/", strangerSession) + require.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/apps/api/internal/model/user_notification_preference.go b/apps/api/internal/model/user_notification_preference.go index bd7b0331..0e6a869b 100644 --- a/apps/api/internal/model/user_notification_preference.go +++ b/apps/api/internal/model/user_notification_preference.go @@ -7,7 +7,11 @@ import ( "gorm.io/gorm" ) -// UserNotificationPreference matches user_notification_preferences (account-level when workspace_id and project_id are null). +// UserNotificationPreference matches user_notification_preferences. A row is +// account-level when workspace_id and project_id are null, workspace-scoped when +// only workspace_id is set, and project-scoped when project_id is set. The +// PropertyChange/StateChange/Comment/Mention/IssueCompleted booleans gate the +// in-app channel; the Email* booleans gate the email channel for the same types. type UserNotificationPreference struct { ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` @@ -18,8 +22,24 @@ type UserNotificationPreference struct { Comment bool `gorm:"column:comment;default:true" json:"comment"` Mention bool `gorm:"column:mention;default:true" json:"mention"` IssueCompleted bool `gorm:"column:issue_completed;default:true" json:"issue_completed"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + EmailPropertyChange bool `gorm:"column:email_property_change;default:true" json:"email_property_change"` + EmailStateChange bool `gorm:"column:email_state_change;default:true" json:"email_state_change"` + EmailComment bool `gorm:"column:email_comment;default:true" json:"email_comment"` + EmailMention bool `gorm:"column:email_mention;default:true" json:"email_mention"` + EmailIssueCompleted bool `gorm:"column:email_issue_completed;default:true" json:"email_issue_completed"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DefaultNotificationPreference returns the effective preference used when a +// user has no stored row: every channel and type enabled. +func DefaultNotificationPreference() UserNotificationPreference { + return UserNotificationPreference{ + PropertyChange: true, StateChange: true, Comment: true, Mention: true, IssueCompleted: true, + EmailPropertyChange: true, EmailStateChange: true, EmailComment: true, EmailMention: true, EmailIssueCompleted: true, + } } func (UserNotificationPreference) TableName() string { return "user_notification_preferences" } diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 71d93889..1f68227c 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -234,6 +234,7 @@ func New(cfg Config) *gin.Engine { AppBaseURL: appBaseURL, } projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc} + notifPrefHandler := &handler.NotificationPreferenceHandler{Prefs: userNotifPrefStore, Ws: workspaceStore, Projects: projectSvc} favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore} stateHandler := &handler.StateHandler{State: stateSvc} labelHandler := &handler.LabelHandler{Label: labelSvc} @@ -265,6 +266,10 @@ func New(cfg Config) *gin.Engine { api.POST("/users/me/set-password/", authHandler.SetPassword) api.GET("/users/me/notification-preferences/", authHandler.GetNotificationPreferences) api.PUT("/users/me/notification-preferences/", authHandler.UpdateNotificationPreferences) + api.GET("/workspaces/:slug/notification-preferences/", notifPrefHandler.GetWorkspace) + api.PUT("/workspaces/:slug/notification-preferences/", notifPrefHandler.UpdateWorkspace) + api.GET("/workspaces/:slug/projects/:projectId/notification-preferences/", notifPrefHandler.GetProject) + api.PUT("/workspaces/:slug/projects/:projectId/notification-preferences/", notifPrefHandler.UpdateProject) api.GET("/users/me/activity/", userHandler.GetActivity) api.GET("/users/me/tokens/", authHandler.ListTokens) api.POST("/users/me/tokens/", authHandler.CreateToken) diff --git a/apps/api/internal/service/notification.go b/apps/api/internal/service/notification.go index efe4fdb5..f3e4a907 100644 --- a/apps/api/internal/service/notification.go +++ b/apps/api/internal/service/notification.go @@ -273,16 +273,29 @@ func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, p // Preference gating: receivers who have disabled the relevant category for // this `sender` value are dropped. Mention notifications still pass unless // the user has explicitly turned mentions off. + // Preference gating, resolved per receiver against this issue's + // project/workspace scope (project → workspace → account), split by channel: + // a receiver may want the in-app notification, the email, both, or neither. + inApp := allowed + email := allowed if s.prefs != nil { - gated := make([]uuid.UUID, 0, len(allowed)) + inApp = make([]uuid.UUID, 0, len(allowed)) + email = make([]uuid.UUID, 0, len(allowed)) for _, id := range allowed { - if s.allowedBySender(ctx, id, params.sender, params.classifyMention) { - gated = append(gated, id) + pref := s.resolvePref(ctx, id, params.issue) + effective := params.sender + if params.classifyMention != nil && params.classifyMention(id) { + effective = model.NotificationSenderMentioned + } + if channelAllows(pref, effective, false) { + inApp = append(inApp, id) + } + if channelAllows(pref, effective, true) { + email = append(email, id) } } - allowed = gated } - if len(allowed) == 0 { + if len(inApp) == 0 && len(email) == 0 { return } @@ -291,8 +304,8 @@ func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, p projectIdent := s.projectIdentifier(ctx, params.issue.ProjectID) issueRef := fmt.Sprintf("%s-%d", projectIdent, params.issue.SequenceID) - rows := make([]model.Notification, 0, len(allowed)) - for _, receiverID := range allowed { + rows := make([]model.Notification, 0, len(inApp)) + for _, receiverID := range inApp { sender := params.sender if params.classifyMention != nil && params.classifyMention(receiverID) { sender = model.NotificationSenderMentioned @@ -323,14 +336,16 @@ func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, p EntityName: model.NotificationEntityIssue, }) } - if err := s.ns.CreateMany(ctx, rows); err != nil { - s.logger().Warn("notification fan-out failed", "err", err, "issue_id", params.issue.ID, "receivers", len(rows)) + if len(rows) > 0 { + if err := s.ns.CreateMany(ctx, rows); err != nil { + s.logger().Warn("notification fan-out failed", "err", err, "issue_id", params.issue.ID, "receivers", len(rows)) + } } // Queue notification emails if email infrastructure is available. // This runs synchronously but logs+swallows errors to avoid breaking in-app notifications. - if s.queue != nil && s.emailLog != nil && s.appURL != "" { - s.enqueueNotificationEmails(ctx, allowed, params, actorName, issueRef) + if len(email) > 0 && s.queue != nil && s.emailLog != nil && s.appURL != "" { + s.enqueueNotificationEmails(ctx, email, params, actorName, issueRef) } } @@ -687,32 +702,48 @@ func buildMessage(in messageInputs) model.JSONMap { // of this sender type. The mention classifier overrides the default sender // when the user is mentioned in this row, so a receiver who has comments // disabled but mentions enabled still gets the mention. -func (s *NotificationService) allowedBySender(ctx context.Context, userID uuid.UUID, sender string, classify func(uuid.UUID) bool) bool { - if s.prefs == nil { - return true - } - effective := sender - if classify != nil && classify(userID) { - effective = model.NotificationSenderMentioned - } - p, err := s.prefs.GetGlobal(ctx, userID) +// resolvePref returns the effective notification preference for a receiver on a +// given issue, resolved project → workspace → account. When the user has no +// stored row (or no preference store is wired), the all-enabled default is used +// so notifications are never silently dropped. +func (s *NotificationService) resolvePref(ctx context.Context, userID uuid.UUID, issue *model.Issue) model.UserNotificationPreference { + if s.prefs == nil || issue == nil { + return model.DefaultNotificationPreference() + } + workspaceID := issue.WorkspaceID + projectID := issue.ProjectID + p, err := s.prefs.Resolve(ctx, userID, &workspaceID, &projectID) if err != nil || p == nil { - // Default: allow everything when no preference row exists. - return true + return model.DefaultNotificationPreference() } - switch effective { + return *p +} + +// channelAllows reports whether a resolved preference permits a notification of +// the given sender category on a channel (email when `email` is true, otherwise +// in-app). Assignment notifications are gated only by the coarse +// property-change toggle, matching the previous behavior. +func channelAllows(p model.UserNotificationPreference, sender string, email bool) bool { + switch sender { case model.NotificationSenderMentioned: + if email { + return p.EmailMention + } return p.Mention case model.NotificationSenderCommented: + if email { + return p.EmailComment + } return p.Comment case model.NotificationSenderStateChanged: + if email { + return p.EmailStateChange + } return p.StateChange - case model.NotificationSenderSubscribed: - return p.PropertyChange - case model.NotificationSenderAssigned: - // Assignment notifications are not separately gated — receiving an - // assignment is fundamental to working on an issue. We honor only - // PropertyChange here as a coarse opt-out. + case model.NotificationSenderSubscribed, model.NotificationSenderAssigned: + if email { + return p.EmailPropertyChange + } return p.PropertyChange } return true diff --git a/apps/api/internal/service/notification_test.go b/apps/api/internal/service/notification_test.go index 4431f7b3..275b77ff 100644 --- a/apps/api/internal/service/notification_test.go +++ b/apps/api/internal/service/notification_test.go @@ -216,3 +216,38 @@ func TestHumanFieldName(t *testing.T) { } } } + +// channelAllows must gate the email and in-app channels independently per type, +// so a receiver can mute one without losing the other. Covers #203. +func TestChannelAllows(t *testing.T) { + p := model.DefaultNotificationPreference() + // Comment: in-app on, email off. + p.Comment = true + p.EmailComment = false + if !channelAllows(p, model.NotificationSenderCommented, false) { + t.Error("in-app comment should be allowed") + } + if channelAllows(p, model.NotificationSenderCommented, true) { + t.Error("email comment should be muted") + } + // Mention: in-app off, email on. + p.Mention = false + p.EmailMention = true + if channelAllows(p, model.NotificationSenderMentioned, false) { + t.Error("in-app mention should be muted") + } + if !channelAllows(p, model.NotificationSenderMentioned, true) { + t.Error("email mention should be allowed") + } + // Subscribed + Assigned both fall back to the property-change toggle. + p.PropertyChange = false + p.EmailPropertyChange = true + for _, sender := range []string{model.NotificationSenderSubscribed, model.NotificationSenderAssigned} { + if channelAllows(p, sender, false) { + t.Errorf("in-app %s should follow property_change (off)", sender) + } + if !channelAllows(p, sender, true) { + t.Errorf("email %s should follow email_property_change (on)", sender) + } + } +} diff --git a/apps/api/internal/store/user_notification_preference.go b/apps/api/internal/store/user_notification_preference.go index 66d2da43..a024ed1b 100644 --- a/apps/api/internal/store/user_notification_preference.go +++ b/apps/api/internal/store/user_notification_preference.go @@ -16,13 +16,29 @@ func NewUserNotificationPreferenceStore(db *gorm.DB) *UserNotificationPreference return &UserNotificationPreferenceStore{db: db} } -// GetGlobal gets the account-level (global) notification preferences for a user. -// Uses workspace_id IS NULL AND project_id IS NULL. -func (s *UserNotificationPreferenceStore) GetGlobal(ctx context.Context, userID uuid.UUID) (*model.UserNotificationPreference, error) { +// scopeWhere narrows a query to the exact (user, workspace, project) scope, +// matching NULLs so account/workspace/project rows never collide. +func scopeWhere(q *gorm.DB, userID uuid.UUID, workspaceID, projectID *uuid.UUID) *gorm.DB { + q = q.Where("user_id = ?", userID) + if workspaceID == nil { + q = q.Where("workspace_id IS NULL") + } else { + q = q.Where("workspace_id = ?", *workspaceID) + } + if projectID == nil { + q = q.Where("project_id IS NULL") + } else { + q = q.Where("project_id = ?", *projectID) + } + return q +} + +// GetScoped returns the preference row for an exact scope, or nil when none +// exists. Pass nils for the account-level (global) row, a workspace id for the +// workspace row, or a project id (with its workspace) for the project row. +func (s *UserNotificationPreferenceStore) GetScoped(ctx context.Context, userID uuid.UUID, workspaceID, projectID *uuid.UUID) (*model.UserNotificationPreference, error) { var p model.UserNotificationPreference - err := s.db.WithContext(ctx). - Where("user_id = ? AND workspace_id IS NULL AND project_id IS NULL", userID). - First(&p).Error + err := scopeWhere(s.db.WithContext(ctx), userID, workspaceID, projectID).First(&p).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil @@ -32,23 +48,68 @@ func (s *UserNotificationPreferenceStore) GetGlobal(ctx context.Context, userID return &p, nil } -// UpsertGlobal creates or updates account-level notification preferences. -func (s *UserNotificationPreferenceStore) UpsertGlobal(ctx context.Context, p *model.UserNotificationPreference) error { - if p.WorkspaceID != nil || p.ProjectID != nil { - p.WorkspaceID = nil - p.ProjectID = nil +// GetGlobal gets the account-level (global) notification preferences for a user. +func (s *UserNotificationPreferenceStore) GetGlobal(ctx context.Context, userID uuid.UUID) (*model.UserNotificationPreference, error) { + return s.GetScoped(ctx, userID, nil, nil) +} + +// Resolve returns the most specific preference row that applies to a +// notification in the given project/workspace: the project row if it exists, +// otherwise the workspace row, otherwise the account-level row. Returns nil when +// the user has no stored preferences at any level, so callers fall back to the +// all-enabled default. +func (s *UserNotificationPreferenceStore) Resolve(ctx context.Context, userID uuid.UUID, workspaceID, projectID *uuid.UUID) (*model.UserNotificationPreference, error) { + if projectID != nil { + if p, err := s.GetScoped(ctx, userID, workspaceID, projectID); err != nil || p != nil { + return p, err + } + } + if workspaceID != nil { + if p, err := s.GetScoped(ctx, userID, workspaceID, nil); err != nil || p != nil { + return p, err + } } - existing, err := s.GetGlobal(ctx, p.UserID) + return s.GetScoped(ctx, userID, nil, nil) +} + +// UpsertScoped creates or updates the preference row for the scope encoded on p +// (its UserID plus WorkspaceID/ProjectID). All ten channel/type booleans are +// written. +func (s *UserNotificationPreferenceStore) UpsertScoped(ctx context.Context, p *model.UserNotificationPreference) error { + existing, err := s.GetScoped(ctx, p.UserID, p.WorkspaceID, p.ProjectID) if err != nil { return err } - if existing != nil { - existing.PropertyChange = p.PropertyChange - existing.StateChange = p.StateChange - existing.Comment = p.Comment - existing.Mention = p.Mention - existing.IssueCompleted = p.IssueCompleted - return s.db.WithContext(ctx).Save(existing).Error + if existing == nil { + // Ensure a row exists at this scope. The toggle columns are DEFAULT TRUE + // and are corrected by the map update below, so their values here don't + // matter. + existing = &model.UserNotificationPreference{ + UserID: p.UserID, WorkspaceID: p.WorkspaceID, ProjectID: p.ProjectID, + } + if err := s.db.WithContext(ctx).Create(existing).Error; err != nil { + return err + } } - return s.db.WithContext(ctx).Create(p).Error + // Write every toggle via a map so disabled (false) ones are persisted rather + // than dropped as zero values. + return s.db.WithContext(ctx).Model(existing).Updates(map[string]any{ + "property_change": p.PropertyChange, + "state_change": p.StateChange, + "comment": p.Comment, + "mention": p.Mention, + "issue_completed": p.IssueCompleted, + "email_property_change": p.EmailPropertyChange, + "email_state_change": p.EmailStateChange, + "email_comment": p.EmailComment, + "email_mention": p.EmailMention, + "email_issue_completed": p.EmailIssueCompleted, + }).Error +} + +// UpsertGlobal creates or updates account-level notification preferences. +func (s *UserNotificationPreferenceStore) UpsertGlobal(ctx context.Context, p *model.UserNotificationPreference) error { + p.WorkspaceID = nil + p.ProjectID = nil + return s.UpsertScoped(ctx, p) } diff --git a/apps/api/internal/store/user_notification_preference_test.go b/apps/api/internal/store/user_notification_preference_test.go new file mode 100644 index 00000000..598f9e6e --- /dev/null +++ b/apps/api/internal/store/user_notification_preference_test.go @@ -0,0 +1,59 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +// Resolve returns the most specific stored row (project → workspace → account), +// and nil when nothing is stored. Covers the scoped-preference resolution in #203. +func TestNotifPref_ResolvePrecedence(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + s := store.NewUserNotificationPreferenceStore(ts.DB) + ctx := context.Background() + ws := w.Workspace.ID + proj := w.Project.ID + + // Nothing stored -> nil (caller uses the all-enabled default). + got, err := s.Resolve(ctx, w.User.ID, &ws, &proj) + require.NoError(t, err) + require.Nil(t, got) + + // Account-level row with comment off. + require.NoError(t, s.UpsertGlobal(ctx, &model.UserNotificationPreference{ + UserID: w.User.ID, PropertyChange: true, StateChange: true, Comment: false, Mention: true, IssueCompleted: true, + })) + got, err = s.Resolve(ctx, w.User.ID, &ws, &proj) + require.NoError(t, err) + require.NotNil(t, got) + require.False(t, got.Comment, "falls back to the account row") + + // Workspace row with comment on, mention off -> now wins over account. + require.NoError(t, s.UpsertScoped(ctx, &model.UserNotificationPreference{ + UserID: w.User.ID, WorkspaceID: &ws, PropertyChange: true, StateChange: true, Comment: true, Mention: false, IssueCompleted: true, + })) + got, err = s.Resolve(ctx, w.User.ID, &ws, &proj) + require.NoError(t, err) + require.True(t, got.Comment) + require.False(t, got.Mention, "workspace row wins over account") + + // Project row with mention on -> most specific, wins over workspace. + require.NoError(t, s.UpsertScoped(ctx, &model.UserNotificationPreference{ + UserID: w.User.ID, WorkspaceID: &ws, ProjectID: &proj, PropertyChange: true, StateChange: true, Comment: true, Mention: true, IssueCompleted: true, + })) + got, err = s.Resolve(ctx, w.User.ID, &ws, &proj) + require.NoError(t, err) + require.True(t, got.Mention, "project row wins over workspace") + + // A different project still resolves to the workspace row (mention off). + otherProj := testutil.CreateProject(t, ts.DB, w.Workspace.ID, w.User.ID) + got, err = s.Resolve(ctx, w.User.ID, &ws, &otherProj.ID) + require.NoError(t, err) + require.False(t, got.Mention, "unconfigured project inherits the workspace row") +} diff --git a/apps/api/migrations/000008_notification_pref_channels.down.sql b/apps/api/migrations/000008_notification_pref_channels.down.sql new file mode 100644 index 00000000..22d24a46 --- /dev/null +++ b/apps/api/migrations/000008_notification_pref_channels.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE user_notification_preferences DROP COLUMN IF EXISTS email_property_change; +ALTER TABLE user_notification_preferences DROP COLUMN IF EXISTS email_state_change; +ALTER TABLE user_notification_preferences DROP COLUMN IF EXISTS email_comment; +ALTER TABLE user_notification_preferences DROP COLUMN IF EXISTS email_mention; +ALTER TABLE user_notification_preferences DROP COLUMN IF EXISTS email_issue_completed; diff --git a/apps/api/migrations/000008_notification_pref_channels.up.sql b/apps/api/migrations/000008_notification_pref_channels.up.sql new file mode 100644 index 00000000..09685057 --- /dev/null +++ b/apps/api/migrations/000008_notification_pref_channels.up.sql @@ -0,0 +1,9 @@ +-- Split notification preferences into per-type email and in-app channels. The +-- existing boolean columns (property_change, ...) remain the in-app toggle; +-- these add the matching email toggle, defaulting to on to preserve today's +-- behavior where an in-app notification also emails the receiver. +ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS email_property_change BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS email_state_change BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS email_comment BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS email_mention BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS email_issue_completed BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ad378df3..cbee096b 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -320,6 +320,11 @@ export interface NotificationPreferencesResponse { comment: boolean; mention: boolean; issue_completed: boolean; + email_property_change: boolean; + email_state_change: boolean; + email_comment: boolean; + email_mention: boolean; + email_issue_completed: boolean; } /** GET /api/users/me/activity/ */ diff --git a/apps/web/src/components/settings/NotificationPreferencesPanel.tsx b/apps/web/src/components/settings/NotificationPreferencesPanel.tsx new file mode 100644 index 00000000..c1c2ea36 --- /dev/null +++ b/apps/web/src/components/settings/NotificationPreferencesPanel.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import type { NotificationPreferencesResponse } from '../../api/types'; + +type Prefs = NotificationPreferencesResponse; +type InAppKey = 'property_change' | 'state_change' | 'issue_completed' | 'comment' | 'mention'; +type EmailKey = keyof Prefs & `email_${string}`; + +const ROWS: { id: string; label: string; desc: string; inApp: InAppKey; email: EmailKey }[] = [ + { + id: 'property', + label: 'Property changes', + desc: "Notify me when work items' properties like assignees, priority, or estimates change.", + inApp: 'property_change', + email: 'email_property_change', + }, + { + id: 'state', + label: 'State change', + desc: 'Notify me when a work item moves to a different state.', + inApp: 'state_change', + email: 'email_state_change', + }, + { + id: 'completed', + label: 'Work item completed', + desc: 'Notify me when a work item is completed.', + inApp: 'issue_completed', + email: 'email_issue_completed', + }, + { + id: 'comments', + label: 'Comments', + desc: 'Notify me when someone comments on a work item.', + inApp: 'comment', + email: 'email_comment', + }, + { + id: 'mentions', + label: 'Mentions', + desc: 'Notify me when someone mentions me in a comment or description.', + inApp: 'mention', + email: 'email_mention', + }, +]; + +function Toggle({ + checked, + disabled, + label, + onToggle, +}: { + checked: boolean; + disabled: boolean; + label: string; + onToggle: () => void; +}) { + return ( + + ); +} + +/** + * Renders the per-type notification toggles with independent In-app and Email + * columns. `load`/`save` abstract the scope (account, workspace, or project) so + * the same panel serves all three. + */ +export function NotificationPreferencesPanel({ + load, + save, + title = 'Notifications', + description = 'Choose which updates reach you in-app and by email.', +}: { + load: () => Promise; + save: (partial: Partial) => Promise; + title?: string; + description?: string; +}) { + const [prefs, setPrefs] = useState(null); + + useEffect(() => { + let cancelled = false; + load() + .then((p) => { + if (!cancelled) setPrefs(p); + }) + .catch(() => { + if (!cancelled) setPrefs(null); + }); + return () => { + cancelled = true; + }; + }, [load]); + + const toggle = async (key: InAppKey | EmailKey) => { + if (!prefs) return; + const next = !prefs[key]; + // Update only this key via a functional update so a concurrent toggle of a + // different switch is never stomped by this one's success or failure. + setPrefs((cur) => (cur ? { ...cur, [key]: next } : cur)); + try { + const saved = await save({ [key]: next }); + setPrefs((cur) => (cur ? { ...cur, [key]: saved[key] } : cur)); + } catch { + setPrefs((cur) => (cur ? { ...cur, [key]: !next } : cur)); + } + }; + + const loaded = prefs !== null; + + return ( +
+
+

{title}

+

{description}

+
+
+
+ Type + In-app + Email +
+ {ROWS.map(({ id, label, desc, inApp, email }) => ( +
+
+

{label}

+

{desc}

+
+
+ void toggle(inApp)} + /> +
+
+ void toggle(email)} + /> +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsNav.tsx b/apps/web/src/components/settings/SettingsNav.tsx index 53030f33..c88f3762 100644 --- a/apps/web/src/components/settings/SettingsNav.tsx +++ b/apps/web/src/components/settings/SettingsNav.tsx @@ -186,7 +186,7 @@ export function SettingsNav({

Administration

- {WORKSPACE_SECTIONS.slice(0, 4).map(({ id, label, icon }) => ( + {WORKSPACE_SECTIONS.slice(0, 5).map(({ id, label, icon }) => ( Developer

- {WORKSPACE_SECTIONS.slice(4).map(({ id, label, icon }) => ( + {WORKSPACE_SECTIONS.slice(5).map(({ id, label, icon }) => ( [] = { id: 'general', label: 'General', icon: }, { id: 'members', label: 'Members', icon: }, { id: 'features', label: 'Features', icon: }, + { id: 'notifications', label: 'Notifications', icon: }, { id: 'states', label: 'States', icon: }, { id: 'labels', label: 'Labels', icon: }, { id: 'estimates', label: 'Estimates', icon: }, @@ -68,11 +71,12 @@ export const ACCOUNT_SECTIONS_DEVELOPER: SettingsSectionConfig }, ]; -// The nav groups these as Administration = slice(0, 4), Developer = slice(4). -// Keep the first four as the admin sections; append Developer-facing ones. +// The nav groups these as Administration = slice(0, 5), Developer = slice(5). +// Keep the first five as the admin sections; append Developer-facing ones. export const WORKSPACE_SECTIONS: SettingsSectionConfig[] = [ { id: 'general', label: 'General', icon: }, { id: 'members', label: 'Members', icon: }, + { id: 'notifications', label: 'Notifications', icon: }, { id: 'integrations', label: 'Integrations', icon: }, { id: 'exports', label: 'Exports', icon: }, { id: 'webhooks', label: 'Webhooks', icon: }, diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 4c31b892..990d1c3e 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -4,6 +4,8 @@ import { Card, CardContent, Button, Avatar, Modal } from '../components/ui'; import { CoverImageModal } from '../components/CoverImageModal'; import { IntegrationsSection } from '../components/integrations/IntegrationsSection'; import { ProjectEstimatesSettings } from '../components/settings/ProjectEstimatesSettings'; +import { NotificationPreferencesPanel } from '../components/settings/NotificationPreferencesPanel'; +import { notificationPreferenceService } from '../services/notificationPreferenceService'; import { UploadImageModal } from '../components/UploadImageModal'; import { ProjectIconModal, ProjectIconDisplay } from '../components/ProjectIconModal'; import { getImageUrl } from '../lib/utils'; @@ -28,6 +30,7 @@ import type { WorkspaceMemberApiResponse, UserActivityItem, ApiTokenResponse, + NotificationPreferencesResponse, } from '../api/types'; import { IconGrid, @@ -145,6 +148,7 @@ export function SettingsPage() { 'general', 'members', 'features', + 'notifications', 'states', 'labels', 'estimates', @@ -161,6 +165,37 @@ export function SettingsPage() { const selectedProject = selectedProjectId ? (projects.find((p) => p.id === selectedProjectId) ?? null) : null; + + // Notification preference load/save, one pair per scope. Kept stable so the + // panel's effect only re-runs when the target scope changes. + const loadAccountNotifPrefs = useCallback(() => userService.getNotificationPreferences(), []); + const saveAccountNotifPrefs = useCallback( + (partial: Partial) => + userService.updateNotificationPreferences(partial), + [], + ); + const loadWorkspaceNotifPrefs = useCallback( + () => notificationPreferenceService.getWorkspace(workspaceSlug ?? ''), + [workspaceSlug], + ); + const saveWorkspaceNotifPrefs = useCallback( + (partial: Partial) => + notificationPreferenceService.updateWorkspace(workspaceSlug ?? '', partial), + [workspaceSlug], + ); + const loadProjectNotifPrefs = useCallback( + () => notificationPreferenceService.getProject(workspaceSlug ?? '', selectedProjectId ?? ''), + [workspaceSlug, selectedProjectId], + ); + const saveProjectNotifPrefs = useCallback( + (partial: Partial) => + notificationPreferenceService.updateProject( + workspaceSlug ?? '', + selectedProjectId ?? '', + partial, + ), + [workspaceSlug, selectedProjectId], + ); const pendingInvites = workspaceInvites.filter((i) => !i.accepted); const pendingProjectInvites = projectInvites.filter((i) => !i.accepted); @@ -297,11 +332,6 @@ export function SettingsPage() { const [firstDayOfWeek, setFirstDayOfWeek] = useState('monday'); const [timezone, setTimezone] = useState('UTC'); const [language, setLanguage] = useState('en'); - const [notifProperty, setNotifProperty] = useState(true); - const [notifState, setNotifState] = useState(true); - const [notifCompleted, setNotifCompleted] = useState(true); - const [notifComments, setNotifComments] = useState(true); - const [notifMentions, setNotifMentions] = useState(true); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -452,7 +482,6 @@ export function SettingsPage() { const [projectTimezoneDropdownOpen, setProjectTimezoneDropdownOpen] = useState(false); const [projectTimezoneSearch, setProjectTimezoneSearch] = useState(''); const projectTimezoneDropdownRef = useRef(null); - const [notifPrefsLoaded, setNotifPrefsLoaded] = useState(false); const [changePasswordLoading, setChangePasswordLoading] = useState(false); const [changePasswordError, setChangePasswordError] = useState(null); const [activityList, setActivityList] = useState([]); @@ -507,29 +536,6 @@ export function SettingsPage() { }; }, [isAccountTab, user?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- user for prefetch; kept for future use - useEffect(() => { - if (!isAccountTab || accountSection !== 'notifications') return; - let cancelled = false; - setNotifPrefsLoaded(false); - userService - .getNotificationPreferences() - .then((p) => { - if (cancelled) return; - setNotifProperty(p.property_change); - setNotifState(p.state_change); - setNotifComments(p.comment); - setNotifMentions(p.mention); - setNotifCompleted(p.issue_completed); - setNotifPrefsLoaded(true); - }) - .catch(() => { - if (!cancelled) setNotifPrefsLoaded(true); - }); - return () => { - cancelled = true; - }; - }, [isAccountTab, accountSection]); - useEffect(() => { if (!isAccountTab || accountSection !== 'activity') return; let cancelled = false; @@ -1195,98 +1201,12 @@ export function SettingsPage() { )} {isAccountTab && accountSection === 'notifications' && ( -
-
-

- Email notifications -

-

- Stay in the loop on Work items you are subscribed to. Enable this to get notified. -

-
-
- {[ - { - id: 'property', - label: 'Property changes', - desc: "Notify me when work items' properties like assignees, priority, estimates or anything else changes.", - value: notifProperty, - set: setNotifProperty, - key: 'property_change' as const, - }, - { - id: 'state', - label: 'State change', - desc: 'Notify me when the work items moves to a different state', - value: notifState, - set: setNotifState, - key: 'state_change' as const, - }, - { - id: 'completed', - label: 'Work item completed', - desc: 'Notify me only when a work item is completed', - value: notifCompleted, - set: setNotifCompleted, - key: 'issue_completed' as const, - }, - { - id: 'comments', - label: 'Comments', - desc: 'Notify me when someone leaves a comment on the work item', - value: notifComments, - set: setNotifComments, - key: 'comment' as const, - }, - { - id: 'mentions', - label: 'Mentions', - desc: 'Notify me only when someone mentions me in the comments or description', - value: notifMentions, - set: setNotifMentions, - key: 'mention' as const, - }, - ].map(({ id, label, desc, value, set, key }) => ( -
-
-

- {label} -

-

{desc}

-
- -
- ))} -
-
+ )} {isAccountTab && accountSection === 'security' && ( @@ -2345,6 +2265,16 @@ export function SettingsPage() { )} + {isProjectsTab && selectedProject && projectSection === 'notifications' && ( + + )} + {isProjectsTab && selectedProject && projectSection === 'states' && (
@@ -3056,6 +2986,16 @@ export function SettingsPage() {
)} + {!isAccountTab && !isProjectsTab && section === 'notifications' && workspaceSlug && ( + + )} + {!isAccountTab && !isProjectsTab && section === 'api-tokens' && workspaceSlug && ( )} diff --git a/apps/web/src/services/notificationPreferenceService.ts b/apps/web/src/services/notificationPreferenceService.ts new file mode 100644 index 00000000..ee8cc673 --- /dev/null +++ b/apps/web/src/services/notificationPreferenceService.ts @@ -0,0 +1,50 @@ +import { apiClient } from '../api/client'; +import type { NotificationPreferencesResponse } from '../api/types'; + +/** + * Workspace- and project-scoped notification preferences. Account-level + * preferences live on {@link userService}. A GET returns the effective + * preferences (project → workspace → account); a PUT writes an override at that + * scope. + */ +export const notificationPreferenceService = { + async getWorkspace(workspaceSlug: string): Promise { + const { data } = await apiClient.get( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/notification-preferences/`, + ); + return data; + }, + + async updateWorkspace( + workspaceSlug: string, + prefs: Partial, + ): Promise { + const { data } = await apiClient.put( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/notification-preferences/`, + prefs, + ); + return data; + }, + + async getProject( + workspaceSlug: string, + projectId: string, + ): Promise { + const { data } = await apiClient.get( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/notification-preferences/`, + ); + return data; + }, + + async updateProject( + workspaceSlug: string, + projectId: string, + prefs: Partial, + ): Promise { + const { data } = await apiClient.put( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/notification-preferences/`, + prefs, + ); + return data; + }, +};