diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index f5268493..65a416f8 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -30,6 +30,7 @@ type AuthHandler struct { Settings *store.InstanceSettingStore Winv *store.WorkspaceInviteStore Ws *store.WorkspaceStore + Projects *store.ProjectStore NotifPrefs *store.UserNotificationPreferenceStore ApiTokens *store.ApiTokenStore InstanceAdmins *store.InstanceAdminStore @@ -330,7 +331,7 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { c.Status(http.StatusNoContent) } -// GetNotificationPreferences returns account-level notification preferences. +// GetNotificationPreferences returns notification preferences for a scope. // GET /api/users/me/notification-preferences/ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) { user := middleware.GetUser(c) @@ -338,50 +339,71 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } + workspaceID, projectID, ok := h.notificationPreferenceScope(c, user.ID) + if !ok { + 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, notificationPreferenceResponse(store.DefaultNotificationPreference(user.ID, workspaceID, projectID), "default")) return } - p, err := h.NotifPrefs.GetGlobal(c.Request.Context(), user.ID) + var ( + p *model.UserNotificationPreference + effectiveScope string + err error + ) + if workspaceID != nil && projectID != nil { + p, effectiveScope, err = h.NotifPrefs.ResolveForIssue(c.Request.Context(), user.ID, *workspaceID, *projectID) + } else if workspaceID != nil { + if p, err = h.NotifPrefs.GetScoped(c.Request.Context(), user.ID, workspaceID, nil); err == nil && p != nil { + effectiveScope = "workspace" + } else if err == nil { + p, err = h.NotifPrefs.GetGlobal(c.Request.Context(), user.ID) + if p == nil && err == nil { + p = store.DefaultNotificationPreference(user.ID, workspaceID, nil) + effectiveScope = "default" + } else { + effectiveScope = "global" + } + } + } else { + p, err = h.NotifPrefs.GetGlobal(c.Request.Context(), user.ID) + if p == nil && err == nil { + p = store.DefaultNotificationPreference(user.ID, nil, nil) + effectiveScope = "default" + } else { + effectiveScope = "global" + } + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load preferences"}) return } - if p == nil { - c.JSON(http.StatusOK, gin.H{ - "property_change": true, - "state_change": true, - "comment": true, - "mention": true, - "issue_completed": true, - }) - 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, notificationPreferenceResponse(p, effectiveScope)) } // 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"` + WorkspaceID *uuid.UUID `json:"workspace_id"` + ProjectID *uuid.UUID `json:"project_id"` + PropertyChange *bool `json:"property_change"` + PropertyChangeInApp *bool `json:"property_change_in_app"` + PropertyChangeEmail *bool `json:"property_change_email"` + StateChange *bool `json:"state_change"` + StateChangeInApp *bool `json:"state_change_in_app"` + StateChangeEmail *bool `json:"state_change_email"` + Comment *bool `json:"comment"` + CommentInApp *bool `json:"comment_in_app"` + CommentEmail *bool `json:"comment_email"` + Mention *bool `json:"mention"` + MentionInApp *bool `json:"mention_in_app"` + MentionEmail *bool `json:"mention_email"` + IssueCompleted *bool `json:"issue_completed"` + IssueCompletedInApp *bool `json:"issue_completed_in_app"` + IssueCompletedEmail *bool `json:"issue_completed_email"` } -// UpdateNotificationPreferences updates account-level notification preferences. +// UpdateNotificationPreferences updates notification preferences for a scope. // PUT /api/users/me/notification-preferences/ func (h *AuthHandler) UpdateNotificationPreferences(c *gin.Context) { user := middleware.GetUser(c) @@ -398,45 +420,202 @@ func (h *AuthHandler) UpdateNotificationPreferences(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) return } - p, err := h.NotifPrefs.GetGlobal(c.Request.Context(), user.ID) + workspaceID := req.WorkspaceID + projectID := req.ProjectID + if workspaceID == nil && projectID == nil { + var ok bool + workspaceID, projectID, ok = h.notificationPreferenceScope(c, user.ID) + if !ok { + return + } + } + if !h.validateNotificationPreferenceScope(c, user.ID, workspaceID, projectID) { + return + } + if workspaceID == nil && projectID != nil { + project, err := h.Projects.GetByID(c.Request.Context(), *projectID) + if err != nil || project == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + workspaceID = &project.WorkspaceID + } + p, err := h.NotifPrefs.GetScoped(c.Request.Context(), user.ID, workspaceID, projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load preferences"}) 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 + p = store.DefaultNotificationPreference(user.ID, workspaceID, projectID) } if req.PropertyChange != nil { p.PropertyChange = *req.PropertyChange + p.PropertyChangeInApp = *req.PropertyChange + p.PropertyChangeEmail = *req.PropertyChange + } + if req.PropertyChangeInApp != nil { + p.PropertyChangeInApp = *req.PropertyChangeInApp + } + if req.PropertyChangeEmail != nil { + p.PropertyChangeEmail = *req.PropertyChangeEmail } if req.StateChange != nil { p.StateChange = *req.StateChange + p.StateChangeInApp = *req.StateChange + p.StateChangeEmail = *req.StateChange + } + if req.StateChangeInApp != nil { + p.StateChangeInApp = *req.StateChangeInApp + } + if req.StateChangeEmail != nil { + p.StateChangeEmail = *req.StateChangeEmail } if req.Comment != nil { p.Comment = *req.Comment + p.CommentInApp = *req.Comment + p.CommentEmail = *req.Comment + } + if req.CommentInApp != nil { + p.CommentInApp = *req.CommentInApp + } + if req.CommentEmail != nil { + p.CommentEmail = *req.CommentEmail } if req.Mention != nil { p.Mention = *req.Mention + p.MentionInApp = *req.Mention + p.MentionEmail = *req.Mention + } + if req.MentionInApp != nil { + p.MentionInApp = *req.MentionInApp + } + if req.MentionEmail != nil { + p.MentionEmail = *req.MentionEmail } if req.IssueCompleted != nil { p.IssueCompleted = *req.IssueCompleted + p.IssueCompletedInApp = *req.IssueCompleted + p.IssueCompletedEmail = *req.IssueCompleted + } + if req.IssueCompletedInApp != nil { + p.IssueCompletedInApp = *req.IssueCompletedInApp } - if err := h.NotifPrefs.UpsertGlobal(c.Request.Context(), p); err != nil { + if req.IssueCompletedEmail != nil { + p.IssueCompletedEmail = *req.IssueCompletedEmail + } + if err := h.NotifPrefs.UpsertScoped(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, notificationPreferenceResponse(p, exactNotificationPreferenceScope(workspaceID, projectID))) +} + +func (h *AuthHandler) notificationPreferenceScope(c *gin.Context, userID uuid.UUID) (*uuid.UUID, *uuid.UUID, bool) { + var workspaceID *uuid.UUID + var projectID *uuid.UUID + if raw := strings.TrimSpace(c.Query("workspace_id")); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace_id"}) + return nil, nil, false + } + workspaceID = &id + } + if raw := strings.TrimSpace(c.Query("project_id")); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project_id"}) + return nil, nil, false + } + projectID = &id + } + if !h.validateNotificationPreferenceScope(c, userID, workspaceID, projectID) { + return nil, nil, false + } + if workspaceID == nil && projectID != nil { + project, err := h.Projects.GetByID(c.Request.Context(), *projectID) + if err != nil || project == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return nil, nil, false + } + workspaceID = &project.WorkspaceID + } + return workspaceID, projectID, true +} + +func (h *AuthHandler) validateNotificationPreferenceScope(c *gin.Context, userID uuid.UUID, workspaceID, projectID *uuid.UUID) bool { + if projectID != nil { + if h.Projects == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Not configured"}) + return false + } + project, err := h.Projects.GetByID(c.Request.Context(), *projectID) + if err != nil || project == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return false + } + if workspaceID != nil && project.WorkspaceID != *workspaceID { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project does not belong to workspace"}) + return false + } + workspaceID = &project.WorkspaceID + } + if workspaceID != nil { + if h.Ws == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Not configured"}) + return false + } + ok, err := h.Ws.IsMember(c.Request.Context(), *workspaceID, userID) + if err != nil || !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "Workspace access required"}) + return false + } + } + return true +} + +func exactNotificationPreferenceScope(workspaceID, projectID *uuid.UUID) string { + if projectID != nil { + return "project" + } + if workspaceID != nil { + return "workspace" + } + return "global" +} + +func notificationPreferenceResponse(p *model.UserNotificationPreference, effectiveScope string) gin.H { + if p == nil { + return gin.H{} + } + resp := gin.H{ + "id": p.ID.String(), + "scope": exactNotificationPreferenceScope(p.WorkspaceID, p.ProjectID), + "effective_scope": effectiveScope, + "user_id": p.UserID.String(), + "property_change": p.PropertyChange, + "property_change_in_app": p.PropertyChangeInApp, + "property_change_email": p.PropertyChangeEmail, + "state_change": p.StateChange, + "state_change_in_app": p.StateChangeInApp, + "state_change_email": p.StateChangeEmail, + "comment": p.Comment, + "comment_in_app": p.CommentInApp, + "comment_email": p.CommentEmail, + "mention": p.Mention, + "mention_in_app": p.MentionInApp, + "mention_email": p.MentionEmail, + "issue_completed": p.IssueCompleted, + "issue_completed_in_app": p.IssueCompletedInApp, + "issue_completed_email": p.IssueCompletedEmail, + } + if p.WorkspaceID != nil { + resp["workspace_id"] = p.WorkspaceID.String() + } + if p.ProjectID != nil { + resp["project_id"] = p.ProjectID.String() + } + return resp } // ListTokens returns the current user's API tokens (without secret values). diff --git a/apps/api/internal/handler/auth_test.go b/apps/api/internal/handler/auth_test.go index 301d952b..e83b3ff1 100644 --- a/apps/api/internal/handler/auth_test.go +++ b/apps/api/internal/handler/auth_test.go @@ -268,6 +268,40 @@ func TestAuth_NotificationPreferences_DefaultsAndUpdate(t *testing.T) { require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) body3 := testutil.MustJSONMap(t, rr3) assert.Equal(t, false, body3["comment"]) + assert.Equal(t, false, body3["comment_in_app"]) + assert.Equal(t, false, body3["comment_email"]) +} + +func TestAuth_NotificationPreferences_ProjectScope(t *testing.T) { + ts := testutil.NewTestServer(t) + world := testutil.SeedWorld(t, ts.DB) + + rr := ts.PUT("/api/users/me/notification-preferences/", map[string]any{ + "workspace_id": world.Workspace.ID.String(), + "project_id": world.Project.ID.String(), + "comment_in_app": true, + "comment_email": false, + "mention_in_app": false, + "mention_email": true, + }, world.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "project", body["scope"]) + assert.Equal(t, true, body["comment_in_app"]) + assert.Equal(t, false, body["comment_email"]) + assert.Equal(t, false, body["mention_in_app"]) + assert.Equal(t, true, body["mention_email"]) + + rr2 := ts.GET("/api/users/me/notification-preferences/?workspace_id="+world.Workspace.ID.String()+"&project_id="+world.Project.ID.String(), world.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + body2 := testutil.MustJSONMap(t, rr2) + assert.Equal(t, "project", body2["effective_scope"]) + assert.Equal(t, false, body2["comment_email"]) + + rr3 := ts.GET("/api/users/me/notification-preferences/", world.Session) + require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) + body3 := testutil.MustJSONMap(t, rr3) + assert.Equal(t, true, body3["comment_email"]) } func TestAuth_Tokens_ListCreateRevoke(t *testing.T) { diff --git a/apps/api/internal/model/user_notification_preference.go b/apps/api/internal/model/user_notification_preference.go index bd7b0331..2b79cee2 100644 --- a/apps/api/internal/model/user_notification_preference.go +++ b/apps/api/internal/model/user_notification_preference.go @@ -9,17 +9,27 @@ import ( // UserNotificationPreference matches user_notification_preferences (account-level when workspace_id and project_id are null). 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"` - WorkspaceID *uuid.UUID `gorm:"type:uuid" json:"workspace_id,omitempty"` - ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` - PropertyChange bool `gorm:"column:property_change;default:true" json:"property_change"` - StateChange bool `gorm:"column:state_change;default:true" json:"state_change"` - 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"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + WorkspaceID *uuid.UUID `gorm:"type:uuid" json:"workspace_id,omitempty"` + ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` + PropertyChange bool `gorm:"column:property_change;default:true" json:"property_change"` + PropertyChangeInApp bool `gorm:"column:property_change_in_app;default:true" json:"property_change_in_app"` + PropertyChangeEmail bool `gorm:"column:property_change_email;default:true" json:"property_change_email"` + StateChange bool `gorm:"column:state_change;default:true" json:"state_change"` + StateChangeInApp bool `gorm:"column:state_change_in_app;default:true" json:"state_change_in_app"` + StateChangeEmail bool `gorm:"column:state_change_email;default:true" json:"state_change_email"` + Comment bool `gorm:"column:comment;default:true" json:"comment"` + CommentInApp bool `gorm:"column:comment_in_app;default:true" json:"comment_in_app"` + CommentEmail bool `gorm:"column:comment_email;default:true" json:"comment_email"` + Mention bool `gorm:"column:mention;default:true" json:"mention"` + MentionInApp bool `gorm:"column:mention_in_app;default:true" json:"mention_in_app"` + MentionEmail bool `gorm:"column:mention_email;default:true" json:"mention_email"` + IssueCompleted bool `gorm:"column:issue_completed;default:true" json:"issue_completed"` + IssueCompletedInApp bool `gorm:"column:issue_completed_in_app;default:true" json:"issue_completed_in_app"` + IssueCompletedEmail bool `gorm:"column:issue_completed_email;default:true" json:"issue_completed_email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } 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..34512525 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -112,6 +112,7 @@ func New(cfg Config) *gin.Engine { Settings: instanceSettingStore, Winv: workspaceInviteStore, Ws: workspaceStore, + Projects: projectStore, NotifPrefs: userNotifPrefStore, ApiTokens: apiTokenStore, InstanceAdmins: instanceAdminStore, diff --git a/apps/api/internal/service/notification.go b/apps/api/internal/service/notification.go index efe4fdb5..09781d90 100644 --- a/apps/api/internal/service/notification.go +++ b/apps/api/internal/service/notification.go @@ -270,19 +270,27 @@ func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, p return } - // 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: in-app rows and email delivery are controlled + // independently. Mention notifications still classify separately from + // comment notifications. + inAppAllowed := allowed + emailAllowed := allowed if s.prefs != nil { - gated := make([]uuid.UUID, 0, len(allowed)) + inAppGated := make([]uuid.UUID, 0, len(allowed)) + emailGated := 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.deliveryPreferenceForSender(ctx, id, params.issue, params.sender, params.classifyMention) + if pref.InApp { + inAppGated = append(inAppGated, id) + } + if pref.Email { + emailGated = append(emailGated, id) } } - allowed = gated + inAppAllowed = inAppGated + emailAllowed = emailGated } - if len(allowed) == 0 { + if len(inAppAllowed) == 0 && len(emailAllowed) == 0 { return } @@ -291,8 +299,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(inAppAllowed)) + for _, receiverID := range inAppAllowed { sender := params.sender if params.classifyMention != nil && params.classifyMention(receiverID) { sender = model.NotificationSenderMentioned @@ -323,18 +331,21 @@ 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) + s.enqueueNotificationEmails(ctx, emailAllowed, params, actorName, issueRef) } } -// enqueueNotificationEmails queues an email for each receiver who received an in-app notification. +// enqueueNotificationEmails queues an email for each receiver whose email +// preferences allow this notification. // Errors are logged and swallowed — email delivery is best-effort and must not break in-app notifications. func (s *NotificationService) enqueueNotificationEmails(ctx context.Context, receivers []uuid.UUID, params emitParams, actorName, issueRef string) { if params.issue == nil || len(receivers) == 0 { @@ -683,39 +694,42 @@ func buildMessage(in messageInputs) model.JSONMap { return m } -// allowedBySender returns true if the receiver's preferences permit a notification -// 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 - } +type deliveryPreference struct { + InApp bool + Email bool +} + +// deliveryPreferenceForSender resolves the receiver's effective scoped +// preferences for this issue and returns the enabled delivery channels. +func (s *NotificationService) deliveryPreferenceForSender(ctx context.Context, userID uuid.UUID, issue *model.Issue, sender string, classify func(uuid.UUID) bool) deliveryPreference { effective := sender if classify != nil && classify(userID) { effective = model.NotificationSenderMentioned } - p, err := s.prefs.GetGlobal(ctx, userID) + if s.prefs == nil || issue == nil { + return deliveryPreference{InApp: true, Email: true} + } + p, _, err := s.prefs.ResolveForIssue(ctx, userID, issue.WorkspaceID, issue.ProjectID) if err != nil || p == nil { // Default: allow everything when no preference row exists. - return true + return deliveryPreference{InApp: true, Email: true} } switch effective { case model.NotificationSenderMentioned: - return p.Mention + return deliveryPreference{InApp: p.MentionInApp, Email: p.MentionEmail} case model.NotificationSenderCommented: - return p.Comment + return deliveryPreference{InApp: p.CommentInApp, Email: p.CommentEmail} case model.NotificationSenderStateChanged: - return p.StateChange + return deliveryPreference{InApp: p.StateChangeInApp, Email: p.StateChangeEmail} case model.NotificationSenderSubscribed: - return p.PropertyChange + return deliveryPreference{InApp: p.PropertyChangeInApp, Email: p.PropertyChangeEmail} 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. - return p.PropertyChange + return deliveryPreference{InApp: p.PropertyChangeInApp, Email: p.PropertyChangeEmail} } - return true + return deliveryPreference{InApp: true, Email: true} } // dedupExclude returns receivers minus exclude, with duplicates and uuid.Nil removed. diff --git a/apps/api/internal/store/user_notification_preference.go b/apps/api/internal/store/user_notification_preference.go index 66d2da43..1770d346 100644 --- a/apps/api/internal/store/user_notification_preference.go +++ b/apps/api/internal/store/user_notification_preference.go @@ -16,13 +16,28 @@ func NewUserNotificationPreferenceStore(db *gorm.DB) *UserNotificationPreference return &UserNotificationPreferenceStore{db: db} } -// GetGlobal gets the account-level (global) notification preferences for a user. +// GetGlobal gets the account-level 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) { + return s.GetScoped(ctx, userID, nil, nil) +} + +// GetScoped gets an exact preference row. Nil workspace/project means the +// column must be NULL, so callers can distinguish global/workspace/project rows. +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 + q := s.db.WithContext(ctx).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) + } + err := q.First(&p).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil @@ -32,23 +47,103 @@ func (s *UserNotificationPreferenceStore) GetGlobal(ctx context.Context, userID return &p, nil } +// ResolveForIssue returns the effective preferences for an issue notification: +// project override, then workspace override, then global, then default-allow. +func (s *UserNotificationPreferenceStore) ResolveForIssue(ctx context.Context, userID, workspaceID, projectID uuid.UUID) (*model.UserNotificationPreference, string, error) { + if p, err := s.GetScoped(ctx, userID, &workspaceID, &projectID); err != nil || p != nil { + return p, "project", err + } + if p, err := s.GetScoped(ctx, userID, &workspaceID, nil); err != nil || p != nil { + return p, "workspace", err + } + if p, err := s.GetGlobal(ctx, userID); err != nil || p != nil { + return p, "global", err + } + return DefaultNotificationPreference(userID, nil, nil), "default", 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 + p.WorkspaceID = nil + p.ProjectID = nil + return s.UpsertScoped(ctx, p) +} + +// UpsertScoped creates or updates a scoped notification preference row. +func (s *UserNotificationPreferenceStore) UpsertScoped(ctx context.Context, p *model.UserNotificationPreference) error { + if p.WorkspaceID == nil && p.ProjectID != nil { + return errors.New("project-scoped notification preference requires workspace_id") } - existing, err := s.GetGlobal(ctx, p.UserID) + s.NormalizeChannels(p) + existing, err := s.GetScoped(ctx, p.UserID, p.WorkspaceID, p.ProjectID) if err != nil { return err } + updates := notificationPreferenceUpdates(p) 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 - } - return s.db.WithContext(ctx).Create(p).Error + return s.db.WithContext(ctx).Model(existing).Updates(updates).Error + } + create := notificationPreferenceUpdates(p) + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + create["id"] = p.ID + create["user_id"] = p.UserID + create["workspace_id"] = p.WorkspaceID + create["project_id"] = p.ProjectID + return s.db.WithContext(ctx).Model(&model.UserNotificationPreference{}).Create(create).Error +} + +// NormalizeChannels mirrors legacy category booleans to enabled when either +// delivery channel is enabled. This keeps older clients meaningful while newer +// clients use the per-channel fields directly. +func (s *UserNotificationPreferenceStore) NormalizeChannels(p *model.UserNotificationPreference) { + p.PropertyChange = p.PropertyChangeInApp || p.PropertyChangeEmail + p.StateChange = p.StateChangeInApp || p.StateChangeEmail + p.Comment = p.CommentInApp || p.CommentEmail + p.Mention = p.MentionInApp || p.MentionEmail + p.IssueCompleted = p.IssueCompletedInApp || p.IssueCompletedEmail +} + +func DefaultNotificationPreference(userID uuid.UUID, workspaceID, projectID *uuid.UUID) *model.UserNotificationPreference { + return &model.UserNotificationPreference{ + UserID: userID, + WorkspaceID: workspaceID, + ProjectID: projectID, + PropertyChange: true, + PropertyChangeInApp: true, + PropertyChangeEmail: true, + StateChange: true, + StateChangeInApp: true, + StateChangeEmail: true, + Comment: true, + CommentInApp: true, + CommentEmail: true, + Mention: true, + MentionInApp: true, + MentionEmail: true, + IssueCompleted: true, + IssueCompletedInApp: true, + IssueCompletedEmail: true, + } +} + +func notificationPreferenceUpdates(p *model.UserNotificationPreference) map[string]any { + return map[string]any{ + "property_change": p.PropertyChange, + "property_change_in_app": p.PropertyChangeInApp, + "property_change_email": p.PropertyChangeEmail, + "state_change": p.StateChange, + "state_change_in_app": p.StateChangeInApp, + "state_change_email": p.StateChangeEmail, + "comment": p.Comment, + "comment_in_app": p.CommentInApp, + "comment_email": p.CommentEmail, + "mention": p.Mention, + "mention_in_app": p.MentionInApp, + "mention_email": p.MentionEmail, + "issue_completed": p.IssueCompleted, + "issue_completed_in_app": p.IssueCompletedInApp, + "issue_completed_email": p.IssueCompletedEmail, + } } diff --git a/apps/api/migrations/000007_notification_preference_channels.down.sql b/apps/api/migrations/000007_notification_preference_channels.down.sql new file mode 100644 index 00000000..c81ef629 --- /dev/null +++ b/apps/api/migrations/000007_notification_preference_channels.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE user_notification_preferences + DROP COLUMN IF EXISTS issue_completed_email, + DROP COLUMN IF EXISTS issue_completed_in_app, + DROP COLUMN IF EXISTS mention_email, + DROP COLUMN IF EXISTS mention_in_app, + DROP COLUMN IF EXISTS comment_email, + DROP COLUMN IF EXISTS comment_in_app, + DROP COLUMN IF EXISTS state_change_email, + DROP COLUMN IF EXISTS state_change_in_app, + DROP COLUMN IF EXISTS property_change_email, + DROP COLUMN IF EXISTS property_change_in_app; diff --git a/apps/api/migrations/000007_notification_preference_channels.up.sql b/apps/api/migrations/000007_notification_preference_channels.up.sql new file mode 100644 index 00000000..24987da1 --- /dev/null +++ b/apps/api/migrations/000007_notification_preference_channels.up.sql @@ -0,0 +1,24 @@ +ALTER TABLE user_notification_preferences + ADD COLUMN property_change_in_app BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN property_change_email BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN state_change_in_app BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN state_change_email BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN comment_in_app BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN comment_email BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN mention_in_app BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN mention_email BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN issue_completed_in_app BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN issue_completed_email BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE user_notification_preferences +SET + property_change_in_app = property_change, + property_change_email = property_change, + state_change_in_app = state_change, + state_change_email = state_change, + comment_in_app = comment, + comment_email = comment, + mention_in_app = mention, + mention_email = mention, + issue_completed_in_app = issue_completed, + issue_completed_email = issue_completed; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ad378df3..dec4c6f5 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -315,11 +315,27 @@ export interface ChangePasswordRequest { /** GET /api/users/me/notification-preferences/ */ export interface NotificationPreferencesResponse { + id?: string; + scope?: 'global' | 'workspace' | 'project'; + effective_scope?: 'default' | 'global' | 'workspace' | 'project'; + user_id?: string; + workspace_id?: string; + project_id?: string; property_change: boolean; + property_change_in_app: boolean; + property_change_email: boolean; state_change: boolean; + state_change_in_app: boolean; + state_change_email: boolean; comment: boolean; + comment_in_app: boolean; + comment_email: boolean; mention: boolean; + mention_in_app: boolean; + mention_email: boolean; issue_completed: boolean; + issue_completed_in_app: boolean; + issue_completed_email: boolean; } /** GET /api/users/me/activity/ */ diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 4c31b892..9c4e7574 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -28,6 +28,7 @@ import type { WorkspaceMemberApiResponse, UserActivityItem, ApiTokenResponse, + NotificationPreferencesResponse, } from '../api/types'; import { IconGrid, @@ -66,6 +67,81 @@ import { ProjectLabelModal } from '../components/settings/modals/ProjectLabelMod import { formatRelativeTime, getTimezoneOptions } from '../lib/settingsHelpers'; const COMPANY_SIZES = ['1-10', '11-50', '51-200', '201-500', '500+']; +type NotificationScope = 'global' | 'workspace' | 'project'; +type NotificationCategory = + | 'property_change' + | 'state_change' + | 'issue_completed' + | 'comment' + | 'mention'; +type NotificationChannel = 'in_app' | 'email'; +type NotificationChannelState = Record>; + +const notificationDefaults: NotificationChannelState = { + property_change: { in_app: true, email: true }, + state_change: { in_app: true, email: true }, + issue_completed: { in_app: true, email: true }, + comment: { in_app: true, email: true }, + mention: { in_app: true, email: true }, +}; + +const notificationRows: { + key: NotificationCategory; + label: string; + desc: string; +}[] = [ + { + key: 'property_change', + label: 'Property changes', + desc: 'Work item properties like assignees, priority, estimates, or dates change.', + }, + { + key: 'state_change', + label: 'State changes', + desc: 'Work items move to a different workflow state.', + }, + { + key: 'issue_completed', + label: 'Work item completed', + desc: 'A work item reaches its completed state.', + }, + { + key: 'comment', + label: 'Comments', + desc: 'Someone leaves a comment on a work item you follow.', + }, + { + key: 'mention', + label: 'Mentions', + desc: 'Someone mentions you in a comment or description.', + }, +]; + +function notificationStateFromApi(p: NotificationPreferencesResponse): NotificationChannelState { + return { + property_change: { + in_app: p.property_change_in_app ?? p.property_change, + email: p.property_change_email ?? p.property_change, + }, + state_change: { + in_app: p.state_change_in_app ?? p.state_change, + email: p.state_change_email ?? p.state_change, + }, + issue_completed: { + in_app: p.issue_completed_in_app ?? p.issue_completed, + email: p.issue_completed_email ?? p.issue_completed, + }, + comment: { + in_app: p.comment_in_app ?? p.comment, + email: p.comment_email ?? p.comment, + }, + mention: { + in_app: p.mention_in_app ?? p.mention, + email: p.mention_email ?? p.mention, + }, + }; +} + export function SettingsPage() { const { workspaceSlug, projectId: projectIdFromPath } = useParams<{ workspaceSlug: string; @@ -297,11 +373,11 @@ 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 [notificationScope, setNotificationScope] = useState('global'); + const [notificationProjectId, setNotificationProjectId] = useState(''); + const [notificationPrefs, setNotificationPrefs] = + useState(notificationDefaults); + const [notificationEffectiveScope, setNotificationEffectiveScope] = useState('default'); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -511,15 +587,33 @@ export function SettingsPage() { if (!isAccountTab || accountSection !== 'notifications') return; let cancelled = false; setNotifPrefsLoaded(false); + const scope = + notificationScope === 'project' + ? { + workspace_id: workspace?.id, + project_id: notificationProjectId || projects[0]?.id, + } + : notificationScope === 'workspace' + ? { workspace_id: workspace?.id } + : undefined; + if (notificationScope !== 'global' && !scope?.workspace_id) { + setNotifPrefsLoaded(true); + return () => { + cancelled = true; + }; + } + if (notificationScope === 'project' && !scope?.project_id) { + setNotifPrefsLoaded(true); + return () => { + cancelled = true; + }; + } userService - .getNotificationPreferences() + .getNotificationPreferences(scope) .then((p) => { if (cancelled) return; - setNotifProperty(p.property_change); - setNotifState(p.state_change); - setNotifComments(p.comment); - setNotifMentions(p.mention); - setNotifCompleted(p.issue_completed); + setNotificationPrefs(notificationStateFromApi(p)); + setNotificationEffectiveScope(p.effective_scope ?? 'default'); setNotifPrefsLoaded(true); }) .catch(() => { @@ -528,7 +622,14 @@ export function SettingsPage() { return () => { cancelled = true; }; - }, [isAccountTab, accountSection]); + }, [ + isAccountTab, + accountSection, + notificationScope, + notificationProjectId, + workspace?.id, + projects, + ]); useEffect(() => { if (!isAccountTab || accountSection !== 'activity') return; @@ -617,6 +718,12 @@ export function SettingsPage() { } }, [isProjectsTab, workspace, projects.length, projectIdParam, navigate]); // eslint-disable-line react-hooks/exhaustive-deps -- projects for redirect; kept for future use + useEffect(() => { + if (!notificationProjectId && projects[0]?.id) { + setNotificationProjectId(projects[0].id); + } + }, [notificationProjectId, projects]); + const filteredMembers = membersSearch.trim() ? workspaceMembers.filter((m) => { const term = membersSearch.toLowerCase(); @@ -639,6 +746,41 @@ export function SettingsPage() { })(), ) : projectMembers; + + const updateNotificationPreference = useCallback( + async (key: NotificationCategory, channel: NotificationChannel, next: boolean) => { + const previous = notificationPrefs; + const updated = { + ...notificationPrefs, + [key]: { + ...notificationPrefs[key], + [channel]: next, + }, + }; + setNotificationPrefs(updated); + const scope = + notificationScope === 'project' + ? { + workspace_id: workspace?.id, + project_id: notificationProjectId || projects[0]?.id, + } + : notificationScope === 'workspace' + ? { workspace_id: workspace?.id } + : {}; + try { + const response = await userService.updateNotificationPreferences({ + ...scope, + [`${key}_${channel}`]: next, + } as Partial); + setNotificationPrefs(notificationStateFromApi(response)); + setNotificationEffectiveScope(response.effective_scope ?? notificationScope); + } catch { + setNotificationPrefs(previous); + } + }, + [notificationPrefs, notificationScope, notificationProjectId, projects, workspace?.id], + ); + if (loading) { return (
@@ -1197,95 +1339,97 @@ export function SettingsPage() { {isAccountTab && accountSection === 'notifications' && (
-

- Email notifications -

+

Notifications

- Stay in the loop on Work items you are subscribed to. Enable this to get notified. + Control where notifications appear for your account, this workspace, or a project.

-
- {[ - { - 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 }) => ( + +
+ {( + [ + { value: 'global' as NotificationScope, label: 'Global' }, + { value: 'workspace' as NotificationScope, label: 'Workspace' }, + { value: 'project' as NotificationScope, label: 'Project' }, + ] as const + ).map((scope) => ( + + ))} + {notificationScope === 'project' && ( +
+ + + + +
+ )} +
+ +
+
+ Type + In-app + Email +
+ {notificationRows.map(({ key, label, desc }) => (
-
-

- {label} -

+
+

{label}

{desc}

- + {(['in_app', 'email'] as const).map((channel) => { + const value = notificationPrefs[key][channel]; + return ( + + ); + })}
))}
+

+ {notificationEffectiveScope === notificationScope + ? 'These settings are saved for the selected scope.' + : `Using ${notificationEffectiveScope} defaults until you change this scope.`} +

)} diff --git a/apps/web/src/services/userService.ts b/apps/web/src/services/userService.ts index 6dd7365e..f16b0378 100644 --- a/apps/web/src/services/userService.ts +++ b/apps/web/src/services/userService.ts @@ -19,9 +19,15 @@ export const userService = { await apiClient.post('/api/users/me/change-password/', payload); }, - async getNotificationPreferences(): Promise { + async getNotificationPreferences(scope?: { + workspace_id?: string; + project_id?: string; + }): Promise { + const params = new URLSearchParams(); + if (scope?.workspace_id) params.set('workspace_id', scope.workspace_id); + if (scope?.project_id) params.set('project_id', scope.project_id); const { data } = await apiClient.get( - '/api/users/me/notification-preferences/', + `/api/users/me/notification-preferences/${params.toString() ? `?${params.toString()}` : ''}`, ); return data; },