feat: add commands for managing organizations and users

Introduce `AddUserToOrganization`, `RemoveAPIKey`, and 
`RemoveOrganization` commands to enhance organization 
management. Implement validation for user addition and 
API key removal. Update GraphQL schema to support new 
mutations and add caching for the new events, ensuring 
that organizations and their relationships are accurately 
represented in the cache.
This commit is contained in:
2025-11-22 18:37:07 +01:00
parent 335a9f3b54
commit ffcf41b85a
14 changed files with 1500 additions and 30 deletions
+6
View File
@@ -23,6 +23,8 @@ func (o *Organization) Apply(event eventsourced.Event) error {
switch e := event.(type) {
case *OrganizationAdded:
e.UpdateOrganization(o)
case *UserAddedToOrganization:
e.UpdateOrganization(o)
case *APIKeyAdded:
o.APIKeys = append(o.APIKeys, APIKey{
Name: e.Name,
@@ -36,6 +38,10 @@ func (o *Organization) Apply(event eventsourced.Event) error {
})
o.ChangedBy = e.Initiator
o.ChangedAt = e.When()
case *APIKeyRemoved:
e.UpdateOrganization(o)
case *OrganizationRemoved:
e.UpdateOrganization(o)
default:
return fmt.Errorf("unexpected event type: %+v", event)
}
+82
View File
@@ -34,6 +34,37 @@ func (a AddOrganization) Event(context.Context) eventsourced.Event {
var _ eventsourced.Command = AddOrganization{}
type AddUserToOrganization struct {
UserId string
Initiator string
}
func (a AddUserToOrganization) Validate(_ context.Context, aggregate eventsourced.Aggregate) error {
if aggregate.Identity() == nil {
return fmt.Errorf("organization does not exist")
}
if len(a.UserId) == 0 {
return fmt.Errorf("userId is required")
}
// Check if user is already in the organization
org := aggregate.(*Organization)
for _, user := range org.Users {
if user == a.UserId {
return fmt.Errorf("user is already a member of this organization")
}
}
return nil
}
func (a AddUserToOrganization) Event(context.Context) eventsourced.Event {
return &UserAddedToOrganization{
UserId: a.UserId,
Initiator: a.Initiator,
}
}
var _ eventsourced.Command = AddUserToOrganization{}
type AddAPIKey struct {
Name string
Key string
@@ -79,6 +110,57 @@ func (a AddAPIKey) Event(context.Context) eventsourced.Event {
var _ eventsourced.Command = AddAPIKey{}
type RemoveAPIKey struct {
KeyName string
Initiator string
}
func (r RemoveAPIKey) Validate(_ context.Context, aggregate eventsourced.Aggregate) error {
if aggregate.Identity() == nil {
return fmt.Errorf("organization does not exist")
}
org := aggregate.(*Organization)
found := false
for _, k := range org.APIKeys {
if k.Name == r.KeyName {
found = true
break
}
}
if !found {
return fmt.Errorf("API key '%s' not found", r.KeyName)
}
return nil
}
func (r RemoveAPIKey) Event(context.Context) eventsourced.Event {
return &APIKeyRemoved{
KeyName: r.KeyName,
Initiator: r.Initiator,
}
}
var _ eventsourced.Command = RemoveAPIKey{}
type RemoveOrganization struct {
Initiator string
}
func (r RemoveOrganization) Validate(_ context.Context, aggregate eventsourced.Aggregate) error {
if aggregate.Identity() == nil {
return fmt.Errorf("organization does not exist")
}
return nil
}
func (r RemoveOrganization) Event(context.Context) eventsourced.Event {
return &OrganizationRemoved{
Initiator: r.Initiator,
}
}
var _ eventsourced.Command = RemoveOrganization{}
type UpdateSubGraph struct {
OrganizationId string
Ref string
+111
View File
@@ -464,3 +464,114 @@ func TestUpdateSubGraph_Event(t *testing.T) {
assert.Equal(t, "type Query { hello: String }", subGraphEvent.Sdl)
assert.Equal(t, "user@example.com", subGraphEvent.Initiator)
}
// RemoveAPIKey tests
func TestRemoveAPIKey_Validate_Success(t *testing.T) {
cmd := RemoveAPIKey{
KeyName: "production-key",
Initiator: "user@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
APIKeys: []APIKey{
{
Name: "production-key",
Key: "hashed-key",
},
},
}
err := cmd.Validate(context.Background(), org)
assert.NoError(t, err)
}
func TestRemoveAPIKey_Validate_OrganizationNotExists(t *testing.T) {
cmd := RemoveAPIKey{
KeyName: "production-key",
Initiator: "user@example.com",
}
org := &Organization{} // No identity means it doesn't exist
err := cmd.Validate(context.Background(), org)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not exist")
}
func TestRemoveAPIKey_Validate_KeyNotFound(t *testing.T) {
cmd := RemoveAPIKey{
KeyName: "non-existent-key",
Initiator: "user@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
APIKeys: []APIKey{
{
Name: "production-key",
Key: "hashed-key",
},
},
}
err := cmd.Validate(context.Background(), org)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Contains(t, err.Error(), "non-existent-key")
}
func TestRemoveAPIKey_Event(t *testing.T) {
cmd := RemoveAPIKey{
KeyName: "production-key",
Initiator: "user@example.com",
}
event := cmd.Event(context.Background())
require.NotNil(t, event)
keyEvent, ok := event.(*APIKeyRemoved)
require.True(t, ok)
assert.Equal(t, "production-key", keyEvent.KeyName)
assert.Equal(t, "user@example.com", keyEvent.Initiator)
}
// RemoveOrganization tests
func TestRemoveOrganization_Validate_Success(t *testing.T) {
cmd := RemoveOrganization{
Initiator: "user@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
Name: "Test Org",
}
err := cmd.Validate(context.Background(), org)
assert.NoError(t, err)
}
func TestRemoveOrganization_Validate_OrganizationNotExists(t *testing.T) {
cmd := RemoveOrganization{
Initiator: "user@example.com",
}
org := &Organization{} // No identity means it doesn't exist
err := cmd.Validate(context.Background(), org)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not exist")
}
func TestRemoveOrganization_Event(t *testing.T) {
cmd := RemoveOrganization{
Initiator: "user@example.com",
}
event := cmd.Event(context.Background())
require.NotNil(t, event)
orgEvent, ok := event.(*OrganizationRemoved)
require.True(t, ok)
assert.Equal(t, "user@example.com", orgEvent.Initiator)
}
+48
View File
@@ -17,6 +17,24 @@ func (a *OrganizationAdded) UpdateOrganization(o *Organization) {
o.ChangedAt = a.When()
}
type UserAddedToOrganization struct {
eventsourced.BaseEvent
UserId string `json:"userId"`
Initiator string `json:"initiator"`
}
func (a *UserAddedToOrganization) UpdateOrganization(o *Organization) {
// Check if user is already in the organization
for _, user := range o.Users {
if user == a.UserId {
return // User already exists, no need to add
}
}
o.Users = append(o.Users, a.UserId)
o.ChangedBy = a.Initiator
o.ChangedAt = a.When()
}
type APIKeyAdded struct {
eventsourced.BaseEvent
OrganizationId string `json:"organizationId"`
@@ -34,6 +52,36 @@ func (a *APIKeyAdded) EnrichFromAggregate(aggregate eventsourced.Aggregate) {
var _ eventsourced.EnrichableEvent = &APIKeyAdded{}
type APIKeyRemoved struct {
eventsourced.BaseEvent
KeyName string `json:"keyName"`
Initiator string `json:"initiator"`
}
func (a *APIKeyRemoved) UpdateOrganization(o *Organization) {
// Remove the API key from the organization
for i, key := range o.APIKeys {
if key.Name == a.KeyName {
o.APIKeys = append(o.APIKeys[:i], o.APIKeys[i+1:]...)
break
}
}
o.ChangedBy = a.Initiator
o.ChangedAt = a.When()
}
type OrganizationRemoved struct {
eventsourced.BaseEvent
Initiator string `json:"initiator"`
}
func (a *OrganizationRemoved) UpdateOrganization(o *Organization) {
// Mark organization as removed by clearing critical fields
// The aggregate will still exist in the event store, but it's logically deleted
o.ChangedBy = a.Initiator
o.ChangedAt = a.When()
}
type SubGraphUpdated struct {
eventsourced.BaseEvent
OrganizationId string `json:"organizationId"`
+254
View File
@@ -0,0 +1,254 @@
package domain
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
)
func TestOrganizationAdded_UpdateOrganization(t *testing.T) {
event := &OrganizationAdded{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
Name: "Test Organization",
Initiator: "user@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
}
event.UpdateOrganization(org)
assert.Equal(t, "Test Organization", org.Name)
assert.Equal(t, []string{"user@example.com"}, org.Users)
assert.Equal(t, "user@example.com", org.CreatedBy)
assert.Equal(t, "user@example.com", org.ChangedBy)
assert.Equal(t, event.When(), org.CreatedAt)
assert.Equal(t, event.When(), org.ChangedAt)
}
func TestUserAddedToOrganization_UpdateOrganization(t *testing.T) {
event := &UserAddedToOrganization{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
UserId: "new-user@example.com",
Initiator: "admin@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
Users: []string{"existing-user@example.com"},
}
event.UpdateOrganization(org)
assert.Len(t, org.Users, 2)
assert.Contains(t, org.Users, "existing-user@example.com")
assert.Contains(t, org.Users, "new-user@example.com")
assert.Equal(t, "admin@example.com", org.ChangedBy)
assert.Equal(t, event.When(), org.ChangedAt)
}
func TestUserAddedToOrganization_UpdateOrganization_DuplicateUser(t *testing.T) {
event := &UserAddedToOrganization{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
UserId: "existing-user@example.com",
Initiator: "admin@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
Users: []string{"existing-user@example.com"},
ChangedBy: "previous-admin@example.com",
}
originalChangedBy := org.ChangedBy
originalChangedAt := org.ChangedAt
event.UpdateOrganization(org)
// User should not be added twice
assert.Len(t, org.Users, 1)
assert.Equal(t, "existing-user@example.com", org.Users[0])
// ChangedBy and ChangedAt should NOT be updated when user already exists (idempotent)
assert.Equal(t, originalChangedBy, org.ChangedBy)
assert.Equal(t, originalChangedAt, org.ChangedAt)
}
func TestAPIKeyRemoved_UpdateOrganization(t *testing.T) {
event := &APIKeyRemoved{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
KeyName: "production-key",
Initiator: "admin@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
APIKeys: []APIKey{
{Name: "dev-key", Key: "hashed-key-1"},
{Name: "production-key", Key: "hashed-key-2"},
{Name: "staging-key", Key: "hashed-key-3"},
},
}
event.UpdateOrganization(org)
assert.Len(t, org.APIKeys, 2)
assert.Equal(t, "dev-key", org.APIKeys[0].Name)
assert.Equal(t, "staging-key", org.APIKeys[1].Name)
assert.Equal(t, "admin@example.com", org.ChangedBy)
assert.Equal(t, event.When(), org.ChangedAt)
}
func TestAPIKeyRemoved_UpdateOrganization_KeyNotFound(t *testing.T) {
event := &APIKeyRemoved{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
KeyName: "non-existent-key",
Initiator: "admin@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
APIKeys: []APIKey{
{Name: "dev-key", Key: "hashed-key-1"},
{Name: "production-key", Key: "hashed-key-2"},
},
}
event.UpdateOrganization(org)
// No keys should be removed
assert.Len(t, org.APIKeys, 2)
assert.Equal(t, "dev-key", org.APIKeys[0].Name)
assert.Equal(t, "production-key", org.APIKeys[1].Name)
// But metadata should still be updated
assert.Equal(t, "admin@example.com", org.ChangedBy)
assert.Equal(t, event.When(), org.ChangedAt)
}
func TestAPIKeyRemoved_UpdateOrganization_OnlyKey(t *testing.T) {
event := &APIKeyRemoved{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
KeyName: "only-key",
Initiator: "admin@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
APIKeys: []APIKey{
{Name: "only-key", Key: "hashed-key"},
},
}
event.UpdateOrganization(org)
// All keys should be removed
assert.Len(t, org.APIKeys, 0)
assert.Equal(t, "admin@example.com", org.ChangedBy)
assert.Equal(t, event.When(), org.ChangedAt)
}
func TestOrganizationRemoved_UpdateOrganization(t *testing.T) {
event := &OrganizationRemoved{
BaseEvent: eventsourced.BaseEvent{
EventTime: eventsourced.EventTime{
Time: time.Now(),
},
},
Initiator: "admin@example.com",
}
org := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
Name: "Test Organization",
Users: []string{"user1@example.com", "user2@example.com"},
APIKeys: []APIKey{
{Name: "key1", Key: "hashed-key-1"},
},
CreatedBy: "creator@example.com",
CreatedAt: time.Now().Add(-24 * time.Hour),
}
event.UpdateOrganization(org)
// Organization data remains (soft delete), but metadata is updated
assert.Equal(t, "Test Organization", org.Name)
assert.Len(t, org.Users, 2)
assert.Len(t, org.APIKeys, 1)
// Metadata should be updated to reflect removal
assert.Equal(t, "admin@example.com", org.ChangedBy)
assert.Equal(t, event.When(), org.ChangedAt)
}
func TestAPIKeyAdded_EnrichFromAggregate(t *testing.T) {
orgId := "org-123"
aggregate := &Organization{
BaseAggregate: eventsourced.BaseAggregateFromString(orgId),
}
event := &APIKeyAdded{
Name: "test-key",
Key: "hashed-key",
Refs: []string{"main"},
Read: true,
Publish: false,
Initiator: "user@example.com",
}
event.EnrichFromAggregate(aggregate)
assert.Equal(t, orgId, event.OrganizationId)
}
func TestSubGraphUpdated_Event(t *testing.T) {
// Verify SubGraphUpdated event structure
url := "http://service.example.com"
wsUrl := "ws://service.example.com"
event := &SubGraphUpdated{
OrganizationId: "org-123",
Ref: "main",
Service: "users-service",
Url: &url,
WSUrl: &wsUrl,
Sdl: "type Query { user: User }",
Initiator: "system",
}
require.NotNil(t, event)
assert.Equal(t, "org-123", event.OrganizationId)
assert.Equal(t, "main", event.Ref)
assert.Equal(t, "users-service", event.Service)
assert.Equal(t, url, *event.Url)
assert.Equal(t, wsUrl, *event.WSUrl)
assert.Equal(t, "type Query { user: User }", event.Sdl)
assert.Equal(t, "system", event.Initiator)
}