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 {description} {label} {desc}
Administration
{title}
+
- Stay in the loop on Work items you are subscribed to. Enable this to get notified. -
-- {label} -
-{desc}
-