From a5cb620bb7a77fdfb593397fc1c134b48a8d8274 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Mon, 8 Dec 2025 20:11:51 +0200 Subject: [PATCH 01/12] chore: fixes --- cmd/app/integration_test.go | 4 ++ cmd/app/internal/database/mock_test.go | 46 ++++++++++++---- pkg/auth/jwt_test.go | 17 +++++- pkg/logger/logger.go | 76 ++++++++++++++++++++++---- pkg/middleware/auth_test.go | 1 + 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/cmd/app/integration_test.go b/cmd/app/integration_test.go index 6b550e3..66fc7cb 100644 --- a/cmd/app/integration_test.go +++ b/cmd/app/integration_test.go @@ -110,7 +110,9 @@ func TestFullUserJourney(t *testing.T) { "username": "testuser1", "password": "password123", } + loginResp := makeHTTPRequest(t, "POST", baseURL+"/login", loginPayload, "") + if loginResp.StatusCode != 200 { t.Fatalf("Expected status 200 for login, got %d. Response: %s", loginResp.StatusCode, loginResp.Body) } @@ -118,6 +120,7 @@ func TestFullUserJourney(t *testing.T) { // Extract token var loginResponse map[string]interface{} err := json.Unmarshal([]byte(loginResp.Body), &loginResponse) + if err != nil { t.Fatalf("Failed to parse login response: %v", err) } @@ -162,6 +165,7 @@ func TestFullUserJourney(t *testing.T) { // Step 5: List users t.Log("5. Listing users...") listResp := makeHTTPRequest(t, "GET", baseURL+"/users", nil, token) + if listResp.StatusCode != 200 { t.Fatalf("Expected status 200 for list users, got %d. Response: %s", listResp.StatusCode, listResp.Body) } diff --git a/cmd/app/internal/database/mock_test.go b/cmd/app/internal/database/mock_test.go index a511861..6db41bb 100644 --- a/cmd/app/internal/database/mock_test.go +++ b/cmd/app/internal/database/mock_test.go @@ -1,6 +1,7 @@ package database import ( + "errors" "fmt" "sync" "testing" @@ -13,7 +14,9 @@ func TestNewMockRepository(t *testing.T) { repo := NewMockRepository() if repo == nil { t.Error("Expected non-nil repository") + return } + if repo.users == nil { t.Error("Expected users map to be initialized") } @@ -38,7 +41,7 @@ func TestMockRepository_CreateUser(t *testing.T) { // Test duplicate creation err = repo.CreateUser(user) - if err != apperrors.ErrUserExists { + if !errors.Is(err, apperrors.ErrUserExists) { t.Errorf("Expected ErrUserExists, got %v", err) } } @@ -52,7 +55,10 @@ func TestMockRepository_GetUser(t *testing.T) { } // Create user first - repo.CreateUser(user) + err = repo.CreateUser(user) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } // Test successful retrieval retrieved, err := repo.GetUser("testuser") @@ -68,7 +74,7 @@ func TestMockRepository_GetUser(t *testing.T) { // Test non-existent user _, err = repo.GetUser("nonexistent") - if err != apperrors.ErrUserNotFound { + if !errors.Is(err, apperrors.ErrUserNotFound) { t.Errorf("Expected ErrUserNotFound, got %v", err) } } @@ -82,10 +88,16 @@ func TestMockRepository_UpdateUser(t *testing.T) { } // Create user first - repo.CreateUser(user) + err = repo.CreateUser(user) + if err != nil { + return + } // Update user - user.UpdateName("Updated User") + err = user.UpdateName("Updated User") + if err != nil { + return + } // Test successful update err = repo.UpdateUser(user) @@ -103,7 +115,7 @@ func TestMockRepository_UpdateUser(t *testing.T) { nonExistentUser, _ := models.NewUser("nonexistent", "Non Existent", "password123") err = repo.UpdateUser(nonExistentUser) - if err != apperrors.ErrUserNotFound { + if !errors.Is(err, apperrors.ErrUserNotFound) { t.Errorf("Expected ErrUserNotFound, got %v", err) } } @@ -126,7 +138,10 @@ func TestMockRepository_UserExists(t *testing.T) { } // Create user - repo.CreateUser(user) + err = repo.CreateUser(user) + if err != nil { + return + } // Test existing user exists, err = repo.UserExists("testuser") @@ -154,8 +169,15 @@ func TestMockRepository_ListUsers(t *testing.T) { user1, _ := models.NewUser("user1", "User One", "password123") user2, _ := models.NewUser("user2", "User Two", "password123") - repo.CreateUser(user1) - repo.CreateUser(user2) + err = repo.CreateUser(user1) + if err != nil { + return + } + + err = repo.CreateUser(user2) + if err != nil { + return + } // Test list with users users, err = repo.ListUsers() @@ -194,7 +216,11 @@ func TestMockRepository_ConcurrentAccess(t *testing.T) { fmt.Sprintf("User %d", id), "password123", ) - repo.CreateUser(user) + + err := repo.CreateUser(user) + if err != nil { + return + } }(i) } diff --git a/pkg/auth/jwt_test.go b/pkg/auth/jwt_test.go index c1af915..2349890 100644 --- a/pkg/auth/jwt_test.go +++ b/pkg/auth/jwt_test.go @@ -51,13 +51,24 @@ func TestNewTokenService(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Save original env value original := os.Getenv("JWT_SECRET") - defer os.Setenv("JWT_SECRET", original) + defer func(key, value string) { + err := os.Setenv(key, value) + if err != nil { + + } + }("JWT_SECRET", original) // Set test env value if tt.envValue != "" { - os.Setenv("JWT_SECRET", tt.envValue) + err := os.Setenv("JWT_SECRET", tt.envValue) + if err != nil { + return + } } else { - os.Unsetenv("JWT_SECRET") + err := os.Unsetenv("JWT_SECRET") + if err != nil { + return + } } cfg := config.Load() diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 492a6ab..c3be6f7 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,47 +1,101 @@ package logger import ( + "context" "log/slog" "os" ) -var Log *slog.Logger +// Logger wraps slog.Logger to intercept log calls +type Logger struct { + *slog.Logger +} + +var Log *Logger + +func sendToThirdParty(level, msg string, args ...any) { + // TODO: Implement your third-party integration +} + +// Info logs at Info level and sends to third-party tools +func (l *Logger) Info(msg string, args ...any) { + sendToThirdParty("INFO", msg, args...) + l.Logger.Info("✅ "+msg, args...) +} + +func (l *Logger) Debug(msg string, args ...any) { + sendToThirdParty("DEBUG", msg, args...) + l.Logger.Debug("🔍"+msg, args...) +} + +func (l *Logger) Error(msg string, args ...any) { + sendToThirdParty("ERROR", msg, args...) + l.Logger.Error("❌ "+msg, args...) +} + +func (l *Logger) Warn(msg string, args ...any) { + sendToThirdParty("WARN", msg, args...) + l.Logger.Warn("⚠️ "+msg, args...) // Warn +} + +func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) { + sendToThirdParty("INFO", msg, args...) + l.Logger.InfoContext(ctx, "✅ "+msg, args...) +} + +func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) { + sendToThirdParty("DEBUG", msg, args...) + l.Logger.DebugContext(ctx, "🔍"+msg, args...) +} + +func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) { + sendToThirdParty("ERROR", msg, args...) + l.Logger.ErrorContext(ctx, "❌ "+msg, args...) +} + +func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) { + sendToThirdParty("WARN", "⚠️ "+msg, args...) +} func init() { env := os.Getenv("ENVIRONMENT") + if env == "" { env = "development" } + var slogLogger *slog.Logger if env == "production" { // JSON format for production (better for AWS CloudWatch) - Log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + slogLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) } else { // Human-readable format for development - Log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + slogLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, })) } + + Log = &Logger{Logger: slogLogger} } // WithComponent returns a logger with a component field -func WithComponent(component string) *slog.Logger { - return Log.With("component", component) +func WithComponent(component string) *Logger { + return &Logger{Logger: Log.With("component", component)} } // WithUser returns a logger with user context -func WithUser(username string) *slog.Logger { - return Log.With("user", username) +func WithUser(username string) *Logger { + return &Logger{Logger: Log.With("user", username)} } // WithError returns a logger with error context -func WithError(err error) *slog.Logger { - return Log.With("error", err.Error()) +func WithError(err error) *Logger { + return &Logger{Logger: Log.With("error", err.Error())} } // WithRequest returns a logger with request context -func WithRequest(requestId string) *slog.Logger { - return Log.With("request_id", requestId) +func WithRequest(requestId string) *Logger { + return &Logger{Logger: Log.With("request_id", requestId)} } diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index 7fc29ca..6c31cc7 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -35,6 +35,7 @@ func TestNewAuthMiddleware(t *testing.T) { if middleware == nil { t.Error("Expected non-nil middleware") + return } if middleware.tokenService != tokenService { From 98afeb73cdb2067b1bf59afb22789b6765443968 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sat, 13 Dec 2025 13:07:59 +0200 Subject: [PATCH 02/12] chore: update taskfile --- Taskfile.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index b887950..7336720 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -131,10 +131,10 @@ tasks: cdk:deploy: desc: 'Deploy infrastructure to AWS' dir: 'deployments/app' - deps: [build:lambda] + deps: [ build:lambda ] cmds: - - echo 'Deploying to AWS...' - - cdk deploy --require-approval never + - echo 'Deploying to AWS with profile {{.profile | default "default"}}' + - cdk deploy --require-approval never --profile {{.profile | default "default"}} - echo 'Deployment completed!' cdk:destroy: @@ -154,7 +154,17 @@ tasks: # Full deployment workflow deploy: desc: 'Full deployment workflow (test, build, deploy)' - deps: [test, build:lambda, cdk:deploy] + + cmds: + - echo "running command with args - {{.AWS_PROFILE}}" + - task: test + - task: build:lambda + - task: cdk:deploy + vars: + profile: + sh: echo {{.AWS_PROFILE | default "default"}} + requires: + vars: [AWS_PROFILE] deploy:diff: desc: 'Preview deployment changes' From e8147657ff23a44f6d179d11eed3e8f52e96fd3e Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 01:04:50 +0200 Subject: [PATCH 03/12] feat(single-table-design): switch from the standard design to Multi-Key GSI --- README.md | 4 + cmd/app/integration_test.go | 2 +- cmd/app/internal/database/constants.go | 11 + cmd/app/internal/database/dynamodb.go | 14 +- cmd/app/internal/database/entity_keys.go | 57 ++ cmd/app/internal/database/factory.go | 1 + .../database/master_skill_repository.go | 187 ++++ cmd/app/internal/database/mock.go | 158 +++- cmd/app/internal/database/mock_test.go | 35 +- cmd/app/internal/database/skill_repository.go | 283 +----- cmd/app/internal/database/user_repository.go | 36 +- .../database/user_skill_repository.go | 303 +++++++ cmd/app/internal/dto/dto.go | 32 + .../internal/handler/master_skill_handler.go | 146 ++++ cmd/app/internal/handler/user_handler.go | 7 +- cmd/app/internal/handler/user_handler_test.go | 9 +- cmd/app/internal/models/keys.go | 24 + cmd/app/internal/models/skill.go | 125 +++ cmd/app/internal/models/user.go | 13 +- cmd/app/internal/models/user_skill.go | 56 +- .../internal/service/master_skill_service.go | 143 +++ cmd/app/internal/service/skill_service.go | 22 +- cmd/app/main.go | 21 +- .../testdata/dynamo-db-multi-keys-queries.md | 295 +++++++ deployments/app/cdk.go | 169 ++-- docs/README-DYNAMODB-DESIGN.md | 346 -------- docs/api-testing/api-test.http | 815 ++++++++++++++++++ docs/api-testing/http-client.env.json | 5 + docs/dynamodb-quick-reference.md | 420 --------- docs/dynamodb-single-table-design-plan.md | 481 ----------- docs/entity-addition-protocol.md | 607 ------------- 31 files changed, 2522 insertions(+), 2305 deletions(-) create mode 100644 cmd/app/internal/database/constants.go create mode 100644 cmd/app/internal/database/entity_keys.go create mode 100644 cmd/app/internal/database/master_skill_repository.go create mode 100644 cmd/app/internal/database/user_skill_repository.go create mode 100644 cmd/app/internal/handler/master_skill_handler.go create mode 100644 cmd/app/internal/models/keys.go create mode 100644 cmd/app/internal/models/skill.go create mode 100644 cmd/app/internal/service/master_skill_service.go create mode 100644 cmd/app/testdata/dynamo-db-multi-keys-queries.md delete mode 100644 docs/README-DYNAMODB-DESIGN.md create mode 100644 docs/api-testing/api-test.http create mode 100644 docs/api-testing/http-client.env.json delete mode 100644 docs/dynamodb-quick-reference.md delete mode 100644 docs/dynamodb-single-table-design-plan.md delete mode 100644 docs/entity-addition-protocol.md diff --git a/README.md b/README.md index 9029823..43f8e16 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ Request → Router → Middleware → Handler → Service → Repository → Dat - **DTO Pattern** - Separate request/response types from domain models - **Service Layer** - Business logic isolated from HTTP concerns +## Data Model +The Single Table Design in modeled using new Dynamo feature: Multi-Keys(composite keys) for GSI approach. +Check: https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/ + ## API Endpoints | Method | Path | Auth | Description | diff --git a/cmd/app/integration_test.go b/cmd/app/integration_test.go index 50bb297..41662df 100644 --- a/cmd/app/integration_test.go +++ b/cmd/app/integration_test.go @@ -49,7 +49,7 @@ func SetupIntegrationTest() *IntegrationTestSuite { userSkillsRepo := database.NewMockRepository() tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(userRepo, tokenService) - userSkillsService := service.NewSkillService(userSkillsRepo) + userSkillsService := service.NewSkillService(userSkillsRepo, userSkillsRepo) // Pass both SkillRepository and MasterSkillRepository apiHandler := handler.New(userService, userSkillsService) authMiddleware := middleware.NewAuthMiddleware(tokenService) diff --git a/cmd/app/internal/database/constants.go b/cmd/app/internal/database/constants.go new file mode 100644 index 0000000..f9fbb59 --- /dev/null +++ b/cmd/app/internal/database/constants.go @@ -0,0 +1,11 @@ +package database + +const ( + // TableName is the single table for all entities + TableName = "glad-entities" + + GSIByUser = "ByUser" + GSISkillsByLevel = "SkillsByLevel" + GSIBySkillID = "BySkillID" + GSIByEntityType = "ByEntityType" +) diff --git a/cmd/app/internal/database/dynamodb.go b/cmd/app/internal/database/dynamodb.go index 5c81cf8..1a66067 100644 --- a/cmd/app/internal/database/dynamodb.go +++ b/cmd/app/internal/database/dynamodb.go @@ -7,15 +7,11 @@ import ( "github.com/aws/aws-sdk-go/service/dynamodb" ) -const ( - // TableName is the single table for all entities - TableName = "glad-entities" - - // GSI1Name GSI names - GSI1Name = "GSI1" -) - -// DynamoDBRepository implements Repository using DynamoDB single table design +// DynamoDBRepository implements all repository interfaces using DynamoDB single table design +// It provides implementations for: +// - UserRepository (user management) +// - MasterSkillRepository (master skills) +// - SkillRepository (user skills) type DynamoDBRepository struct { client *dynamodb.DynamoDB } diff --git a/cmd/app/internal/database/entity_keys.go b/cmd/app/internal/database/entity_keys.go new file mode 100644 index 0000000..7c4f0eb --- /dev/null +++ b/cmd/app/internal/database/entity_keys.go @@ -0,0 +1,57 @@ +package database + +import ( + "fmt" + "strings" +) + +// Entity ID utility functions for consistent key generation across the application. +// All entity IDs use "#" as the delimiter for better DynamoDB practices. + +// BuildUserEntityID creates an entity ID for a User +// Format: USER# +func BuildUserEntityID(username string) string { + return fmt.Sprintf("USER#%s", strings.ToLower(username)) +} + +// BuildUserSkillEntityID creates an entity ID for a UserSkill +// Format: USERSKILL## +func BuildUserSkillEntityID(username, skillID string) string { + return fmt.Sprintf("USERSKILL#%s#%s", strings.ToLower(username), strings.ToLower(skillID)) +} + +// BuildMasterSkillEntityID creates an entity ID for a MasterSkill +// Format: SKILL# +func BuildMasterSkillEntityID(skillID string) string { + return fmt.Sprintf("SKILL#%s", strings.ToLower(skillID)) +} + +// ParseUserEntityID extracts the username from a User entity ID +// Returns the username or empty string if invalid format +func ParseUserEntityID(entityID string) string { + parts := strings.Split(entityID, "#") + if len(parts) == 2 && parts[0] == "USER" { + return parts[1] + } + return "" +} + +// ParseUserSkillEntityID extracts username and skillID from a UserSkill entity ID +// Returns username, skillID, or empty strings if invalid format +func ParseUserSkillEntityID(entityID string) (username, skillID string) { + parts := strings.Split(entityID, "#") + if len(parts) == 3 && parts[0] == "USERSKILL" { + return parts[1], parts[2] + } + return "", "" +} + +// ParseMasterSkillEntityID extracts the skillID from a MasterSkill entity ID +// Returns the skillID or empty string if invalid format +func ParseMasterSkillEntityID(entityID string) string { + parts := strings.Split(entityID, "#") + if len(parts) == 2 && parts[0] == "SKILL" { + return parts[1] + } + return "" +} diff --git a/cmd/app/internal/database/factory.go b/cmd/app/internal/database/factory.go index 132de7f..49bed0e 100644 --- a/cmd/app/internal/database/factory.go +++ b/cmd/app/internal/database/factory.go @@ -11,6 +11,7 @@ import ( type Repository interface { UserRepository SkillRepository + MasterSkillRepository } // NewRepository creates the appropriate repository implementation based on configuration diff --git a/cmd/app/internal/database/master_skill_repository.go b/cmd/app/internal/database/master_skill_repository.go new file mode 100644 index 0000000..75f8a75 --- /dev/null +++ b/cmd/app/internal/database/master_skill_repository.go @@ -0,0 +1,187 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// MasterSkillRepository defines operations for master skills +type MasterSkillRepository interface { + CreateMasterSkill(skill *models.Skill) error + GetMasterSkill(skillID string) (*models.Skill, error) + UpdateMasterSkill(skill *models.Skill) error + DeleteMasterSkill(skillID string) error + ListMasterSkills() ([]*models.Skill, error) +} + +// CreateMasterSkill inserts a new master skill +func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting master skill creation") + + skill.SetKeys() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + log.Info("Master skill created successfully", "duration", time.Since(start)) + return nil +} + +// GetMasterSkill retrieves a master skill by ID +func (r *DynamoDBRepository) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting master skill retrieval") + + entityID := BuildMasterSkillEntityID(skillID) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("Master skill not found", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + var skill models.Skill + err = dynamodbattribute.UnmarshalMap(result.Item, &skill) + if err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Master skill retrieved successfully", "duration", time.Since(start)) + return &skill, nil +} + +// UpdateMasterSkill updates an existing master skill +func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting master skill update") + + skill.SetKeys() + skill.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Master skill updated successfully", "duration", time.Since(start)) + return nil +} + +// DeleteMasterSkill removes a master skill +func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting master skill deletion") + + entityID := BuildMasterSkillEntityID(skillID) + + input := &dynamodb.DeleteItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err := r.client.DeleteItem(input) + if err != nil { + log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Master skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills +func (r *DynamoDBRepository) ListMasterSkills() ([]*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "ListMasterSkills") + start := time.Now() + + log.Debug("Starting master skills list retrieval") + + // Use GSI for better performance instead of Scan + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByEntityType), + KeyConditionExpression: aws.String("EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":entityType": {S: aws.String("Skill")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query master skills", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.Skill + for i, item := range result.Items { + var skill models.Skill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Master skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/mock.go b/cmd/app/internal/database/mock.go index 9b5d928..b655bb6 100644 --- a/cmd/app/internal/database/mock.go +++ b/cmd/app/internal/database/mock.go @@ -9,12 +9,13 @@ import ( "github.com/hackmajoris/glad/pkg/logger" ) -// MockRepository implements both UserRepository and SkillRepository for testing +// MockRepository implements UserRepository, SkillRepository, and MasterSkillRepository for testing // This matches the DynamoDBRepository structure with unified implementation type MockRepository struct { - users map[string]*models.User // key: username - skills map[string]*models.UserSkill // key: "username#skillname" - mutex sync.RWMutex + users map[string]*models.User // key: username + skills map[string]*models.UserSkill // key: "username#skillname" + masterSkills map[string]*models.Skill // key: skill_id + mutex sync.RWMutex } // NewMockRepository creates a new unified mock repository @@ -23,8 +24,9 @@ func NewMockRepository() *MockRepository { log.Info("Initializing unified Mock repository for local development") repo := &MockRepository{ - users: make(map[string]*models.User), - skills: make(map[string]*models.UserSkill), + users: make(map[string]*models.User), + skills: make(map[string]*models.UserSkill), + masterSkills: make(map[string]*models.Skill), } log.Info("Unified Mock repository initialized successfully") @@ -135,7 +137,7 @@ func (m *MockRepository) ListUsers() ([]*models.User, error) { // CreateSkill creates a user skill in memory func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill", skill.SkillName, "repository", "mock") + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") start := time.Now() log.Debug("Starting skill creation in mock repository") @@ -143,7 +145,7 @@ func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { m.mutex.Lock() defer m.mutex.Unlock() - key := skill.Username + "#" + skill.SkillName + key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) if _, exists := m.skills[key]; exists { log.Debug("Skill already exists", "duration", time.Since(start)) return apperrors.ErrSkillAlreadyExists @@ -155,8 +157,8 @@ func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { } // GetSkill retrieves a user skill from memory -func (m *MockRepository) GetSkill(username, skillName string) (*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill", skillName, "repository", "mock") +func (m *MockRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID, "repository", "mock") start := time.Now() log.Debug("Starting skill retrieval from mock repository") @@ -164,7 +166,7 @@ func (m *MockRepository) GetSkill(username, skillName string) (*models.UserSkill m.mutex.RLock() defer m.mutex.RUnlock() - key := username + "#" + skillName + key := models.BuildUserSkillEntityID(username, skillID) skill, exists := m.skills[key] if !exists { log.Debug("Skill not found in mock repository", "duration", time.Since(start)) @@ -177,7 +179,7 @@ func (m *MockRepository) GetSkill(username, skillName string) (*models.UserSkill // UpdateSkill updates a user skill in memory func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill", skill.SkillName, "repository", "mock") + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") start := time.Now() log.Debug("Starting skill update in mock repository") @@ -185,7 +187,7 @@ func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { m.mutex.Lock() defer m.mutex.Unlock() - key := skill.Username + "#" + skill.SkillName + key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) if _, exists := m.skills[key]; !exists { log.Debug("Skill not found for update", "duration", time.Since(start)) return apperrors.ErrSkillNotFound @@ -197,8 +199,8 @@ func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { } // DeleteSkill deletes a user skill from memory -func (m *MockRepository) DeleteSkill(username, skillName string) error { - log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill", skillName, "repository", "mock") +func (m *MockRepository) DeleteSkill(username, skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID, "repository", "mock") start := time.Now() log.Debug("Starting skill deletion from mock repository") @@ -206,7 +208,7 @@ func (m *MockRepository) DeleteSkill(username, skillName string) error { m.mutex.Lock() defer m.mutex.Unlock() - key := username + "#" + skillName + key := models.BuildUserSkillEntityID(username, skillID) if _, exists := m.skills[key]; !exists { log.Debug("Skill not found for deletion", "duration", time.Since(start)) return apperrors.ErrSkillNotFound @@ -259,6 +261,27 @@ func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill return skills, nil } +// QueryUserSkillsBySkillID retrieves all users with a specific skill from memory +func (m *MockRepository) QueryUserSkillsBySkillID(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + // ListUsersBySkillAndLevel retrieves all users with a specific skill and proficiency level from memory func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel, "repository", "mock") @@ -279,3 +302,106 @@ func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyL log.Info("Users retrieved successfully by skill and level from mock repository", "count", len(skills), "duration", time.Since(start)) return skills, nil } + +// ============================================================================ +// MASTER SKILL REPOSITORY METHODS +// ============================================================================ + +// CreateMasterSkill creates a master skill in memory +func (m *MockRepository) CreateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skill.SkillID]; exists { + log.Debug("Master skill already exists", "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + m.masterSkills[skill.SkillID] = skill + log.Info("Master skill created successfully in mock repository", "total_master_skills", len(m.masterSkills), "duration", time.Since(start)) + return nil +} + +// GetMasterSkill retrieves a master skill from memory +func (m *MockRepository) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + skill, exists := m.masterSkills[skillID] + if !exists { + log.Debug("Master skill not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + log.Debug("Master skill retrieved successfully from mock repository", "duration", time.Since(start)) + return skill, nil +} + +// UpdateMasterSkill updates a master skill in memory +func (m *MockRepository) UpdateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skill.SkillID]; !exists { + log.Debug("Master skill not found for update", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + m.masterSkills[skill.SkillID] = skill + log.Info("Master skill updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// DeleteMasterSkill deletes a master skill from memory +func (m *MockRepository) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill deletion from mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skillID]; !exists { + log.Debug("Master skill not found for deletion", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + delete(m.masterSkills, skillID) + log.Info("Master skill deleted successfully from mock repository", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills from memory +func (m *MockRepository) ListMasterSkills() ([]*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "ListMasterSkills", "repository", "mock") + start := time.Now() + + log.Debug("Starting master skills list retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.Skill + for _, skill := range m.masterSkills { + skills = append(skills, skill) + } + + log.Info("Master skills retrieved successfully from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/mock_test.go b/cmd/app/internal/database/mock_test.go index 69e0fe4..b7cfc88 100644 --- a/cmd/app/internal/database/mock_test.go +++ b/cmd/app/internal/database/mock_test.go @@ -261,7 +261,7 @@ func TestMockRepository_ConcurrentAccess(t *testing.T) { func TestMockRepository_CreateSkill(t *testing.T) { repo := NewMockRepository() - skill, err := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) + skill, err := models.NewUserSkill("testuser", "go", "Go", "Programming", models.ProficiencyIntermediate, 3) if err != nil { t.Fatalf("Failed to create skill: %v", err) } @@ -282,7 +282,7 @@ func TestMockRepository_CreateSkill(t *testing.T) { func TestMockRepository_GetSkill(t *testing.T) { repo := NewMockRepository() - skill, err := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) + skill, err := models.NewUserSkill("testuser", "go", "Go", "Programming", models.ProficiencyIntermediate, 3) if err != nil { t.Fatalf("Failed to create skill: %v", err) } @@ -290,9 +290,10 @@ func TestMockRepository_GetSkill(t *testing.T) { // Create skill first repo.CreateSkill(skill) - // Test successful retrieval - retrieved, err := repo.GetSkill("testuser", "Go") + // Test successful retrieval (using skillID not skillName) + retrieved, err := repo.GetSkill("testuser", "go") if err != nil { + t.Errorf("Expected no error, got %v", err) } if retrieved.Username != "testuser" { @@ -302,7 +303,7 @@ func TestMockRepository_GetSkill(t *testing.T) { t.Errorf("Expected skill name Go, got %s", retrieved.SkillName) } - // Test non-existent skill + // Test non-existent skill (using skillID not skillName) _, err = repo.GetSkill("testuser", "nonexistent") if err != apperrors.ErrSkillNotFound { t.Errorf("Expected ErrSkillNotFound, got %v", err) @@ -322,9 +323,9 @@ func TestMockRepository_ListSkillsForUser(t *testing.T) { } // Create multiple skills for the same user - skill1, _ := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) - skill2, _ := models.NewUserSkill("testuser", "Python", models.ProficiencyAdvanced, 5) - skill3, _ := models.NewUserSkill("otheruser", "Java", models.ProficiencyBeginner, 1) + skill1, _ := models.NewUserSkill("testuser", "go", "Go", "Programming", models.ProficiencyIntermediate, 1) + skill2, _ := models.NewUserSkill("testuser", "python", "Python", "Programming", models.ProficiencyIntermediate, 2) + skill3, _ := models.NewUserSkill("testuser", "java", "Java", "Programming", models.ProficiencyIntermediate, 3) repo.CreateSkill(skill1) repo.CreateSkill(skill2) @@ -335,8 +336,8 @@ func TestMockRepository_ListSkillsForUser(t *testing.T) { if err != nil { t.Errorf("Expected no error, got %v", err) } - if len(skills) != 2 { - t.Errorf("Expected 2 skills, got %d", len(skills)) + if len(skills) != 3 { + t.Errorf("Expected 3 skills, got %d", len(skills)) } // Verify correct skills are returned @@ -353,8 +354,8 @@ func TestMockRepository_ListSkillsForUser(t *testing.T) { if !skillNames["Python"] { t.Error("Expected Python skill to be in the list") } - if skillNames["Java"] { - t.Error("Did not expect Java skill to be in the list for testuser") + if !skillNames["Java"] { + t.Error("Expected Java skill to be in the list") } } @@ -362,9 +363,9 @@ func TestMockRepository_ListUsersBySkill(t *testing.T) { repo := NewMockRepository() // Create skills for different users with same skill name - skill1, _ := models.NewUserSkill("user1", "Go", models.ProficiencyIntermediate, 3) - skill2, _ := models.NewUserSkill("user2", "Go", models.ProficiencyAdvanced, 5) - skill3, _ := models.NewUserSkill("user3", "Python", models.ProficiencyBeginner, 1) + skill1, _ := models.NewUserSkill("user1", "go", "Go", "Programming", models.ProficiencyIntermediate, 3) + skill2, _ := models.NewUserSkill("user2", "go", "Go", "Programming", models.ProficiencyAdvanced, 5) + skill3, _ := models.NewUserSkill("user3", "python", "Python", "Programming", models.ProficiencyBeginner, 1) repo.CreateSkill(skill1) repo.CreateSkill(skill2) @@ -412,7 +413,7 @@ func TestMockRepository_UnifiedInterface(t *testing.T) { // Test as SkillRepository var skillRepo SkillRepository = repo - skill, _ := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) + skill, _ := models.NewUserSkill("testuser", "go", "Go", "Programming", models.ProficiencyIntermediate, 3) err = skillRepo.CreateSkill(skill) if err != nil { t.Errorf("Failed to create skill via SkillRepository interface: %v", err) @@ -424,7 +425,7 @@ func TestMockRepository_UnifiedInterface(t *testing.T) { if err != nil { t.Errorf("Failed to get user via Repository interface: %v", err) } - _, err = combinedRepo.GetSkill("testuser", "Go") + _, err = combinedRepo.GetSkill("testuser", "go") if err != nil { t.Errorf("Failed to get skill via Repository interface: %v", err) } diff --git a/cmd/app/internal/database/skill_repository.go b/cmd/app/internal/database/skill_repository.go index 2118ef3..60455d0 100644 --- a/cmd/app/internal/database/skill_repository.go +++ b/cmd/app/internal/database/skill_repository.go @@ -1,278 +1,9 @@ package database -import ( - "fmt" - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) - -// ============================================================================ -// SKILL REPOSITORY METHODS -// ============================================================================ - -type SkillRepository interface { - CreateSkill(skill *models.UserSkill) error - GetSkill(username, skillName string) (*models.UserSkill, error) - UpdateSkill(skill *models.UserSkill) error - DeleteSkill(username, skillName string) error - ListSkillsForUser(username string) ([]*models.UserSkill, error) - ListUsersBySkill(skillName string) ([]*models.UserSkill, error) - ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) -} - -// CreateSkill inserts a new user skill into DynamoDB -func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill", skill.SkillName) - start := time.Now() - - log.Debug("Starting skill creation") - - // Ensure keys are set - skill.SetKeys() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(PK) AND attribute_not_exists(SK)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - log.Info("Skill created successfully", "duration", time.Since(start)) - return nil -} - -// GetSkill retrieves a specific skill for a user -func (r *DynamoDBRepository) GetSkill(username, skillName string) (*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill", skillName) - start := time.Now() - - log.Debug("Starting skill retrieval") - - pk := fmt.Sprintf("USER#%s", username) - sk := fmt.Sprintf("SKILL#%s", skillName) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "PK": {S: aws.String(pk)}, - "SK": {S: aws.String(sk)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Debug("Skill not found", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - var skill models.UserSkill - err = dynamodbattribute.UnmarshalMap(result.Item, &skill) - if err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("Skill retrieved successfully", "duration", time.Since(start)) - return &skill, nil -} - -// UpdateSkill updates an existing skill -func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill", skill.SkillName) - start := time.Now() - - log.Debug("Starting skill update") - - // Ensure keys are set - skill.SetKeys() - skill.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(PK) AND attribute_exists(SK)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Skill updated successfully", "duration", time.Since(start)) - return nil -} - -// DeleteSkill removes a skill from a user -func (r *DynamoDBRepository) DeleteSkill(username, skillName string) error { - log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill", skillName) - start := time.Now() - - log.Debug("Starting skill deletion") - - pk := fmt.Sprintf("USER#%s", username) - sk := fmt.Sprintf("SKILL#%s", skillName) - - input := &dynamodb.DeleteItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "PK": {S: aws.String(pk)}, - "SK": {S: aws.String(sk)}, - }, - ConditionExpression: aws.String("attribute_exists(PK) AND attribute_exists(SK)"), - } - - _, err := r.client.DeleteItem(input) - if err != nil { - log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Skill deleted successfully", "duration", time.Since(start)) - return nil -} - -// ListSkillsForUser retrieves all skills for a specific user (item collection query) -func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username) - start := time.Now() - - log.Debug("Starting skills list retrieval for user") - - pk := fmt.Sprintf("USER#%s", username) - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - KeyConditionExpression: aws.String("PK = :pk AND begins_with(SK, :sk_prefix)"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":pk": {S: aws.String(pk)}, - ":sk_prefix": {S: aws.String("SKILL#")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query skills for user", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkill retrieves all users who have a specific skill (GSI1 query) -func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName) - start := time.Now() - - log.Debug("Starting users list retrieval by skill") - - gsi1pk := fmt.Sprintf("SKILL#%s", skillName) - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSI1Name), - KeyConditionExpression: aws.String("GSI1PK = :gsi1pk"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":gsi1pk": {S: aws.String(gsi1pk)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query users by skill", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level (GSI1 query with sort key filter) -func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) - start := time.Now() - - log.Debug("Starting users list retrieval by skill and level") - - gsi1pk := fmt.Sprintf("SKILL#%s", skillName) - gsi1skPrefix := fmt.Sprintf("LEVEL#%s#", proficiencyLevel) - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSI1Name), - KeyConditionExpression: aws.String("GSI1PK = :gsi1pk AND begins_with(GSI1SK, :gsi1sk_prefix)"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":gsi1pk": {S: aws.String(gsi1pk)}, - ":gsi1sk_prefix": {S: aws.String(gsi1skPrefix)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query users by skill and level", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} +// DEPRECATED: This file is kept for backwards compatibility. +// Repository implementations have been moved to: +// - master_skill_repository.go (MasterSkillRepository) +// - user_skill_repository.go (SkillRepository) +// - constants.go (GSI constants) +// +// This file will be removed in a future version. diff --git a/cmd/app/internal/database/user_repository.go b/cmd/app/internal/database/user_repository.go index 0063709..9acd381 100644 --- a/cmd/app/internal/database/user_repository.go +++ b/cmd/app/internal/database/user_repository.go @@ -1,7 +1,6 @@ package database import ( - "fmt" "time" apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" @@ -45,7 +44,7 @@ func (r *DynamoDBRepository) CreateUser(user *models.User) error { input := &dynamodb.PutItemInput{ TableName: aws.String(TableName), Item: item, - ConditionExpression: aws.String("attribute_not_exists(PK) AND attribute_not_exists(SK)"), + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), } _, err = r.client.PutItem(input) @@ -65,25 +64,24 @@ func (r *DynamoDBRepository) GetUser(username string) (*models.User, error) { log.Debug("Starting user retrieval") - pk := fmt.Sprintf("USER#%s", username) - sk := "PROFILE" + entityID := models.BuildUserEntityID(username) + log.Info("Attempting to retrieve user", "entity_id", entityID, "table", TableName) input := &dynamodb.GetItemInput{ TableName: aws.String(TableName), Key: map[string]*dynamodb.AttributeValue{ - "PK": {S: aws.String(pk)}, - "SK": {S: aws.String(sk)}, + "entity_id": {S: aws.String(entityID)}, }, } result, err := r.client.GetItem(input) if err != nil { - log.Error("Failed to get user from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + log.Error("Failed to get user from DynamoDB", "error", err.Error(), "entity_id", entityID, "duration", time.Since(start)) return nil, err } if result.Item == nil { - log.Debug("User not found", "duration", time.Since(start)) + log.Info("User not found in DynamoDB", "entity_id", entityID, "duration", time.Since(start)) return nil, apperrors.ErrUserNotFound } @@ -105,16 +103,14 @@ func (r *DynamoDBRepository) UserExists(username string) (bool, error) { log.Debug("Checking if user exists") - pk := fmt.Sprintf("USER#%s", username) - sk := "PROFILE" + entityID := models.BuildUserEntityID(username) input := &dynamodb.GetItemInput{ TableName: aws.String(TableName), Key: map[string]*dynamodb.AttributeValue{ - "PK": {S: aws.String(pk)}, - "SK": {S: aws.String(sk)}, + "entity_id": {S: aws.String(entityID)}, }, - ProjectionExpression: aws.String("PK"), + ProjectionExpression: aws.String("entity_id"), } result, err := r.client.GetItem(input) @@ -148,7 +144,7 @@ func (r *DynamoDBRepository) UpdateUser(user *models.User) error { input := &dynamodb.PutItemInput{ TableName: aws.String(TableName), Item: item, - ConditionExpression: aws.String("attribute_exists(PK) AND attribute_exists(SK)"), + ConditionExpression: aws.String("attribute_exists(entity_id)"), } _, err = r.client.PutItem(input) @@ -168,17 +164,17 @@ func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { log.Debug("Starting users list retrieval") - // Use Scan with filter for EntityType = "User" and SK = "PROFILE" - input := &dynamodb.ScanInput{ - TableName: aws.String(TableName), - FilterExpression: aws.String("EntityType = :entityType AND SK = :sk"), + // Use Scan with filter for EntityType = "User" + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByEntityType), + KeyConditionExpression: aws.String("EntityType = :entityType"), ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ ":entityType": {S: aws.String("User")}, - ":sk": {S: aws.String("PROFILE")}, }, } - result, err := r.client.Scan(input) + result, err := r.client.Query(input) if err != nil { log.Error("Failed to scan users table", "error", err.Error(), "duration", time.Since(start)) return nil, err diff --git a/cmd/app/internal/database/user_skill_repository.go b/cmd/app/internal/database/user_skill_repository.go new file mode 100644 index 0000000..40b72df --- /dev/null +++ b/cmd/app/internal/database/user_skill_repository.go @@ -0,0 +1,303 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// SkillRepository defines operations for user skills +type SkillRepository interface { + CreateSkill(skill *models.UserSkill) error + GetSkill(username, skillID string) (*models.UserSkill, error) + UpdateSkill(skill *models.UserSkill) error + DeleteSkill(username, skillID string) error + ListSkillsForUser(username string) ([]*models.UserSkill, error) + ListUsersBySkill(skillName string) ([]*models.UserSkill, error) + ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) + QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) +} + +// CreateSkill inserts a new user skill into DynamoDB +func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting skill creation") + + // Ensure keys are set + skill.SetKeys() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + log.Info("Skill created successfully", "duration", time.Since(start)) + return nil +} + +// GetSkill retrieves a specific skill for a user by skill_id +func (r *DynamoDBRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID) + start := time.Now() + + log.Debug("Starting skill retrieval") + + entityID := BuildUserSkillEntityID(username, skillID) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("Skill not found", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + var skill models.UserSkill + err = dynamodbattribute.UnmarshalMap(result.Item, &skill) + if err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Skill retrieved successfully", "duration", time.Since(start)) + return &skill, nil +} + +// UpdateSkill updates an existing skill +func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting skill update") + + // Ensure keys are set + skill.SetKeys() + skill.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Skill updated successfully", "duration", time.Since(start)) + return nil +} + +// DeleteSkill removes a skill from a user +func (r *DynamoDBRepository) DeleteSkill(username, skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID) + start := time.Now() + + log.Debug("Starting skill deletion") + + entityID := BuildUserSkillEntityID(username, skillID) + + input := &dynamodb.DeleteItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err := r.client.DeleteItem(input) + if err != nil { + log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListSkillsForUser retrieves all skills for a specific user using GSI ByUser +func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username) + start := time.Now() + + log.Debug("Starting skills list retrieval for user") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByUser), + KeyConditionExpression: aws.String("Username = :username AND EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":username": {S: aws.String(username)}, + ":entityType": {S: aws.String("UserSkill")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query skills for user", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkill retrieves all users who have a specific skill using GSI SkillsByLevel +func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName) + start := time.Now() + + log.Debug("Starting users list retrieval by skill") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSISkillsByLevel), + KeyConditionExpression: aws.String("SkillName = :skillName"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillName": {S: aws.String(skillName)}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users by skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level +// Uses composite partition key on SkillName + ProficiencyLevel +func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) + start := time.Now() + + log.Debug("Starting users list retrieval by skill and level") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSISkillsByLevel), + KeyConditionExpression: aws.String("SkillName = :skillName AND ProficiencyLevel = :level"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillName": {S: aws.String(skillName)}, + ":level": {S: aws.String(string(proficiencyLevel))}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users by skill and level", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// QueryUserSkillsBySkillID retrieves all UserSkills that reference a specific skill_id +// Used when syncing denormalized data after master skill updates +func (r *DynamoDBRepository) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "QueryUserSkillsBySkillID", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting UserSkills retrieval by skill_id") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIBySkillID), + KeyConditionExpression: aws.String("skill_id = :skillID"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillID": {S: aws.String(skillID)}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query UserSkills by skill_id", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("UserSkills by skill_id retrieved successfully", "skill_id", skillID, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/dto/dto.go b/cmd/app/internal/dto/dto.go index 01693bc..33b30ee 100644 --- a/cmd/app/internal/dto/dto.go +++ b/cmd/app/internal/dto/dto.go @@ -100,3 +100,35 @@ type UserSkillResponse struct { Endorsements int `json:"endorsements"` LastUsedDate string `json:"last_used_date"` } + +// Master Skill Request DTOs + +// CreateMasterSkillRequest represents a request to create a master skill +type CreateMasterSkillRequest struct { + SkillID string `json:"skill_id" validate:"required,min=1,max=50"` + SkillName string `json:"skill_name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` + Category string `json:"category" validate:"required,min=1,max=50"` + Tags []string `json:"tags,omitempty"` +} + +// UpdateMasterSkillRequest represents a request to update a master skill +type UpdateMasterSkillRequest struct { + SkillName string `json:"skill_name,omitempty" validate:"omitempty,min=1,max=100"` + Description string `json:"description,omitempty" validate:"omitempty,max=500"` + Category string `json:"category,omitempty" validate:"omitempty,min=1,max=50"` + Tags []string `json:"tags,omitempty"` +} + +// Master Skill Response DTOs + +// MasterSkillResponse represents a master skill in responses +type MasterSkillResponse struct { + SkillID string `json:"skill_id"` + SkillName string `json:"skill_name"` + Description string `json:"description"` + Category string `json:"category"` + Tags []string `json:"tags,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/cmd/app/internal/handler/master_skill_handler.go b/cmd/app/internal/handler/master_skill_handler.go new file mode 100644 index 0000000..c88094c --- /dev/null +++ b/cmd/app/internal/handler/master_skill_handler.go @@ -0,0 +1,146 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/hackmajoris/glad/cmd/app/internal/dto" + "github.com/hackmajoris/glad/cmd/app/internal/service" + + "github.com/aws/aws-lambda-go/events" +) + +// MasterSkillHandler handles master skill HTTP requests +type MasterSkillHandler struct { + service *service.MasterSkillService + errorMapper *ErrorMapper +} + +// NewMasterSkillHandler creates a new MasterSkillHandler +func NewMasterSkillHandler(service *service.MasterSkillService) *MasterSkillHandler { + return &MasterSkillHandler{ + service: service, + errorMapper: NewErrorMapper(), + } +} + +// CreateMasterSkill handles creating a new master skill +// POST /skills +func (h *MasterSkillHandler) CreateMasterSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Parse request body + var req dto.CreateMasterSkillRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Create master skill + skill, err := h.service.CreateMasterSkill(req.SkillID, req.SkillName, req.Description, req.Category, req.Tags) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusCreated, dto.MasterSkillResponse{ + SkillID: skill.SkillID, + SkillName: skill.SkillName, + Description: skill.Description, + Category: skill.Category, + Tags: skill.Tags, + CreatedAt: skill.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: skill.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// GetMasterSkill handles retrieving a master skill by ID +// GET /skills/{skillID} +func (h *MasterSkillHandler) GetMasterSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get skill ID from path parameter + skillID, ok := request.PathParameters["skillID"] + if !ok || skillID == "" { + return errorResponse(http.StatusBadRequest, "Skill ID is required"), nil + } + + // Get master skill + skill, err := h.service.GetMasterSkill(skillID) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.MasterSkillResponse{ + SkillID: skill.SkillID, + SkillName: skill.SkillName, + Description: skill.Description, + Category: skill.Category, + Tags: skill.Tags, + CreatedAt: skill.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: skill.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// UpdateMasterSkill handles updating an existing master skill +// PUT /skills/{skillID} +func (h *MasterSkillHandler) UpdateMasterSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get skill ID from path parameter + skillID, ok := request.PathParameters["skillID"] + if !ok || skillID == "" { + return errorResponse(http.StatusBadRequest, "Skill ID is required"), nil + } + + // Parse request body + var req dto.UpdateMasterSkillRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Update master skill + skill, err := h.service.UpdateMasterSkill(skillID, req.SkillName, req.Description, req.Category, req.Tags) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.MasterSkillResponse{ + SkillID: skill.SkillID, + SkillName: skill.SkillName, + Description: skill.Description, + Category: skill.Category, + Tags: skill.Tags, + CreatedAt: skill.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: skill.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// DeleteMasterSkill handles deleting a master skill +// DELETE /skills/{skillID} +func (h *MasterSkillHandler) DeleteMasterSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get skill ID from path parameter + skillID, ok := request.PathParameters["skillID"] + if !ok || skillID == "" { + return errorResponse(http.StatusBadRequest, "Skill ID is required"), nil + } + + // Delete master skill + if err := h.service.DeleteMasterSkill(skillID); err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.MessageResponse{ + Message: "Master skill deleted successfully", + }), nil +} + +// ListMasterSkills handles listing all master skills +// GET /skills +func (h *MasterSkillHandler) ListMasterSkills(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // List all master skills + skills, err := h.service.ListMasterSkills() + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, skills), nil +} + +// handleServiceError converts service errors to HTTP responses using the error mapper +func (h *MasterSkillHandler) handleServiceError(err error) events.APIGatewayProxyResponse { + statusCode, message := h.errorMapper.MapToHTTP(err) + return errorResponse(statusCode, message) +} diff --git a/cmd/app/internal/handler/user_handler.go b/cmd/app/internal/handler/user_handler.go index e2e0fb9..3f1fc7d 100644 --- a/cmd/app/internal/handler/user_handler.go +++ b/cmd/app/internal/handler/user_handler.go @@ -4,14 +4,14 @@ import ( "encoding/json" "net/http" + "github.com/aws/aws-lambda-go/events" "github.com/hackmajoris/glad/cmd/app/internal/dto" "github.com/hackmajoris/glad/cmd/app/internal/models" "github.com/hackmajoris/glad/cmd/app/internal/service" "github.com/hackmajoris/glad/cmd/app/internal/validation" "github.com/hackmajoris/glad/pkg/auth" _ "github.com/hackmajoris/glad/pkg/errors" - - "github.com/aws/aws-lambda-go/events" + "github.com/hackmajoris/glad/pkg/logger" ) // Handler handles HTTP requests @@ -137,6 +137,9 @@ func (h *Handler) GetCurrentUser(request events.APIGatewayProxyRequest) (events. return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil } + log := logger.WithComponent("handler").With("operation", "GetCurrentUser", "username", claims.Username) + log.Debug("Fetching current user") + user, err := h.userService.GetUser(claims.Username) if err != nil { return h.handleServiceError(err), nil diff --git a/cmd/app/internal/handler/user_handler_test.go b/cmd/app/internal/handler/user_handler_test.go index e06cf4c..17fade0 100644 --- a/cmd/app/internal/handler/user_handler_test.go +++ b/cmd/app/internal/handler/user_handler_test.go @@ -110,6 +110,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Create unified mock repository mockRepo := database.NewMockRepository() + masterSkillsRepo := database.NewMockRepository() if tt.setupRepo != nil { tt.setupRepo(mockRepo) @@ -118,7 +119,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { // Create services with mock repository tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) - skillService := service.NewSkillService(mockRepo) + skillService := service.NewSkillService(mockRepo, masterSkillsRepo) // Create handler h := New(userService, skillService) @@ -178,7 +179,8 @@ func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) mockRepository := database.NewMockRepository() - skillService := service.NewSkillService(mockRepository) + masterSkillRepository := database.NewMockRepository() + skillService := service.NewSkillService(mockRepository, masterSkillRepository) h := New(userService, skillService) request := events.APIGatewayProxyRequest{ @@ -226,7 +228,8 @@ func TestHandler_GetCurrentUser_DoesNotExposePassword(t *testing.T) { tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) skillMockRepo := database.NewMockRepository() - skillService := service.NewSkillService(skillMockRepo) + masterSkillMockRepo := database.NewMockRepository() + skillService := service.NewSkillService(skillMockRepo, masterSkillMockRepo) h := New(userService, skillService) request := events.APIGatewayProxyRequest{ diff --git a/cmd/app/internal/models/keys.go b/cmd/app/internal/models/keys.go new file mode 100644 index 0000000..0d98a51 --- /dev/null +++ b/cmd/app/internal/models/keys.go @@ -0,0 +1,24 @@ +package models + +import ( + "fmt" + "strings" +) + +// BuildUserEntityID constructs the entity_id for a User +// Format: USER# +func BuildUserEntityID(username string) string { + return fmt.Sprintf("USER#%s", strings.ToLower(username)) +} + +// BuildMasterSkillEntityID constructs the entity_id for a Master Skill +// Format: SKILL# +func BuildMasterSkillEntityID(skillID string) string { + return fmt.Sprintf("SKILL#%s", skillID) +} + +// BuildUserSkillEntityID constructs the entity_id for a User Skill +// Format: USERSKILL## +func BuildUserSkillEntityID(username, skillID string) string { + return fmt.Sprintf("USERSKILL#%s#%s", username, skillID) +} diff --git a/cmd/app/internal/models/skill.go b/cmd/app/internal/models/skill.go new file mode 100644 index 0000000..8a8495f --- /dev/null +++ b/cmd/app/internal/models/skill.go @@ -0,0 +1,125 @@ +package models + +import ( + "errors" + "time" + + apperrors "github.com/hackmajoris/glad/pkg/errors" +) + +// Skill represents a master skill entity in the system +// This is the authoritative source for skill metadata +// UserSkills reference skills via skill_id and denormalize name/category +type Skill struct { + // Business attributes + SkillID string `json:"skill_id" dynamodbav:"skill_id"` // Immutable ID (e.g., "python") + SkillName string `json:"skill_name" dynamodbav:"SkillName"` // Display name (e.g., "Python") + Description string `json:"description" dynamodbav:"Description"` + Category string `json:"category" dynamodbav:"Category"` // e.g., "Programming", "Cloud", "DevOps" + Tags []string `json:"tags,omitempty" dynamodbav:"Tags,omitempty"` + CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` + UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` + + // DynamoDB attributes + EntityID string `json:"-" dynamodbav:"entity_id"` + EntityType string `json:"entity_type" dynamodbav:"EntityType"` +} + +// NewSkill creates a new master Skill +// skillID must be lowercase alphanumeric with dashes only (e.g., "python", "aws-lambda", "react-js") +// skillName is the display name (e.g., "Python", "AWS Lambda", "React.js") +// category should be a valid category (e.g., "Programming", "Cloud", "DevOps", "Database") +func NewSkill(skillID, skillName, description, category string, tags []string) (*Skill, error) { + if skillID == "" || skillName == "" || category == "" { + return nil, apperrors.ErrRequiredField + } + + if !isValidSkillID(skillID) { + return nil, errors.New("invalid skill_id: must be lowercase alphanumeric with dashes, max 50 chars") + } + + if len(skillName) < 2 || len(skillName) > 100 { + return nil, errors.New("invalid skill_name: must be between 2 and 100 characters") + } + + if !isValidCategory(category) { + return nil, errors.New("invalid category: must be one of Programming, Cloud, DevOps, Database, Frontend, Backend, Mobile, Data, Security, Other") + } + + now := time.Now() + skill := &Skill{ + SkillID: skillID, + SkillName: skillName, + Description: description, + Category: category, + Tags: tags, + CreatedAt: now, + UpdatedAt: now, + } + + skill.SetKeys() + return skill, nil +} + +// isValidSkillID validates that a skill ID follows the required format: +// - lowercase letters (a-z) +// - numbers (0-9) +// - dashes (-) +// - length between 1 and 50 characters +func isValidSkillID(id string) bool { + if id == "" || len(id) > 50 { + return false + } + for _, c := range id { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { + return false + } + } + return true +} + +// validCategories defines the allowed skill categories +var validCategories = map[string]bool{ + "Programming": true, + "Cloud": true, + "DevOps": true, + "Database": true, + "Frontend": true, + "Backend": true, + "Mobile": true, + "Data": true, + "Security": true, + "Other": true, +} + +// isValidCategory checks if the category is in the allowed list +func isValidCategory(category string) bool { + return validCategories[category] +} + +// SetKeys configures the entity_id for DynamoDB +func (s *Skill) SetKeys() { + s.EntityID = BuildMasterSkillEntityID(s.SkillID) + s.EntityType = "Skill" +} + +// UpdateMetadata updates skill display name, description, and category +// Note: This requires syncing all UserSkills that reference this skill +func (s *Skill) UpdateMetadata(skillName, description, category string) { + if skillName != "" { + s.SkillName = skillName + } + if description != "" { + s.Description = description + } + if category != "" { + s.Category = category + } + s.UpdatedAt = time.Now() +} + +// UpdateTags updates the skill tags +func (s *Skill) UpdateTags(tags []string) { + s.Tags = tags + s.UpdatedAt = time.Now() +} diff --git a/cmd/app/internal/models/user.go b/cmd/app/internal/models/user.go index 2066471..e9e0393 100644 --- a/cmd/app/internal/models/user.go +++ b/cmd/app/internal/models/user.go @@ -1,7 +1,6 @@ package models import ( - "fmt" "time" apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" @@ -23,10 +22,9 @@ type User struct { CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` - // DynamoDB system attributes for single table design - PK string `json:"-" dynamodbav:"PK"` - SK string `json:"-" dynamodbav:"SK"` - EntityType string `json:"entity_type" dynamodbav:"EntityType"` + // DynamoDB attributes + EntityID string `json:"-" dynamodbav:"entity_id"` // Unique: USER# + EntityType string `json:"entity_type" dynamodbav:"EntityType"` // "User" } // NewUser creates a new User with the given credentials @@ -56,11 +54,8 @@ func NewUser(username, name, password string) (*User, error) { return user, nil } -// SetKeys configures the PK and SK for DynamoDB single table design func (u *User) SetKeys() { - // Base table keys: User profile uses a fixed SK of "PROFILE" - u.PK = fmt.Sprintf("USER#%s", u.Username) - u.SK = "PROFILE" + u.EntityID = BuildUserEntityID(u.Username) u.EntityType = "User" } diff --git a/cmd/app/internal/models/user_skill.go b/cmd/app/internal/models/user_skill.go index c1bb969..8137afc 100644 --- a/cmd/app/internal/models/user_skill.go +++ b/cmd/app/internal/models/user_skill.go @@ -1,7 +1,6 @@ package models import ( - "fmt" "time" apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" @@ -27,15 +26,20 @@ var validProficiencyLevels = map[ProficiencyLevel]bool{ } // UserSkill represents a skill associated with a user (domain model) -// This entity uses single table design with the following key structure: -// - PK: USER# -// - SK: SKILL# -// - GSI1PK: SKILL# -// - GSI1SK: LEVEL##USER# +// This entity uses single table design with multi-attribute composite keys: +// - entity_id: USERSKILL## +// - skill_id: Immutable skill reference (e.g., "python") +// - SkillName: Denormalized display name for GSI queries +// - Category: Denormalized from master Skill +// +// GSI SkillsByLevel uses: SkillName + ProficiencyLevel + YearsOfExperience + Username +// GSI ByUser uses: Username + EntityType type UserSkill struct { - // Business attributes + // Business attributes - used directly in GSI composite keys Username string `json:"username" dynamodbav:"Username"` - SkillName string `json:"skill_name" dynamodbav:"SkillName"` + SkillID string `json:"skill_id" dynamodbav:"skill_id"` // Immutable reference + SkillName string `json:"skill_name" dynamodbav:"SkillName"` // Denormalized for GSI + Category string `json:"category" dynamodbav:"Category"` // Denormalized from Skill ProficiencyLevel ProficiencyLevel `json:"proficiency_level" dynamodbav:"ProficiencyLevel"` YearsOfExperience int `json:"years_of_experience" dynamodbav:"YearsOfExperience"` Endorsements int `json:"endorsements" dynamodbav:"Endorsements"` @@ -44,23 +48,21 @@ type UserSkill struct { CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` - // DynamoDB system attributes for single table design - PK string `json:"-" dynamodbav:"PK"` - SK string `json:"-" dynamodbav:"SK"` + // DynamoDB attributes + EntityID string `json:"-" dynamodbav:"entity_id"` EntityType string `json:"entity_type" dynamodbav:"EntityType"` - - // GSI1 attributes for cross-user skill queries - GSI1PK string `json:"-" dynamodbav:"GSI1PK,omitempty"` - GSI1SK string `json:"-" dynamodbav:"GSI1SK,omitempty"` } // NewUserSkill creates a new UserSkill with proper validation -func NewUserSkill(username, skillName string, proficiencyLevel ProficiencyLevel, yearsOfExperience int) (*UserSkill, error) { +// skillID: Immutable skill identifier (e.g., "python") +// skillName: Display name (e.g., "Python") - denormalized from master Skill +// category: Skill category (e.g., "Programming") - denormalized from master Skill +func NewUserSkill(username, skillID, skillName, category string, proficiencyLevel ProficiencyLevel, yearsOfExperience int) (*UserSkill, error) { if username == "" { return nil, errors.ErrRequiredField } - if skillName == "" { + if skillID == "" || skillName == "" { return nil, errors.ErrRequiredField } @@ -75,7 +77,9 @@ func NewUserSkill(username, skillName string, proficiencyLevel ProficiencyLevel, now := time.Now() skill := &UserSkill{ Username: username, + SkillID: skillID, SkillName: skillName, + Category: category, ProficiencyLevel: proficiencyLevel, YearsOfExperience: yearsOfExperience, Endorsements: 0, @@ -91,20 +95,11 @@ func NewUserSkill(username, skillName string, proficiencyLevel ProficiencyLevel, return skill, nil } -// SetKeys configures the PK, SK, and GSI keys for DynamoDB single table design func (s *UserSkill) SetKeys() { - // Base table keys: Item collection pattern - // All skills for a user share the same PK - s.PK = fmt.Sprintf("USER#%s", s.Username) - s.SK = fmt.Sprintf("SKILL#%s", s.SkillName) - - // Entity type for filtering + // Base table key: Unique identifier + s.EntityID = BuildUserSkillEntityID(s.Username, s.SkillID) s.EntityType = "UserSkill" - - // GSI1 keys: For querying users by skill - // Enables: "Find all users with skill X" or "Find all expert users with skill X" - s.GSI1PK = fmt.Sprintf("SKILL#%s", s.SkillName) - s.GSI1SK = fmt.Sprintf("LEVEL#%s#USER#%s", s.ProficiencyLevel, s.Username) + // No GSI concatenation needed - composite keys use actual attribute values! } // UpdateProficiency updates the skill proficiency level @@ -116,9 +111,6 @@ func (s *UserSkill) UpdateProficiency(level ProficiencyLevel) error { s.ProficiencyLevel = level s.UpdatedAt = time.Now() - // Update GSI keys to reflect new proficiency - s.SetKeys() - return nil } diff --git a/cmd/app/internal/service/master_skill_service.go b/cmd/app/internal/service/master_skill_service.go new file mode 100644 index 0000000..8b029e5 --- /dev/null +++ b/cmd/app/internal/service/master_skill_service.go @@ -0,0 +1,143 @@ +package service + +import ( + "time" + + "github.com/hackmajoris/glad/cmd/app/internal/database" + "github.com/hackmajoris/glad/cmd/app/internal/dto" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// MasterSkillService handles master skill business logic +type MasterSkillService struct { + repo database.MasterSkillRepository +} + +// NewMasterSkillService creates a new MasterSkillService +func NewMasterSkillService(repo database.MasterSkillRepository) *MasterSkillService { + return &MasterSkillService{ + repo: repo, + } +} + +// CreateMasterSkill creates a new master skill +func (s *MasterSkillService) CreateMasterSkill(skillID, skillName, description, category string, tags []string) (*models.Skill, error) { + log := logger.WithComponent("service").With("operation", "CreateMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Info("Processing create master skill request") + + // Create new master skill + skill, err := models.NewSkill(skillID, skillName, description, category, tags) + if err != nil { + log.Error("Failed to create skill model", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Save to database + if err := s.repo.CreateMasterSkill(skill); err != nil { + log.Error("Failed to save master skill to database", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Info("Master skill created successfully", "duration", time.Since(start)) + return skill, nil +} + +// GetMasterSkill retrieves a master skill by ID +func (s *MasterSkillService) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("service").With("operation", "GetMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Retrieving master skill") + + skill, err := s.repo.GetMasterSkill(skillID) + if err != nil { + log.Error("Failed to get master skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Master skill retrieved successfully", "duration", time.Since(start)) + return skill, nil +} + +// UpdateMasterSkill updates an existing master skill +func (s *MasterSkillService) UpdateMasterSkill(skillID, skillName, description, category string, tags []string) (*models.Skill, error) { + log := logger.WithComponent("service").With("operation", "UpdateMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Info("Processing update master skill request") + + // Get existing skill + skill, err := s.repo.GetMasterSkill(skillID) + if err != nil { + log.Error("Failed to get master skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Update fields if provided + if skillName != "" || description != "" || category != "" { + skill.UpdateMetadata(skillName, description, category) + } + + if tags != nil { + skill.UpdateTags(tags) + } + + // Save updated skill + if err := s.repo.UpdateMasterSkill(skill); err != nil { + log.Error("Failed to update master skill in database", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Info("Master skill updated successfully", "duration", time.Since(start)) + return skill, nil +} + +// DeleteMasterSkill deletes a master skill +func (s *MasterSkillService) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("service").With("operation", "DeleteMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Info("Processing delete master skill request") + + if err := s.repo.DeleteMasterSkill(skillID); err != nil { + log.Error("Failed to delete master skill", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Master skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills +func (s *MasterSkillService) ListMasterSkills() ([]dto.MasterSkillResponse, error) { + log := logger.WithComponent("service").With("operation", "ListMasterSkills") + start := time.Now() + + log.Info("Retrieving all master skills") + + skills, err := s.repo.ListMasterSkills() + if err != nil { + log.Error("Failed to retrieve master skills", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Convert to response DTOs + result := make([]dto.MasterSkillResponse, len(skills)) + for i, skill := range skills { + result[i] = dto.MasterSkillResponse{ + SkillID: skill.SkillID, + SkillName: skill.SkillName, + Description: skill.Description, + Category: skill.Category, + Tags: skill.Tags, + CreatedAt: skill.CreatedAt.Format(time.RFC3339), + UpdatedAt: skill.UpdatedAt.Format(time.RFC3339), + } + } + + log.Info("Master skills retrieved successfully", "count", len(result), "duration", time.Since(start)) + return result, nil +} diff --git a/cmd/app/internal/service/skill_service.go b/cmd/app/internal/service/skill_service.go index 6708a2e..7e14021 100644 --- a/cmd/app/internal/service/skill_service.go +++ b/cmd/app/internal/service/skill_service.go @@ -21,25 +21,37 @@ var ( // SkillService handles skill business logic type SkillService struct { - repo database.SkillRepository + repo database.SkillRepository + masterSkillRepo database.MasterSkillRepository } // NewSkillService creates a new SkillService -func NewSkillService(repo database.SkillRepository) *SkillService { +func NewSkillService(repo database.SkillRepository, masterSkillRepo database.MasterSkillRepository) *SkillService { return &SkillService{ - repo: repo, + repo: repo, + masterSkillRepo: masterSkillRepo, } } // AddSkill adds a new skill to a user +// The skillName parameter is used as the skillID to look up the master skill func (s *SkillService) AddSkill(username, skillName string, proficiencyLevel models.ProficiencyLevel, yearsOfExperience int, notes string) (*models.UserSkill, error) { log := logger.WithComponent("service").With("operation", "AddSkill", "username", username, "skill", skillName) start := time.Now() log.Info("Processing add skill request") - // Create new skill - skill, err := models.NewUserSkill(username, skillName, proficiencyLevel, yearsOfExperience) + // Look up master skill to get skillID, skillName, and category + masterSkill, err := s.masterSkillRepo.GetMasterSkill(skillName) + if err != nil { + log.Error("Master skill not found", "error", err.Error(), "skill_id", skillName, "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + log.Debug("Master skill found", "skill_id", masterSkill.SkillID, "skill_name", masterSkill.SkillName, "category", masterSkill.Category) + + // Create new user skill with data from master skill + skill, err := models.NewUserSkill(username, masterSkill.SkillID, masterSkill.SkillName, masterSkill.Category, proficiencyLevel, yearsOfExperience) if err != nil { log.Error("Failed to create skill model", "error", err.Error(), "duration", time.Since(start)) return nil, err diff --git a/cmd/app/main.go b/cmd/app/main.go index a61fe0d..6692c6e 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -25,14 +25,16 @@ func main() { // Initialize services userService := service.NewUserService(repo, tokenService) - skillService := service.NewSkillService(repo) + skillService := service.NewSkillService(repo, repo) // repo implements both SkillRepository and MasterSkillRepository + masterSkillService := service.NewMasterSkillService(repo) - // Initialize handler + // Initialize handlers apiHandler := handler.New(userService, skillService) + masterSkillHandler := handler.NewMasterSkillHandler(masterSkillService) authMiddleware := middleware.NewAuthMiddleware(tokenService) // Setup router - r := setupRouter(apiHandler, authMiddleware) + r := setupRouter(apiHandler, masterSkillHandler, authMiddleware) // Start Lambda lambda.Start(func(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { @@ -41,7 +43,7 @@ func main() { }) } -func setupRouter(h *handler.Handler, auth *middleware.AuthMiddleware) *router.Router { +func setupRouter(h *handler.Handler, msh *handler.MasterSkillHandler, auth *middleware.AuthMiddleware) *router.Router { r := router.New() // Public routes @@ -54,7 +56,14 @@ func setupRouter(h *handler.Handler, auth *middleware.AuthMiddleware) *router.Ro r.PUT("/user", h.UpdateUser, auth.RequireAuth()) r.GET("/users", h.ListUsers, auth.RequireAuth()) - // Protected routes - Skill Management + // Protected routes - Master Skill Management + r.POST("/master-skills", msh.CreateMasterSkill, auth.RequireAuth()) + r.GET("/master-skills", msh.ListMasterSkills, auth.RequireAuth()) + r.GET("/master-skills/{skillID}", msh.GetMasterSkill, auth.RequireAuth()) + r.PUT("/master-skills/{skillID}", msh.UpdateMasterSkill, auth.RequireAuth()) + r.DELETE("/master-skills/{skillID}", msh.DeleteMasterSkill, auth.RequireAuth()) + + // Protected routes - User Skill Management // Manage skills for a specific user r.POST("/users/{username}/skills", h.AddSkill, auth.RequireAuth()) r.GET("/users/{username}/skills", h.ListSkillsForUser, auth.RequireAuth()) @@ -62,7 +71,7 @@ func setupRouter(h *handler.Handler, auth *middleware.AuthMiddleware) *router.Ro r.PUT("/users/{username}/skills/{skillName}", h.UpdateSkill, auth.RequireAuth()) r.DELETE("/users/{username}/skills/{skillName}", h.DeleteSkill, auth.RequireAuth()) - // Query users by skill (cross-user queries using GSI1) + // Query users by skill (cross-user queries using GSI) r.GET("/skills/{skillName}/users", h.ListUsersBySkill, auth.RequireAuth()) return r diff --git a/cmd/app/testdata/dynamo-db-multi-keys-queries.md b/cmd/app/testdata/dynamo-db-multi-keys-queries.md new file mode 100644 index 0000000..ea0a843 --- /dev/null +++ b/cmd/app/testdata/dynamo-db-multi-keys-queries.md @@ -0,0 +1,295 @@ + +# Get all Expert Python users with 5+ years: + aws dynamodb query \ + --table-name glad-entities-production \ + --index-name SkillsByLevel \ + --key-condition-expression "SkillName = :skill AND ProficiencyLevel = :level AND YearsOfExperience >= :years" \ + --expression-attribute-values '{ + ":skill": {"S": "Python"}, + ":level": {"S": "Expert"}, + ":years": {"N": "5"} + }' + +# Get user profile + all skills: + aws dynamodb query \ + --table-name glad-entities-production \ + --index-name ByUser \ + --profile passbrains-ilisa-amplify \ + --key-condition-expression "Username = :user" \ + --expression-attribute-values '{":user": {"S": "john"}}' + +# Get just user profile: + aws dynamodb query \ + --table-name glad-entities-production \ + --profile passbrains-ilisa-amplify \ + --index-name ByUser \ + --key-condition-expression "Username = :user AND EntityType = :type" \ + --expression-attribute-values '{ + ":user": {"S": "john"}, + ":type": {"S": "User"} + }' + + +# User 1: John Doe +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "USER-john"}, +"EntityType": {"S": "User"}, +"Username": {"S": "john"}, +"Name": {"S": "John Doe"}, +"Email": {"S": "john@example.com"}, +"PasswordHash": {"S": "$2a$10$examplehash"}, +"CreatedAt": {"S": "2025-01-01T10:00:00Z"}, +"UpdatedAt": {"S": "2025-01-01T10:00:00Z"} +}' + +# User 2: Jane Smith +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "USER-jane"}, +"EntityType": {"S": "User"}, +"Username": {"S": "jane"}, +"Name": {"S": "Jane Smith"}, +"Email": {"S": "jane@example.com"}, +"PasswordHash": {"S": "$2a$10$examplehash"}, +"CreatedAt": {"S": "2025-01-15T09:00:00Z"}, +"UpdatedAt": {"S": "2025-01-15T09:00:00Z"} +}' + +# User 3: Bob Wilson +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "USER-bob"}, +"EntityType": {"S": "User"}, +"Username": {"S": "bob"}, +"Name": {"S": "Bob Wilson"}, +"Email": {"S": "bob@example.com"}, +"PasswordHash": {"S": "$2a$10$examplehash"}, +"CreatedAt": {"S": "2025-02-01T08:00:00Z"}, +"UpdatedAt": {"S": "2025-02-01T08:00:00Z"} +}' + + +# Create User Skills +# John's Skills +# Python - Expert, 7 years + +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "SKILL-john-Python"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "john"}, +"SkillName": {"S": "Python"}, +"ProficiencyLevel": {"S": "Expert"}, +"YearsOfExperience": {"N": "7"}, +"Endorsements": {"N": "15"}, +"LastUsedDate": {"S": "2025-12-10"}, +"Notes": {"S": "Specialized in data science and ML"}, +"CreatedAt": {"S": "2025-01-02T10:00:00Z"}, +"UpdatedAt": {"S": "2025-12-10T10:00:00Z"} +}' + +# JavaScript - Advanced, 5 years +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "SKILL-john-JavaScript"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "john"}, +"SkillName": {"S": "JavaScript"}, +"ProficiencyLevel": {"S": "Advanced"}, +"YearsOfExperience": {"N": "5"}, +"Endorsements": {"N": "8"}, +"LastUsedDate": {"S": "2025-12-13"}, +"Notes": {"S": "React and Node.js expert"}, +"CreatedAt": {"S": "2025-01-02T10:00:00Z"}, +"UpdatedAt": {"S": "2025-12-13T10:00:00Z"} +}' + +# Jane's Skills +# Python - Expert, 10 years +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "SKILL-jane-Python"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "jane"}, +"SkillName": {"S": "Python"}, +"ProficiencyLevel": {"S": "Expert"}, +"YearsOfExperience": {"S": "10"}, +"Endorsements": {"N": "25"}, +"LastUsedDate": {"S": "2025-12-12"}, +"Notes": {"S": "Backend systems and APIs"}, +"CreatedAt": {"S": "2025-01-16T09:00:00Z"}, +"UpdatedAt": {"S": "2025-12-12T09:00:00Z"} +}' + +# Go - Advanced, 6 years + +aws dynamodb put-item \ +--profile passbrains-ilisa-amplify \ +--table-name glad-entities-production \ +--item '{ +"entity_id": {"S": "SKILL-jane-Go"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "jane"}, +"SkillName": {"S": "Go"}, +"ProficiencyLevel": {"S": "Advanced"}, +"YearsOfExperience": {"S": "6"}, +"Endorsements": {"N": "12"}, +"LastUsedDate": {"S": "2025-12-11"}, +"Notes": {"S": "Microservices and cloud infrastructure"}, +"CreatedAt": {"S": "2025-01-16T09:00:00Z"}, +"UpdatedAt": {"S": "2025-12-11T09:00:00Z"} +}' + +# Bob's Skills +# Python - Intermediate, 3 years +aws dynamodb put-item \ +--table-name glad-entities-production \ +--item '{ +"entity_id": {"S": "SKILL-bob-Python"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "bob"}, +"SkillName": {"S": "Python"}, +"ProficiencyLevel": {"S": "Intermediate"}, +"YearsOfExperience": {"N": "3"}, +"Endorsements": {"N": "5"}, +"LastUsedDate": {"S": "2025-12-09"}, +"Notes": {"S": "Learning Django framework"}, +"CreatedAt": {"S": "2025-02-02T08:00:00Z"}, +"UpdatedAt": {"S": "2025-12-09T08:00:00Z"} +}' + +# JavaScript - Expert, 8 years +aws dynamodb put-item \ +--table-name glad-entities-production \ +--item '{ +"entity_id": {"S": "SKILL-bob-JavaScript"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "bob"}, +"SkillName": {"S": "JavaScript"}, +"ProficiencyLevel": {"S": "Expert"}, +"YearsOfExperience": {"N": "8"}, +"Endorsements": {"N": "20"}, +"LastUsedDate": {"S": "2025-12-13"}, +"Notes": {"S": "Full-stack JavaScript developer"}, +"CreatedAt": {"S": "2025-02-02T08:00:00Z"}, +"UpdatedAt": {"S": "2025-12-13T08:00:00Z"} +}' + +# TypeScript - Advanced, 4 years +aws dynamodb put-item \ +--table-name glad-entities-production \ +--item '{ +"entity_id": {"S": "SKILL-bob-TypeScript"}, +"EntityType": {"S": "UserSkill"}, +"Username": {"S": "bob"}, +"SkillName": {"S": "TypeScript"}, +"ProficiencyLevel": {"S": "Advanced"}, +"YearsOfExperience": {"N": "4"}, +"Endorsements": {"N": "10"}, +"LastUsedDate": {"S": "2025-12-13"}, +"Notes": {"S": "Building enterprise applications"}, +"CreatedAt": {"S": "2025-02-02T08:00:00Z"}, +"UpdatedAt": {"S": "2025-12-13T08:00:00Z"} +}' + +# Query Examples Using Composite Keys + +# Find all Expert Python users +aws dynamodb query \ +--profile passbrains-ilisa-amplify \ +--table-name glad-entities-production \ +--index-name SkillsByLevel \ +--key-condition-expression "SkillName = :skill AND ProficiencyLevel = :level" \ +--expression-attribute-values '{ +":skill": {"S": "Python"}, +":level": {"S": "Expert"} +}' + +# Find Expert Python users with 5+ years experience +aws dynamodb query \ +--table-name glad-entities-production \ +--index-name SkillsByLevel \ +--key-condition-expression "SkillName = :skill AND ProficiencyLevel = :level AND YearsOfExperience >= :years" \ +--expression-attribute-values '{ +":skill": {"S": "Python"}, +":level": {"S": "Expert"}, +":years": {"N": "5"} +}' + +# Get all of John's profile and skills +aws dynamodb query \ +--table-name glad-entities-production \ +--index-name ByUser \ +--key-condition-expression "Username = :user" \ +--expression-attribute-values '{":user": {"S": "john"}}' + +# Get only John's profile +aws dynamodb query \ +--table-name glad-entities-production \ +--index-name ByUser \ +--key-condition-expression "Username = :user AND EntityType = :type" \ +--expression-attribute-values '{ +":user": {"S": "john"}, +":type": {"S": "User"} +}' + +# Get only John's skills +aws dynamodb query \ +--table-name glad-entities-production \ +--index-name ByUser \ +--key-condition-expression "Username = :user AND EntityType = :type" \ +--expression-attribute-values '{ +":user": {"S": "john"}, +":type": {"S": "UserSkill"} +}' + +aws dynamodb put-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--item '{ +"entity_id": {"S": "SKILL-Python"}, +"EntityType": {"S": "Skill"}, +"SkillName": {"S": "Python"}, +"Description": {"S": "High-level programming language for general-purpose programming"}, +"Category": {"S": "Programming"}, +"CreatedAt": {"S": "2025-01-01T00:00:00Z"}, +"UpdatedAt": {"S": "2025-01-01T00:00:00Z"} +}' + +# Get all available skills (not user-specific) +aws dynamodb query \ +--table-name glad-entities-production \ +--index-name ByEntityType \ +--profile passbrains-ilisa-amplify \ +--key-condition-expression "EntityType = :type" \ +--expression-attribute-values '{":type": {"S": "Skill"}}' + +# Get a specific skill +aws dynamodb get-item \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--key '{"entity_id": {"S": "SKILL-Python"}}' + +# Get all skills in Programming category +aws dynamodb query \ +--table-name glad-entities-production \ +--profile passbrains-ilisa-amplify \ +--index-name SkillsByCategory \ +--key-condition-expression "EntityType = :type AND Category = :cat" \ +--expression-attribute-values '{ +":type": {"S": "Skill"}, +":cat": {"S": "Programming"} +}' diff --git a/deployments/app/cdk.go b/deployments/app/cdk.go index eebdbe3..a945786 100644 --- a/deployments/app/cdk.go +++ b/deployments/app/cdk.go @@ -17,84 +17,94 @@ type CdkStackProps struct { awscdk.StackProps } -func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) awscdk.Stack { - var sprops awscdk.StackProps - if props != nil { - sprops = props.StackProps - } - stack := awscdk.NewStack(scope, &id, &sprops) - - ENVIRONMENT := "production" // todo: will be parametrised - - // Add environment tag - awscdk.Tags_Of(stack).Add(jsii.String("Environment"), jsii.String(ENVIRONMENT), nil) - - // The code that defines your stack goes here - - // example resource - // queue := awssqs.NewQueue(stack, jsii.String("CdkQueue"), &awssqs.QueueProps{ - // VisibilityTimeout: awscdk.Duration_Seconds(jsii.Number(300)), - // }) - - // Create DynamoDB Single Table - // This table uses single-table design pattern to store multiple entity types - // Entities: User, UserSkill (and future: Project, Settings, etc.) - // Key structure: - // - User: PK=USER#, SK=PROFILE - // - UserSkill: PK=USER#, SK=SKILL# - - entitiesTable := awsdynamodb.NewTableV2(stack, jsii.String(id+"-entities-table"), &awsdynamodb.TablePropsV2{ +func createEntitiesTable(stack awscdk.Stack, id *string, environment string) awsdynamodb.TableV2 { + entitiesTable := awsdynamodb.NewTableV2(stack, id, &awsdynamodb.TablePropsV2{ TableName: jsii.String("glad-entities"), - // Partition Key: PK (stores entity identifier) PartitionKey: &awsdynamodb.Attribute{ - Name: jsii.String("PK"), + Name: jsii.String("entity_id"), Type: awsdynamodb.AttributeType_STRING, }, - // Sort Key: SK (stores entity type and sub-identifier) - SortKey: &awsdynamodb.Attribute{ - Name: jsii.String("SK"), - Type: awsdynamodb.AttributeType_STRING, - }, - - // GSI1: For cross-entity queries (e.g., find all users with a skill) GlobalSecondaryIndexes: &[]*awsdynamodb.GlobalSecondaryIndexPropsV2{ + // GSI1: Query skills by name, optionally filter by level { - IndexName: jsii.String("GSI1"), + IndexName: jsii.String("SkillsByLevel"), PartitionKey: &awsdynamodb.Attribute{ - Name: jsii.String("GSI1PK"), + Name: jsii.String("SkillName"), Type: awsdynamodb.AttributeType_STRING, }, SortKey: &awsdynamodb.Attribute{ - Name: jsii.String("GSI1SK"), + Name: jsii.String("ProficiencyLevel"), Type: awsdynamodb.AttributeType_STRING, }, - // INCLUDE projection for cost optimization - // Only includes essential attributes needed for queries ProjectionType: awsdynamodb.ProjectionType_INCLUDE, NonKeyAttributes: jsii.Strings( "EntityType", + "Notes", + "Email", + "skill_id", + "Category", + "YearsOfExperience", "Username", - "SkillName", - "ProficiencyLevel", - "Name", ), }, - }, - - // Enable point-in-time recovery for data protection - PointInTimeRecovery: jsii.Bool(true), + // GSI2: Query all items for a user (profile + skills) + { + IndexName: jsii.String("ByUser"), + PartitionKey: &awsdynamodb.Attribute{ + Name: jsii.String("Username"), + Type: awsdynamodb.AttributeType_STRING, + }, + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("EntityType"), + Type: awsdynamodb.AttributeType_STRING, + }, + }, + // GSI for querying skills: + { + IndexName: jsii.String("SkillsByCategory"), + PartitionKey: &awsdynamodb.Attribute{ + Name: jsii.String("EntityType"), + Type: awsdynamodb.AttributeType_STRING, + }, + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("Category"), + Type: awsdynamodb.AttributeType_STRING, + }, + }, - // Enable DynamoDB Streams for event-driven architecture - DynamoStream: awsdynamodb.StreamViewType_NEW_AND_OLD_IMAGES, + // GSI for query on EntityType + { + IndexName: jsii.String("ByEntityType"), + PartitionKey: &awsdynamodb.Attribute{ + Name: jsii.String("EntityType"), + Type: awsdynamodb.AttributeType_STRING, + }, + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("SkillName"), + Type: awsdynamodb.AttributeType_STRING, + }, + }, + // GSI: BySkillID - Query UserSkills by skill_id (for syncing denormalized data) + { + IndexName: jsii.String("BySkillID"), + PartitionKey: &awsdynamodb.Attribute{ + Name: jsii.String("skill_id"), + Type: awsdynamodb.AttributeType_STRING, + }, + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("Username"), + Type: awsdynamodb.AttributeType_STRING, + }, + }, + }, - // Remove table on stack deletion (for dev/testing) - RemovalPolicy: awscdk.RemovalPolicy_DESTROY, + PointInTimeRecovery: jsii.Bool(false), + DynamoStream: awsdynamodb.StreamViewType_NEW_AND_OLD_IMAGES, + RemovalPolicy: awscdk.RemovalPolicy_DESTROY, - // Additional tags Tags: &[]*awscdk.CfnTag{ - { Key: jsii.String("Purpose"), Value: jsii.String("Single-Table-Design"), @@ -106,6 +116,22 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw }, }) + return entitiesTable +} + +func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) awscdk.Stack { + var sprops awscdk.StackProps + if props != nil { + sprops = props.StackProps + } + stack := awscdk.NewStack(scope, &id, &sprops) + + ENVIRONMENT := "production" // todo: will be parametrised + + // Add environment tag + awscdk.Tags_Of(stack).Add(jsii.String("Environment"), jsii.String(ENVIRONMENT), nil) + + entitiesTable := createEntitiesTable(stack, jsii.String(id+"-entities-table-"+ENVIRONMENT), ENVIRONMENT) // Create Lambda myFunc := awslambda.NewFunction(stack, jsii.String(id+"-go-func"), &awslambda.FunctionProps{ Runtime: awslambda.Runtime_PROVIDED_AL2023(), @@ -113,7 +139,7 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw Handler: jsii.String("main"), }) - myFunc.AddEnvironment(jsii.String("environment"), jsii.String(ENVIRONMENT), nil) + myFunc.AddEnvironment(jsii.String("ENVIRONMENT"), jsii.String(ENVIRONMENT), nil) // Grant Lambda read/write access to DynamoDB table entitiesTable.GrantReadWriteData(myFunc) @@ -208,6 +234,39 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw AuthorizationType: awsapigateway.AuthorizationType_NONE, }) + // Master Skills Management Endpoints + // Pattern: /master-skills + masterSkillsResource := api.Root().AddResource(jsii.String("master-skills"), nil) + + // POST /master-skills - Create a master skill + masterSkillsResource.AddMethod(jsii.String("POST"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // GET /master-skills - List all master skills + masterSkillsResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // Specific master skill endpoints + // Pattern: /master-skills/{skillID} + masterSkillResource := masterSkillsResource.AddResource(jsii.String("{skillID}"), nil) + + // GET /master-skills/{skillID} - Get specific master skill + masterSkillResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // PUT /master-skills/{skillID} - Update master skill + masterSkillResource.AddMethod(jsii.String("PUT"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // DELETE /master-skills/{skillID} - Delete master skill + masterSkillResource.AddMethod(jsii.String("DELETE"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + // Create UsagePlan AFTER all methods are defined awsapigateway.NewUsagePlan(stack, jsii.String(id+"-api-gateway-usage-plan"), &awsapigateway.UsagePlanProps{ Name: jsii.String(id + "-api-gateway-usage-plan"), diff --git a/docs/README-DYNAMODB-DESIGN.md b/docs/README-DYNAMODB-DESIGN.md deleted file mode 100644 index a757a0d..0000000 --- a/docs/README-DYNAMODB-DESIGN.md +++ /dev/null @@ -1,346 +0,0 @@ -# DynamoDB Single Table Design - Complete Solution - -## Overview - -This directory contains a comprehensive solution for implementing DynamoDB single table design in the GLAD project, including support for the new **multi-key composite GSI feature** (up to 4 partition keys + 4 sort keys). - -## 📋 Documentation Files - -### 1. **Main Planning Document** -📄 [`dynamodb-single-table-design-plan.md`](./dynamodb-single-table-design-plan.md) - -**Purpose**: Comprehensive implementation plan covering all phases - -**Contents**: -- Current state analysis -- Proposed single table design (3 phases) -- Entity designs (User, UserSkill with examples) -- Access pattern mapping (13+ patterns) -- Migration strategy -- Cost analysis and capacity planning -- Security, monitoring, and best practices - -**Use this for**: Understanding the big picture, planning phases, and architectural decisions - ---- - -### 2. **Entity Addition Protocol** -📄 [`entity-addition-protocol.md`](./entity-addition-protocol.md) - -**Purpose**: Step-by-step guide for adding new entity types - -**Contents**: -- 5-step protocol with decision trees -- Visual flowcharts for key pattern selection -- Go code templates for models and repository -- Complete checklist for implementation -- Common patterns reference -- Capacity planning formulas -- Troubleshooting guide - -**Use this for**: Adding new entities (Projects, Settings, etc.) to the single table - ---- - -### 3. **Quick Reference Guide** -📄 [`dynamodb-quick-reference.md`](./dynamodb-quick-reference.md) - -**Purpose**: Cheat sheet for daily development - -**Contents**: -- Table structure overview -- Key patterns cheat sheet -- Common query examples -- Composite multi-key GSI rules (8-key support) -- Cost comparison (traditional vs composite) -- Capacity planning formulas -- Anti-patterns to avoid -- AWS CLI commands -- Monitoring metrics - -**Use this for**: Daily development, quick lookups, and reference during coding - ---- - -### 4. **Skill File with Examples** -📄 [`../.claude/skills/dynamo-db-single-table-design.md`](../.claude/skills/dynamo-db-single-table-design.md) - -**Purpose**: Concrete examples demonstrating all patterns - -**Contents**: -- Example 1: User Profile + Skills (Item Collection) -- Example 2: Multi-Key Composite GSI (8-key feature) -- Example 3: Adding Projects entity -- Example 4: Capacity planning & cost estimation -- Example 5: Migration from multi-table to single table - -**Use this for**: Learning by example, understanding patterns in practice - ---- - -## 🚀 Quick Start - -### Phase 1: Understand Current State -1. Read the "Current State Analysis" section in the main planning document -2. Review existing code: - - `/cmd/app/internal/database/dynamodb.go` - Current repository - - `/cmd/app/internal/models/user.go` - User model - - `/deployments/app/cdk.go` - Infrastructure - -### Phase 2: Design Your First Entity (Skills) -1. Review Example 1 in the skill file -2. Follow the Entity Addition Protocol for UserSkill -3. Use the Quick Reference for query patterns - -### Phase 3: Implement -1. Update CDK stack (add SK, GSI1, optionally GSI2) -2. Create UserSkill model -3. Add repository methods -4. Add service layer -5. Create API endpoints -6. Test thoroughly - -## 🎯 Key Concepts - -### Single Table Design Benefits -- ✅ **Single Query Efficiency**: Get user + all skills in one query -- ✅ **Cost Optimization**: 1 table vs multiple tables -- ✅ **Better Performance**: Data locality, fewer round trips -- ✅ **Simpler Operations**: One table to manage, backup, monitor - -### Multi-Key Composite GSI (New Feature!) -DynamoDB now supports **up to 4 partition keys + 4 sort keys** in GSIs: - -``` -GSI2 Example: - PK1: SkillName (String) - PK2: ProficiencyLevel (String) - SK1: YearsOfExperience (Number) ← Native number type! - SK2: LastUsedDate (String) - -Query: - Find Python experts with 5+ years used recently - PK1="Python" AND PK2="Expert" AND SK1>=5 AND SK2>="2024-01-01" -``` - -**Benefits**: -- Native data types (no concatenation!) -- Type-safe queries -- Better maintainability -- More flexible filtering - -## 📊 Entity Design Patterns - -### Pattern 1: Item Collection (User-Owned Entities) -``` -PK: USER#john, SK: PROFILE → User -PK: USER#john, SK: SKILL#golang → Skill -PK: USER#john, SK: SKILL#python → Skill -PK: USER#john, SK: PROJECT#123 → Project - -Query(PK="USER#john") → Returns ALL entities for user! -``` - -### Pattern 2: Cross-User Queries (GSI1) -``` -Base Table: - PK: USER#john, SK: SKILL#golang - -GSI1: - GSI1PK: SKILL#golang, GSI1SK: LEVEL#Expert#USER#john - -Query GSI1(GSI1PK="SKILL#golang") → Find all users with skill -``` - -### Pattern 3: Multi-Dimensional Queries (GSI2 Composite) -``` -GSI2 with 4 PKs + 4 SKs for complex filtering -Example: Find active, high-priority projects due before date -``` - -## 🔧 Implementation Checklist - -### Step 1: Infrastructure (CDK) -- [ ] Add sort key (SK) to table -- [ ] Add GSI1 (GSI1PK, GSI1SK) -- [ ] Add GSI2 with composite keys (optional) -- [ ] Update table name to `glad-entities` -- [ ] Deploy infrastructure - -### Step 2: Models -- [ ] Update User model with PK/SK fields -- [ ] Create UserSkill model -- [ ] Add SetKeys() methods -- [ ] Add validation - -### Step 3: Repository Layer -- [ ] Update user repository methods -- [ ] Create skill repository methods -- [ ] Add GSI query methods -- [ ] Add error handling - -### Step 4: Service Layer -- [ ] Update user service -- [ ] Create skill service -- [ ] Add business logic validation - -### Step 5: API Layer -- [ ] Create skill endpoints -- [ ] Update API documentation -- [ ] Add request/response DTOs - -### Step 6: Testing -- [ ] Unit tests for models -- [ ] Unit tests for repository -- [ ] Integration tests -- [ ] Load testing - -### Step 7: Migration -- [ ] Create migration script -- [ ] Test on sample data -- [ ] Execute migration -- [ ] Verify data integrity -- [ ] Monitor performance - -## 💰 Cost Estimation - -For 10,000 users with 5 skills each: - -| Component | Monthly Cost | -|-----------|-------------| -| Base Table (reads) | $324 | -| Base Table (writes) | $324 | -| GSI1 (reads) | $81 | -| GSI1 (writes) | $243 | -| Storage | ~$0.02 | -| **Total** | **~$972/month** | - -**Optimization Tips**: -- Use eventually consistent reads (50% savings) ✅ -- Add caching for hot users (30% RPS reduction) -- Use sparse GSIs (already applied) ✅ -- Batch operations where possible - -## 📈 Performance Targets - -| Metric | Target | Notes | -|--------|--------|-------| -| GetItem latency | < 10ms | P99 | -| Query latency | < 20ms | P99 | -| Throughput | 1000+ RPS | Per table | -| Availability | 99.99% | DynamoDB SLA | - -## 🔍 Monitoring - -### Key Metrics -- ConsumedReadCapacityUnits -- ConsumedWriteCapacityUnits -- UserErrors (throttling) -- P50, P99 latency - -### Critical Alarms -``` -ThrottledRequests > 0 -UserErrors > 100 in 5 min -P99 latency > 100ms -ConsumedRCU > 80% of provisioned -``` - -## 🚨 Common Pitfalls to Avoid - -❌ **Don't**: Use Scan operations for queries -✅ **Do**: Use Query with proper key conditions - -❌ **Don't**: Create generic keys (PK, SK, GSI1PK) -✅ **Do**: Use descriptive keys (USER#john, SKILL#golang) - -❌ **Don't**: Over-normalize (separate table per entity) -✅ **Do**: Use item collections for related entities - -❌ **Don't**: Use mutable attributes as GSI keys -✅ **Do**: Use stable attributes or accept write amplification - -❌ **Don't**: Store everything in one item -✅ **Do**: Use item collections with separate items - -## 📚 Additional Resources - -### AWS Documentation -- [Multi-Key GSI Support](https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/) -- [Zepto's DynamoDB Architecture](https://aws.amazon.com/blogs/database/how-zepto-scales-to-millions-of-orders-per-day-using-amazon-dynamodb/) -- [Evolving DynamoDB Data Models](https://aws.amazon.com/blogs/database/evolve-your-amazon-dynamodb-tables-data-model/) -- [SQL to NoSQL Migration](https://aws.amazon.com/blogs/database/sql-to-nosql-planning-your-application-migration-to-amazon-dynamodb/) - -### Tools -- **DynamoDB Local**: Local testing environment -- **NoSQL Workbench**: Visual data modeling -- **AWS CLI**: Command-line operations -- **AWS SDK for Go**: v2.x with improved DynamoDB support - -## 🎓 Learning Path - -1. **Beginner**: Read Example 1 in skill file (User + Skills) -2. **Intermediate**: Review Quick Reference, understand access patterns -3. **Advanced**: Study composite multi-key GSI (Example 2) -4. **Expert**: Read full planning document, understand cost optimization - -## 🤝 Contributing - -When adding new entity types: -1. Follow the Entity Addition Protocol -2. Document access patterns -3. Update this README with new examples -4. Add capacity planning estimates -5. Update monitoring dashboards - -## 📞 Support - -- **Planning Questions**: Review main planning document -- **Implementation Help**: Use Entity Addition Protocol -- **Quick Lookups**: Use Quick Reference Guide -- **Learning**: Review examples in skill file - -## 🏗️ Project Structure - -``` -docs/ -├── README-DYNAMODB-DESIGN.md (this file) -├── dynamodb-single-table-design-plan.md (main planning doc) -├── entity-addition-protocol.md (step-by-step guide) -└── dynamodb-quick-reference.md (daily reference) - -.claude/skills/ -└── dynamo-db-single-table-design.md (examples) - -cmd/app/internal/ -├── database/ -│ └── dynamodb.go (repository layer) -├── models/ -│ ├── user.go (user model) -│ └── user_skill.go (skill model - to be added) -└── service/ - ├── user_service.go (user service) - └── skill_service.go (skill service - to be added) - -deployments/app/ -└── cdk.go (infrastructure) -``` - -## ✅ Next Steps - -1. **Review**: Read this README and the main planning document -2. **Understand**: Study Example 1 (User + Skills) in the skill file -3. **Plan**: Review Phase 1 implementation checklist -4. **Discuss**: Align with team on approach and timeline -5. **Implement**: Start with CDK changes, then models, then repository -6. **Test**: Write comprehensive tests before migration -7. **Deploy**: Execute migration during low-traffic window -8. **Monitor**: Watch metrics closely after deployment - ---- - -**Last Updated**: 2025-12-07 -**Version**: 1.0 -**Maintained By**: GLAD Engineering Team - -**Questions?** Review the documentation files linked above or consult the AWS resources. \ No newline at end of file diff --git a/docs/api-testing/api-test.http b/docs/api-testing/api-test.http new file mode 100644 index 0000000..499d846 --- /dev/null +++ b/docs/api-testing/api-test.http @@ -0,0 +1,815 @@ + +############################################################################### +### 1. USER AUTHENTICATION +############################################################################### + + + +### 1.1 Register User - John Doe +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "john_doe", + "name": "John Doe", + "password": "secure123" +} + +### 1.2 Register User - Jane Doe +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "jane_doe", + "name": "Jane Doe", + "password": "secure123" +} + +### 1.3 Register User - Alice +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "alice", + "name": "Alice Smith", + "password": "pass123" +} + +### 1.4 Register User - Bob +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "bob", + "name": "Bob Johnson", + "password": "pass123" +} + +### 1.5 Login - John Doe +# @name login +POST {{API_URL}}/login +Content-Type: application/json + +{ + "username": "john_doe", + "password": "secure123" +} + +> {% + client.global.set("token", response.body.access_token); + client.log("Token saved: " + response.body.access_token); +%} + +### 1.6 Login - Jane Doe +POST {{API_URL}}/login +Content-Type: application/json + +{ + "username": "jane_doe", + "password": "secure123" +} + +### 1.7 Login - Alice +POST {{API_URL}}/login +Content-Type: application/json + +{ + "username": "alice", + "password": "pass123" +} + +### 1.8 Login - Bob +POST {{API_URL}}/login +Content-Type: application/json + +{ + "username": "bob", + "password": "pass123" +} + +############################################################################### +### 2. USER MANAGEMENT (Protected Routes) +############################################################################### + +### 2.1 Get Current User +GET {{API_URL}}/me +Authorization: Bearer {{token}} + +### 2.2 Update User Profile - Name Only +PUT {{API_URL}}/user +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "John Smith" +} + +### 2.3 Update User Profile - Password Only +PUT {{API_URL}}/user +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "password": "newPassword123" +} + +### 2.4 Update User Profile - Both Name and Password +PUT {{API_URL}}/user +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "John Updated", + "password": "newSecure456" +} + +### 2.5 List All Users +GET {{API_URL}}/users +Authorization: Bearer {{token}} + +### 2.6 Test Protected Route +GET {{API_URL}}/protected +Authorization: Bearer {{token}} + +############################################################################### +### 3. MASTER SKILLS MANAGEMENT (Protected Routes) +############################################################################### + +### 3.1 Create Master Skill - Python +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "python", + "skill_name": "Python", + "description": "Python programming language", + "category": "Programming", + "tags": ["backend", "scripting", "data-science"] +} + +### 3.2 Create Master Skill - JavaScript +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "javascript", + "skill_name": "JavaScript", + "description": "JavaScript programming language", + "category": "Programming", + "tags": ["frontend", "backend", "nodejs"] +} + +### 3.3 Create Master Skill - Go +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "golang", + "skill_name": "Go", + "description": "Go programming language", + "category": "Programming", + "tags": ["backend", "systems", "concurrent"] +} + +### 3.4 Create Master Skill - AWS +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "aws", + "skill_name": "Amazon Web Services", + "description": "Cloud computing platform", + "category": "Cloud", + "tags": ["cloud", "infrastructure", "devops"] +} + +### 3.5 Create Master Skill - Docker +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "docker", + "skill_name": "Docker", + "description": "Container platform", + "category": "DevOps", + "tags": ["containers", "devops", "deployment"] +} + +### 3.6 Create Master Skill - Kubernetes +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "kubernetes", + "skill_name": "Kubernetes", + "description": "Container orchestration platform", + "category": "DevOps", + "tags": ["containers", "orchestration", "cloud-native"] +} + +### 3.7 Create Master Skill - React +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "react", + "skill_name": "React", + "description": "JavaScript library for building user interfaces", + "category": "Frontend", + "tags": ["frontend", "javascript", "ui"] +} + +### 3.8 Get Master Skill - Python +GET {{API_URL}}/master-skills/python +Authorization: Bearer {{token}} + +### 3.9 Get Master Skill - JavaScript +GET {{API_URL}}/master-skills/javascript +Authorization: Bearer {{token}} + +### 3.10 List All Master Skills +GET {{API_URL}}/master-skills +Authorization: Bearer {{token}} + +### 3.11 Update Master Skill - Python +PUT {{API_URL}}/master-skills/python +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Python 3", + "description": "Python 3 programming language - modern version", + "tags": ["backend", "scripting", "data-science", "ai", "machine-learning"] +} + +### 3.12 Update Master Skill - JavaScript (partial update) +PUT {{API_URL}}/master-skills/javascript +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "description": "JavaScript - The language of the web" +} + +### 3.13 Delete Master Skill - React (if needed for testing) +DELETE {{API_URL}}/master-skills/react +Authorization: Bearer {{token}} + +############################################################################### +### 4. USER SKILLS MANAGEMENT (Protected Routes) +############################################################################### + +### 4.1 Add Skill to User - John Doe - Python (Advanced) +POST {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Python", + "proficiency_level": "Advanced", + "years_of_experience": 5, + "notes": "Used for backend development and data analysis" +} + +### 4.2 Add Skill to User - John Doe - JavaScript (Expert) +POST {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "JavaScript", + "proficiency_level": "Expert", + "years_of_experience": 7, + "notes": "Full-stack JavaScript development" +} + +### 4.3 Add Skill to User - John Doe - AWS (Intermediate) +POST {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Amazon Web Services", + "proficiency_level": "Intermediate", + "years_of_experience": 3, + "notes": "Lambda, DynamoDB, API Gateway" +} + +### 4.4 Add Skill to User - John Doe - Docker (Beginner) +POST {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Docker", + "proficiency_level": "Beginner", + "years_of_experience": 1 +} + +### 4.5 Add Skill to User - Jane Doe - Python (Intermediate) +POST {{API_URL}}/users/jane_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Python", + "proficiency_level": "Intermediate", + "years_of_experience": 3, + "notes": "Data science and automation" +} + +### 4.6 Add Skill to User - Jane Doe - JavaScript (Advanced) +POST {{API_URL}}/users/jane_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "JavaScripdt", + "proficiency_level": "Advanced", + "years_of_experience": 5, + "notes": "Frontend development with React" +} + +### 4.7 Add Skill to User - Alice - Go (Expert) +POST {{API_URL}}/users/alice/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Go", + "proficiency_level": "Expert", + "years_of_experience": 6, + "notes": "Microservices and backend systems" +} + +### 4.8 Add Skill to User - Bob - Go (Intermediate) +POST {{API_URL}}/users/bob/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Go", + "proficiency_level": "Intermediate", + "years_of_experience": 2, + "notes": "Learning backend development" +} + +### 4.9 Get Specific User Skill - John Doe - Python +GET {{API_URL}}/users/john_doe/skills/Python +Authorization: Bearer {{token}} + +### 4.10 Get Specific User Skill - John Doe - JavaScript +GET {{API_URL}}/users/john_doe/skills/JavaScript +Authorization: Bearer {{token}} + +### 4.11 List All Skills for User - John Doe +GET {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} + +### 4.12 List All Skills for User - Jane Doe +GET {{API_URL}}/users/jane_doe/skills +Authorization: Bearer {{token}} + +### 4.13 Update User Skill - John Doe - Python (to Expert) +PUT {{API_URL}}/users/john_doe/skills/Python +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "proficiency_level": "Expert", + "years_of_experience": 6, + "notes": "Updated: Now leading Python development team" +} + +### 4.14 Update User Skill - John Doe - JavaScript (only years) +PUT {{API_URL}}/users/john_doe/skills/JavaScript +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "years_of_experience": 8 +} + +### 4.15 Update User Skill - Jane Doe - Python (only proficiency) +PUT {{API_URL}}/users/jane_doe/skills/Python +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "proficiency_level": "Advanced" +} + +### 4.16 Update User Skill - John Doe - AWS (only notes) +PUT {{API_URL}}/users/john_doe/skills/Amazon Web Services +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "notes": "Now working with ECS, EKS, and CloudFormation" +} + +### 4.17 Delete User Skill - John Doe - Docker +DELETE {{API_URL}}/users/john_doe/skills/Docker +Authorization: Bearer {{token}} + +############################################################################### +### 5. CROSS-USER SKILL QUERIES (Protected Routes) +############################################################################### + +### 5.1 Find All Users with Python +GET {{API_URL}}/skills/Python/users +Authorization: Bearer {{token}} + +### 5.2 Find All Users with JavaScript +GET {{API_URL}}/skills/JavaScript/users +Authorization: Bearer {{token}} + +### 5.3 Find All Users with Go +GET {{API_URL}}/skills/Go/users +Authorization: Bearer {{token}} + +### 5.4 Find Expert Python Developers +GET {{API_URL}}/skills/Python/users?level=Expert +Authorization: Bearer {{token}} + +### 5.5 Find Intermediate Python Developers +GET {{API_URL}}/skills/Python/users?level=Intermediate +Authorization: Bearer {{token}} + +### 5.6 Find Advanced Python Developers +GET {{API_URL}}/skills/Python/users?level=Advanced +Authorization: Bearer {{token}} + +### 5.7 Find Beginner Python Developers +GET {{API_URL}}/skills/Python/users?level=Beginner +Authorization: Bearer {{token}} + +### 5.8 Find Expert JavaScript Developers +GET {{API_URL}}/skills/JavaScript/users?level=Expert +Authorization: Bearer {{token}} + +### 5.9 Find Advanced JavaScript Developers +GET {{API_URL}}/skills/JavaScript/users?level=Advanced +Authorization: Bearer {{token}} + +### 5.10 Find Expert Go Developers +GET {{API_URL}}/skills/Go/users?level=Expert +Authorization: Bearer {{token}} + +### 5.11 Find Intermediate Go Developers +GET {{API_URL}}/skills/Go/users?level=Intermediate +Authorization: Bearer {{token}} + +############################################################################### +### 6. ERROR CASES +############################################################################### + +### 6.1 Unauthorized Access - No Token +GET {{API_URL}}/me + +### 6.2 Unauthorized Access - Invalid Token +GET {{API_URL}}/me +Authorization: Bearer invalid_token_here + +### 6.3 User Already Exists +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "john_doe", + "name": "John Duplicate", + "password": "password123" +} + +### 6.4 Invalid Login Credentials +POST {{API_URL}}/login +Content-Type: application/json + +{ + "username": "john_doe", + "password": "wrongpassword" +} + +### 6.5 Skill Not Found +GET {{API_URL}}/users/john_doe/skills/NonExistentSkill +Authorization: Bearer {{token}} + +### 6.6 Master Skill Already Exists +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "python", + "skill_name": "Python Duplicate", + "description": "Duplicate skill", + "category": "Programming" +} + +### 6.7 Invalid Proficiency Level +POST {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Python", + "proficiency_level": "SuperExpert", + "years_of_experience": 5 +} + +### 6.8 Master Skill Not Found +GET {{API_URL}}/master-skills/nonexistent +Authorization: Bearer {{token}} + +### 6.9 Delete Master Skill Not Found +DELETE {{API_URL}}/master-skills/nonexistent +Authorization: Bearer {{token}} + +### 6.10 Update Master Skill Not Found +PUT {{API_URL}}/master-skills/nonexistent +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Updated Name" +} + +### 6.11 User Not Found - Get Skills +GET {{API_URL}}/users/nonexistent_user/skills +Authorization: Bearer {{token}} + +### 6.12 Invalid Request Body - Missing Required Fields +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "test" +} + +### 6.13 Invalid Request Body - Invalid JSON +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "test", + "name": "Test User", + "password": "test123" +} + +############################################################################### +### 7. VALIDATION TESTS +############################################################################### + +### 7.1 Register - Username Too Short +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "ab", + "name": "Test User", + "password": "secure123" +} + +### 7.2 Register - Password Too Short +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "testuser", + "name": "Test User", + "password": "123" +} + +### 7.3 Register - Name Too Short +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "testuser", + "name": "T", + "password": "secure123" +} + +### 7.4 Add Skill - Negative Years of Experience +POST {{API_URL}}/users/john_doe/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Python", + "proficiency_level": "Expert", + "years_of_experience": -5 +} + +### 7.5 Create Master Skill - Missing Required Field (skill_id) +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Ruby", + "description": "Ruby programming language", + "category": "Programming" +} + +### 7.6 Create Master Skill - Missing Required Field (category) +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "ruby", + "skill_name": "Ruby", + "description": "Ruby programming language" +} + +############################################################################### +### 8. COMPLETE WORKFLOW TEST +############################################################################### + +### 8.1 WORKFLOW Step 1 - Register New User +POST {{API_URL}}/register +Content-Type: application/json + +{ + "username": "workflow_user", + "name": "Workflow Test User", + "password": "workflow123" +} + +### 8.2 WORKFLOW Step 2 - Login +POST {{API_URL}}/login +Content-Type: application/json + +{ + "username": "workflow_user", + "password": "workflow123" +} + +> {% + client.global.set("token", response.body.access_token); + client.log("Token saved: " + response.body.access_token); +%} + + +### 8.3 WORKFLOW Step 3 - Get Current User +GET {{API_URL}}/me +Authorization: Bearer {{token}} + +### 8.4 WORKFLOW Step 4 - Create Master Skill +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "rust", + "skill_name": "Rust", + "description": "Systems programming language", + "category": "Programming", + "tags": ["systems", "performance", "safety"] +} + +### 8.5 WORKFLOW Step 5 - Add Skill to User +POST {{API_URL}}/users/workflow_user/skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_name": "Rust", + "proficiency_level": "Beginner", + "years_of_experience": 1, + "notes": "Learning systems programming" +} + +### 8.6 WORKFLOW Step 6 - Get User's Skills +GET {{API_URL}}/users/workflow_user/skills +Authorization: Bearer {{token}} + +### 8.7 WORKFLOW Step 7 - Update Skill +PUT {{API_URL}}/users/workflow_user/skills/Rust +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "proficiency_level": "Intermediate", + "years_of_experience": 2, + "notes": "Making good progress with Rust" +} + +### 8.8 WORKFLOW Step 8 - Find All Rust Developers +GET {{API_URL}}/skills/Rust/users +Authorization: Bearer {{token}} + +### 8.9 WORKFLOW Step 9 - Update User Profile +PUT {{API_URL}}/user +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "Workflow User Updated" +} + +### 8.10 WORKFLOW Step 10 - List All Users +GET {{API_URL}}/users +Authorization: Bearer {{token}} + +### 8.11 WORKFLOW Step 11 - Delete Skill +DELETE {{API_URL}}/users/workflow_user/skills/Rust +Authorization: Bearer {{token}} + +### 8.12 WORKFLOW Step 12 - Verify Skill Deleted +GET {{API_URL}}/users/workflow_user/skills +Authorization: Bearer {{token}} + +############################################################################### +### 9. PERFORMANCE & STRESS TESTING (Run these individually) +############################################################################### + +### 9.1 Create Multiple Master Skills - Skill 1 +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "test_skill_1", + "skill_name": "Test Skill 1", + "description": "Performance test skill 1", + "category": "Testing" +} + +### 9.2 Create Multiple Master Skills - Skill 2 +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "test_skill_2", + "skill_name": "Test Skill 2", + "description": "Performance test skill 2", + "category": "Testing" +} + +### 9.3 Create Multiple Master Skills - Skill 3 +POST {{API_URL}}/master-skills +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "skill_id": "test_skill_3", + "skill_name": "Test Skill 3", + "description": "Performance test skill 3", + "category": "Testing" +} + +############################################################################### +### 10. CLEANUP OPERATIONS +############################################################################### + +### 10.1 Delete Test Skills - Skill 1 +DELETE {{API_URL}}/master-skills/test_skill_1 +Authorization: Bearer {{token}} + +### 10.2 Delete Test Skills - Skill 2 +DELETE {{API_URL}}/master-skills/test_skill_2 +Authorization: Bearer {{token}} + +### 10.3 Delete Test Skills - Skill 3 +DELETE {{API_URL}}/master-skills/test_skill_3 +Authorization: Bearer {{token}} + +### 10.4 Delete Rust Master Skill +DELETE {{API_URL}}/master-skills/rust +Authorization: Bearer {{token}} + +############################################################################### +### NOTES +############################################################################### +# 1. Valid Proficiency Levels: Beginner, Intermediate, Advanced, Expert +# 2. After running login request, token is automatically saved to {{token}} +# 3. Update @API_URL at the top for your environment +# 4. Use URL encoding for skill names with spaces (e.g., Amazon%20Web%20Services) +# 5. All routes except /register and /login require authentication +# 6. Use http-client.env.json for environment-specific variables + +############################################################################### +### ENVIRONMENT SETUP (Create http-client.env.json) +############################################################################### +# Create a file named http-client.env.json in the same directory: +# { +# "dev": { +# "API_URL": "http://localhost:3000" +# }, +# "staging": { +# "API_URL": "https://staging-api.example.com" +# }, +# "production": { +# "API_URL": "https://api.example.com" +# } +# } \ No newline at end of file diff --git a/docs/api-testing/http-client.env.json b/docs/api-testing/http-client.env.json new file mode 100644 index 0000000..d1a88c0 --- /dev/null +++ b/docs/api-testing/http-client.env.json @@ -0,0 +1,5 @@ +{ + "dev": { + "API_URL": "https://ame7n22jj9.execute-api.eu-central-1.amazonaws.com/prod" + } +} \ No newline at end of file diff --git a/docs/dynamodb-quick-reference.md b/docs/dynamodb-quick-reference.md deleted file mode 100644 index c714ff8..0000000 --- a/docs/dynamodb-quick-reference.md +++ /dev/null @@ -1,420 +0,0 @@ -# DynamoDB Single Table Design - Quick Reference - -## Table Structure Overview - -``` -Table: glad-entities -- Partition Key: PK (String) -- Sort Key: SK (String) -- GSI1-PK: GSI1PK (String) -- GSI1-SK: GSI1SK (String) -- GSI2: Up to 4 PKs + 4 SKs (composite multi-key support) -``` - -## Current Entity Types - -### User Profile -``` -PK: USER# -SK: PROFILE -EntityType: User -``` - -### User Skill -``` -PK: USER# -SK: SKILL# -EntityType: UserSkill - -GSI1PK: SKILL# -GSI1SK: LEVEL##USER# -``` - -## Key Patterns Cheat Sheet - -| Pattern | PK | SK | GSI1PK | GSI1SK | Use Case | -|---------------|---------------|----------------|-----------------|--------------------------|-----------------| -| User Profile | `USER#john` | `PROFILE` | - | - | User data | -| User Skill | `USER#john` | `SKILL#golang` | `SKILL#golang` | `LEVEL#Expert#USER#john` | Skills per user | -| User Project | `USER#john` | `PROJECT#123` | `PROJECT#123` | `USER#john` | Projects | -| Setting | `USER#john` | `SETTINGS` | - | - | User settings | -| Global Entity | `PRODUCT#123` | `PRODUCT` | `CATEGORY#tech` | `PRICE#999` | Products | - -## Common Query Examples - -### Get User Profile -```go -GetItem( - PK: "USER#john", - SK: "PROFILE" -) -``` - -### Get User with All Skills -```go -Query( - PK: "USER#john" -) -// Returns: PROFILE + all SKILL#* items -``` - -### Get Specific Skill -```go -GetItem( - PK: "USER#john", - SK: "SKILL#golang" -) -``` - -### Find All Users with Skill -```go -Query( - IndexName: "GSI1", - GSI1PK: "SKILL#golang" -) -``` - -### Find Expert Users for Skill -```go -Query( - IndexName: "GSI1", - GSI1PK: "SKILL#golang", - GSI1SK: begins_with("LEVEL#Expert#") -) -``` - -## Composite Multi-Key GSI (New Feature - Up to 8 Keys!) - -DynamoDB now supports up to **4 partition keys** and **4 sort keys** in composite GSIs. - -### Rules -1. **Equality (=)** required on ALL partition key attributes -2. **Range operators** (<, >, BETWEEN) only on the LAST sort key -3. **Cannot skip** sort keys (must use left-to-right) -4. **Native data types** - no concatenation needed! - -### Example: Advanced Skill Search - -```go -// GSI2 Definition -GSI2 Composite Keys: - PK1: SkillName (String) - PK2: ProficiencyLevel (String) - SK1: YearsOfExperience (Number) - SK2: LastUsedDate (String) - -// Query: Find Python experts with 5+ years who used it recently -Query( - IndexName: "GSI2", - GSI2PK1: "Python", // Equality - GSI2PK2: "Expert", // Equality - GSI2SK1: >= 5, // Range (not last, so must use >=) - GSI2SK2: >= "2024-01-01" // Range on last SK -) -``` - -### When to Use Multi-Key Composite GSI - -✅ **Use when**: -- Need to filter by 3+ dimensions -- Want type-safe queries (no string parsing) -- Complex business queries across multiple attributes -- Need efficient multi-dimensional searches - -❌ **Don't use when**: -- Simple 1-2 dimension queries (use traditional GSI) -- Attributes change frequently (write amplification) -- Low query volume (not worth complexity) - -## Cost Comparison - -### Traditional Concatenated SK -``` -Item: { - PK: "SKILL#golang", - SK: "LEVEL#Expert#EXP#5#DATE#2024-01-01#USER#john" -} - -Drawbacks: -- String parsing required -- No type safety for numbers/dates -- Complex to maintain -- Range queries limited -``` - -### Composite Multi-Key -``` -Item: { - GSI2PK1: "golang", // String - GSI2PK2: "Expert", // String - GSI2SK1: 5, // Number (native!) - GSI2SK2: "2024-01-01", // String - GSI2SK3: "john" // String -} - -Benefits: -+ Native data types -+ No string parsing -+ Type-safe queries -+ Better maintainability -``` - -## Capacity Planning Quick Reference - -``` -RCU (Eventually Consistent) = (reads/sec × KB) ÷ 8 -RCU (Strongly Consistent) = (reads/sec × KB) ÷ 4 -WCU = (writes/sec × KB) ÷ 1 - -Partition Limits: -- 3,000 RCU per partition -- 1,000 WCU per partition -- 10 GB per partition - -Hot Partition Threshold: -- Write RPS per distinct PK > 1000 → Add sharding -- Read RPS per distinct PK > 3000 → Add caching -``` - -## Item Size Guidelines - -``` -Target: < 10 KB per item (optimal) -Warning: 10-100 KB (acceptable, watch costs) -Critical: > 100 KB (consider S3 offload) -Maximum: 400 KB (hard DynamoDB limit) -``` - -## GSI Projection Types - -| Type | Storage Cost | Read Cost | Query Latency | Use When | -|-----------|--------------|-----------|---------------|------------------------------------------| -| KEYS_ONLY | Lowest | Lowest* | Higher | Need PK/SK only, can GetItem for details | -| INCLUDE | Medium | Medium | Medium | Need 2-3 specific attributes | -| ALL | Highest | Highest | Lowest | Need most attributes, avoid GetItem | - -\* Requires additional GetItem if you need non-key attributes - -## Error Handling Quick Reference - -### ProvisionedThroughputExceededException -- **Cause**: Hot partition, insufficient capacity -- **Fix**: Add exponential backoff, enable auto-scaling, add sharding - -### ConditionalCheckFailedException -- **Cause**: Condition not met (item exists, version mismatch) -- **Fix**: Expected in normal operations, handle gracefully - -### ValidationException -- **Cause**: Invalid request (missing keys, wrong types) -- **Fix**: Validate input before DynamoDB call - -### ItemCollectionSizeLimitExceededException -- **Cause**: Item collection > 10 GB -- **Fix**: Redistribute items across more partition keys - -## Access Pattern Complexity Matrix - -| Complexity | Pattern | Solution | Example | -|--------------|--------------------------|----------------------------------------|----------------------------| -| Simple | Single item lookup | GetItem | Get user by username | -| Medium | Items for one PK | Query on base table | Get all skills for user | -| Medium+ | Items across PKs | Query on GSI | Find all users with skill | -| Complex | Multi-dimensional filter | Composite multi-key GSI | Experts with 5+ years | -| Very Complex | Cross-table aggregation | Consider separate table or denormalize | User stats across entities | - -## Decision Trees - -### Should I Add a GSI? - -``` -Need to query by non-key attribute? -├─ YES: Can you use base table SK? -│ ├─ YES: Use SK patterns (hierarchical, composite) -│ └─ NO: Need to query across different PKs? -│ ├─ YES: Add GSI -│ └─ NO: Filter in application (if < 1MB result) -└─ NO: Use base table -``` - -### Should I Use Composite Multi-Key GSI? - -``` -Need to filter by how many attributes? -├─ 1-2 attributes: Use traditional GSI with composite SK -├─ 3-4 attributes: Consider composite multi-key GSI -│ ├─ Attributes change frequently? -│ │ ├─ YES: Stick with traditional (avoid write amplification) -│ │ └─ NO: Use composite multi-key GSI -│ └─ Need range queries on multiple attributes? -│ ├─ YES: Use composite multi-key (range on last only) -│ └─ NO: Traditional GSI is simpler -└─ 5+ attributes: Consider denormalization or separate table -``` - -### Should I Denormalize? - -``` -How often is data accessed together? -├─ > 80%: Strong denormalization candidate -│ └─ How often does it change? -│ ├─ Rarely: Denormalize ✅ -│ └─ Frequently: Keep normalized, use GSI -├─ 50-80%: Calculate cost -│ └─ (Read_RPS × 2) > (Write_RPS × N_copies)? -│ ├─ YES: Denormalize -│ └─ NO: Keep normalized -└─ < 50%: Keep normalized -``` - -## Common Anti-Patterns to Avoid - -❌ **Scan instead of Query** -```go -// BAD -Scan(TableName: "glad-entities") - -// GOOD -Query(PK: "USER#john", SK: begins_with("SKILL#")) -``` - -❌ **Over-normalized (too many tables)** -``` -// BAD: Separate table for each entity -users-table, skills-table, projects-table, settings-table - -// GOOD: Single table with entity types -glad-entities (with PK/SK patterns) -``` - -❌ **Under-normalized (god object)** -```go -// BAD: Everything in one item -{ - PK: "USER#john", - SK: "PROFILE", - Skills: [...100 skills...], - Projects: [...50 projects...], - Settings: {...} -} - -// GOOD: Item collection -PK: USER#john, SK: PROFILE -PK: USER#john, SK: SKILL#golang -PK: USER#john, SK: SKILL#python -``` - -❌ **Mutable GSI keys** -```go -// BAD: Using frequently changing attribute as GSI key -GSI1PK: "STATUS#" + currentStatus // Changes every hour! - -// GOOD: Use stable attributes or accept write amplification -GSI1PK: "SKILL#" + skillName // Rarely changes -``` - -❌ **Generic key names** -```go -// BAD -PK: "123" -SK: "456" -GSI1PK: "abc" - -// GOOD -PK: "USER#john" -SK: "SKILL#golang" -GSI1PK: "SKILL#golang" -``` - -## Performance Optimization Tips - -1. **Batch Operations**: Use BatchGetItem/BatchWriteItem for multiple items -2. **Eventually Consistent Reads**: Use for non-critical paths (50% cost reduction) -3. **Projection Expressions**: Only fetch attributes you need -4. **Pagination**: Limit queries, use LastEvaluatedKey -5. **Caching**: Add DAX or ElastiCache for hot data -6. **Parallel Queries**: Query multiple partitions concurrently -7. **Local Testing**: Use DynamoDB Local for development - -## Monitoring Metrics - -### Critical Alarms -``` -ConsumedReadCapacityUnits > 80% of provisioned -ConsumedWriteCapacityUnits > 80% of provisioned -UserErrors > 100 in 5 minutes -SystemErrors > 10 in 5 minutes -ThrottledRequests > 0 -``` - -### Key Metrics to Track -- P50, P99 latency for GetItem, Query, PutItem -- Consumed vs Provisioned capacity -- Item count and table size -- GSI backfill progress (if adding new GSI) - -## Tools and Resources - -### Development Tools -- **DynamoDB Local**: Local testing environment -- **NoSQL Workbench**: Visual data modeling tool -- **AWS CLI**: Command-line operations -- **AWS SDK**: Go, Python, Java, JavaScript SDKs - -### Useful AWS CLI Commands -```bash -# Query table -aws dynamodb query --table-name glad-entities \ - --key-condition-expression "PK = :pk" \ - --expression-attribute-values '{":pk":{"S":"USER#john"}}' - -# Get item -aws dynamodb get-item --table-name glad-entities \ - --key '{"PK":{"S":"USER#john"},"SK":{"S":"PROFILE"}}' - -# Scan table (use sparingly!) -aws dynamodb scan --table-name glad-entities \ - --filter-expression "EntityType = :type" \ - --expression-attribute-values '{":type":{"S":"UserSkill"}}' - -# Describe table -aws dynamodb describe-table --table-name glad-entities - -# Update item -aws dynamodb update-item --table-name glad-entities \ - --key '{"PK":{"S":"USER#john"},"SK":{"S":"SKILL#golang"}}' \ - --update-expression "SET ProficiencyLevel = :level" \ - --expression-attribute-values '{":level":{"S":"Expert"}}' -``` - -## Testing Checklist - -- [ ] Test with realistic item sizes -- [ ] Test pagination for large result sets -- [ ] Test conditional operations -- [ ] Test GSI queries -- [ ] Test error handling (throttling, not found, etc.) -- [ ] Load test with expected RPS -- [ ] Test hot partition scenarios -- [ ] Verify monitoring and alarms - -## Migration Checklist - -- [ ] Backup existing data -- [ ] Create new table structure -- [ ] Test migration script on sample data -- [ ] Run migration during low-traffic window -- [ ] Verify data integrity -- [ ] Update application code -- [ ] Monitor for errors -- [ ] Keep old table as backup (don't delete immediately) - ---- - -**Quick Links**: -- [Full Planning Document](./dynamodb-single-table-design-plan.md) -- [Entity Addition Protocol](./entity-addition-protocol.md) -- [AWS DynamoDB Documentation](https://docs.aws.amazon.com/dynamodb/) -- [AWS Blog: Multi-Key GSI](https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/) - -**Last Updated**: 2025-12-07 diff --git a/docs/dynamodb-single-table-design-plan.md b/docs/dynamodb-single-table-design-plan.md deleted file mode 100644 index 1c0481c..0000000 --- a/docs/dynamodb-single-table-design-plan.md +++ /dev/null @@ -1,481 +0,0 @@ -# DynamoDB Single Table Design - Implementation Plan - -## Executive Summary - -This document outlines the migration from a simple users table to a comprehensive single table design that supports multiple entity types while leveraging DynamoDB's new multi-key composite GSI capabilities (up to 4 partition keys + 4 sort keys). - -## Current State Analysis - -### Existing Table Structure -``` -Table: users -- Partition Key: username (String) -- Sort Key: None -- Attributes: name, password_hash, created_at, updated_at -``` - -### Current Access Patterns -| Pattern # | Description | Type | Implementation | RPS Estimate | -|-----------|------------------------------|-------|-------------------------|------------------| -| AP1 | Get user by username (login) | Read | GetItem(username) | High (500/sec) | -| AP2 | Create new user (register) | Write | PutItem with condition | Medium (50/sec) | -| AP3 | Update user profile | Write | UpdateItem(username) | Low (10/sec) | -| AP4 | Check if user exists | Read | GetItem with projection | Medium (100/sec) | -| AP5 | List all users | Read | Scan (anti-pattern!) | Low (1/sec) | - -### Pain Points -1. **No sort key** - Limited query flexibility -2. **Single entity type** - Can't scale to multiple entities -3. **Scan operation** - ListUsers is inefficient and expensive -4. **No relationships** - Can't model related entities - -## Proposed Single Table Design - -### Phase 1: Enhanced Users Table with Sort Key - -#### New Table Structure -``` -Table: glad-entities -- Partition Key: PK (String) -- Sort Key: SK (String) -- GSI1-PK: GSI1PK (String) -- GSI1-SK: GSI1SK (String) -``` - -#### Entity Type: User -``` -Item Structure: -{ - PK: "USER#", - SK: "PROFILE", - EntityType: "User", - Username: "", - Name: "", - PasswordHash: "", - CreatedAt: "", - UpdatedAt: "" -} -``` - -**Access Pattern Mapping:** -- AP1 (Get user): GetItem(PK="USER#john", SK="PROFILE") -- AP2 (Create user): PutItem with ConditionExpression on PK+SK -- AP3 (Update user): UpdateItem(PK="USER#john", SK="PROFILE") -- AP4 (User exists): GetItem with ProjectionExpression - -### Phase 2: Add User Skills Entity - -#### Entity Type: UserSkill -``` -Item Structure: -{ - PK: "USER#", - SK: "SKILL#", - EntityType: "UserSkill", - Username: "", - SkillName: "", - ProficiencyLevel: "Beginner|Intermediate|Advanced|Expert", - YearsOfExperience: , - Endorsements: , - LastUsedDate: "", - CreatedAt: "", - UpdatedAt: "", - - // GSI attributes for querying skills globally - GSI1PK: "SKILL#", - GSI1SK: "LEVEL##USER#" -} -``` - -#### New Access Patterns with Skills -| Pattern # | Description | Type | Implementation | Notes | -|-----------|-----------------------------|-------|-----------------------------------------------------------------------|------------------------------| -| AP6 | Get all skills for a user | Read | Query(PK="USER#john", SK begins_with "SKILL#") | Item collection | -| AP7 | Get specific skill for user | Read | GetItem(PK="USER#john", SK="SKILL#golang") | Direct lookup | -| AP8 | Add skill to user | Write | PutItem(PK="USER#john", SK="SKILL#golang") | Create skill | -| AP9 | Update skill proficiency | Write | UpdateItem(PK, SK, attributes) | Update skill | -| AP10 | Remove skill from user | Write | DeleteItem(PK="USER#john", SK="SKILL#golang") | Delete skill | -| AP11 | Find users by skill name | Read | Query GSI1(GSI1PK="SKILL#golang") | Cross-user query | -| AP12 | Find expert users for skill | Read | Query GSI1(GSI1PK="SKILL#golang", GSI1SK begins_with "LEVEL#Expert#") | Filtered query | -| AP13 | Get user with all skills | Read | Query(PK="USER#john") | Returns profile + all skills | - -### Phase 3: Using Multi-Key Composite GSIs (New DynamoDB Feature) - -For more advanced querying, we can leverage the new multi-key composite GSI support: - -#### GSI2: Multi-dimensional Skill Queries -``` -GSI2 Structure (using composite keys): -- Partition Keys: [SkillName, ProficiencyLevel] -- Sort Keys: [YearsOfExperience, LastUsedDate] - -This enables queries like: -"Find users with 'Python' skills at 'Expert' level with 5+ years of experience who used it recently" -``` - -**Query Pattern:** -``` -Query GSI2 where: - PK1 = "Python" AND - PK2 = "Expert" AND - SK1 >= 5 AND - SK2 >= "2024-01-01" -``` - -## Design Patterns Applied - -### 1. Item Collection Pattern -- **Usage**: User + UserSkills share the same PK -- **Benefit**: Single query retrieves user profile and all skills -- **Example**: Query(PK="USER#john") returns profile + all skills - -### 2. Composite Sort Key Pattern (Traditional) -- **Usage**: SK = "SKILL#" enables hierarchical queries -- **Benefit**: Efficient filtering using begins_with, between operators -- **Example**: SK begins_with "SKILL#" returns all skills - -### 3. Sparse GSI Pattern -- **Usage**: Only items with skills populate GSI1 -- **Benefit**: Reduced GSI storage and write costs -- **Example**: User profiles without skills aren't in skill-lookup GSI - -### 4. Natural Key Pattern -- **Usage**: Descriptive keys (USER#, SKILL#) vs generic (PK, SK) -- **Benefit**: Self-documenting, easier debugging -- **Trade-off**: Slightly longer key storage - -### 5. Multi-Key Composite GSI Pattern (New Feature) -- **Usage**: Complex multi-dimensional queries -- **Benefit**: No string concatenation, native data types -- **Limitation**: Equality on all PKs, range only on last SK - -## Migration Strategy - -### Step 1: Table Structure Update (CDK) -1. Rename table from "users" to "glad-entities" -2. Add sort key (SK) as String -3. Add GSI1 with GSI1PK and GSI1SK -4. Add GSI2 with composite multi-keys (optional, future) - -### Step 2: Data Migration -1. Scan existing users table -2. Transform each user: - - PK: "USER#" + username - - SK: "PROFILE" - - Keep all existing attributes -3. BatchWrite to new table -4. Verify migration -5. Update application to use new table - -### Step 3: Code Refactoring -1. Update models (User, add UserSkill) -2. Update repository layer -3. Update service layer -4. Add new endpoints for skills management -5. Update tests - -### Step 4: Skills Feature Implementation -1. Add UserSkill model -2. Add skill repository methods -3. Add skill service methods -4. Add skill API endpoints -5. Add validation and tests - -## Protocol for Adding New Entity Types - -### Step 1: Define Entity Structure -```go -// Example: Adding "Project" entity - -type Project struct { - ProjectID string `json:"project_id" dynamodbav:"project_id"` - OwnerUsername string `json:"owner_username" dynamodbav:"owner_username"` - Name string `json:"name" dynamodbav:"name"` - Description string `json:"description" dynamodbav:"description"` - Status string `json:"status" dynamodbav:"status"` - CreatedAt time.Time `json:"created_at" dynamodbav:"created_at"` - UpdatedAt time.Time `json:"updated_at" dynamodbav:"updated_at"` - - // DynamoDB keys - PK string `json:"-" dynamodbav:"PK"` - SK string `json:"-" dynamodbav:"SK"` - EntityType string `json:"entity_type" dynamodbav:"EntityType"` - - // GSI keys (if needed for cross-user queries) - GSI1PK string `json:"-" dynamodbav:"GSI1PK,omitempty"` - GSI1SK string `json:"-" dynamodbav:"GSI1SK,omitempty"` -} -``` - -### Step 2: Define Key Pattern -``` -Key Pattern Decision Tree: - -1. Is this entity owned by a user? - YES → PK: "USER#" - NO → PK: "#" - -2. Can a user have multiple of these? - YES → SK: "#" - NO → SK: "PROFILE" or "" - -3. Need to query across all users? - YES → Add GSI1PK: "#" - NO → Skip GSI - -4. Need multi-dimensional filtering? - YES → Consider composite GSI2 with up to 4 PKs + 4 SKs - NO → Use traditional single-key GSI -``` - -### Step 3: Document Access Patterns -```markdown -| Pattern # | Description | Implementation | RPS | -|-----------|-------------|----------------|-----| -| APxx | Get entity by ID | GetItem(PK, SK) | xxx | -| APxx | List entities for user | Query(PK begins_with) | xxx | -| APxx | Query across users | Query GSI1 | xxx | -``` - -### Step 4: Add to Table Taxonomy -```markdown -## Entity Types in glad-entities Table - -### USER -- PK: USER# -- SK: PROFILE -- Purpose: Core user profile data - -### SKILL -- PK: USER# -- SK: SKILL# -- Purpose: User skills and proficiency levels -- GSI1PK: SKILL# -- GSI1SK: LEVEL##USER# - -### PROJECT (example for future) -- PK: USER# -- SK: PROJECT# -- Purpose: User-owned projects -- GSI1PK: PROJECT# -- GSI1SK: STATUS##DATE# -``` - -### Step 5: Capacity Planning -``` -Formula for new entity: -1. Estimate item size (KB) -2. Estimate write RPS (peak/average) -3. Estimate read RPS (peak/average) -4. Calculate WCU = writes/sec × (item_size_KB / 1) -5. Calculate RCU = reads/sec × (item_size_KB / 4) -6. Check partition limits: 1000 WCU, 3000 RCU per partition -7. If exceeded, consider write sharding -``` - -## Implementation Checklist - -### Phase 1: Foundation (Users with SK) -- [ ] Update CDK stack with new table structure -- [ ] Create migration script -- [ ] Update User model with PK/SK -- [ ] Refactor repository layer -- [ ] Update service layer -- [ ] Update API handlers -- [ ] Add integration tests -- [ ] Deploy and verify -- [ ] Migrate data -- [ ] Monitor and validate - -### Phase 2: Add Skills Entity -- [ ] Define UserSkill model -- [ ] Add skill repository methods -- [ ] Add skill service methods -- [ ] Create skill API endpoints - - POST /users/{username}/skills - - GET /users/{username}/skills - - GET /users/{username}/skills/{skillName} - - PUT /users/{username}/skills/{skillName} - - DELETE /users/{username}/skills/{skillName} - - GET /skills/{skillName}/users (GSI query) -- [ ] Add validation logic -- [ ] Write unit tests -- [ ] Write integration tests -- [ ] Add API documentation -- [ ] Deploy and verify - -### Phase 3: Documentation -- [ ] Create single-table-design.md guide -- [ ] Create entity-addition-protocol.md -- [ ] Create access-patterns-catalog.md -- [ ] Add architecture diagrams -- [ ] Create runbook for common operations - -## Cost Analysis - -### Current (Simple Table) -``` -Assumptions: -- 10,000 users -- 1000 reads/sec (mostly user lookups) -- 100 writes/sec (registrations + updates) -- Average item size: 1 KB - -Monthly Costs: -- Reads: 1000 RPS × 2.59M seconds × $0.125/M / 2 (eventually consistent) = $162 -- Writes: 100 RPS × 2.59M seconds × $0.625/M = $162 -- Storage: 10K users × 1 KB × $0.25/GB = $0.002 -Total: ~$324/month -``` - -### Proposed (Single Table with Skills) -``` -Assumptions: -- 10,000 users -- Average 5 skills per user = 50,000 skill items -- 1200 reads/sec (users + skills queries) -- 150 writes/sec (users + skills updates) -- User item: 1 KB, Skill item: 0.5 KB -- GSI1 projection: INCLUDE (user_id, skill_name, level) = 0.3 KB - -Base Table: -- Reads: 1200 RPS × 2.59M × $0.125/M / 2 = $194 -- Writes: 150 RPS × 2.59M × $0.625/M = $243 -- Storage: (10K × 1KB + 50K × 0.5KB) × $0.25/GB = $0.009 - -GSI1: -- Reads: 100 RPS × 2.59M × $0.125/M / 2 = $16 -- Writes: 50 RPS × 2.59M × $0.625/M = $81 (only skill writes) -- Storage: 50K × 0.3KB × $0.25/GB = $0.004 - -Total: ~$534/month (~65% increase for 5x more entities) -``` - -## Performance Considerations - -### Hot Partition Analysis -``` -Current Risk: LOW -- User lookups distributed across 10K usernames -- 1000 RPS / 10,000 users = 0.1 RPS per partition -- Well below 3000 RCU limit - -Future Risk with Skills: LOW-MEDIUM -- Skills query via GSI1 could concentrate on popular skills -- Example: "JavaScript" skill queried 100 RPS -- Mitigation: Add random shard suffix for very popular skills - - GSI1PK: "SKILL#javascript#shard_0" through "SKILL#javascript#shard_9" -``` - -### Query Optimization Strategies - -1. **Batch Operations**: Use BatchGetItem for retrieving multiple users -2. **Projection Expressions**: Only fetch needed attributes -3. **Eventually Consistent Reads**: Use for non-critical paths (2x cheaper) -4. **Pagination**: Implement cursor-based pagination for list operations -5. **Caching**: Add ElastiCache/DAX for frequently accessed users - -## Security Considerations - -1. **Fine-grained Access Control**: Use IAM policies with condition on PK prefix - ```json - { - "Condition": { - "ForAllValues:StringLike": { - "dynamodb:LeadingKeys": ["USER#${cognito:username}"] - } - } - } - ``` - -2. **Encryption**: Enable encryption at rest (default in DynamoDB) -3. **VPC Endpoints**: Access DynamoDB via VPC endpoints -4. **Point-in-time Recovery**: Enable PITR for disaster recovery -5. **Backup Strategy**: Automated daily backups with 35-day retention - -## Monitoring and Alerting - -### Key Metrics to Track -1. **ConsumedReadCapacityUnits** / **ConsumedWriteCapacityUnits** -2. **UserErrors** (throttling, validation errors) -3. **SystemErrors** (service errors) -4. **GetItem/Query/Scan latency** (p50, p99) -5. **GSI throttling events** - -### Recommended Alarms -``` -1. ThrottledRequests > 10 in 5 minutes -2. UserErrors > 100 in 5 minutes -3. P99 latency > 100ms -4. ConsumedRCU > 80% of provisioned (if not on-demand) -``` - -## Future Enhancements - -### Phase 4: Additional Entity Types (Examples) -1. **Projects**: User projects with skills mapping -2. **Endorsements**: Skill endorsements between users -3. **Certifications**: Professional certifications -4. **Experience**: Work experience entries -5. **Education**: Educational background - -### Phase 5: Advanced Features -1. **DynamoDB Streams**: Event-driven architecture -2. **TTL**: Auto-expire temporary data (e.g., session tokens) -3. **Global Tables**: Multi-region replication -4. **Transactions**: Atomic multi-item operations -5. **PartiQL**: SQL-compatible query language - -## References - -1. [AWS Blog: Multi-key support for GSI](https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/) -2. [AWS Blog: How Zepto scales with DynamoDB](https://aws.amazon.com/blogs/database/how-zepto-scales-to-millions-of-orders-per-day-using-amazon-dynamodb/) -3. [AWS Blog: Evolve your DynamoDB data model](https://aws.amazon.com/blogs/database/evolve-your-amazon-dynamodb-tables-data-model/) -4. [AWS Best Practices for DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html) - -## Appendix: Key Patterns Quick Reference - -### Pattern 1: Item Collection -``` -PK: USER#john -SK: PROFILE → User entity -SK: SKILL#golang → Skill entity -SK: SKILL#python → Skill entity -SK: PROJECT#123 → Project entity -``` - -### Pattern 2: One-to-Many with GSI -``` -Base: PK=ORDER#123, SK=ORDER, customer_id=456 -GSI1: PK=CUSTOMER#456, SK=ORDER#123 -Query: All orders for customer → Query GSI1(PK=CUSTOMER#456) -``` - -### Pattern 3: Many-to-Many -``` -UserSkillsTable: - PK: USER#john, SK: SKILL#golang -SkillUsersGSI: - GSI1PK: SKILL#golang, GSI1SK: USER#john -``` - -### Pattern 4: Composite Sort Key (Traditional) -``` -SK: STATUS#ACTIVE#DATE#2024-01-01#ID#123 -Query: begins_with "STATUS#ACTIVE#DATE#2024-01" -``` - -### Pattern 5: Multi-Key Composite GSI (New Feature) -``` -GSI2: - PK1=SkillName, PK2=Level - SK1=YearsExp, SK2=LastUsed -Query: Skill="Python" AND Level="Expert" AND YearsExp>=5 -``` - ---- - -**Document Version**: 1.0 -**Last Updated**: 2025-12-07 -**Status**: Planning Phase -**Next Review**: After Phase 1 implementation \ No newline at end of file diff --git a/docs/entity-addition-protocol.md b/docs/entity-addition-protocol.md deleted file mode 100644 index 16641d7..0000000 --- a/docs/entity-addition-protocol.md +++ /dev/null @@ -1,607 +0,0 @@ -# Protocol for Adding New Entity Types to Single Table Design - -## Quick Reference Guide - -This document provides a step-by-step protocol for adding new entity types to the `glad-entities` DynamoDB table using single table design patterns. - -## Prerequisites - -- Understand the existing table structure (PK, SK, GSI1PK, GSI1SK, GSI2*) -- Have documented access patterns for the new entity -- Know the relationship between new entity and existing entities - -## 5-Step Protocol - -### Step 1: Analyze Access Patterns - -Document all access patterns for your new entity using this template: - -```markdown -## Access Patterns for - -| Pattern # | Description | Type | Expected RPS | Data Needed | -|-----------|-------------|------|--------------|-------------| -| APxx | [Clear description] | Read/Write | [Peak/Avg] | [Attributes] | - -Examples: -- Get entity by ID -- List entities for user -- Query entities by status -- Update entity attributes -- Delete entity -- Cross-user queries (if applicable) -``` - -### Step 2: Choose Key Pattern - -Use this decision tree to determine your PK/SK structure: - -``` -┌─────────────────────────────────────────┐ -│ Is entity owned by a user? │ -└─────────────┬───────────────────────────┘ - │ - ┌─────────┴─────────┐ - │ YES │ NO - ▼ ▼ -PK: USER# PK: # - │ │ - │ │ -┌───┴──────────────────────────────────┐ -│ Can user have multiple instances? │ -└───┬──────────────────────────────────┘ - │ - ├── YES: SK: # - │ Example: SKILL#golang, PROJECT#123 - │ - └── NO: SK: or PROFILE - Example: SETTINGS, PROFILE - -┌─────────────────────────────────────────┐ -│ Need to query across all users? │ -└─────────────┬───────────────────────────┘ - │ - ┌─────────┴─────────┐ - │ YES │ NO - ▼ ▼ -Add GSI1: Skip GSI1 -GSI1PK: # -GSI1SK: ##USER# - -┌─────────────────────────────────────────┐ -│ Need multi-dimensional filtering? │ -│ (e.g., by status AND date AND amount) │ -└─────────────┬───────────────────────────┘ - │ - ┌─────────┴─────────┐ - │ YES │ NO - ▼ ▼ -Add GSI2 with Use traditional -composite keys single-key GSI -(up to 4 PKs + 4 SKs) -``` - -### Step 3: Define Go Model - -Create your entity struct following this template: - -```go -package models - -import "time" - -// represents [description] -type struct { - // Business attributes - string `json:"" dynamodbav:""` - string `json:"" dynamodbav:""` - // ... add all business attributes - - CreatedAt time.Time `json:"created_at" dynamodbav:"created_at"` - UpdatedAt time.Time `json:"updated_at" dynamodbav:"updated_at"` - - // DynamoDB system attributes - PK string `json:"-" dynamodbav:"PK"` - SK string `json:"-" dynamodbav:"SK"` - EntityType string `json:"entity_type" dynamodbav:"EntityType"` - - // GSI attributes (if needed) - GSI1PK string `json:"-" dynamodbav:"GSI1PK,omitempty"` - GSI1SK string `json:"-" dynamodbav:"GSI1SK,omitempty"` - - // GSI2 composite key attributes (if using new multi-key feature) - GSI2PK1 string `json:"-" dynamodbav:"GSI2PK1,omitempty"` - GSI2PK2 string `json:"-" dynamodbav:"GSI2PK2,omitempty"` - GSI2SK1 string `json:"-" dynamodbav:"GSI2SK1,omitempty"` - GSI2SK2 string `json:"-" dynamodbav:"GSI2SK2,omitempty"` -} - -// New creates a new instance with proper key structure -func New(/* params */) (*, error) { - // Validation - if /* validation */ { - return nil, errors.New("validation error") - } - - now := time.Now() - entity := &{ - // Set business attributes - CreatedAt: now, - UpdatedAt: now, - EntityType: "", - } - - // Set keys according to your pattern - entity.SetKeys() - - return entity, nil -} - -// SetKeys configures PK, SK, and GSI keys -func (e *) SetKeys() { - // Base table keys - e.PK = fmt.Sprintf("USER#%s", e.Username) // or "#" - e.SK = fmt.Sprintf("#%s", e.ID) - - // GSI1 keys (if needed) - e.GSI1PK = fmt.Sprintf("#%s", e.SomeAttribute) - e.GSI1SK = fmt.Sprintf("TYPE#%s#USER#%s", e.Type, e.Username) - - // GSI2 composite keys (if using multi-key feature) - // e.GSI2PK1 = e.Attribute1 - // e.GSI2PK2 = e.Attribute2 - // e.GSI2SK1 = fmt.Sprintf("%d", e.NumericValue) - // e.GSI2SK2 = e.DateValue -} -``` - -### Step 4: Implement Repository Methods - -Add repository methods following this template: - -```go -// Create adds a new entity to the table -func (r *DynamoDBRepository) Create(entity *models.) error { - log := logger.WithComponent("database").With("operation", "Create") - start := time.Now() - - // Ensure keys are set - entity.SetKeys() - - item, err := dynamodbattribute.MarshalMap(entity) - if err != nil { - log.Error("Failed to marshal entity", "error", err) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(PK) AND attribute_not_exists(SK)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create entity", "error", err) - return err - } - - log.Info("Entity created successfully", "duration", time.Since(start)) - return nil -} - -// Get retrieves an entity by its keys -func (r *DynamoDBRepository) Get(pk, sk string) (*models., error) { - log := logger.WithComponent("database").With("operation", "Get") - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "PK": {S: aws.String(pk)}, - "SK": {S: aws.String(sk)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get entity", "error", err) - return nil, err - } - - if result.Item == nil { - return nil, errors.New("entity not found") - } - - var entity models. - err = dynamodbattribute.UnmarshalMap(result.Item, &entity) - if err != nil { - log.Error("Failed to unmarshal entity", "error", err) - return nil, err - } - - return &entity, nil -} - -// ListForUser retrieves all entities for a user (item collection query) -func (r *DynamoDBRepository) ListForUser(username string) ([]*models., error) { - log := logger.WithComponent("database").With("operation", "ListForUser") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - KeyConditionExpression: aws.String("PK = :pk AND begins_with(SK, :sk_prefix)"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":pk": {S: aws.String(fmt.Sprintf("USER#%s", username))}, - ":sk_prefix": {S: aws.String("#")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query entities", "error", err) - return nil, err - } - - var entities []*models. - for _, item := range result.Items { - var entity models. - if err := dynamodbattribute.UnmarshalMap(item, &entity); err != nil { - log.Error("Failed to unmarshal entity", "error", err) - continue - } - entities = append(entities, &entity) - } - - return entities, nil -} - -// QueryByGSI queries entities using GSI1 -func (r *DynamoDBRepository) QueryByGSI(gsi1pk string, gsi1skPrefix string) ([]*models., error) { - log := logger.WithComponent("database").With("operation", "QueryByGSI") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String("GSI1"), - KeyConditionExpression: aws.String("GSI1PK = :pk AND begins_with(GSI1SK, :sk)"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":pk": {S: aws.String(gsi1pk)}, - ":sk": {S: aws.String(gsi1skPrefix)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query GSI", "error", err) - return nil, err - } - - var entities []*models. - for _, item := range result.Items { - var entity models. - if err := dynamodbattribute.UnmarshalMap(item, &entity); err != nil { - log.Error("Failed to unmarshal entity", "error", err) - continue - } - entities = append(entities, &entity) - } - - return entities, nil -} - -// Update updates an existing entity -func (r *DynamoDBRepository) Update(entity *models.) error { - log := logger.WithComponent("database").With("operation", "Update") - - entity.UpdatedAt = time.Now() - entity.SetKeys() // Ensure keys are current - - item, err := dynamodbattribute.MarshalMap(entity) - if err != nil { - log.Error("Failed to marshal entity", "error", err) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(PK) AND attribute_exists(SK)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update entity", "error", err) - return err - } - - return nil -} - -// Delete removes an entity from the table -func (r *DynamoDBRepository) Delete(pk, sk string) error { - log := logger.WithComponent("database").With("operation", "Delete") - - input := &dynamodb.DeleteItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "PK": {S: aws.String(pk)}, - "SK": {S: aws.String(sk)}, - }, - ConditionExpression: aws.String("attribute_exists(PK) AND attribute_exists(SK)"), - } - - _, err := r.client.DeleteItem(input) - if err != nil { - log.Error("Failed to delete entity", "error", err) - return err - } - - return nil -} -``` - -### Step 5: Update Table Taxonomy Documentation - -Add your entity to the table taxonomy document: - -```markdown -## Entity - -### Purpose -[Brief description of what this entity represents and why it exists] - -### Key Structure -``` -PK: -SK: -EntityType: "" - -# GSI1 (if applicable) -GSI1PK: -GSI1SK: - -# GSI2 (if using composite multi-key) -GSI2PK1: -GSI2PK2: -GSI2SK1: -GSI2SK2: -``` - -### Example Items -```json -{ - "PK": "USER#john", - "SK": "#", - "EntityType": "", - // ... business attributes - "CreatedAt": "2024-01-01T00:00:00Z", - "UpdatedAt": "2024-01-01T00:00:00Z" -} -``` - -### Access Patterns - -| Pattern # | Description | Implementation | Notes | -|-----------|-------------|----------------|-------| -| APxx | [Description] | [DynamoDB operation] | [Special considerations] | - -### Relationships -- **Parent**: [If applicable] -- **Children**: [If applicable] -- **Related Entities**: [Cross-references] - -### Capacity Estimates -- **Average Item Size**: X KB -- **Expected Items**: X -- **Read RPS**: X (peak), X (avg) -- **Write RPS**: X (peak), X (avg) -- **Storage**: ~X KB total - -### Special Considerations -- [Any unique aspects, limitations, or important notes] -``` - -## Checklist for Adding New Entity - -Use this checklist to ensure you've completed all steps: - -### Planning Phase -- [ ] Document all access patterns -- [ ] Determine key structure (PK, SK, GSI keys) -- [ ] Calculate capacity requirements -- [ ] Check for hot partition risks -- [ ] Review with team/lead - -### Implementation Phase -- [ ] Create Go model with proper struct tags -- [ ] Implement `New()` constructor -- [ ] Implement `SetKeys()` method -- [ ] Add validation logic -- [ ] Create repository methods: - - [ ] Create - - [ ] Get - - [ ] List/Query - - [ ] Update - - [ ] Delete -- [ ] Add service layer methods -- [ ] Create API handlers/endpoints -- [ ] Add input validation -- [ ] Implement error handling - -### Testing Phase -- [ ] Write unit tests for model -- [ ] Write unit tests for repository -- [ ] Write unit tests for service -- [ ] Write integration tests -- [ ] Test GSI queries (if applicable) -- [ ] Test error cases -- [ ] Performance test with realistic data - -### Documentation Phase -- [ ] Update table taxonomy -- [ ] Document access patterns -- [ ] Add code examples -- [ ] Update API documentation -- [ ] Add runbook entries - -### Deployment Phase -- [ ] Update CDK if GSI changes needed -- [ ] Deploy infrastructure changes -- [ ] Deploy application code -- [ ] Monitor metrics -- [ ] Verify functionality in production - -## Common Patterns Reference - -### Pattern 1: User-Owned Entity (One-to-Many) -``` -Example: User Skills -PK: USER#john -SK: SKILL#golang -GSI1PK: SKILL#golang -GSI1SK: LEVEL#Expert#USER#john -``` - -### Pattern 2: Global Entity (Not User-Owned) -``` -Example: Products -PK: PRODUCT#123 -SK: PRODUCT -GSI1PK: CATEGORY#electronics -GSI1SK: PRICE#999 -``` - -### Pattern 3: Relationship Entity (Many-to-Many) -``` -Example: User-Project Membership -PK: USER#john -SK: PROJECT#456 -GSI1PK: PROJECT#456 -GSI1SK: USER#john -``` - -### Pattern 4: Hierarchical Data -``` -Example: Course Lessons -PK: COURSE#101 -SK: LESSON#1 -SK: LESSON#2 -SK: LESSON#3 -``` - -### Pattern 5: Sparse GSI (Filtered Queries) -``` -Example: Active Subscriptions Only -PK: USER#john -SK: SUBSCRIPTION -Status: ACTIVE ← Only items with Status populate GSI -GSI1PK: STATUS#ACTIVE -GSI1SK: EXPIRES#2024-12-31 -``` - -### Pattern 6: Composite Multi-Key GSI (New Feature) -``` -Example: Advanced Product Search -Base Table: - PK: PRODUCT#123 - SK: PRODUCT - -GSI2 (Composite Keys): - PK1: Category (string) - PK2: Brand (string) - SK1: Price (number) - SK2: Rating (number) - -Query: - Category="Electronics" AND - Brand="Apple" AND - Price >= 500 AND - Rating >= 4.5 -``` - -## Capacity Planning Formulas - -``` -Item Size Calculation: -- Base overhead: ~100 bytes per item -- Attribute overhead: ~1 byte per attribute name -- String: length in bytes (UTF-8) -- Number: ~variable (1-21 bytes) -- Boolean: 1 byte -- List/Map: sum of elements + overhead - -Total Item Size = 100 + Σ(attribute_name_length + attribute_value_size) - -RCU Calculation (Eventually Consistent): -RCU = (reads_per_sec × item_size_KB) / 8 -RCU = (reads_per_sec × item_size_KB) / 4 (Strongly Consistent) - -WCU Calculation: -WCU = (writes_per_sec × item_size_KB) / 1 - -Partition Throughput Limits: -- Max 3,000 RCU per partition -- Max 1,000 WCU per partition - -Hot Partition Check: -If (entity_write_RPS / distinct_partition_keys) > 1000: - → Implement write sharding - -If (entity_read_RPS / distinct_partition_keys) > 3000: - → Add caching layer or distribute reads -``` - -## Troubleshooting Guide - -### Issue: Hot Partition -**Symptoms**: Throttling errors, high latency -**Solutions**: -1. Add write sharding: `PK: #` -2. Use composite keys to distribute load -3. Add caching layer (DAX, ElastiCache) - -### Issue: Expensive GSI -**Symptoms**: High write costs, storage costs -**Solutions**: -1. Use sparse GSI (only index subset) -2. Change projection to KEYS_ONLY or INCLUDE -3. Consider denormalization instead - -### Issue: Complex Queries -**Symptoms**: Multiple DynamoDB calls, slow performance -**Solutions**: -1. Use item collections (same PK) -2. Add GSI for cross-user queries -3. Consider composite multi-key GSI -4. Denormalize frequently accessed data - -### Issue: Large Item Size -**Symptoms**: Item > 400KB limit -**Solutions**: -1. Split into multiple items (item collection) -2. Store large attributes in S3, reference in DynamoDB -3. Use compression for large text fields - -## Best Practices Summary - -1. **Keys should be descriptive**: Use `USER#john` not `PK=abc123` -2. **Group related entities**: Use item collections when access correlation > 50% -3. **Project minimally**: Only include attributes you query in GSI -4. **Estimate capacity**: Calculate RCU/WCU before implementation -5. **Monitor from day one**: Set up alarms for throttling -6. **Document everything**: Future you will thank present you -7. **Test with realistic data**: Production data patterns matter -8. **Consider write amplification**: Each GSI doubles write cost -9. **Use sparse GSIs**: When querying <50% of items -10. **Leverage new composite keys**: For multi-dimensional queries - -## Getting Help - -- Review the main planning document: `docs/dynamodb-single-table-design-plan.md` -- Check AWS documentation: https://docs.aws.amazon.com/dynamodb/ -- Consult with team lead before adding GSIs -- Test queries in DynamoDB Local first - ---- - -**Document Version**: 1.0 -**Last Updated**: 2025-12-07 -**Maintained By**: GLAD Engineering Team \ No newline at end of file From d0c67e88f16d76d212ce20051f27dbd85f72facf Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 10:38:07 +0200 Subject: [PATCH 04/12] chore: refactor the database layer | update docs --- .github/pull_request_template.md | 0 README.md | 415 ++++++++++++++---- Taskfile.yml | 1 + cmd/app/internal/database/client.go | 58 +++ .../database/{mock_test.go => client_test.go} | 0 cmd/app/internal/database/dynamodb.go | 31 -- cmd/app/internal/database/factory.go | 3 +- .../database/master_skill_repository.go | 177 +------- .../master_skill_repository_dynamodb.go | 178 ++++++++ .../database/master_skill_repository_mock.go | 108 +++++ cmd/app/internal/database/mock.go | 407 ----------------- cmd/app/internal/database/skill_repository.go | 9 - cmd/app/internal/database/user_repository.go | 185 +------- .../database/user_repository_dynamodb.go | 182 ++++++++ .../internal/database/user_repository_mock.go | 103 +++++ .../database/user_skill_repository.go | 290 +----------- .../user_skill_repository_dynamodb.go | 291 ++++++++++++ .../database/user_skill_repository_mock.go | 177 ++++++++ cmd/app/internal/models/{keys.go => utils.go} | 0 scripts/.gitkeep | 0 20 files changed, 1428 insertions(+), 1187 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 cmd/app/internal/database/client.go rename cmd/app/internal/database/{mock_test.go => client_test.go} (100%) delete mode 100644 cmd/app/internal/database/dynamodb.go create mode 100644 cmd/app/internal/database/master_skill_repository_dynamodb.go create mode 100644 cmd/app/internal/database/master_skill_repository_mock.go delete mode 100644 cmd/app/internal/database/mock.go delete mode 100644 cmd/app/internal/database/skill_repository.go create mode 100644 cmd/app/internal/database/user_repository_dynamodb.go create mode 100644 cmd/app/internal/database/user_repository_mock.go create mode 100644 cmd/app/internal/database/user_skill_repository_dynamodb.go create mode 100644 cmd/app/internal/database/user_skill_repository_mock.go rename cmd/app/internal/models/{keys.go => utils.go} (100%) create mode 100644 scripts/.gitkeep diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 43f8e16..8ab8c98 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # GLAD Stack - Go, Lambda, ApiGateway, DynamoDB -A comprehensive serverless API platform built with Go, demonstrating modern cloud-native architecture using AWS serverless technologies and production ready API. +A comprehensive serverless API platform built with Go, +demonstrating modern Cloud-Native architecture using AWS serverless technologies and production-ready API. ## What is GLAD? @@ -8,24 +9,45 @@ A comprehensive serverless API platform built with Go, demonstrating modern clou - **G**o - Modern, efficient programming language with excellent performance and concurrency - **L**ambda - AWS serverless compute platform for running code without managing servers - **A**piGateway - AWS managed API gateway service for creating, deploying, and managing REST APIs -- **D**ynamoDB - AWS NoSQL database(Single Table Design) service providing fast and predictable performance with seamless scalability +- **D**ynamoDB - AWS NoSQL database (Single Table Design) service providing fast and predictable performance with seamless scalability -This project showcases how these four technologies work together to create a production-ready, scalable, and cost-effective serverless API platform that can handle millions of requests while maintaining low latency and high availability. +This project showcases how these four technologies work together to create a production-ready, scalable, and cost-effective serverless API platform +that can handle millions of requests while maintaining low latency and high availability. -[![Tests](https://img.shields.io/badge/tests-passing-brightgreen)]() [![Go Version](https://img.shields.io/badge/go-1.24.0-blue)]() -[![Production Ready](https://img.shields.io/badge/status-production%20ready-success)]() ## Features -- **RESTful API** with user authentication and management -- **JWT Authentication** with token-based authorization -- **Serverless Architecture** using AWS Lambda + API Gateway -- **DynamoDB Integration** for data persistence -- **Clean Architecture** with layered design (Handler → Service → Repository) -- **Comprehensive Testing** with unit and integration tests -- **Structured Logging** using Go's slog package -- **Infrastructure as Code** with AWS CDK +### User Management +- ✅ User registration with validation (username, name, password) +- ✅ User authentication with JWT tokens +- ✅ User profile updates (name, password changes) +- ✅ List all users in the system +- ✅ Bcrypt password hashing for security +- ✅ Get current authenticated user info + +### Skills Management +- ✅ **Master Skills Catalog** - Centralized skill definitions +- ✅ **User Skills** - Assign skills to users with proficiency tracking +- ✅ **Proficiency Levels** - Beginner, Intermediate, Advanced, Expert +- ✅ **Years of Experience** - Track experience per skill +- ✅ **Skill Categories** - Organize skills by category (Programming, DevOps, etc.) +- ✅ **Endorsements** - Track skill endorsement counts +- ✅ **Last Used Date** - Track when skill was last used +- ✅ **Skill Notes** - Add custom notes/comments to user skills +- ✅ **Cross-User Queries** - Find all users with a specific skill +- ✅ **Filter by Proficiency** - Query users by skill and proficiency level + +### Architecture & Infrastructure +- ✅ **Serverless Architecture** using AWS Lambda + API Gateway +- ✅ **Single Table DynamoDB Design** with Multi-Key GSI pattern +- ✅ **Clean Architecture** with layered design (Handler → Service → Repository) +- ✅ **Repository Pattern** with DynamoDB and Mock implementations +- ✅ **Comprehensive Testing** - unit, integration, and API tests +- ✅ **Structured Logging** using Go's slog package with component tracking +- ✅ **Infrastructure as Code** with AWS CDK (Go) +- ✅ **JWT Authentication** with configurable token expiry +- ✅ **Automatic Mock/Production** repository switching ## Project Structure @@ -36,9 +58,8 @@ glad/ │ ├── main.go # Lambda entry point │ ├── integration_test.go # Integration tests │ ├── testdata/ # Test data files -│ │ └── sample_request.json # Sample API requests │ └── internal/ # App-specific code -│ ├── database/ # Repository implementations +│ ├── database/ # Repository layer (see Database Layer Organization) │ ├── dto/ # Request/Response DTOs │ ├── errors/ # App-specific errors │ ├── handler/ # HTTP handlers (thin layer) @@ -46,8 +67,6 @@ glad/ │ ├── router/ # Router abstraction │ ├── service/ # Business logic │ └── validation/ # Input validation -├── internal/ # Shared private code -│ └── errors/ # Domain error definitions ├── pkg/ # Shared public packages │ ├── auth/ # JWT token service │ ├── config/ # Configuration management @@ -56,14 +75,7 @@ glad/ │ └── middleware/ # HTTP middleware ├── deployments/ │ └── app/ # AWS CDK infrastructure -│ ├── cdk.json # CDK configuration -│ └── cdk.out/ # CDK build output -├── scripts/ # Utility scripts -├── site/ # Documentation/website -├── .github/ -│ ├── workflows/ -│ │ └── ci.yml # GitHub Actions CI -│ └── dependabot.yml # Dependency updates +│ ├── cdk.go # CDK stack definition ├── Taskfile.yml # Task runner configuration ├── .golangci.yml # Go linter configuration └── README.md # This file @@ -79,34 +91,133 @@ Request → Router → Middleware → Handler → Service → Repository → Dat ### Layers -1. **Router** - Route matching and middleware chaining +1. **Router** - Route matching and middleware chaining for Lambda 2. **Middleware** - JWT validation, logging, CORS 3. **Handler** - HTTP layer (JSON marshaling/unmarshaling) 4. **Service** - Business logic and validation -5. **Repository** - Data access abstraction -6. **Database** - DynamoDB or Mock implementation +5. **Repository** - Data access abstraction (interface-based) +6. **Database** - DynamoDB (production) or Mock (development/testing) ### Design Patterns - **Layered Architecture** - Clear separation of concerns -- **Repository Pattern** - Interface-based data access -- **Dependency Injection** - Config-based initialization +- **Repository Pattern** - Interface-based data access with factory +- **Dependency Injection** - Constructor injection throughout - **DTO Pattern** - Separate request/response types from domain models - **Service Layer** - Business logic isolated from HTTP concerns +- **Factory Pattern** - Auto-selects Mock vs DynamoDB based on environment +- **Single Responsibility** - Each layer has one clear purpose -## Data Model -The Single Table Design in modeled using new Dynamo feature: Multi-Keys(composite keys) for GSI approach. -Check: https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/ +## Database Layer Organization + +The database package follows a scalable file organization pattern designed for growth to 10+ repositories: + +``` +cmd/app/internal/database/ +├── client.go # Repository struct definitions +├── constants.go # Table names, GSI constants +├── entity_keys.go # Entity ID builders and parsers +├── factory.go # Repository factory + unified interface +│ +├── user_repository.go # UserRepository interface +├── user_repository_dynamodb.go # DynamoDB implementation +├── user_repository_mock.go # Mock implementation +│ +├── user_skill_repository.go # SkillRepository interface +├── user_skill_repository_dynamodb.go # DynamoDB implementation +├── user_skill_repository_mock.go # Mock implementation +│ +├── master_skill_repository.go # MasterSkillRepository interface +├── master_skill_repository_dynamodb.go # DynamoDB implementation +└── master_skill_repository_mock.go # Mock implementation +``` + +**File Naming Pattern**: `{entity}_repository.go`, `{entity}_repository_{implementation}.go` + +This pattern ensures: +- Files are grouped by entity when sorted alphabetically +- Clear separation between interface and implementation +- Easy to locate code: interface → DynamoDB impl → Mock impl +- Scales to 10+ repositories without confusion + +**Repository Pattern**: Each entity has: +1. An interface defining operations (e.g., `UserRepository`) +2. DynamoDB implementation using single-table design +3. Mock implementation for local development and testing + +The unified `Repository` interface composes all entity repositories, allowing both `DynamoDBRepository` and `MockRepository` to implement the same interface. + +**Auto-Selection Logic**: +- If `AWS_LAMBDA_FUNCTION_NAME` environment variable exists → DynamoDB +- If `ENVIRONMENT=production` → DynamoDB +- If `ENVIRONMENT=development` or `DB_MOCK=true` → Mock +- Default: DynamoDB + +## Data Model - Single Table Design with Multi-Key GSI + +The Single Table Design is modeled using DynamoDB's Multi-Key (composite keys) for GSI feature. +Read more: https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/ + +### Table: `glad-entities` +- **Partition Key**: `entity_id` (STRING) + +### Entity ID Format (using `#` delimiter): +- **Users**: `USER#` (e.g., `USER#john`) +- **User Skills**: `USERSKILL##` (e.g., `USERSKILL#john#python`) +- **Master Skills**: `SKILL#` (e.g., `SKILL#python`) + +### Global Secondary Indexes (5 GSIs): + +1. **SkillsByLevel** - Query users by skill and proficiency level + - PK: `SkillName`, SK: `ProficiencyLevel` +2. **ByUser** - Get all entities for a user + - PK: `Username`, SK: `EntityType` +3. **SkillsByCategory** - Find skills by category + - PK: `EntityType`, SK: `Category` +4. **ByEntityType** - Query all entities of a type + - PK: `EntityType`, SK: `SkillName` +5. **BySkillID** - Find all users with a specific skill + - PK: `skill_id`, SK: `Username` ## API Endpoints +### Authentication (Public) | Method | Path | Auth | Description | |--------|------------|------|------------------------------| | POST | /register | No | User registration | | POST | /login | No | Authentication (returns JWT) | -| GET | /protected | JWT | Protected resource demo | -| PUT | /user | JWT | Update user profile | + +### User Management (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|------------|------|------------------------------| +| GET | /me | JWT | Get current user info | | GET | /users | JWT | List all users | +| PUT | /user | JWT | Update user profile | +| GET | /protected | JWT | Protected resource demo | + +### User Skills (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|------------------------------------|------|--------------------------| +| POST | /users/{username}/skills | JWT | Add skill to user | +| GET | /users/{username}/skills | JWT | List all skills for user | +| GET | /users/{username}/skills/{skillID} | JWT | Get specific user skill | +| PUT | /users/{username}/skills/{skillID} | JWT | Update user skill | +| DELETE | /users/{username}/skills/{skillID} | JWT | Delete user skill | + +### Master Skills (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|--------------------------|------|---------------------------| +| POST | /master-skills | JWT | Create master skill | +| GET | /master-skills | JWT | List all master skills | +| GET | /master-skills/{skillID} | JWT | Get specific master skill | +| PUT | /master-skills/{skillID} | JWT | Update master skill | +| DELETE | /master-skills/{skillID} | JWT | Delete master skill | + +### Cross-User Skill Queries (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|----------------------------------------|------|--------------------------------| +| GET | /skills/{skillName}/users | JWT | Find all users with skill | +| GET | /skills/{skillName}/users?level=Expert | JWT | Find users with skill at level | ## Getting Started @@ -114,8 +225,8 @@ Check: https://aws.amazon.com/blogs/database/multi-key-support-for-global-second - Go 1.21+ (tested with 1.24.0) - [Task](https://taskfile.dev/) installed (`brew install go-task/tap/go-task`) -- AWS CLI configured -- AWS CDK installed (for deployment) +- AWS CLI configured with credentials +- AWS CDK installed (for deployment): `npm install -g aws-cdk` ### Local Development @@ -138,25 +249,29 @@ task lint # Format code task fmt -# Full development test cycle +# Quick test cycle (format + test) +task dev:quick-test + +# Full development test cycle (format + lint + test + build) task dev:full-test ``` ### Testing the API +#### User Registration & Authentication ```bash # Register a user curl -X POST http://localhost:8080/register \ -H "Content-Type: application/json" \ -d '{"username":"testuser","name":"Test User","password":"password123"}' -# Login +# Login (returns JWT token) curl -X POST http://localhost:8080/login \ -H "Content-Type: application/json" \ -d '{"username":"testuser","password":"password123"}' -# Access protected route (use token from login) -curl -X GET http://localhost:8080/protected \ +# Get current user info +curl -X GET http://localhost:8080/me \ -H "Authorization: Bearer YOUR_TOKEN_HERE" # Update user profile @@ -165,11 +280,55 @@ curl -X PUT http://localhost:8080/user \ -H "Content-Type: application/json" \ -d '{"name":"Updated Name"}' -# List users +# List all users curl -X GET http://localhost:8080/users \ -H "Authorization: Bearer YOUR_TOKEN_HERE" ``` +#### Master Skills Management +```bash +# Create a master skill +curl -X POST http://localhost:8080/master-skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{ + "skill_id":"python", + "skill_name":"Python", + "category":"Programming" + }' + +# List all master skills +curl -X GET http://localhost:8080/master-skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +#### User Skills Management +```bash +# Add skill to user +curl -X POST http://localhost:8080/users/testuser/skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{ + "skill_id":"python", + "skill_name":"Python", + "category":"Programming", + "proficiency_level":"Intermediate", + "years_of_experience":3 + }' + +# Get user's skills +curl -X GET http://localhost:8080/users/testuser/skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" + +# Find all users with Python skill +curl -X GET http://localhost:8080/skills/Python/users \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" + +# Find Expert Python developers +curl -X GET "http://localhost:8080/skills/Python/users?level=Expert" \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + ### Building for Lambda ```bash @@ -182,16 +341,16 @@ task build:lambda ### Deploying to AWS ```bash -# Bootstrap CDK (first time only) +# Bootstrap CDK (first time only, per account/region) task cdk:bootstrap +# Preview deployment changes +task cdk:diff + # Deploy infrastructure and application task cdk:deploy -# Preview deployment changes -task deploy:diff - -# Full deployment workflow (test, build, deploy) +# Full deployment workflow (test → build → deploy) task deploy # Destroy stack (use with caution!) @@ -202,14 +361,17 @@ task cdk:destroy Set environment variables for configuration: -| Variable | Description | Default | -|------------------|-------------------------------|----------------------| -| `JWT_SECRET` | JWT signing secret | "default-secret-key" | -| `JWT_EXPIRY` | Token expiry duration | 24h | -| `DYNAMODB_TABLE` | DynamoDB table name | "users" | -| `AWS_REGION` | AWS region | "us-east-1" | -| `ENVIRONMENT` | "production" or "development" | "development" | -| `PORT` | Server port (local only) | 8080 | +| Variable | Description | Default | +|----------------------------|-------------------------------|----------------------| +| `JWT_SECRET` | JWT signing secret | "default-secret-key" | +| `JWT_EXPIRY` | Token expiry duration | 24h | +| `JWT_SIGNING_ALG` | JWT signing algorithm | "HS256" | +| `DYNAMODB_TABLE` | DynamoDB table name | "users" | +| `AWS_REGION` | AWS region for DynamoDB | "us-east-1" | +| `ENVIRONMENT` | "production" or "development" | "development" | +| `PORT` | Server port (local only) | 8080 | +| `DB_MOCK` | Force mock DB usage | (not set) | +| `AWS_LAMBDA_FUNCTION_NAME` | Auto-detected in Lambda | (auto) | ## Testing @@ -224,6 +386,9 @@ go test -v ./... # Run specific package tests go test ./pkg/auth/... + +# Run with race detection +go test -race ./... ``` ### Integration Tests @@ -242,71 +407,141 @@ task test:database # Database tests only task test:models # Model tests only ``` +### Test Coverage + +```bash +# Generate coverage report +task test:coverage + +# View coverage in browser +go tool cover -html=coverage.out +``` + Test coverage includes: -- ✅ Handler layer tests +- ✅ Handler layer tests (user, skill, master skill) - ✅ Service layer tests -- ✅ Database layer tests (mock) +- ✅ Database layer tests (Mock repository) - ✅ Authentication & middleware tests - ✅ Full user journey integration tests - ✅ Security & authorization tests +- ✅ Domain model validation tests ## Key Components ### Config (`pkg/config/`) -Centralized configuration loading from environment variables with typed structs. - -### Errors (`internal/errors/` & `pkg/errors/`) -Domain-specific and reusable error definitions with proper error wrapping. - -### Validation (`internal/validation/`) -Shared validation logic for user input with clear error messages. +Centralized configuration loading from environment variables with typed structs and defaults. + +### Errors (`cmd/app/internal/errors/` & `pkg/errors/`) +- Domain-specific error definitions +- Reusable error utilities +- HTTP status code mapping +- Proper error wrapping with context + +### Validation (`cmd/app/internal/validation/`) +- Username validation (3-50 chars, alphanumeric + underscore) +- Password validation (min 6 chars) +- Name validation (non-empty) +- Skill ID validation (lowercase alphanumeric + dashes) +- Proficiency level enum validation +- Years of experience validation (non-negative) ### Authentication (`pkg/auth/`) -JWT token generation and validation with configurable expiry. +- JWT token generation with configurable expiry +- Token validation and claims extraction +- HS256 signing algorithm +- Username embedded in token claims ### Middleware (`pkg/middleware/`) -JWT authentication middleware for protecting routes. +- JWT authentication middleware +- Bearer token extraction from Authorization header +- Route protection +- Error handling in auth flow ### Logging (`pkg/logger/`) -Structured logging with slog, including request duration tracking. +- Structured logging with Go's slog package +- Component-based logging (e.g., "database", "handler") +- Operation tracking +- Duration tracking for performance monitoring +- Levels: Info (✅), Debug (🔍), Error (❌), Warn (⚠️) +- JSON format for production, text for development ## AWS Infrastructure -Deployed resources (via CDK): - -- **DynamoDB Table**: `glad-entities` table -- **Lambda Function**: Go 1.x runtime with provided.al2023 -- **API Gateway**: REST API with CORS enabled -- **IAM Roles**: Least-privilege access for Lambda +Deployed resources (via AWS CDK in Go): + +### DynamoDB Table +- **Name**: `glad-entities` +- **Single table** with 5 Global Secondary Indexes +- **DynamoDB Streams**: Enabled (NEW_AND_OLD_IMAGES) +- **Capacity**: On-demand billing mode +- **Point-in-time recovery**: Disabled (dev-friendly) +- **Removal policy**: DESTROY (dev-friendly) + +### Lambda Function +- **Runtime**: provided.al2023 (custom Go runtime) +- **Handler**: bootstrap binary +- **Architecture**: AMD64 +- **Timeout**: 30 seconds +- **Environment**: ENVIRONMENT=production +- **Permissions**: DynamoDB read/write on `glad-entities` + +### API Gateway +- **Type**: REST API +- **Name**: glad-api-gateway +- **CORS**: Enabled (* origins) +- **Throttling**: 100 RPS, 200 burst +- **Deployment**: Production stage +- **Usage Plan**: Attached with rate limiting + +### IAM Roles +- Lambda execution role with DynamoDB permissions +- Least-privilege access pattern ## Development Workflow -1. Write code following the layered architecture -2. Add unit tests for new functionality -3. Format and lint code (`task dev:quick-test`) -4. Run full test suite (`task dev:full-test`) -5. Run integration tests (`task test:integration`) -6. Build Lambda package (`task build:lambda`) -7. Deploy to AWS (`task deploy`) +1. **Write code** following the layered architecture +2. **Add unit tests** for new functionality +3. **Format and lint** code (`task dev:quick-test`) +4. **Run full test suite** (`task dev:full-test`) +5. **Run integration tests** (`task test:integration`) +6. **Build Lambda package** (`task build:lambda`) +7. **Preview changes** (`task cdk:diff`) +8. **Deploy to AWS** (`task deploy`) ## Code Quality -- **Linting**: golangci-lint with 10+ linters -- **Test Coverage**: Unit and integration tests -- **CI/CD**: GitHub Actions workflow -- **Security**: JWT authentication, bcrypt password hashing +- **Linting**: golangci-lint via `task lint` +- **Test Coverage**: Unit and integration tests across all layers +- **CI/CD**: GitHub Actions workflow (see `.github/workflows/`) +- **Security**: + - JWT authentication with Bearer tokens + - Bcrypt password hashing (cost: 10) + - Input validation on all endpoints + - Proper error handling without leaking sensitive data - **Logging**: Structured logging throughout all layers +- **Error Handling**: Domain-specific errors with HTTP mapping + +## Dependencies + +### Go Packages +- `github.com/aws/aws-lambda-go` - Lambda runtime +- `github.com/aws/aws-sdk-go` - DynamoDB client +- `github.com/golang-jwt/jwt/v5` - JWT token handling +- `golang.org/x/crypto` - Bcrypt password hashing + +### AWS Services +- AWS Lambda (serverless compute) +- API Gateway (REST API management) +- DynamoDB (NoSQL database with single-table design) +- CloudFormation (infrastructure via CDK) +- IAM (permissions management) ## Contributing -This is a learning project. Feel free to explore, fork, and experiment! +This is a learning project demonstrating serverless Go architecture. Feel free to explore, fork, and experiment! ## License This project is for educational purposes. ---- - -**Status**: Production Ready ✅ - -All tests passing. Clean architecture. Comprehensive error handling. Security best practices implemented. \ No newline at end of file +--- \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 7336720..f967c07 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -42,6 +42,7 @@ tasks: - go test -coverprofile=coverage.out ./... - go tool cover -html=coverage.out -o coverage.html - echo 'Coverage report generated - coverage.html' + - go tool cover -html=coverage.out test:suite: desc: 'Run comprehensive test suite with detailed reporting' diff --git a/cmd/app/internal/database/client.go b/cmd/app/internal/database/client.go new file mode 100644 index 0000000..9e6f0ae --- /dev/null +++ b/cmd/app/internal/database/client.go @@ -0,0 +1,58 @@ +package database + +import ( + "sync" + + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/dynamodb" +) + +// DynamoDBRepository implements all repository interfaces using DynamoDB single table design +// It provides implementations for: +// - UserRepository (user management) +// - MasterSkillRepository (master skills) +// - SkillRepository (user skills) +type DynamoDBRepository struct { + client *dynamodb.DynamoDB +} + +// NewDynamoDBRepository creates a new DynamoDB repository +func NewDynamoDBRepository() *DynamoDBRepository { + log := logger.WithComponent("database") + log.Info("Initializing DynamoDB repository", "table", TableName) + + sess := session.Must(session.NewSession()) + repo := &DynamoDBRepository{ + client: dynamodb.New(sess), + } + + log.Info("DynamoDB repository initialized successfully") + return repo +} + +// MockRepository implements UserRepository, SkillRepository, and MasterSkillRepository for testing +// This matches the DynamoDBRepository structure with unified implementation +type MockRepository struct { + users map[string]*models.User // key: username + skills map[string]*models.UserSkill // key: "username#skillname" + masterSkills map[string]*models.Skill // key: skill_id + mutex sync.RWMutex +} + +// NewMockRepository creates a new unified mock repository +func NewMockRepository() *MockRepository { + log := logger.WithComponent("database") + log.Info("Initializing unified Mock repository for local development") + + repo := &MockRepository{ + users: make(map[string]*models.User), + skills: make(map[string]*models.UserSkill), + masterSkills: make(map[string]*models.Skill), + } + + log.Info("Unified Mock repository initialized successfully") + return repo +} diff --git a/cmd/app/internal/database/mock_test.go b/cmd/app/internal/database/client_test.go similarity index 100% rename from cmd/app/internal/database/mock_test.go rename to cmd/app/internal/database/client_test.go diff --git a/cmd/app/internal/database/dynamodb.go b/cmd/app/internal/database/dynamodb.go deleted file mode 100644 index 1a66067..0000000 --- a/cmd/app/internal/database/dynamodb.go +++ /dev/null @@ -1,31 +0,0 @@ -package database - -import ( - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/dynamodb" -) - -// DynamoDBRepository implements all repository interfaces using DynamoDB single table design -// It provides implementations for: -// - UserRepository (user management) -// - MasterSkillRepository (master skills) -// - SkillRepository (user skills) -type DynamoDBRepository struct { - client *dynamodb.DynamoDB -} - -// NewDynamoDBRepository creates a new DynamoDB repository -func NewDynamoDBRepository() *DynamoDBRepository { - log := logger.WithComponent("database") - log.Info("Initializing DynamoDB repository", "table", TableName) - - sess := session.Must(session.NewSession()) - repo := &DynamoDBRepository{ - client: dynamodb.New(sess), - } - - log.Info("DynamoDB repository initialized successfully") - return repo -} diff --git a/cmd/app/internal/database/factory.go b/cmd/app/internal/database/factory.go index 49bed0e..c926a3d 100644 --- a/cmd/app/internal/database/factory.go +++ b/cmd/app/internal/database/factory.go @@ -7,7 +7,8 @@ import ( "github.com/hackmajoris/glad/pkg/logger" ) -// Repository combines all repository interfaces for unified access +// Repository combines all repository interfaces for unified access. +// Both DynamoDBRepository and MockRepository implement this interface. type Repository interface { UserRepository SkillRepository diff --git a/cmd/app/internal/database/master_skill_repository.go b/cmd/app/internal/database/master_skill_repository.go index 75f8a75..30e3f73 100644 --- a/cmd/app/internal/database/master_skill_repository.go +++ b/cmd/app/internal/database/master_skill_repository.go @@ -1,16 +1,6 @@ package database -import ( - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) +import "github.com/hackmajoris/glad/cmd/app/internal/models" // MasterSkillRepository defines operations for master skills type MasterSkillRepository interface { @@ -20,168 +10,3 @@ type MasterSkillRepository interface { DeleteMasterSkill(skillID string) error ListMasterSkills() ([]*models.Skill, error) } - -// CreateMasterSkill inserts a new master skill -func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting master skill creation") - - skill.SetKeys() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - log.Info("Master skill created successfully", "duration", time.Since(start)) - return nil -} - -// GetMasterSkill retrieves a master skill by ID -func (r *DynamoDBRepository) GetMasterSkill(skillID string) (*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID) - start := time.Now() - - log.Debug("Starting master skill retrieval") - - entityID := BuildMasterSkillEntityID(skillID) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Debug("Master skill not found", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - var skill models.Skill - err = dynamodbattribute.UnmarshalMap(result.Item, &skill) - if err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("Master skill retrieved successfully", "duration", time.Since(start)) - return &skill, nil -} - -// UpdateMasterSkill updates an existing master skill -func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting master skill update") - - skill.SetKeys() - skill.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Master skill updated successfully", "duration", time.Since(start)) - return nil -} - -// DeleteMasterSkill removes a master skill -func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID) - start := time.Now() - - log.Debug("Starting master skill deletion") - - entityID := BuildMasterSkillEntityID(skillID) - - input := &dynamodb.DeleteItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err := r.client.DeleteItem(input) - if err != nil { - log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Master skill deleted successfully", "duration", time.Since(start)) - return nil -} - -// ListMasterSkills retrieves all master skills -func (r *DynamoDBRepository) ListMasterSkills() ([]*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "ListMasterSkills") - start := time.Now() - - log.Debug("Starting master skills list retrieval") - - // Use GSI for better performance instead of Scan - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIByEntityType), - KeyConditionExpression: aws.String("EntityType = :entityType"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":entityType": {S: aws.String("Skill")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query master skills", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.Skill - for i, item := range result.Items { - var skill models.Skill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Master skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} diff --git a/cmd/app/internal/database/master_skill_repository_dynamodb.go b/cmd/app/internal/database/master_skill_repository_dynamodb.go new file mode 100644 index 0000000..8980a47 --- /dev/null +++ b/cmd/app/internal/database/master_skill_repository_dynamodb.go @@ -0,0 +1,178 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// CreateMasterSkill inserts a new master skill +func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting master skill creation") + + skill.SetKeys() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + log.Info("Master skill created successfully", "duration", time.Since(start)) + return nil +} + +// GetMasterSkill retrieves a master skill by ID +func (r *DynamoDBRepository) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting master skill retrieval") + + entityID := BuildMasterSkillEntityID(skillID) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("Master skill not found", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + var skill models.Skill + err = dynamodbattribute.UnmarshalMap(result.Item, &skill) + if err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Master skill retrieved successfully", "duration", time.Since(start)) + return &skill, nil +} + +// UpdateMasterSkill updates an existing master skill +func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting master skill update") + + skill.SetKeys() + skill.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Master skill updated successfully", "duration", time.Since(start)) + return nil +} + +// DeleteMasterSkill removes a master skill +func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting master skill deletion") + + entityID := BuildMasterSkillEntityID(skillID) + + input := &dynamodb.DeleteItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err := r.client.DeleteItem(input) + if err != nil { + log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Master skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills +func (r *DynamoDBRepository) ListMasterSkills() ([]*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "ListMasterSkills") + start := time.Now() + + log.Debug("Starting master skills list retrieval") + + // Use GSI for better performance instead of Scan + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByEntityType), + KeyConditionExpression: aws.String("EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":entityType": {S: aws.String("Skill")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query master skills", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.Skill + for i, item := range result.Items { + var skill models.Skill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Master skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/master_skill_repository_mock.go b/cmd/app/internal/database/master_skill_repository_mock.go new file mode 100644 index 0000000..e8125a5 --- /dev/null +++ b/cmd/app/internal/database/master_skill_repository_mock.go @@ -0,0 +1,108 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// CreateMasterSkill creates a master skill in memory +func (m *MockRepository) CreateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skill.SkillID]; exists { + log.Debug("Master skill already exists", "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + m.masterSkills[skill.SkillID] = skill + log.Info("Master skill created successfully in mock repository", "total_master_skills", len(m.masterSkills), "duration", time.Since(start)) + return nil +} + +// GetMasterSkill retrieves a master skill from memory +func (m *MockRepository) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + skill, exists := m.masterSkills[skillID] + if !exists { + log.Debug("Master skill not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + log.Debug("Master skill retrieved successfully from mock repository", "duration", time.Since(start)) + return skill, nil +} + +// UpdateMasterSkill updates a master skill in memory +func (m *MockRepository) UpdateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skill.SkillID]; !exists { + log.Debug("Master skill not found for update", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + m.masterSkills[skill.SkillID] = skill + log.Info("Master skill updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// DeleteMasterSkill deletes a master skill from memory +func (m *MockRepository) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill deletion from mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skillID]; !exists { + log.Debug("Master skill not found for deletion", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + delete(m.masterSkills, skillID) + log.Info("Master skill deleted successfully from mock repository", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills from memory +func (m *MockRepository) ListMasterSkills() ([]*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "ListMasterSkills", "repository", "mock") + start := time.Now() + + log.Debug("Starting master skills list retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.Skill + for _, skill := range m.masterSkills { + skills = append(skills, skill) + } + + log.Info("Master skills retrieved successfully from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/mock.go b/cmd/app/internal/database/mock.go deleted file mode 100644 index b655bb6..0000000 --- a/cmd/app/internal/database/mock.go +++ /dev/null @@ -1,407 +0,0 @@ -package database - -import ( - "sync" - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" -) - -// MockRepository implements UserRepository, SkillRepository, and MasterSkillRepository for testing -// This matches the DynamoDBRepository structure with unified implementation -type MockRepository struct { - users map[string]*models.User // key: username - skills map[string]*models.UserSkill // key: "username#skillname" - masterSkills map[string]*models.Skill // key: skill_id - mutex sync.RWMutex -} - -// NewMockRepository creates a new unified mock repository -func NewMockRepository() *MockRepository { - log := logger.WithComponent("database") - log.Info("Initializing unified Mock repository for local development") - - repo := &MockRepository{ - users: make(map[string]*models.User), - skills: make(map[string]*models.UserSkill), - masterSkills: make(map[string]*models.Skill), - } - - log.Info("Unified Mock repository initialized successfully") - return repo -} - -// ============================================================================ -// USER REPOSITORY METHODS -// ============================================================================ - -// CreateUser creates a user in memory -func (m *MockRepository) CreateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username, "repository", "mock") - start := time.Now() - - log.Debug("Starting user creation in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.users[user.Username]; exists { - log.Debug("User already exists", "duration", time.Since(start)) - return apperrors.ErrUserExists - } - - m.users[user.Username] = user - log.Info("User created successfully in mock repository", "total_users", len(m.users), "duration", time.Since(start)) - return nil -} - -// GetUser retrieves a user from memory -func (m *MockRepository) GetUser(username string) (*models.User, error) { - log := logger.WithComponent("database").With("operation", "GetUser", "username", username, "repository", "mock") - start := time.Now() - - log.Debug("Starting user retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - user, exists := m.users[username] - if !exists { - log.Debug("User not found in mock repository", "duration", time.Since(start)) - return nil, apperrors.ErrUserNotFound - } - - log.Debug("User retrieved successfully from mock repository", "duration", time.Since(start)) - return user, nil -} - -// UpdateUser updates a user in memory -func (m *MockRepository) UpdateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username, "repository", "mock") - start := time.Now() - - log.Debug("Starting user update in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.users[user.Username]; !exists { - log.Debug("User not found for update", "duration", time.Since(start)) - return apperrors.ErrUserNotFound - } - - m.users[user.Username] = user - log.Info("User updated successfully in mock repository", "duration", time.Since(start)) - return nil -} - -// UserExists checks if a user exists in memory -func (m *MockRepository) UserExists(username string) (bool, error) { - log := logger.WithComponent("database").With("operation", "UserExists", "username", username, "repository", "mock") - start := time.Now() - - log.Debug("Checking if user exists in mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - _, exists := m.users[username] - log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) - return exists, nil -} - -// ListUsers retrieves all users from memory -func (m *MockRepository) ListUsers() ([]*models.User, error) { - log := logger.WithComponent("database").With("operation", "ListUsers", "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var users []*models.User - for _, user := range m.users { - users = append(users, user) - } - - log.Info("Users retrieved successfully from mock repository", "count", len(users), "duration", time.Since(start)) - return users, nil -} - -// ============================================================================ -// SKILL REPOSITORY METHODS -// ============================================================================ - -// CreateSkill creates a user skill in memory -func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill creation in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) - if _, exists := m.skills[key]; exists { - log.Debug("Skill already exists", "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - m.skills[key] = skill - log.Info("Skill created successfully in mock repository", "total_skills", len(m.skills), "duration", time.Since(start)) - return nil -} - -// GetSkill retrieves a user skill from memory -func (m *MockRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - key := models.BuildUserSkillEntityID(username, skillID) - skill, exists := m.skills[key] - if !exists { - log.Debug("Skill not found in mock repository", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - log.Debug("Skill retrieved successfully from mock repository", "duration", time.Since(start)) - return skill, nil -} - -// UpdateSkill updates a user skill in memory -func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill update in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) - if _, exists := m.skills[key]; !exists { - log.Debug("Skill not found for update", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - m.skills[key] = skill - log.Info("Skill updated successfully in mock repository", "duration", time.Since(start)) - return nil -} - -// DeleteSkill deletes a user skill from memory -func (m *MockRepository) DeleteSkill(username, skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill deletion from mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - key := models.BuildUserSkillEntityID(username, skillID) - if _, exists := m.skills[key]; !exists { - log.Debug("Skill not found for deletion", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - delete(m.skills, key) - log.Info("Skill deleted successfully from mock repository", "duration", time.Since(start)) - return nil -} - -// ListSkillsForUser retrieves all skills for a specific user from memory -func (m *MockRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username, "repository", "mock") - start := time.Now() - - log.Debug("Starting skills list retrieval for user from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.Username == username { - skills = append(skills, skill) - } - } - - log.Info("Skills retrieved successfully for user from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkill retrieves all users with a specific skill from memory -func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval by skill from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.SkillName == skillName { - skills = append(skills, skill) - } - } - - log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// QueryUserSkillsBySkillID retrieves all users with a specific skill from memory -func (m *MockRepository) QueryUserSkillsBySkillID(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval by skill from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.SkillName == skillName { - skills = append(skills, skill) - } - } - - log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkillAndLevel retrieves all users with a specific skill and proficiency level from memory -func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel, "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval by skill and level from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel { - skills = append(skills, skill) - } - } - - log.Info("Users retrieved successfully by skill and level from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ============================================================================ -// MASTER SKILL REPOSITORY METHODS -// ============================================================================ - -// CreateMasterSkill creates a master skill in memory -func (m *MockRepository) CreateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill creation in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.masterSkills[skill.SkillID]; exists { - log.Debug("Master skill already exists", "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - m.masterSkills[skill.SkillID] = skill - log.Info("Master skill created successfully in mock repository", "total_master_skills", len(m.masterSkills), "duration", time.Since(start)) - return nil -} - -// GetMasterSkill retrieves a master skill from memory -func (m *MockRepository) GetMasterSkill(skillID string) (*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - skill, exists := m.masterSkills[skillID] - if !exists { - log.Debug("Master skill not found in mock repository", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - log.Debug("Master skill retrieved successfully from mock repository", "duration", time.Since(start)) - return skill, nil -} - -// UpdateMasterSkill updates a master skill in memory -func (m *MockRepository) UpdateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill update in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.masterSkills[skill.SkillID]; !exists { - log.Debug("Master skill not found for update", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - m.masterSkills[skill.SkillID] = skill - log.Info("Master skill updated successfully in mock repository", "duration", time.Since(start)) - return nil -} - -// DeleteMasterSkill deletes a master skill from memory -func (m *MockRepository) DeleteMasterSkill(skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill deletion from mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.masterSkills[skillID]; !exists { - log.Debug("Master skill not found for deletion", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - delete(m.masterSkills, skillID) - log.Info("Master skill deleted successfully from mock repository", "duration", time.Since(start)) - return nil -} - -// ListMasterSkills retrieves all master skills from memory -func (m *MockRepository) ListMasterSkills() ([]*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "ListMasterSkills", "repository", "mock") - start := time.Now() - - log.Debug("Starting master skills list retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.Skill - for _, skill := range m.masterSkills { - skills = append(skills, skill) - } - - log.Info("Master skills retrieved successfully from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} diff --git a/cmd/app/internal/database/skill_repository.go b/cmd/app/internal/database/skill_repository.go deleted file mode 100644 index 60455d0..0000000 --- a/cmd/app/internal/database/skill_repository.go +++ /dev/null @@ -1,9 +0,0 @@ -package database - -// DEPRECATED: This file is kept for backwards compatibility. -// Repository implementations have been moved to: -// - master_skill_repository.go (MasterSkillRepository) -// - user_skill_repository.go (SkillRepository) -// - constants.go (GSI constants) -// -// This file will be removed in a future version. diff --git a/cmd/app/internal/database/user_repository.go b/cmd/app/internal/database/user_repository.go index 9acd381..893309b 100644 --- a/cmd/app/internal/database/user_repository.go +++ b/cmd/app/internal/database/user_repository.go @@ -1,20 +1,6 @@ package database -import ( - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) - -// ============================================================================ -// USER REPOSITORY METHODS -// ============================================================================ +import "github.com/hackmajoris/glad/cmd/app/internal/models" // UserRepository defines the interface for user data operations type UserRepository interface { @@ -24,172 +10,3 @@ type UserRepository interface { UserExists(username string) (bool, error) ListUsers() ([]*models.User, error) } - -// CreateUser inserts a new user into DynamoDB -func (r *DynamoDBRepository) CreateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username) - start := time.Now() - - log.Debug("Starting user creation") - - // Ensure keys are set - user.SetKeys() - - item, err := dynamodbattribute.MarshalMap(user) - if err != nil { - log.Error("Failed to marshal user data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return err - } - - log.Info("User created successfully", "duration", time.Since(start)) - return nil -} - -// GetUser retrieves a user by username from DynamoDB -func (r *DynamoDBRepository) GetUser(username string) (*models.User, error) { - log := logger.WithComponent("database").With("operation", "GetUser", "username", username) - start := time.Now() - - log.Debug("Starting user retrieval") - - entityID := models.BuildUserEntityID(username) - log.Info("Attempting to retrieve user", "entity_id", entityID, "table", TableName) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get user from DynamoDB", "error", err.Error(), "entity_id", entityID, "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Info("User not found in DynamoDB", "entity_id", entityID, "duration", time.Since(start)) - return nil, apperrors.ErrUserNotFound - } - - var user models.User - err = dynamodbattribute.UnmarshalMap(result.Item, &user) - if err != nil { - log.Error("Failed to unmarshal user data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("User retrieved successfully", "duration", time.Since(start)) - return &user, nil -} - -// UserExists checks if a user exists in DynamoDB -func (r *DynamoDBRepository) UserExists(username string) (bool, error) { - log := logger.WithComponent("database").With("operation", "UserExists", "username", username) - start := time.Now() - - log.Debug("Checking if user exists") - - entityID := models.BuildUserEntityID(username) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - ProjectionExpression: aws.String("entity_id"), - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to check user existence", "error", err.Error(), "duration", time.Since(start)) - return false, err - } - - exists := result.Item != nil - log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) - return exists, nil -} - -// UpdateUser updates an existing user in DynamoDB -func (r *DynamoDBRepository) UpdateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username) - start := time.Now() - - log.Debug("Starting user update") - - // Ensure keys are set - user.SetKeys() - user.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(user) - if err != nil { - log.Error("Failed to marshal user data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return err - } - - log.Info("User updated successfully", "duration", time.Since(start)) - return nil -} - -// ListUsers retrieves all users from DynamoDB using Query on EntityType -func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { - log := logger.WithComponent("database").With("operation", "ListUsers") - start := time.Now() - - log.Debug("Starting users list retrieval") - - // Use Scan with filter for EntityType = "User" - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIByEntityType), - KeyConditionExpression: aws.String("EntityType = :entityType"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":entityType": {S: aws.String("User")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to scan users table", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var users []*models.User - for i, item := range result.Items { - var user models.User - if err := dynamodbattribute.UnmarshalMap(item, &user); err != nil { - log.Error("Failed to unmarshal user data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - return nil, err - } - users = append(users, &user) - } - - log.Info("Users retrieved successfully", "count", len(users), "scanned_count", *result.ScannedCount, "duration", time.Since(start)) - return users, nil -} diff --git a/cmd/app/internal/database/user_repository_dynamodb.go b/cmd/app/internal/database/user_repository_dynamodb.go new file mode 100644 index 0000000..2e6cc06 --- /dev/null +++ b/cmd/app/internal/database/user_repository_dynamodb.go @@ -0,0 +1,182 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// CreateUser inserts a new user into DynamoDB +func (r *DynamoDBRepository) CreateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username) + start := time.Now() + + log.Debug("Starting user creation") + + // Ensure keys are set + user.SetKeys() + + item, err := dynamodbattribute.MarshalMap(user) + if err != nil { + log.Error("Failed to marshal user data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("User created successfully", "duration", time.Since(start)) + return nil +} + +// GetUser retrieves a user by username from DynamoDB +func (r *DynamoDBRepository) GetUser(username string) (*models.User, error) { + log := logger.WithComponent("database").With("operation", "GetUser", "username", username) + start := time.Now() + + log.Debug("Starting user retrieval") + + entityID := models.BuildUserEntityID(username) + log.Info("Attempting to retrieve user", "entity_id", entityID, "table", TableName) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get user from DynamoDB", "error", err.Error(), "entity_id", entityID, "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Info("User not found in DynamoDB", "entity_id", entityID, "duration", time.Since(start)) + return nil, apperrors.ErrUserNotFound + } + + var user models.User + err = dynamodbattribute.UnmarshalMap(result.Item, &user) + if err != nil { + log.Error("Failed to unmarshal user data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("User retrieved successfully", "duration", time.Since(start)) + return &user, nil +} + +// UserExists checks if a user exists in DynamoDB +func (r *DynamoDBRepository) UserExists(username string) (bool, error) { + log := logger.WithComponent("database").With("operation", "UserExists", "username", username) + start := time.Now() + + log.Debug("Checking if user exists") + + entityID := models.BuildUserEntityID(username) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ProjectionExpression: aws.String("entity_id"), + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to check user existence", "error", err.Error(), "duration", time.Since(start)) + return false, err + } + + exists := result.Item != nil + log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) + return exists, nil +} + +// UpdateUser updates an existing user in DynamoDB +func (r *DynamoDBRepository) UpdateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username) + start := time.Now() + + log.Debug("Starting user update") + + // Ensure keys are set + user.SetKeys() + user.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(user) + if err != nil { + log.Error("Failed to marshal user data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("User updated successfully", "duration", time.Since(start)) + return nil +} + +// ListUsers retrieves all users from DynamoDB using Query on EntityType +func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { + log := logger.WithComponent("database").With("operation", "ListUsers") + start := time.Now() + + log.Debug("Starting users list retrieval") + + // Use Scan with filter for EntityType = "User" + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByEntityType), + KeyConditionExpression: aws.String("EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":entityType": {S: aws.String("User")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to scan users table", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var users []*models.User + for i, item := range result.Items { + var user models.User + if err := dynamodbattribute.UnmarshalMap(item, &user); err != nil { + log.Error("Failed to unmarshal user data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + return nil, err + } + users = append(users, &user) + } + + log.Info("Users retrieved successfully", "count", len(users), "scanned_count", *result.ScannedCount, "duration", time.Since(start)) + return users, nil +} diff --git a/cmd/app/internal/database/user_repository_mock.go b/cmd/app/internal/database/user_repository_mock.go new file mode 100644 index 0000000..481bcf0 --- /dev/null +++ b/cmd/app/internal/database/user_repository_mock.go @@ -0,0 +1,103 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// CreateUser creates a user in memory +func (m *MockRepository) CreateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username, "repository", "mock") + start := time.Now() + + log.Debug("Starting user creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.users[user.Username]; exists { + log.Debug("User already exists", "duration", time.Since(start)) + return apperrors.ErrUserExists + } + + m.users[user.Username] = user + log.Info("User created successfully in mock repository", "total_users", len(m.users), "duration", time.Since(start)) + return nil +} + +// GetUser retrieves a user from memory +func (m *MockRepository) GetUser(username string) (*models.User, error) { + log := logger.WithComponent("database").With("operation", "GetUser", "username", username, "repository", "mock") + start := time.Now() + + log.Debug("Starting user retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + user, exists := m.users[username] + if !exists { + log.Debug("User not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrUserNotFound + } + + log.Debug("User retrieved successfully from mock repository", "duration", time.Since(start)) + return user, nil +} + +// UpdateUser updates a user in memory +func (m *MockRepository) UpdateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username, "repository", "mock") + start := time.Now() + + log.Debug("Starting user update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.users[user.Username]; !exists { + log.Debug("User not found for update", "duration", time.Since(start)) + return apperrors.ErrUserNotFound + } + + m.users[user.Username] = user + log.Info("User updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// UserExists checks if a user exists in memory +func (m *MockRepository) UserExists(username string) (bool, error) { + log := logger.WithComponent("database").With("operation", "UserExists", "username", username, "repository", "mock") + start := time.Now() + + log.Debug("Checking if user exists in mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + _, exists := m.users[username] + log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) + return exists, nil +} + +// ListUsers retrieves all users from memory +func (m *MockRepository) ListUsers() ([]*models.User, error) { + log := logger.WithComponent("database").With("operation", "ListUsers", "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var users []*models.User + for _, user := range m.users { + users = append(users, user) + } + + log.Info("Users retrieved successfully from mock repository", "count", len(users), "duration", time.Since(start)) + return users, nil +} diff --git a/cmd/app/internal/database/user_skill_repository.go b/cmd/app/internal/database/user_skill_repository.go index 40b72df..d22e79a 100644 --- a/cmd/app/internal/database/user_skill_repository.go +++ b/cmd/app/internal/database/user_skill_repository.go @@ -1,16 +1,6 @@ package database -import ( - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) +import "github.com/hackmajoris/glad/cmd/app/internal/models" // SkillRepository defines operations for user skills type SkillRepository interface { @@ -23,281 +13,3 @@ type SkillRepository interface { ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) } - -// CreateSkill inserts a new user skill into DynamoDB -func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting skill creation") - - // Ensure keys are set - skill.SetKeys() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - log.Info("Skill created successfully", "duration", time.Since(start)) - return nil -} - -// GetSkill retrieves a specific skill for a user by skill_id -func (r *DynamoDBRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID) - start := time.Now() - - log.Debug("Starting skill retrieval") - - entityID := BuildUserSkillEntityID(username, skillID) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Debug("Skill not found", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - var skill models.UserSkill - err = dynamodbattribute.UnmarshalMap(result.Item, &skill) - if err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("Skill retrieved successfully", "duration", time.Since(start)) - return &skill, nil -} - -// UpdateSkill updates an existing skill -func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting skill update") - - // Ensure keys are set - skill.SetKeys() - skill.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Skill updated successfully", "duration", time.Since(start)) - return nil -} - -// DeleteSkill removes a skill from a user -func (r *DynamoDBRepository) DeleteSkill(username, skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID) - start := time.Now() - - log.Debug("Starting skill deletion") - - entityID := BuildUserSkillEntityID(username, skillID) - - input := &dynamodb.DeleteItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err := r.client.DeleteItem(input) - if err != nil { - log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Skill deleted successfully", "duration", time.Since(start)) - return nil -} - -// ListSkillsForUser retrieves all skills for a specific user using GSI ByUser -func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username) - start := time.Now() - - log.Debug("Starting skills list retrieval for user") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIByUser), - KeyConditionExpression: aws.String("Username = :username AND EntityType = :entityType"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":username": {S: aws.String(username)}, - ":entityType": {S: aws.String("UserSkill")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query skills for user", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkill retrieves all users who have a specific skill using GSI SkillsByLevel -func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName) - start := time.Now() - - log.Debug("Starting users list retrieval by skill") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSISkillsByLevel), - KeyConditionExpression: aws.String("SkillName = :skillName"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":skillName": {S: aws.String(skillName)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query users by skill", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level -// Uses composite partition key on SkillName + ProficiencyLevel -func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) - start := time.Now() - - log.Debug("Starting users list retrieval by skill and level") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSISkillsByLevel), - KeyConditionExpression: aws.String("SkillName = :skillName AND ProficiencyLevel = :level"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":skillName": {S: aws.String(skillName)}, - ":level": {S: aws.String(string(proficiencyLevel))}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query users by skill and level", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// QueryUserSkillsBySkillID retrieves all UserSkills that reference a specific skill_id -// Used when syncing denormalized data after master skill updates -func (r *DynamoDBRepository) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "QueryUserSkillsBySkillID", "skill_id", skillID) - start := time.Now() - - log.Debug("Starting UserSkills retrieval by skill_id") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIBySkillID), - KeyConditionExpression: aws.String("skill_id = :skillID"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":skillID": {S: aws.String(skillID)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query UserSkills by skill_id", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("UserSkills by skill_id retrieved successfully", "skill_id", skillID, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} diff --git a/cmd/app/internal/database/user_skill_repository_dynamodb.go b/cmd/app/internal/database/user_skill_repository_dynamodb.go new file mode 100644 index 0000000..96ba229 --- /dev/null +++ b/cmd/app/internal/database/user_skill_repository_dynamodb.go @@ -0,0 +1,291 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// CreateSkill inserts a new user skill into DynamoDB +func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting skill creation") + + // Ensure keys are set + skill.SetKeys() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + log.Info("Skill created successfully", "duration", time.Since(start)) + return nil +} + +// GetSkill retrieves a specific skill for a user by skill_id +func (r *DynamoDBRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID) + start := time.Now() + + log.Debug("Starting skill retrieval") + + entityID := BuildUserSkillEntityID(username, skillID) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("Skill not found", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + var skill models.UserSkill + err = dynamodbattribute.UnmarshalMap(result.Item, &skill) + if err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Skill retrieved successfully", "duration", time.Since(start)) + return &skill, nil +} + +// UpdateSkill updates an existing skill +func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting skill update") + + // Ensure keys are set + skill.SetKeys() + skill.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Skill updated successfully", "duration", time.Since(start)) + return nil +} + +// DeleteSkill removes a skill from a user +func (r *DynamoDBRepository) DeleteSkill(username, skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID) + start := time.Now() + + log.Debug("Starting skill deletion") + + entityID := BuildUserSkillEntityID(username, skillID) + + input := &dynamodb.DeleteItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err := r.client.DeleteItem(input) + if err != nil { + log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + log.Info("Skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListSkillsForUser retrieves all skills for a specific user using GSI ByUser +func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username) + start := time.Now() + + log.Debug("Starting skills list retrieval for user") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByUser), + KeyConditionExpression: aws.String("Username = :username AND EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":username": {S: aws.String(username)}, + ":entityType": {S: aws.String("UserSkill")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query skills for user", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkill retrieves all users who have a specific skill using GSI SkillsByLevel +func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName) + start := time.Now() + + log.Debug("Starting users list retrieval by skill") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSISkillsByLevel), + KeyConditionExpression: aws.String("SkillName = :skillName"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillName": {S: aws.String(skillName)}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users by skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level +// Uses composite partition key on SkillName + ProficiencyLevel +func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) + start := time.Now() + + log.Debug("Starting users list retrieval by skill and level") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSISkillsByLevel), + KeyConditionExpression: aws.String("SkillName = :skillName AND ProficiencyLevel = :level"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillName": {S: aws.String(skillName)}, + ":level": {S: aws.String(string(proficiencyLevel))}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users by skill and level", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// QueryUserSkillsBySkillID retrieves all UserSkills that reference a specific skill_id +// Used when syncing denormalized data after master skill updates +func (r *DynamoDBRepository) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "QueryUserSkillsBySkillID", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting UserSkills retrieval by skill_id") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIBySkillID), + KeyConditionExpression: aws.String("skill_id = :skillID"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillID": {S: aws.String(skillID)}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query UserSkills by skill_id", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("UserSkills by skill_id retrieved successfully", "skill_id", skillID, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/user_skill_repository_mock.go b/cmd/app/internal/database/user_skill_repository_mock.go new file mode 100644 index 0000000..97b920b --- /dev/null +++ b/cmd/app/internal/database/user_skill_repository_mock.go @@ -0,0 +1,177 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// CreateSkill creates a user skill in memory +func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) + if _, exists := m.skills[key]; exists { + log.Debug("Skill already exists", "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + m.skills[key] = skill + log.Info("Skill created successfully in mock repository", "total_skills", len(m.skills), "duration", time.Since(start)) + return nil +} + +// GetSkill retrieves a user skill from memory +func (m *MockRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + key := models.BuildUserSkillEntityID(username, skillID) + skill, exists := m.skills[key] + if !exists { + log.Debug("Skill not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + log.Debug("Skill retrieved successfully from mock repository", "duration", time.Since(start)) + return skill, nil +} + +// UpdateSkill updates a user skill in memory +func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) + if _, exists := m.skills[key]; !exists { + log.Debug("Skill not found for update", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + m.skills[key] = skill + log.Info("Skill updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// DeleteSkill deletes a user skill from memory +func (m *MockRepository) DeleteSkill(username, skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill deletion from mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := models.BuildUserSkillEntityID(username, skillID) + if _, exists := m.skills[key]; !exists { + log.Debug("Skill not found for deletion", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + delete(m.skills, key) + log.Info("Skill deleted successfully from mock repository", "duration", time.Since(start)) + return nil +} + +// ListSkillsForUser retrieves all skills for a specific user from memory +func (m *MockRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username, "repository", "mock") + start := time.Now() + + log.Debug("Starting skills list retrieval for user from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.Username == username { + skills = append(skills, skill) + } + } + + log.Info("Skills retrieved successfully for user from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkill retrieves all users with a specific skill from memory +func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// QueryUserSkillsBySkillID retrieves all users with a specific skill from memory +func (m *MockRepository) QueryUserSkillsBySkillID(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkillAndLevel retrieves all users with a specific skill and proficiency level from memory +func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill and level from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill and level from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/models/keys.go b/cmd/app/internal/models/utils.go similarity index 100% rename from cmd/app/internal/models/keys.go rename to cmd/app/internal/models/utils.go diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 From 99fc416b63effb017b39015d66cd00c40e015598 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:19:56 +0200 Subject: [PATCH 05/12] Update cmd/app/internal/database/user_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/user_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/user_repository_dynamodb.go b/cmd/app/internal/database/user_repository_dynamodb.go index 2e6cc06..b48fd22 100644 --- a/cmd/app/internal/database/user_repository_dynamodb.go +++ b/cmd/app/internal/database/user_repository_dynamodb.go @@ -151,7 +151,7 @@ func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { log.Debug("Starting users list retrieval") - // Use Scan with filter for EntityType = "User" + // Use Query on GSI for EntityType = "User" input := &dynamodb.QueryInput{ TableName: aws.String(TableName), IndexName: aws.String(GSIByEntityType), From e280a706a55ca9e7fb71fb98c86e540300c9b920 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:20:12 +0200 Subject: [PATCH 06/12] Update cmd/app/internal/database/user_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/user_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/user_repository_dynamodb.go b/cmd/app/internal/database/user_repository_dynamodb.go index b48fd22..6410dfb 100644 --- a/cmd/app/internal/database/user_repository_dynamodb.go +++ b/cmd/app/internal/database/user_repository_dynamodb.go @@ -163,7 +163,7 @@ func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { result, err := r.client.Query(input) if err != nil { - log.Error("Failed to scan users table", "error", err.Error(), "duration", time.Since(start)) + log.Error("Failed to query users table", "error", err.Error(), "duration", time.Since(start)) return nil, err } From 56a6285b5e8afae321717ae7a4b1b5819de06726 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:20:25 +0200 Subject: [PATCH 07/12] Update cmd/app/internal/database/user_skill_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/user_skill_repository_dynamodb.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/app/internal/database/user_skill_repository_dynamodb.go b/cmd/app/internal/database/user_skill_repository_dynamodb.go index 96ba229..bc22e59 100644 --- a/cmd/app/internal/database/user_skill_repository_dynamodb.go +++ b/cmd/app/internal/database/user_skill_repository_dynamodb.go @@ -34,10 +34,11 @@ func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { ConditionExpression: aws.String("attribute_not_exists(entity_id)"), } + _, err = r.client.PutItem(input) _, err = r.client.PutItem(input) if err != nil { log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists + return err } log.Info("Skill created successfully", "duration", time.Since(start)) From 837cc5728fc42dd31c1d2634ba4782551d289db7 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:20:41 +0200 Subject: [PATCH 08/12] Update cmd/app/internal/database/user_skill_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/user_skill_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/user_skill_repository_dynamodb.go b/cmd/app/internal/database/user_skill_repository_dynamodb.go index bc22e59..57b3124 100644 --- a/cmd/app/internal/database/user_skill_repository_dynamodb.go +++ b/cmd/app/internal/database/user_skill_repository_dynamodb.go @@ -109,7 +109,7 @@ func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { _, err = r.client.PutItem(input) if err != nil { log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound + return err } log.Info("Skill updated successfully", "duration", time.Since(start)) From 496ca7bfebe9238edd5e311fb8c5eaf6a34ef10e Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:20:54 +0200 Subject: [PATCH 09/12] Update cmd/app/internal/database/user_skill_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/user_skill_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/user_skill_repository_dynamodb.go b/cmd/app/internal/database/user_skill_repository_dynamodb.go index 57b3124..4dc25ec 100644 --- a/cmd/app/internal/database/user_skill_repository_dynamodb.go +++ b/cmd/app/internal/database/user_skill_repository_dynamodb.go @@ -136,7 +136,7 @@ func (r *DynamoDBRepository) DeleteSkill(username, skillID string) error { _, err := r.client.DeleteItem(input) if err != nil { log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound + return err } log.Info("Skill deleted successfully", "duration", time.Since(start)) From 3465e387c3554db48af001bdc3b6ce6688ef40b5 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:21:05 +0200 Subject: [PATCH 10/12] Update cmd/app/internal/database/master_skill_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/master_skill_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/master_skill_repository_dynamodb.go b/cmd/app/internal/database/master_skill_repository_dynamodb.go index 8980a47..09c95b3 100644 --- a/cmd/app/internal/database/master_skill_repository_dynamodb.go +++ b/cmd/app/internal/database/master_skill_repository_dynamodb.go @@ -36,7 +36,7 @@ func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error { _, err = r.client.PutItem(input) if err != nil { log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists + return err } log.Info("Master skill created successfully", "duration", time.Since(start)) From 91ced37aa5e5b6bc6d15a01ee098a3b1bfc7499c Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:21:16 +0200 Subject: [PATCH 11/12] Update cmd/app/internal/database/master_skill_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/master_skill_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/master_skill_repository_dynamodb.go b/cmd/app/internal/database/master_skill_repository_dynamodb.go index 09c95b3..b7f327e 100644 --- a/cmd/app/internal/database/master_skill_repository_dynamodb.go +++ b/cmd/app/internal/database/master_skill_repository_dynamodb.go @@ -106,7 +106,7 @@ func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error { _, err = r.client.PutItem(input) if err != nil { log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound + return err } log.Info("Master skill updated successfully", "duration", time.Since(start)) From c55cb4cfb8ccf7237d1b2a6fac370b110006749e Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 14 Dec 2025 14:21:26 +0200 Subject: [PATCH 12/12] Update cmd/app/internal/database/master_skill_repository_dynamodb.go Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com> --- cmd/app/internal/database/master_skill_repository_dynamodb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/internal/database/master_skill_repository_dynamodb.go b/cmd/app/internal/database/master_skill_repository_dynamodb.go index b7f327e..99397c1 100644 --- a/cmd/app/internal/database/master_skill_repository_dynamodb.go +++ b/cmd/app/internal/database/master_skill_repository_dynamodb.go @@ -133,7 +133,7 @@ func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error { _, err := r.client.DeleteItem(input) if err != nil { log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound + return err } log.Info("Master skill deleted successfully", "duration", time.Since(start))