Skip to content

Commit 30548e8

Browse files
Merge branch 'main' into refactor/admin-ui-child
2 parents 8998b08 + 8cf5e59 commit 30548e8

24 files changed

Lines changed: 4948 additions & 4035 deletions

File tree

.coderabbit.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
2+
language: "en-US"
3+
early_access: false
4+
reviews:
5+
profile: "chill"
6+
request_changes_workflow: false
7+
high_level_summary: true
8+
high_level_summary_in_walkthrough: true
9+
poem: false
10+
review_status: true
11+
review_details: false
12+
auto_review:
13+
enabled: true
14+
drafts: false
15+
chat:
16+
auto_reply: true

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
44
VERSION := $(shell git describe --tags ${TAG})
55
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
66
.DEFAULT_GOAL := build
7-
PROTON_COMMIT := "b1687af73f994fa9612a023c850aa97c35735af8"
7+
PROTON_COMMIT := "e806c3a6f5ae280a23d02480becddc2818661715"
88

99
admin-app:
1010
@echo " > generating admin build"

core/preference/filter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ type Filter struct {
77
GroupID string `json:"group_id"`
88
ResourceID string `json:"resource_id"`
99
ResourceType string `json:"resource_type"`
10+
// ScopeType and ScopeID filter preferences by their scope
11+
// e.g., to get user preferences scoped to a specific organization
12+
ScopeType string `json:"scope_type"`
13+
ScopeID string `json:"scope_id"`
1014
}

core/preference/preference.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ var (
1313
ErrInvalidFilter = fmt.Errorf("invalid preference filter set")
1414
ErrTraitNotFound = fmt.Errorf("preference trait not found, preferences can only be created with valid trait")
1515
ErrInvalidValue = fmt.Errorf("invalid value for preference")
16+
ErrInvalidScope = fmt.Errorf("invalid scope: trait does not support scoping or scope_type/scope_id mismatch")
1617
)
1718

1819
type TraitInput string
@@ -70,6 +71,20 @@ type Trait struct {
7071
InputHints string `json:"input_hints" yaml:"input_hints"`
7172
// Default value to be used for the trait if the preference is not set (say "true" for a TraitInput of type Checkbox)
7273
Default string `json:"default" yaml:"default"`
74+
// AllowedScopes specifies which scope types are valid for this trait
75+
// e.g., ["app/organization"] allows org-scoped preferences
76+
// Empty means the trait is global only (no scoping allowed)
77+
AllowedScopes []string `json:"allowed_scopes" yaml:"allowed_scopes"`
78+
}
79+
80+
// IsValidScope checks if the given scope type is allowed for this trait
81+
func (t Trait) IsValidScope(scopeType string) bool {
82+
for _, allowed := range t.AllowedScopes {
83+
if allowed == scopeType {
84+
return true
85+
}
86+
}
87+
return false
7388
}
7489

7590
func (t Trait) GetValidator() PreferenceValidator {

core/preference/service.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@ func (s *Service) Create(ctx context.Context, preference Preference) (Preference
4545
if matchedTrait == nil {
4646
return Preference{}, ErrTraitNotFound
4747
}
48+
49+
// validate scope
50+
hasScope := preference.ScopeType != "" || preference.ScopeID != ""
51+
traitRequiresScope := len(matchedTrait.AllowedScopes) > 0
52+
53+
if traitRequiresScope {
54+
// Trait requires scope - must provide both scope_type and scope_id
55+
if preference.ScopeType == "" || preference.ScopeID == "" {
56+
return Preference{}, ErrInvalidScope
57+
}
58+
// Scope type must be in the trait's allowed scopes
59+
if !matchedTrait.IsValidScope(preference.ScopeType) {
60+
return Preference{}, ErrInvalidScope
61+
}
62+
} else if hasScope {
63+
// Trait is global-only but scope was provided
64+
return Preference{}, ErrInvalidScope
65+
}
66+
4867
validator := matchedTrait.GetValidator()
4968
if !validator.Validate(preference.Value) {
5069
return Preference{}, ErrInvalidValue
@@ -68,6 +87,71 @@ func (s *Service) Describe(ctx context.Context) []Trait {
6887
return s.traits
6988
}
7089

90+
// LoadUserPreferences loads user preferences and merges them with trait defaults.
91+
// Always returns a complete preference set with priority:
92+
// 1. Org-scoped DB values (if scope provided, highest priority)
93+
// 2. Global DB values (fallback)
94+
// 3. Trait defaults (for anything not in DB)
95+
func (s *Service) LoadUserPreferences(ctx context.Context, filter Filter) ([]Preference, error) {
96+
hasScope := filter.ScopeType != "" && filter.ScopeID != ""
97+
98+
// Fetch global preferences
99+
globalPrefs, err := s.repo.List(ctx, Filter{
100+
UserID: filter.UserID,
101+
// No scope = global preferences (repo will use ScopeTypeGlobal/ScopeIDGlobal)
102+
})
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
// Build preference map starting with global preferences
108+
prefMap := make(map[string]Preference)
109+
for _, pref := range globalPrefs {
110+
prefMap[pref.Name] = pref
111+
}
112+
113+
// If scope provided, fetch scoped preferences and override globals
114+
if hasScope {
115+
scopedPrefs, err := s.repo.List(ctx, filter)
116+
if err != nil {
117+
return nil, err
118+
}
119+
for _, pref := range scopedPrefs {
120+
prefMap[pref.Name] = pref
121+
}
122+
}
123+
124+
// Build result with trait ordering, filling in defaults for missing preferences
125+
var result []Preference
126+
for _, trait := range s.traits {
127+
if trait.ResourceType != schema.UserPrincipal {
128+
continue
129+
}
130+
if pref, exists := prefMap[trait.Name]; exists {
131+
result = append(result, pref)
132+
delete(prefMap, trait.Name) // mark as processed
133+
} else if trait.Default != "" {
134+
// Add default preference for unset trait
135+
result = append(result, Preference{
136+
Name: trait.Name,
137+
Value: trait.Default,
138+
ResourceID: filter.UserID,
139+
ResourceType: schema.UserPrincipal,
140+
ScopeType: filter.ScopeType,
141+
ScopeID: filter.ScopeID,
142+
})
143+
}
144+
}
145+
146+
// Add any remaining preferences that don't have a matching trait
147+
// (shouldn't happen normally but handles edge cases)
148+
for _, pref := range prefMap {
149+
result = append(result, pref)
150+
}
151+
152+
return result, nil
153+
}
154+
71155
// LoadPlatformPreferences loads platform preferences from the database
72156
// and returns a map of preference name to value
73157
// if a preference is not set in the database, the default value is used from DefaultTraits

core/preference/service_test.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package preference
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/uuid"
8+
"github.com/raystack/frontier/internal/bootstrap/schema"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/mock"
11+
)
12+
13+
type MockRepository struct {
14+
mock.Mock
15+
}
16+
17+
func (m *MockRepository) Set(ctx context.Context, preference Preference) (Preference, error) {
18+
args := m.Called(ctx, preference)
19+
return args.Get(0).(Preference), args.Error(1)
20+
}
21+
22+
func (m *MockRepository) Get(ctx context.Context, id uuid.UUID) (Preference, error) {
23+
args := m.Called(ctx, id)
24+
return args.Get(0).(Preference), args.Error(1)
25+
}
26+
27+
func (m *MockRepository) List(ctx context.Context, filter Filter) ([]Preference, error) {
28+
args := m.Called(ctx, filter)
29+
if args.Get(0) == nil {
30+
return nil, args.Error(1)
31+
}
32+
return args.Get(0).([]Preference), args.Error(1)
33+
}
34+
35+
func TestLoadUserPreferences(t *testing.T) {
36+
ctx := context.Background()
37+
userID := "user-123"
38+
orgID := "org-456"
39+
40+
// Define test traits with defaults
41+
testTraits := []Trait{
42+
{
43+
ResourceType: schema.UserPrincipal,
44+
Name: "theme",
45+
Default: "light",
46+
},
47+
{
48+
ResourceType: schema.UserPrincipal,
49+
Name: "language",
50+
Default: "en",
51+
},
52+
{
53+
ResourceType: schema.UserPrincipal,
54+
Name: "notifications",
55+
Default: "", // No default
56+
},
57+
{
58+
ResourceType: schema.OrganizationNamespace, // Should be ignored for user prefs
59+
Name: "org_setting",
60+
Default: "default",
61+
},
62+
}
63+
64+
t.Run("without scope returns global DB preferences plus defaults", func(t *testing.T) {
65+
mockRepo := new(MockRepository)
66+
svc := NewService(mockRepo, testTraits)
67+
68+
filter := Filter{UserID: userID}
69+
dbPrefs := []Preference{
70+
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID},
71+
}
72+
mockRepo.On("List", ctx, filter).Return(dbPrefs, nil)
73+
74+
result, err := svc.LoadUserPreferences(ctx, filter)
75+
76+
assert.NoError(t, err)
77+
// Should have "theme" (from DB) and "language" (default)
78+
assert.Len(t, result, 2)
79+
80+
resultMap := make(map[string]Preference)
81+
for _, p := range result {
82+
resultMap[p.Name] = p
83+
}
84+
85+
assert.Equal(t, "dark", resultMap["theme"].Value)
86+
assert.Equal(t, "1", resultMap["theme"].ID)
87+
assert.Equal(t, "en", resultMap["language"].Value) // default
88+
assert.Equal(t, "", resultMap["language"].ID) // no ID for default
89+
mockRepo.AssertExpectations(t)
90+
})
91+
92+
t.Run("with scope returns complete preference set with scoped, global, and defaults", func(t *testing.T) {
93+
mockRepo := new(MockRepository)
94+
svc := NewService(mockRepo, testTraits)
95+
96+
scopedFilter := Filter{
97+
UserID: userID,
98+
ScopeType: schema.OrganizationNamespace,
99+
ScopeID: orgID,
100+
}
101+
globalFilter := Filter{
102+
UserID: userID,
103+
}
104+
105+
// Scoped: "theme" is set for this org
106+
scopedPrefs := []Preference{
107+
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID, ScopeType: schema.OrganizationNamespace, ScopeID: orgID},
108+
}
109+
// Global: "language" is set globally
110+
globalPrefs := []Preference{
111+
{ID: "2", Name: "language", Value: "fr", ResourceType: schema.UserPrincipal, ResourceID: userID},
112+
}
113+
114+
mockRepo.On("List", ctx, scopedFilter).Return(scopedPrefs, nil)
115+
mockRepo.On("List", ctx, globalFilter).Return(globalPrefs, nil)
116+
117+
result, err := svc.LoadUserPreferences(ctx, scopedFilter)
118+
119+
assert.NoError(t, err)
120+
// Should have "theme" (scoped DB), "language" (global DB)
121+
// "notifications" has no default so should not be included
122+
assert.Len(t, result, 2)
123+
124+
// Build a map for easier assertions
125+
resultMap := make(map[string]Preference)
126+
for _, p := range result {
127+
resultMap[p.Name] = p
128+
}
129+
130+
// theme should be from scoped DB (dark)
131+
assert.Equal(t, "dark", resultMap["theme"].Value)
132+
assert.Equal(t, "1", resultMap["theme"].ID)
133+
134+
// language should be from global DB (fr)
135+
assert.Equal(t, "fr", resultMap["language"].Value)
136+
assert.Equal(t, "2", resultMap["language"].ID)
137+
138+
mockRepo.AssertExpectations(t)
139+
})
140+
141+
t.Run("scoped preference takes priority over global", func(t *testing.T) {
142+
mockRepo := new(MockRepository)
143+
svc := NewService(mockRepo, testTraits)
144+
145+
scopedFilter := Filter{
146+
UserID: userID,
147+
ScopeType: schema.OrganizationNamespace,
148+
ScopeID: orgID,
149+
}
150+
globalFilter := Filter{
151+
UserID: userID,
152+
}
153+
154+
// Both scoped and global have "theme"
155+
scopedPrefs := []Preference{
156+
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID, ScopeType: schema.OrganizationNamespace, ScopeID: orgID},
157+
}
158+
globalPrefs := []Preference{
159+
{ID: "2", Name: "theme", Value: "light", ResourceType: schema.UserPrincipal, ResourceID: userID},
160+
}
161+
162+
mockRepo.On("List", ctx, scopedFilter).Return(scopedPrefs, nil)
163+
mockRepo.On("List", ctx, globalFilter).Return(globalPrefs, nil)
164+
165+
result, err := svc.LoadUserPreferences(ctx, scopedFilter)
166+
167+
assert.NoError(t, err)
168+
169+
resultMap := make(map[string]Preference)
170+
for _, p := range result {
171+
resultMap[p.Name] = p
172+
}
173+
174+
// Scoped value should win
175+
assert.Equal(t, "dark", resultMap["theme"].Value)
176+
assert.Equal(t, "1", resultMap["theme"].ID)
177+
178+
mockRepo.AssertExpectations(t)
179+
})
180+
181+
t.Run("with scope but all prefs set returns no defaults", func(t *testing.T) {
182+
mockRepo := new(MockRepository)
183+
svc := NewService(mockRepo, testTraits)
184+
185+
scopedFilter := Filter{
186+
UserID: userID,
187+
ScopeType: schema.OrganizationNamespace,
188+
ScopeID: orgID,
189+
}
190+
globalFilter := Filter{
191+
UserID: userID,
192+
}
193+
194+
// All traits with defaults are set in scoped DB
195+
scopedPrefs := []Preference{
196+
{ID: "1", Name: "theme", Value: "dark", ResourceType: schema.UserPrincipal, ResourceID: userID},
197+
{ID: "2", Name: "language", Value: "de", ResourceType: schema.UserPrincipal, ResourceID: userID},
198+
}
199+
mockRepo.On("List", ctx, scopedFilter).Return(scopedPrefs, nil)
200+
mockRepo.On("List", ctx, globalFilter).Return([]Preference{}, nil)
201+
202+
result, err := svc.LoadUserPreferences(ctx, scopedFilter)
203+
204+
assert.NoError(t, err)
205+
assert.Len(t, result, 2)
206+
207+
resultMap := make(map[string]Preference)
208+
for _, p := range result {
209+
resultMap[p.Name] = p
210+
}
211+
212+
// Both should be from DB
213+
assert.Equal(t, "dark", resultMap["theme"].Value)
214+
assert.Equal(t, "de", resultMap["language"].Value)
215+
mockRepo.AssertExpectations(t)
216+
})
217+
218+
t.Run("repository error is propagated", func(t *testing.T) {
219+
mockRepo := new(MockRepository)
220+
svc := NewService(mockRepo, testTraits)
221+
222+
filter := Filter{UserID: userID}
223+
mockRepo.On("List", ctx, filter).Return(nil, ErrInvalidFilter)
224+
225+
result, err := svc.LoadUserPreferences(ctx, filter)
226+
227+
assert.Error(t, err)
228+
assert.Nil(t, result)
229+
assert.Equal(t, ErrInvalidFilter, err)
230+
mockRepo.AssertExpectations(t)
231+
})
232+
}

0 commit comments

Comments
 (0)