Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 9 additions & 59 deletions apps/api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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).
Expand Down
232 changes: 232 additions & 0 deletions apps/api/internal/handler/notification_preference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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.
func (h *NotificationPreferenceHandler) baseForScope(c *gin.Context, userID uuid.UUID, workspaceID, projectID *uuid.UUID) model.UserNotificationPreference {
if p, err := h.Prefs.Resolve(c.Request.Context(), userID, workspaceID, projectID); err == nil && p != nil {
b := *p
b.ID = uuid.Nil
return b
}
return model.DefaultNotificationPreference()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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 := h.baseForScope(c, user.ID, &wrk.ID, nil)
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 := h.baseForScope(c, user.ID, &proj.WorkspaceID, &proj.ID)
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
}
60 changes: 60 additions & 0 deletions apps/api/internal/handler/notification_preference_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading