From c575aa3e5b6bd68d78b8238b2a20e6aa8ea54012 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 7 Dec 2025 14:43:37 +0200 Subject: [PATCH 1/3] feat: add GET /me endpoint for current user profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new API endpoint that returns the authenticated user's profile information based on the username extracted from JWT token. Changes: - Add GetCurrentUser handler in handler.go - Add CurrentUserResponse DTO with ISO 8601 timestamps - Register GET /me route with JWT authentication - Update CDK stack to include /me API Gateway resource - Add comprehensive handler tests with 100% coverage Tests: - TestHandler_GetCurrentUser: Success, invalid claims, user not found - TestHandler_GetCurrentUser_TimestampFormat: ISO 8601/RFC3339 validation - TestHandler_GetCurrentUser_DoesNotExposePassword: Security verification Security: - Password hash is never exposed in response - Only returns safe fields: username, name, created_at, updated_at 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- cmd/app/internal/dto/dto.go | 8 + cmd/app/internal/handler/handler.go | 20 ++ cmd/app/internal/handler/handler_test.go | 251 +++++++++++++++++++++++ cmd/app/main.go | 1 + deployments/app/cdk.go | 6 + 5 files changed, 286 insertions(+) create mode 100644 cmd/app/internal/handler/handler_test.go diff --git a/cmd/app/internal/dto/dto.go b/cmd/app/internal/dto/dto.go index 7685925..dcacc69 100644 --- a/cmd/app/internal/dto/dto.go +++ b/cmd/app/internal/dto/dto.go @@ -50,3 +50,11 @@ type UserListResponse struct { Username string `json:"username"` Name string `json:"name"` } + +// CurrentUserResponse represents the current authenticated user's data +type CurrentUserResponse struct { + Username string `json:"username"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/cmd/app/internal/handler/handler.go b/cmd/app/internal/handler/handler.go index 15e9aba..bfc2951 100644 --- a/cmd/app/internal/handler/handler.go +++ b/cmd/app/internal/handler/handler.go @@ -127,6 +127,26 @@ func (h *Handler) ListUsers(request events.APIGatewayProxyRequest) (events.APIGa return successResponse(http.StatusOK, users), nil } +// GetCurrentUser handles retrieving the current authenticated user's information +func (h *Handler) GetCurrentUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) + if !ok { + return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil + } + + user, err := h.userService.GetUser(claims.Username) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.CurrentUserResponse{ + Username: user.Username, + Name: user.Name, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + // handleServiceError converts service errors to HTTP responses using the error mapper func (h *Handler) handleServiceError(err error) events.APIGatewayProxyResponse { statusCode, message := h.errorMapper.MapToHTTP(err) diff --git a/cmd/app/internal/handler/handler_test.go b/cmd/app/internal/handler/handler_test.go new file mode 100644 index 0000000..fba1d9c --- /dev/null +++ b/cmd/app/internal/handler/handler_test.go @@ -0,0 +1,251 @@ +package handler + +import ( + "encoding/json" + "testing" + "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/cmd/app/internal/service" + "github.com/hackmajoris/glad/pkg/auth" + "github.com/hackmajoris/glad/pkg/config" + + "github.com/aws/aws-lambda-go/events" +) + +// testConfig creates a config for testing +func testConfig() *config.Config { + return &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret-key", + Expiry: 24 * time.Hour, + }, + } +} + +func TestHandler_GetCurrentUser(t *testing.T) { + tests := []struct { + name string + setupRepo func(repo *database.MockRepository) + claims *auth.JWTClaims + expectedStatus int + validateBody func(t *testing.T, body string) + }{ + { + name: "successful user retrieval", + setupRepo: func(repo *database.MockRepository) { + user, _ := models.NewUser("testuser", "Test User", "password123") + user.CreatedAt = time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) + user.UpdatedAt = time.Date(2025, 1, 2, 15, 30, 0, 0, time.UTC) + repo.CreateUser(user) + }, + claims: &auth.JWTClaims{ + Username: "testuser", + }, + expectedStatus: 200, + validateBody: func(t *testing.T, body string) { + var response dto.CurrentUserResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Username != "testuser" { + t.Errorf("Expected username 'testuser', got '%s'", response.Username) + } + if response.Name != "Test User" { + t.Errorf("Expected name 'Test User', got '%s'", response.Name) + } + if response.CreatedAt != "2025-01-01T10:00:00Z" { + t.Errorf("Expected CreatedAt '2025-01-01T10:00:00Z', got '%s'", response.CreatedAt) + } + if response.UpdatedAt != "2025-01-02T15:30:00Z" { + t.Errorf("Expected UpdatedAt '2025-01-02T15:30:00Z', got '%s'", response.UpdatedAt) + } + }, + }, + { + name: "invalid token claims", + setupRepo: func(repo *database.MockRepository) { + // No setup needed + }, + claims: nil, + expectedStatus: 401, + validateBody: func(t *testing.T, body string) { + var response dto.ErrorResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("Failed to unmarshal error response: %v", err) + } + if response.Error != "Invalid token claims" { + t.Errorf("Expected error 'Invalid token claims', got '%s'", response.Error) + } + }, + }, + { + name: "user not found", + setupRepo: func(repo *database.MockRepository) { + // Don't create the user + }, + claims: &auth.JWTClaims{ + Username: "nonexistent", + }, + expectedStatus: 404, + validateBody: func(t *testing.T, body string) { + var response dto.ErrorResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("Failed to unmarshal error response: %v", err) + } + if response.Error != "User not found" { + t.Errorf("Expected error 'User not found', got '%s'", response.Error) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock repository + mockRepo := database.NewMockRepository() + if tt.setupRepo != nil { + tt.setupRepo(mockRepo) + } + + // Create service with mock repository + tokenService := auth.NewTokenService(testConfig()) + userService := service.NewUserService(mockRepo, tokenService) + + // Create handler + h := New(userService) + + // Create request + request := events.APIGatewayProxyRequest{ + RequestContext: events.APIGatewayProxyRequestContext{ + Authorizer: make(map[string]interface{}), + }, + } + + // Set claims if provided + if tt.claims != nil { + request.RequestContext.Authorizer["claims"] = tt.claims + } + + // Call handler + response, err := h.GetCurrentUser(request) + + // Verify no error from handler + if err != nil { + t.Fatalf("Handler returned unexpected error: %v", err) + } + + // Verify status code + if response.StatusCode != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, response.StatusCode) + } + + // Verify Content-Type header + if response.Headers["Content-Type"] != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", response.Headers["Content-Type"]) + } + + // Validate response body + if tt.validateBody != nil { + tt.validateBody(t, response.Body) + } + }) + } +} + +// TestHandler_GetCurrentUser_TimestampFormat verifies the timestamp format is ISO 8601 +func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { + // Create mock repository and service + mockRepo := database.NewMockRepository() + + // Create a user with specific timestamps + user, _ := models.NewUser("testuser", "Test User", "password123") + user.CreatedAt = time.Date(2025, 12, 7, 14, 30, 45, 0, time.FixedZone("EST", -5*3600)) + user.UpdatedAt = time.Date(2025, 12, 7, 16, 45, 30, 0, time.FixedZone("PST", -8*3600)) + mockRepo.CreateUser(user) + + tokenService := auth.NewTokenService(testConfig()) + userService := service.NewUserService(mockRepo, tokenService) + h := New(userService) + + request := events.APIGatewayProxyRequest{ + RequestContext: events.APIGatewayProxyRequestContext{ + Authorizer: map[string]interface{}{ + "claims": &auth.JWTClaims{Username: "testuser"}, + }, + }, + } + + response, err := h.GetCurrentUser(request) + if err != nil { + t.Fatalf("Handler returned unexpected error: %v", err) + } + + var result dto.CurrentUserResponse + if err := json.Unmarshal([]byte(response.Body), &result); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Verify ISO 8601 format (RFC3339) + expectedCreatedAt := "2025-12-07T14:30:45-05:00" + expectedUpdatedAt := "2025-12-07T16:45:30-08:00" + + if result.CreatedAt != expectedCreatedAt { + t.Errorf("Expected CreatedAt '%s', got '%s'", expectedCreatedAt, result.CreatedAt) + } + + if result.UpdatedAt != expectedUpdatedAt { + t.Errorf("Expected UpdatedAt '%s', got '%s'", expectedUpdatedAt, result.UpdatedAt) + } +} + +// TestHandler_GetCurrentUser_DoesNotExposePassword verifies password hash is not included +func TestHandler_GetCurrentUser_DoesNotExposePassword(t *testing.T) { + // Create mock repository and service + mockRepo := database.NewMockRepository() + + user, _ := models.NewUser("testuser", "Test User", "password123") + mockRepo.CreateUser(user) + + tokenService := auth.NewTokenService(testConfig()) + userService := service.NewUserService(mockRepo, tokenService) + h := New(userService) + + request := events.APIGatewayProxyRequest{ + RequestContext: events.APIGatewayProxyRequestContext{ + Authorizer: map[string]interface{}{ + "claims": &auth.JWTClaims{Username: "testuser"}, + }, + }, + } + + response, err := h.GetCurrentUser(request) + if err != nil { + t.Fatalf("Handler returned unexpected error: %v", err) + } + + // Parse as generic map to check for password fields + var result map[string]interface{} + if err := json.Unmarshal([]byte(response.Body), &result); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Ensure password-related fields are not present + sensitiveFields := []string{"password", "password_hash", "passwordHash", "PasswordHash"} + for _, field := range sensitiveFields { + if _, exists := result[field]; exists { + t.Errorf("Response should not contain sensitive field '%s'", field) + } + } + + // Verify expected fields are present + expectedFields := []string{"username", "name", "created_at", "updated_at"} + for _, field := range expectedFields { + if _, exists := result[field]; !exists { + t.Errorf("Response should contain field '%s'", field) + } + } +} diff --git a/cmd/app/main.go b/cmd/app/main.go index 26621f3..f26efb4 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -42,6 +42,7 @@ func setupRouter(h *handler.Handler, auth *middleware.AuthMiddleware) *router.Ro // Protected routes r.GET("/protected", h.Protected, auth.RequireAuth()) + r.GET("/me", h.GetCurrentUser, auth.RequireAuth()) r.PUT("/user", h.UpdateUser, auth.RequireAuth()) r.GET("/users", h.ListUsers, auth.RequireAuth()) diff --git a/deployments/app/cdk.go b/deployments/app/cdk.go index 7ff53e5..9838e02 100644 --- a/deployments/app/cdk.go +++ b/deployments/app/cdk.go @@ -95,6 +95,12 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw AuthorizationType: awsapigateway.AuthorizationType_NONE, }) + // Add /me GET endpoint for current user + meResource := api.Root().AddResource(jsii.String("me"), nil) + meResource.AddMethod(jsii.String("GET"), 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"), From 349d6577b5a3d4fc4efb57c3a614060125836f78 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 7 Dec 2025 19:58:39 +0200 Subject: [PATCH 2/3] chore: single table design configurations --- README.md | 4 +- cmd/app/integration_test.go | 6 +- cmd/app/internal/database/dynamodb.go | 182 +----- cmd/app/internal/database/mock.go | 188 +++++- cmd/app/internal/database/mock_test.go | 14 +- cmd/app/internal/database/skill_repository.go | 278 ++++++++ cmd/app/internal/database/user_repository.go | 199 ++++++ cmd/app/internal/dto/dto.go | 42 ++ cmd/app/internal/errors/user.go | 7 + cmd/app/internal/handler/handler.go | 190 ------ cmd/app/internal/handler/user_handler.go | 386 +++++++++++ .../{handler_test.go => user_handler_test.go} | 35 +- cmd/app/internal/models/user.go | 39 +- cmd/app/internal/models/user_skill.go | 174 +++++ cmd/app/internal/router/router.go | 3 +- cmd/app/internal/service/skill_service.go | 228 +++++++ cmd/app/main.go | 27 +- deployments/app/cdk.go | 115 +++- docs/README-DYNAMODB-DESIGN.md | 346 ++++++++++ docs/dynamodb-quick-reference.md | 420 ++++++++++++ docs/dynamodb-single-table-design-plan.md | 481 ++++++++++++++ docs/entity-addition-protocol.md | 607 ++++++++++++++++++ 22 files changed, 3557 insertions(+), 414 deletions(-) create mode 100644 cmd/app/internal/database/skill_repository.go create mode 100644 cmd/app/internal/database/user_repository.go delete mode 100644 cmd/app/internal/handler/handler.go create mode 100644 cmd/app/internal/handler/user_handler.go rename cmd/app/internal/handler/{handler_test.go => user_handler_test.go} (87%) create mode 100644 cmd/app/internal/models/user_skill.go create mode 100644 cmd/app/internal/service/skill_service.go create mode 100644 docs/README-DYNAMODB-DESIGN.md create mode 100644 docs/dynamodb-quick-reference.md create mode 100644 docs/dynamodb-single-table-design-plan.md create mode 100644 docs/entity-addition-protocol.md diff --git a/README.md b/README.md index 478c118..9029823 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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 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. @@ -270,7 +270,7 @@ Structured logging with slog, including request duration tracking. Deployed resources (via CDK): -- **DynamoDB Table**: `users` table with username as partition key. Single Table Design - progress ⏳ +- **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 diff --git a/cmd/app/integration_test.go b/cmd/app/integration_test.go index 6b550e3..7417488 100644 --- a/cmd/app/integration_test.go +++ b/cmd/app/integration_test.go @@ -45,10 +45,12 @@ func testConfig() *config.Config { // SetupIntegrationTest creates a test environment func SetupIntegrationTest() *IntegrationTestSuite { - userRepo := database.NewMockRepository() + userRepo := database.NewUserMockRepository() + userSkillsRepo := database.NewUserSkillsMockRepository() tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(userRepo, tokenService) - apiHandler := handler.New(userService) + userSkillsService := service.NewSkillService(userSkillsRepo) + apiHandler := handler.New(userService, userSkillsService) authMiddleware := middleware.NewAuthMiddleware(tokenService) // Create HTTP server with the same routing as local-server.go diff --git a/cmd/app/internal/database/dynamodb.go b/cmd/app/internal/database/dynamodb.go index 2756439..3f8849a 100644 --- a/cmd/app/internal/database/dynamodb.go +++ b/cmd/app/internal/database/dynamodb.go @@ -1,32 +1,27 @@ 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/aws/session" "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" ) const ( - UsersTableName = "users" + // TableName is the single table for all entities + TableName = "glad-entities" + + // GSI1Name GSI names + GSI1Name = "GSI1" ) -// UserRepository defines the interface for user data operations -type UserRepository interface { - CreateUser(user *models.User) error - GetUser(username string) (*models.User, error) - UpdateUser(user *models.User) error - UserExists(username string) (bool, error) - ListUsers() ([]*models.User, error) +// Repository combines all repository interfaces +type Repository interface { + UserRepository + SkillRepository } -// DynamoDBRepository implements UserRepository using DynamoDB +// DynamoDBRepository implements Repository using DynamoDB single table design type DynamoDBRepository struct { client *dynamodb.DynamoDB } @@ -34,7 +29,7 @@ type DynamoDBRepository struct { // NewDynamoDBRepository creates a new DynamoDB repository func NewDynamoDBRepository() *DynamoDBRepository { log := logger.WithComponent("database") - log.Info("Initializing DynamoDB repository", "table", UsersTableName) + log.Info("Initializing DynamoDB repository", "table", TableName) sess := session.Must(session.NewSession()) repo := &DynamoDBRepository{ @@ -44,158 +39,3 @@ func NewDynamoDBRepository() *DynamoDBRepository { log.Info("DynamoDB repository initialized successfully") return repo } - -// 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") - - 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(UsersTableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(username)"), - } - - _, 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") - - input := &dynamodb.GetItemInput{ - TableName: aws.String(UsersTableName), - Key: map[string]*dynamodb.AttributeValue{ - "username": { - S: aws.String(username), - }, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get user from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Debug("User not found", "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") - - input := &dynamodb.GetItemInput{ - TableName: aws.String(UsersTableName), - Key: map[string]*dynamodb.AttributeValue{ - "username": { - S: aws.String(username), - }, - }, - ProjectionExpression: aws.String("username"), - } - - 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") - - 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(UsersTableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(username)"), - } - - _, 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 -func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { - log := logger.WithComponent("database").With("operation", "ListUsers") - start := time.Now() - - log.Debug("Starting users list retrieval") - - input := &dynamodb.ScanInput{ - TableName: aws.String(UsersTableName), - } - - result, err := r.client.Scan(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/mock.go b/cmd/app/internal/database/mock.go index 1708c91..b0ece79 100644 --- a/cmd/app/internal/database/mock.go +++ b/cmd/app/internal/database/mock.go @@ -9,18 +9,23 @@ import ( "github.com/hackmajoris/glad/pkg/logger" ) -// MockRepository implements UserRepository for testing -type MockRepository struct { +// UserMockRepository implements UserRepository for testing +type UserMockRepository struct { users map[string]*models.User mutex sync.RWMutex } -// NewMockRepository creates a new mock repository -func NewMockRepository() *MockRepository { +type UserSkillsMockRepository struct { + users map[string]*models.UserSkill + mutex sync.RWMutex +} + +// NewUserMockRepository creates a new mock repository +func NewUserMockRepository() *UserMockRepository { log := logger.WithComponent("database") log.Info("Initializing Mock repository for local development") - repo := &MockRepository{ + repo := &UserMockRepository{ users: make(map[string]*models.User), } @@ -28,8 +33,21 @@ func NewMockRepository() *MockRepository { return repo } +// NewUserSkillsMockRepository NewUserMockRepository creates a new mock repository +func NewUserSkillsMockRepository() *UserSkillsMockRepository { + log := logger.WithComponent("database") + log.Info("Initializing Mock repository for local development") + + repo := &UserSkillsMockRepository{ + users: make(map[string]*models.UserSkill), + } + + log.Info("Mock repository initialized successfully") + return repo +} + // CreateUser creates a user in memory -func (m *MockRepository) CreateUser(user *models.User) error { +func (m *UserMockRepository) CreateUser(user *models.User) error { log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username, "repository", "mock") start := time.Now() @@ -49,7 +67,7 @@ func (m *MockRepository) CreateUser(user *models.User) error { } // GetUser retrieves a user from memory -func (m *MockRepository) GetUser(username string) (*models.User, error) { +func (m *UserMockRepository) GetUser(username string) (*models.User, error) { log := logger.WithComponent("database").With("operation", "GetUser", "username", username, "repository", "mock") start := time.Now() @@ -69,7 +87,7 @@ func (m *MockRepository) GetUser(username string) (*models.User, error) { } // UpdateUser updates a user in memory -func (m *MockRepository) UpdateUser(user *models.User) error { +func (m *UserMockRepository) UpdateUser(user *models.User) error { log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username, "repository", "mock") start := time.Now() @@ -89,7 +107,7 @@ func (m *MockRepository) UpdateUser(user *models.User) error { } // UserExists checks if a user exists in memory -func (m *MockRepository) UserExists(username string) (bool, error) { +func (m *UserMockRepository) UserExists(username string) (bool, error) { log := logger.WithComponent("database").With("operation", "UserExists", "username", username, "repository", "mock") start := time.Now() @@ -104,7 +122,7 @@ func (m *MockRepository) UserExists(username string) (bool, error) { } // ListUsers retrieves all users from memory -func (m *MockRepository) ListUsers() ([]*models.User, error) { +func (m *UserMockRepository) ListUsers() ([]*models.User, error) { log := logger.WithComponent("database").With("operation", "ListUsers", "repository", "mock") start := time.Now() @@ -121,3 +139,153 @@ func (m *MockRepository) ListUsers() ([]*models.User, error) { log.Info("Users retrieved successfully from mock repository", "count", len(users), "duration", time.Since(start)) return users, nil } + +// CreateSkill creates a user skill in memory +func (m *UserSkillsMockRepository) CreateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill", skill.SkillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := skill.Username + "#" + skill.SkillName + if _, exists := m.users[key]; exists { + log.Debug("Skill already exists", "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + m.users[key] = skill + log.Info("Skill created successfully in mock repository", "total_skills", len(m.users), "duration", time.Since(start)) + return nil +} + +// GetSkill retrieves a user skill from memory +func (m *UserSkillsMockRepository) GetSkill(username, skillName string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + key := username + "#" + skillName + skill, exists := m.users[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 *UserSkillsMockRepository) UpdateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill", skill.SkillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := skill.Username + "#" + skill.SkillName + if _, exists := m.users[key]; !exists { + log.Debug("Skill not found for update", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + m.users[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 *UserSkillsMockRepository) DeleteSkill(username, skillName string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill deletion from mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := username + "#" + skillName + if _, exists := m.users[key]; !exists { + log.Debug("Skill not found for deletion", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + delete(m.users, 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 *UserSkillsMockRepository) 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 key, skill := range m.users { + if skill.Username == username { + skills = append(skills, skill) + } + _ = key + } + + 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 *UserSkillsMockRepository) 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 key, skill := range m.users { + if skill.SkillName == skillName { + skills = append(skills, skill) + } + _ = key + } + + 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 *UserSkillsMockRepository) 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 key, skill := range m.users { + if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel { + skills = append(skills, skill) + } + _ = key + } + + 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/database/mock_test.go b/cmd/app/internal/database/mock_test.go index a511861..c75e7eb 100644 --- a/cmd/app/internal/database/mock_test.go +++ b/cmd/app/internal/database/mock_test.go @@ -10,7 +10,7 @@ import ( ) func TestNewMockRepository(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() if repo == nil { t.Error("Expected non-nil repository") } @@ -23,7 +23,7 @@ func TestNewMockRepository(t *testing.T) { } func TestMockRepository_CreateUser(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -44,7 +44,7 @@ func TestMockRepository_CreateUser(t *testing.T) { } func TestMockRepository_GetUser(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -74,7 +74,7 @@ func TestMockRepository_GetUser(t *testing.T) { } func TestMockRepository_UpdateUser(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -109,7 +109,7 @@ func TestMockRepository_UpdateUser(t *testing.T) { } func TestMockRepository_UserExists(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -139,7 +139,7 @@ func TestMockRepository_UserExists(t *testing.T) { } func TestMockRepository_ListUsers(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() // Test empty list users, err := repo.ListUsers() @@ -180,7 +180,7 @@ func TestMockRepository_ListUsers(t *testing.T) { } func TestMockRepository_ConcurrentAccess(t *testing.T) { - repo := NewMockRepository() + repo := NewUserMockRepository() var wg sync.WaitGroup concurrency := 10 diff --git a/cmd/app/internal/database/skill_repository.go b/cmd/app/internal/database/skill_repository.go new file mode 100644 index 0000000..2118ef3 --- /dev/null +++ b/cmd/app/internal/database/skill_repository.go @@ -0,0 +1,278 @@ +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 +} diff --git a/cmd/app/internal/database/user_repository.go b/cmd/app/internal/database/user_repository.go new file mode 100644 index 0000000..0063709 --- /dev/null +++ b/cmd/app/internal/database/user_repository.go @@ -0,0 +1,199 @@ +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" +) + +// ============================================================================ +// USER REPOSITORY METHODS +// ============================================================================ + +// UserRepository defines the interface for user data operations +type UserRepository interface { + CreateUser(user *models.User) error + GetUser(username string) (*models.User, error) + UpdateUser(user *models.User) error + 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(PK) AND attribute_not_exists(SK)"), + } + + _, 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") + + pk := fmt.Sprintf("USER#%s", username) + sk := "PROFILE" + + 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 user from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("User not found", "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") + + pk := fmt.Sprintf("USER#%s", username) + sk := "PROFILE" + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "PK": {S: aws.String(pk)}, + "SK": {S: aws.String(sk)}, + }, + ProjectionExpression: aws.String("PK"), + } + + 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(PK) AND attribute_exists(SK)"), + } + + _, 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" and SK = "PROFILE" + input := &dynamodb.ScanInput{ + TableName: aws.String(TableName), + FilterExpression: aws.String("EntityType = :entityType AND SK = :sk"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":entityType": {S: aws.String("User")}, + ":sk": {S: aws.String("PROFILE")}, + }, + } + + result, err := r.client.Scan(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/dto/dto.go b/cmd/app/internal/dto/dto.go index dcacc69..01693bc 100644 --- a/cmd/app/internal/dto/dto.go +++ b/cmd/app/internal/dto/dto.go @@ -58,3 +58,45 @@ type CurrentUserResponse struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } + +// Skill Request DTOs + +// CreateSkillRequest represents a request to add a skill to a user +type CreateSkillRequest struct { + SkillName string `json:"skill_name" validate:"required,min=1,max=100"` + ProficiencyLevel string `json:"proficiency_level" validate:"required,oneof=Beginner Intermediate Advanced Expert"` + YearsOfExperience int `json:"years_of_experience" validate:"min=0"` + Notes string `json:"notes,omitempty" validate:"max=500"` +} + +// UpdateSkillRequest represents a request to update a user's skill +type UpdateSkillRequest struct { + ProficiencyLevel *string `json:"proficiency_level,omitempty" validate:"omitempty,oneof=Beginner Intermediate Advanced Expert"` + YearsOfExperience *int `json:"years_of_experience,omitempty" validate:"omitempty,min=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +// Skill Response DTOs + +// SkillResponse represents a skill in responses +type SkillResponse struct { + SkillName string `json:"skill_name"` + ProficiencyLevel string `json:"proficiency_level"` + YearsOfExperience int `json:"years_of_experience"` + Endorsements int `json:"endorsements"` + LastUsedDate string `json:"last_used_date"` + Notes string `json:"notes,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UserSkillResponse represents a user with a specific skill (for cross-user queries) +type UserSkillResponse struct { + Username string `json:"username"` + Name string `json:"name,omitempty"` // From GSI projection + SkillName string `json:"skill_name"` + ProficiencyLevel string `json:"proficiency_level"` + YearsOfExperience int `json:"years_of_experience"` + Endorsements int `json:"endorsements"` + LastUsedDate string `json:"last_used_date"` +} diff --git a/cmd/app/internal/errors/user.go b/cmd/app/internal/errors/user.go index 4f7411a..5747054 100644 --- a/cmd/app/internal/errors/user.go +++ b/cmd/app/internal/errors/user.go @@ -15,4 +15,11 @@ var ( // ErrInvalidCredentials Authentication errors ErrInvalidCredentials = errors.New("invalid credentials") + + // ErrSkillNotFound Skill-related errors + ErrSkillNotFound = errors.New("skill not found") + ErrSkillAlreadyExists = errors.New("skill already exists for this user") + ErrInvalidProficiencyLevel = errors.New("proficiency level must be Beginner, Intermediate, Advanced, or Expert") + ErrInvalidYearsOfExperience = errors.New("years of experience must be non-negative") + ErrInvalidSkillName = errors.New("skill name must be between 1 and 100 characters") ) diff --git a/cmd/app/internal/handler/handler.go b/cmd/app/internal/handler/handler.go deleted file mode 100644 index bfc2951..0000000 --- a/cmd/app/internal/handler/handler.go +++ /dev/null @@ -1,190 +0,0 @@ -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/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" -) - -// Handler handles HTTP requests -type Handler struct { - userService *service.UserService - errorMapper *ErrorMapper - validator *validation.Validator -} - -// New creates a new Handler -func New(userService *service.UserService) *Handler { - return &Handler{ - userService: userService, - errorMapper: NewErrorMapper(), - validator: validation.New(), - } -} - -// Register handles user registration -func (h *Handler) Register(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - var req dto.RegisterRequest - if err := json.Unmarshal([]byte(request.Body), &req); err != nil { - return errorResponse(http.StatusBadRequest, "Invalid request body"), nil - } - - // Validate input at handler layer - if err := h.validator.ValidateRegisterInput(req.Username, req.Name, req.Password); err != nil { - return h.handleServiceError(err), nil - } - - _, err := h.userService.Register(req.Username, req.Name, req.Password) - if err != nil { - return h.handleServiceError(err), nil - } - - return successResponse(http.StatusCreated, dto.MessageResponse{ - Message: "User created successfully", - }), nil -} - -// Login handles user authentication -func (h *Handler) Login(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - var req dto.LoginRequest - if err := json.Unmarshal([]byte(request.Body), &req); err != nil { - return errorResponse(http.StatusBadRequest, "Invalid request body"), nil - } - - // Validate input at handler layer - if err := h.validator.ValidateLoginInput(req.Username, req.Password); err != nil { - return h.handleServiceError(err), nil - } - - result, err := h.userService.Login(req.Username, req.Password) - if err != nil { - return h.handleServiceError(err), nil - } - - return successResponse(http.StatusOK, dto.TokenResponse{ - AccessToken: result.AccessToken, - TokenType: result.TokenType, - }), nil -} - -// Protected handles protected resource access -func (h *Handler) Protected(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) - if !ok { - return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil - } - - return successResponse(http.StatusOK, dto.ProtectedResponse{ - Message: "Access granted to protected resource", - Username: claims.Username, - }), nil -} - -// UpdateUser handles user profile updates -func (h *Handler) UpdateUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) - if !ok { - return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil - } - - var req dto.UpdateUserRequest - if err := json.Unmarshal([]byte(request.Body), &req); err != nil { - return errorResponse(http.StatusBadRequest, "Invalid request body"), nil - } - - // Validate optional inputs at handler layer - if err := h.validator.ValidateOptionalName(req.Name); err != nil { - return h.handleServiceError(err), nil - } - if err := h.validator.ValidateOptionalPassword(req.Password); err != nil { - return h.handleServiceError(err), nil - } - - err := h.userService.UpdateUser(claims.Username, req.Name, req.Password) - if err != nil { - return h.handleServiceError(err), nil - } - - return successResponse(http.StatusOK, dto.MessageResponse{ - Message: "User updated successfully", - }), nil -} - -// ListUsers handles listing all users -func (h *Handler) ListUsers(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - users, err := h.userService.ListUsers() - if err != nil { - return h.handleServiceError(err), nil - } - - return successResponse(http.StatusOK, users), nil -} - -// GetCurrentUser handles retrieving the current authenticated user's information -func (h *Handler) GetCurrentUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) - if !ok { - return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil - } - - user, err := h.userService.GetUser(claims.Username) - if err != nil { - return h.handleServiceError(err), nil - } - - return successResponse(http.StatusOK, dto.CurrentUserResponse{ - Username: user.Username, - Name: user.Name, - CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), - }), nil -} - -// handleServiceError converts service errors to HTTP responses using the error mapper -func (h *Handler) handleServiceError(err error) events.APIGatewayProxyResponse { - statusCode, message := h.errorMapper.MapToHTTP(err) - return errorResponse(statusCode, message) -} - -func successResponse(statusCode int, data interface{}) events.APIGatewayProxyResponse { - body, err := json.Marshal(data) - if err != nil { - // If marshaling fails, return an error response - return errorResponse(http.StatusInternalServerError, "Internal server error") - } - return events.APIGatewayProxyResponse{ - StatusCode: statusCode, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - Body: string(body), - } -} - -func errorResponse(statusCode int, message string) events.APIGatewayProxyResponse { - body, err := json.Marshal(dto.ErrorResponse{Error: message}) - if err != nil { - // Fallback to plain text if JSON marshaling fails - return events.APIGatewayProxyResponse{ - StatusCode: http.StatusInternalServerError, - Headers: map[string]string{ - "Content-Type": "text/plain", - }, - Body: "Internal server error", - } - } - return events.APIGatewayProxyResponse{ - StatusCode: statusCode, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - Body: string(body), - } -} diff --git a/cmd/app/internal/handler/user_handler.go b/cmd/app/internal/handler/user_handler.go new file mode 100644 index 0000000..e2e0fb9 --- /dev/null +++ b/cmd/app/internal/handler/user_handler.go @@ -0,0 +1,386 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "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" +) + +// Handler handles HTTP requests +type Handler struct { + userService *service.UserService + skillService *service.SkillService + errorMapper *ErrorMapper + validator *validation.Validator +} + +// New creates a new Handler +func New(userService *service.UserService, skillService *service.SkillService) *Handler { + return &Handler{ + userService: userService, + skillService: skillService, + errorMapper: NewErrorMapper(), + validator: validation.New(), + } +} + +// Register handles user registration +func (h *Handler) Register(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + var req dto.RegisterRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Validate input at handler layer + if err := h.validator.ValidateRegisterInput(req.Username, req.Name, req.Password); err != nil { + return h.handleServiceError(err), nil + } + + _, err := h.userService.Register(req.Username, req.Name, req.Password) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusCreated, dto.MessageResponse{ + Message: "User created successfully", + }), nil +} + +// Login handles user authentication +func (h *Handler) Login(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + var req dto.LoginRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Validate input at handler layer + if err := h.validator.ValidateLoginInput(req.Username, req.Password); err != nil { + return h.handleServiceError(err), nil + } + + result, err := h.userService.Login(req.Username, req.Password) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.TokenResponse{ + AccessToken: result.AccessToken, + TokenType: result.TokenType, + }), nil +} + +// Protected handles protected resource access +func (h *Handler) Protected(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) + if !ok { + return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil + } + + return successResponse(http.StatusOK, dto.ProtectedResponse{ + Message: "Access granted to protected resource", + Username: claims.Username, + }), nil +} + +// UpdateUser handles user profile updates +func (h *Handler) UpdateUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) + if !ok { + return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil + } + + var req dto.UpdateUserRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Validate optional inputs at handler layer + if err := h.validator.ValidateOptionalName(req.Name); err != nil { + return h.handleServiceError(err), nil + } + if err := h.validator.ValidateOptionalPassword(req.Password); err != nil { + return h.handleServiceError(err), nil + } + + err := h.userService.UpdateUser(claims.Username, req.Name, req.Password) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.MessageResponse{ + Message: "User updated successfully", + }), nil +} + +// ListUsers handles listing all users +func (h *Handler) ListUsers(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + users, err := h.userService.ListUsers() + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, users), nil +} + +// GetCurrentUser handles retrieving the current authenticated user's information +func (h *Handler) GetCurrentUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) + if !ok { + return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil + } + + user, err := h.userService.GetUser(claims.Username) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.CurrentUserResponse{ + Username: user.Username, + Name: user.Name, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// ============================================================================ +// SKILL HANDLERS +// ============================================================================ + +// AddSkill handles adding a new skill to a user +// POST /users/{username}/skills +func (h *Handler) AddSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get username from path parameter + username, ok := request.PathParameters["username"] + if !ok || username == "" { + return errorResponse(http.StatusBadRequest, "Username is required"), nil + } + + // Parse request body + var req dto.CreateSkillRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Convert proficiency level string to type + proficiencyLevel := models.ProficiencyLevel(req.ProficiencyLevel) + + // Add skill + skill, err := h.skillService.AddSkill(username, req.SkillName, proficiencyLevel, req.YearsOfExperience, req.Notes) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusCreated, dto.SkillResponse{ + SkillName: skill.SkillName, + ProficiencyLevel: string(skill.ProficiencyLevel), + YearsOfExperience: skill.YearsOfExperience, + Endorsements: skill.Endorsements, + LastUsedDate: skill.LastUsedDate, + Notes: skill.Notes, + CreatedAt: skill.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: skill.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// GetSkill handles retrieving a specific skill for a user +// GET /users/{username}/skills/{skillName} +func (h *Handler) GetSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get path parameters + username, ok := request.PathParameters["username"] + if !ok || username == "" { + return errorResponse(http.StatusBadRequest, "Username is required"), nil + } + + skillName, ok := request.PathParameters["skillName"] + if !ok || skillName == "" { + return errorResponse(http.StatusBadRequest, "Skill name is required"), nil + } + + // Get skill + skill, err := h.skillService.GetSkill(username, skillName) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.SkillResponse{ + SkillName: skill.SkillName, + ProficiencyLevel: string(skill.ProficiencyLevel), + YearsOfExperience: skill.YearsOfExperience, + Endorsements: skill.Endorsements, + LastUsedDate: skill.LastUsedDate, + Notes: skill.Notes, + CreatedAt: skill.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: skill.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// ListSkillsForUser handles listing all skills for a user +// GET /users/{username}/skills +func (h *Handler) ListSkillsForUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get username from path parameter + username, ok := request.PathParameters["username"] + if !ok || username == "" { + return errorResponse(http.StatusBadRequest, "Username is required"), nil + } + + // Get skills + skills, err := h.skillService.ListSkillsForUser(username) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, skills), nil +} + +// UpdateSkill handles updating an existing skill +// PUT /users/{username}/skills/{skillName} +func (h *Handler) UpdateSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get path parameters + username, ok := request.PathParameters["username"] + if !ok || username == "" { + return errorResponse(http.StatusBadRequest, "Username is required"), nil + } + + skillName, ok := request.PathParameters["skillName"] + if !ok || skillName == "" { + return errorResponse(http.StatusBadRequest, "Skill name is required"), nil + } + + // Parse request body + var req dto.UpdateSkillRequest + if err := json.Unmarshal([]byte(request.Body), &req); err != nil { + return errorResponse(http.StatusBadRequest, "Invalid request body"), nil + } + + // Convert proficiency level if provided + var proficiencyLevel *models.ProficiencyLevel + if req.ProficiencyLevel != nil { + level := models.ProficiencyLevel(*req.ProficiencyLevel) + proficiencyLevel = &level + } + + // Update skill + skill, err := h.skillService.UpdateSkill(username, skillName, proficiencyLevel, req.YearsOfExperience, req.Notes) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.SkillResponse{ + SkillName: skill.SkillName, + ProficiencyLevel: string(skill.ProficiencyLevel), + YearsOfExperience: skill.YearsOfExperience, + Endorsements: skill.Endorsements, + LastUsedDate: skill.LastUsedDate, + Notes: skill.Notes, + CreatedAt: skill.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: skill.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + +// DeleteSkill handles deleting a skill from a user +// DELETE /users/{username}/skills/{skillName} +func (h *Handler) DeleteSkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get path parameters + username, ok := request.PathParameters["username"] + if !ok || username == "" { + return errorResponse(http.StatusBadRequest, "Username is required"), nil + } + + skillName, ok := request.PathParameters["skillName"] + if !ok || skillName == "" { + return errorResponse(http.StatusBadRequest, "Skill name is required"), nil + } + + // Delete skill + if err := h.skillService.DeleteSkill(username, skillName); err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.MessageResponse{ + Message: "Skill deleted successfully", + }), nil +} + +// ListUsersBySkill handles finding all users with a specific skill +// GET /skills/{skillName}/users +func (h *Handler) ListUsersBySkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + // Get skill name from path parameter + skillName, ok := request.PathParameters["skillName"] + if !ok || skillName == "" { + return errorResponse(http.StatusBadRequest, "Skill name is required"), nil + } + + // Check for proficiency level filter in query parameters + proficiencyLevel, ok := request.QueryStringParameters["level"] + if ok && proficiencyLevel != "" { + // Query with level filter + level := models.ProficiencyLevel(proficiencyLevel) + users, err := h.skillService.ListUsersBySkillAndLevel(skillName, level) + if err != nil { + return h.handleServiceError(err), nil + } + return successResponse(http.StatusOK, users), nil + } + + // Query all users with skill + users, err := h.skillService.ListUsersBySkill(skillName) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, users), nil +} + +// ============================================================================ +// HELPER METHODS +// ============================================================================ + +// handleServiceError converts service errors to HTTP responses using the error mapper +func (h *Handler) handleServiceError(err error) events.APIGatewayProxyResponse { + statusCode, message := h.errorMapper.MapToHTTP(err) + return errorResponse(statusCode, message) +} + +func successResponse(statusCode int, data interface{}) events.APIGatewayProxyResponse { + body, err := json.Marshal(data) + if err != nil { + // If marshaling fails, return an error response + return errorResponse(http.StatusInternalServerError, "Internal server error") + } + return events.APIGatewayProxyResponse{ + StatusCode: statusCode, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: string(body), + } +} + +func errorResponse(statusCode int, message string) events.APIGatewayProxyResponse { + body, err := json.Marshal(dto.ErrorResponse{Error: message}) + if err != nil { + // Fallback to plain text if JSON marshaling fails + return events.APIGatewayProxyResponse{ + StatusCode: http.StatusInternalServerError, + Headers: map[string]string{ + "Content-Type": "text/plain", + }, + Body: "Internal server error", + } + } + return events.APIGatewayProxyResponse{ + StatusCode: statusCode, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: string(body), + } +} diff --git a/cmd/app/internal/handler/handler_test.go b/cmd/app/internal/handler/user_handler_test.go similarity index 87% rename from cmd/app/internal/handler/handler_test.go rename to cmd/app/internal/handler/user_handler_test.go index fba1d9c..8b23a95 100644 --- a/cmd/app/internal/handler/handler_test.go +++ b/cmd/app/internal/handler/user_handler_test.go @@ -28,14 +28,14 @@ func testConfig() *config.Config { func TestHandler_GetCurrentUser(t *testing.T) { tests := []struct { name string - setupRepo func(repo *database.MockRepository) + setupRepo func(repo *database.UserMockRepository) claims *auth.JWTClaims expectedStatus int validateBody func(t *testing.T, body string) }{ { name: "successful user retrieval", - setupRepo: func(repo *database.MockRepository) { + setupRepo: func(repo *database.UserMockRepository) { user, _ := models.NewUser("testuser", "Test User", "password123") user.CreatedAt = time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) user.UpdatedAt = time.Date(2025, 1, 2, 15, 30, 0, 0, time.UTC) @@ -67,7 +67,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { }, { name: "invalid token claims", - setupRepo: func(repo *database.MockRepository) { + setupRepo: func(repo *database.UserMockRepository) { // No setup needed }, claims: nil, @@ -84,7 +84,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { }, { name: "user not found", - setupRepo: func(repo *database.MockRepository) { + setupRepo: func(repo *database.UserMockRepository) { // Don't create the user }, claims: &auth.JWTClaims{ @@ -105,18 +105,21 @@ func TestHandler_GetCurrentUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create mock repository - mockRepo := database.NewMockRepository() + // Create mock repositories + userMockRepo := database.NewUserMockRepository() + skillMockRepo := database.NewUserSkillsMockRepository() + if tt.setupRepo != nil { - tt.setupRepo(mockRepo) + tt.setupRepo(userMockRepo) } - // Create service with mock repository + // Create services with mock repositories tokenService := auth.NewTokenService(testConfig()) - userService := service.NewUserService(mockRepo, tokenService) + userService := service.NewUserService(userMockRepo, tokenService) + skillService := service.NewSkillService(skillMockRepo) // Create handler - h := New(userService) + h := New(userService, skillService) // Create request request := events.APIGatewayProxyRequest{ @@ -159,7 +162,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { // TestHandler_GetCurrentUser_TimestampFormat verifies the timestamp format is ISO 8601 func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { // Create mock repository and service - mockRepo := database.NewMockRepository() + mockRepo := database.NewUserMockRepository() // Create a user with specific timestamps user, _ := models.NewUser("testuser", "Test User", "password123") @@ -169,7 +172,9 @@ func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) - h := New(userService) + skillMockRepo := database.NewUserSkillsMockRepository() + skillService := service.NewSkillService(skillMockRepo) + h := New(userService, skillService) request := events.APIGatewayProxyRequest{ RequestContext: events.APIGatewayProxyRequestContext{ @@ -205,14 +210,16 @@ func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { // TestHandler_GetCurrentUser_DoesNotExposePassword verifies password hash is not included func TestHandler_GetCurrentUser_DoesNotExposePassword(t *testing.T) { // Create mock repository and service - mockRepo := database.NewMockRepository() + mockRepo := database.NewUserMockRepository() user, _ := models.NewUser("testuser", "Test User", "password123") mockRepo.CreateUser(user) tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) - h := New(userService) + skillMockRepo := database.NewUserSkillsMockRepository() + skillService := service.NewSkillService(skillMockRepo) + h := New(userService, skillService) request := events.APIGatewayProxyRequest{ RequestContext: events.APIGatewayProxyRequestContext{ diff --git a/cmd/app/internal/models/user.go b/cmd/app/internal/models/user.go index fae4d4c..2066471 100644 --- a/cmd/app/internal/models/user.go +++ b/cmd/app/internal/models/user.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "time" apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" @@ -10,12 +11,22 @@ import ( ) // User represents a user in the system (domain model) +// This entity uses single table design with the following key structure: +// - PK: USER# +// - SK: PROFILE type User struct { - Username string `json:"username" dynamodbav:"username"` - Name string `json:"name" dynamodbav:"name"` - PasswordHash string `json:"-" dynamodbav:"password"` - CreatedAt time.Time `json:"created_at" dynamodbav:"created_at"` - UpdatedAt time.Time `json:"updated_at" dynamodbav:"updated_at"` + // Business attributes + Username string `json:"username" dynamodbav:"Username"` + Name string `json:"name" dynamodbav:"Name"` + PasswordHash string `json:"-" dynamodbav:"PasswordHash"` + Email string `json:"email,omitempty" dynamodbav:"Email,omitempty"` + 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"` } // NewUser creates a new User with the given credentials @@ -30,13 +41,27 @@ func NewUser(username, name, password string) (*User, error) { } now := time.Now() - return &User{ + user := &User{ Username: username, Name: name, PasswordHash: string(hashedPassword), CreatedAt: now, UpdatedAt: now, - }, nil + EntityType: "User", + } + + // Set DynamoDB keys + user.SetKeys() + + 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.EntityType = "User" } // UpdateName updates the user's name diff --git a/cmd/app/internal/models/user_skill.go b/cmd/app/internal/models/user_skill.go new file mode 100644 index 0000000..c1bb969 --- /dev/null +++ b/cmd/app/internal/models/user_skill.go @@ -0,0 +1,174 @@ +package models + +import ( + "fmt" + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/pkg/errors" +) + +// ProficiencyLevel represents the proficiency level for a skill +type ProficiencyLevel string + +const ( + ProficiencyBeginner ProficiencyLevel = "Beginner" + ProficiencyIntermediate ProficiencyLevel = "Intermediate" + ProficiencyAdvanced ProficiencyLevel = "Advanced" + ProficiencyExpert ProficiencyLevel = "Expert" +) + +// Valid proficiency levels +var validProficiencyLevels = map[ProficiencyLevel]bool{ + ProficiencyBeginner: true, + ProficiencyIntermediate: true, + ProficiencyAdvanced: true, + ProficiencyExpert: true, +} + +// 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# +type UserSkill struct { + // Business attributes + Username string `json:"username" dynamodbav:"Username"` + SkillName string `json:"skill_name" dynamodbav:"SkillName"` + ProficiencyLevel ProficiencyLevel `json:"proficiency_level" dynamodbav:"ProficiencyLevel"` + YearsOfExperience int `json:"years_of_experience" dynamodbav:"YearsOfExperience"` + Endorsements int `json:"endorsements" dynamodbav:"Endorsements"` + LastUsedDate string `json:"last_used_date" dynamodbav:"LastUsedDate"` // ISO 8601 format + Notes string `json:"notes,omitempty" dynamodbav:"Notes,omitempty"` + 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"` + + // 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) { + if username == "" { + return nil, errors.ErrRequiredField + } + + if skillName == "" { + return nil, errors.ErrRequiredField + } + + if !validProficiencyLevels[proficiencyLevel] { + return nil, apperrors.ErrInvalidProficiencyLevel + } + + if yearsOfExperience < 0 { + return nil, apperrors.ErrInvalidYearsOfExperience + } + + now := time.Now() + skill := &UserSkill{ + Username: username, + SkillName: skillName, + ProficiencyLevel: proficiencyLevel, + YearsOfExperience: yearsOfExperience, + Endorsements: 0, + LastUsedDate: now.Format("2006-01-02"), // ISO 8601 date format + CreatedAt: now, + UpdatedAt: now, + EntityType: "UserSkill", + } + + // Set DynamoDB keys + skill.SetKeys() + + 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 + 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) +} + +// UpdateProficiency updates the skill proficiency level +func (s *UserSkill) UpdateProficiency(level ProficiencyLevel) error { + if !validProficiencyLevels[level] { + return apperrors.ErrInvalidProficiencyLevel + } + + s.ProficiencyLevel = level + s.UpdatedAt = time.Now() + + // Update GSI keys to reflect new proficiency + s.SetKeys() + + return nil +} + +// UpdateYearsOfExperience updates the years of experience +func (s *UserSkill) UpdateYearsOfExperience(years int) error { + if years < 0 { + return apperrors.ErrInvalidYearsOfExperience + } + + s.YearsOfExperience = years + s.UpdatedAt = time.Now() + + return nil +} + +// UpdateLastUsed updates the last used date to now +func (s *UserSkill) UpdateLastUsed() { + s.LastUsedDate = time.Now().Format("2006-01-02") + s.UpdatedAt = time.Now() +} + +// AddEndorsement increments the endorsement count +func (s *UserSkill) AddEndorsement() { + s.Endorsements++ + s.UpdatedAt = time.Now() +} + +// UpdateNotes updates the skill notes +func (s *UserSkill) UpdateNotes(notes string) { + s.Notes = notes + s.UpdatedAt = time.Now() +} + +// IsValid performs validation on the skill +func (s *UserSkill) IsValid() error { + if s.Username == "" { + return errors.ErrRequiredField + } + + if s.SkillName == "" { + return errors.ErrRequiredField + } + + if !validProficiencyLevels[s.ProficiencyLevel] { + return apperrors.ErrInvalidProficiencyLevel + } + + if s.YearsOfExperience < 0 { + return apperrors.ErrInvalidYearsOfExperience + } + + return nil +} diff --git a/cmd/app/internal/router/router.go b/cmd/app/internal/router/router.go index 7e90ac4..b311bb0 100644 --- a/cmd/app/internal/router/router.go +++ b/cmd/app/internal/router/router.go @@ -70,7 +70,8 @@ func (r *Router) DELETE(path string, handler HandlerFunc, middleware ...Middlewa // Route handles an incoming request func (r *Router) Route(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - pathRoutes, exists := r.routes[request.Path] + // Use Resource instead of Path to match route patterns (handles stage prefix) + pathRoutes, exists := r.routes[request.Resource] if !exists { return NotFoundResponse(), nil } diff --git a/cmd/app/internal/service/skill_service.go b/cmd/app/internal/service/skill_service.go new file mode 100644 index 0000000..6708a2e --- /dev/null +++ b/cmd/app/internal/service/skill_service.go @@ -0,0 +1,228 @@ +package service + +import ( + "time" + + "github.com/hackmajoris/glad/cmd/app/internal/database" + "github.com/hackmajoris/glad/cmd/app/internal/dto" + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// Re-export domain errors for convenience in handler layer +var ( + ErrSkillNotFound = apperrors.ErrSkillNotFound + ErrSkillAlreadyExists = apperrors.ErrSkillAlreadyExists + ErrInvalidProficiencyLevel = apperrors.ErrInvalidProficiencyLevel + ErrInvalidYearsOfExperience = apperrors.ErrInvalidYearsOfExperience + ErrInvalidSkillName = apperrors.ErrInvalidSkillName +) + +// SkillService handles skill business logic +type SkillService struct { + repo database.SkillRepository +} + +// NewSkillService creates a new SkillService +func NewSkillService(repo database.SkillRepository) *SkillService { + return &SkillService{ + repo: repo, + } +} + +// AddSkill adds a new skill to a user +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) + if err != nil { + log.Error("Failed to create skill model", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if notes != "" { + skill.UpdateNotes(notes) + } + + // Save skill to database + if err := s.repo.CreateSkill(skill); err != nil { + log.Error("Failed to save skill to database", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Info("Skill added successfully", "duration", time.Since(start)) + return skill, nil +} + +// GetSkill retrieves a specific skill for a user +func (s *SkillService) GetSkill(username, skillName string) (*models.UserSkill, error) { + log := logger.WithComponent("service").With("operation", "GetSkill", "username", username, "skill", skillName) + start := time.Now() + + log.Debug("Retrieving skill") + + skill, err := s.repo.GetSkill(username, skillName) + if err != nil { + log.Error("Failed to get skill", "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 (s *SkillService) UpdateSkill(username, skillName string, proficiencyLevel *models.ProficiencyLevel, yearsOfExperience *int, notes *string) (*models.UserSkill, error) { + log := logger.WithComponent("service").With("operation", "UpdateSkill", "username", username, "skill", skillName) + start := time.Now() + + log.Info("Processing update skill request") + + // Get existing skill + skill, err := s.repo.GetSkill(username, skillName) + if err != nil { + log.Error("Failed to get skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Update fields if provided + if proficiencyLevel != nil { + if err := skill.UpdateProficiency(*proficiencyLevel); err != nil { + log.Error("Failed to update proficiency level", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + } + + if yearsOfExperience != nil { + if err := skill.UpdateYearsOfExperience(*yearsOfExperience); err != nil { + log.Error("Failed to update years of experience", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + } + + if notes != nil { + skill.UpdateNotes(*notes) + } + + // Save updated skill + if err := s.repo.UpdateSkill(skill); err != nil { + log.Error("Failed to update skill in database", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Info("Skill updated successfully", "duration", time.Since(start)) + return skill, nil +} + +// DeleteSkill removes a skill from a user +func (s *SkillService) DeleteSkill(username, skillName string) error { + log := logger.WithComponent("service").With("operation", "DeleteSkill", "username", username, "skill", skillName) + start := time.Now() + + log.Info("Processing delete skill request") + + if err := s.repo.DeleteSkill(username, skillName); err != nil { + log.Error("Failed to delete skill", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListSkillsForUser retrieves all skills for a user +func (s *SkillService) ListSkillsForUser(username string) ([]dto.SkillResponse, error) { + log := logger.WithComponent("service").With("operation", "ListSkillsForUser", "username", username) + start := time.Now() + + log.Info("Retrieving skills for user") + + skills, err := s.repo.ListSkillsForUser(username) + if err != nil { + log.Error("Failed to retrieve skills", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Convert to response DTOs + result := make([]dto.SkillResponse, len(skills)) + for i, skill := range skills { + result[i] = dto.SkillResponse{ + SkillName: skill.SkillName, + ProficiencyLevel: string(skill.ProficiencyLevel), + YearsOfExperience: skill.YearsOfExperience, + Endorsements: skill.Endorsements, + LastUsedDate: skill.LastUsedDate, + Notes: skill.Notes, + CreatedAt: skill.CreatedAt.Format(time.RFC3339), + UpdatedAt: skill.UpdatedAt.Format(time.RFC3339), + } + } + + log.Info("Skills retrieved successfully", "count", len(result), "duration", time.Since(start)) + return result, nil +} + +// ListUsersBySkill retrieves all users who have a specific skill +func (s *SkillService) ListUsersBySkill(skillName string) ([]dto.UserSkillResponse, error) { + log := logger.WithComponent("service").With("operation", "ListUsersBySkill", "skill", skillName) + start := time.Now() + + log.Info("Retrieving users by skill") + + skills, err := s.repo.ListUsersBySkill(skillName) + if err != nil { + log.Error("Failed to retrieve users by skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Convert to response DTOs + result := make([]dto.UserSkillResponse, len(skills)) + for i, skill := range skills { + result[i] = dto.UserSkillResponse{ + Username: skill.Username, + SkillName: skill.SkillName, + ProficiencyLevel: string(skill.ProficiencyLevel), + YearsOfExperience: skill.YearsOfExperience, + Endorsements: skill.Endorsements, + LastUsedDate: skill.LastUsedDate, + } + } + + log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(result), "duration", time.Since(start)) + return result, nil +} + +// ListUsersBySkillAndLevel retrieves users with a skill at a specific proficiency level +func (s *SkillService) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]dto.UserSkillResponse, error) { + log := logger.WithComponent("service").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) + start := time.Now() + + log.Info("Retrieving users by skill and level") + + skills, err := s.repo.ListUsersBySkillAndLevel(skillName, proficiencyLevel) + if err != nil { + log.Error("Failed to retrieve users by skill and level", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + // Convert to response DTOs + result := make([]dto.UserSkillResponse, len(skills)) + for i, skill := range skills { + result[i] = dto.UserSkillResponse{ + Username: skill.Username, + SkillName: skill.SkillName, + ProficiencyLevel: string(skill.ProficiencyLevel), + YearsOfExperience: skill.YearsOfExperience, + Endorsements: skill.Endorsements, + LastUsedDate: skill.LastUsedDate, + } + } + + log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(result), "duration", time.Since(start)) + return result, nil +} diff --git a/cmd/app/main.go b/cmd/app/main.go index f26efb4..25ee0f5 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,6 +1,8 @@ package main import ( + "log" + "github.com/hackmajoris/glad/cmd/app/internal/database" "github.com/hackmajoris/glad/cmd/app/internal/handler" "github.com/hackmajoris/glad/cmd/app/internal/router" @@ -18,10 +20,15 @@ func main() { cfg := config.Load() // Initialize dependencies - userRepo := database.NewDynamoDBRepository() + repo := database.NewDynamoDBRepository() tokenService := auth.NewTokenService(cfg) - userService := service.NewUserService(userRepo, tokenService) - apiHandler := handler.New(userService) + + // Initialize services + userService := service.NewUserService(repo, tokenService) + skillService := service.NewSkillService(repo) + + // Initialize handler + apiHandler := handler.New(userService, skillService) authMiddleware := middleware.NewAuthMiddleware(tokenService) // Setup router @@ -29,6 +36,7 @@ func main() { // Start Lambda lambda.Start(func(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + log.Println(request) return r.Route(request) }) } @@ -40,11 +48,22 @@ func setupRouter(h *handler.Handler, auth *middleware.AuthMiddleware) *router.Ro r.POST("/register", h.Register) r.POST("/login", h.Login) - // Protected routes + // Protected routes - User Management r.GET("/protected", h.Protected, auth.RequireAuth()) r.GET("/me", h.GetCurrentUser, auth.RequireAuth()) r.PUT("/user", h.UpdateUser, auth.RequireAuth()) r.GET("/users", h.ListUsers, auth.RequireAuth()) + // Protected routes - 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()) + r.GET("/users/{username}/skills/{skillName}", h.GetSkill, auth.RequireAuth()) + 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) + r.GET("/skills/{skillName}/users", h.ListUsersBySkill, auth.RequireAuth()) + return r } diff --git a/deployments/app/cdk.go b/deployments/app/cdk.go index 9838e02..61f23c8 100644 --- a/deployments/app/cdk.go +++ b/deployments/app/cdk.go @@ -34,15 +34,74 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw // VisibilityTimeout: awscdk.Duration_Seconds(jsii.Number(300)), // }) - // Create DynamoTable + // 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# - userTable := awsdynamodb.NewTableV2(stack, jsii.String(id+"-users-table"), &awsdynamodb.TablePropsV2{ - TableName: jsii.String("users"), + entitiesTable := awsdynamodb.NewTableV2(stack, jsii.String(id+"-entities-table"), &awsdynamodb.TablePropsV2{ + TableName: jsii.String("glad-entities"), + + // Partition Key: PK (stores entity identifier) PartitionKey: &awsdynamodb.Attribute{ - Name: jsii.String("username"), + Name: jsii.String("PK"), Type: awsdynamodb.AttributeType_STRING, }, - RemovalPolicy: awscdk.RemovalPolicy_DESTROY, // For dev environments + + // 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{ + { + IndexName: jsii.String("GSI1"), + PartitionKey: &awsdynamodb.Attribute{ + Name: jsii.String("GSI1PK"), + Type: awsdynamodb.AttributeType_STRING, + }, + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("GSI1SK"), + Type: awsdynamodb.AttributeType_STRING, + }, + // INCLUDE projection for cost optimization + // Only includes essential attributes needed for queries + ProjectionType: awsdynamodb.ProjectionType_INCLUDE, + NonKeyAttributes: jsii.Strings( + "EntityType", + "Username", + "SkillName", + "ProficiencyLevel", + "Name", + ), + }, + }, + + // Enable point-in-time recovery for data protection + PointInTimeRecovery: jsii.Bool(true), + + // Enable DynamoDB Streams for event-driven architecture + DynamoStream: awsdynamodb.StreamViewType_NEW_AND_OLD_IMAGES, + + // Remove table on stack deletion (for dev/testing) + RemovalPolicy: awscdk.RemovalPolicy_DESTROY, + + // Additional tags + Tags: &[]*awscdk.CfnTag{ + + { + Key: jsii.String("Purpose"), + Value: jsii.String("Single-Table-Design"), + }, + { + Key: jsii.String("DataModel"), + Value: jsii.String("Multi-Entity"), + }, + }, }) // Create Lambda @@ -52,7 +111,8 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw Handler: jsii.String("main"), }) - userTable.GrantReadWriteData(myFunc) + // Grant Lambda read/write access to DynamoDB table + entitiesTable.GrantReadWriteData(myFunc) api := awsapigateway.NewRestApi(stack, jsii.String(id+"-api-gateway"), &awsapigateway.RestApiProps{ RestApiName: jsii.String("glad-api gateway"), @@ -101,6 +161,49 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw AuthorizationType: awsapigateway.AuthorizationType_NONE, }) + // Skill Management Endpoints + // Pattern: /users/{username}/skills + usersSkillsResource := usersResource.AddResource(jsii.String("{username}"), nil) + skillsResource := usersSkillsResource.AddResource(jsii.String("skills"), nil) + + // POST /users/{username}/skills - Add a skill + skillsResource.AddMethod(jsii.String("POST"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // GET /users/{username}/skills - List all skills for user + skillsResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // Specific skill endpoints + // Pattern: /users/{username}/skills/{skillName} + skillResource := skillsResource.AddResource(jsii.String("{skillName}"), nil) + + // GET /users/{username}/skills/{skillName} - Get specific skill + skillResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // PUT /users/{username}/skills/{skillName} - Update skill + skillResource.AddMethod(jsii.String("PUT"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // DELETE /users/{username}/skills/{skillName} - Delete skill + skillResource.AddMethod(jsii.String("DELETE"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // Global skill query endpoint + // GET /skills/{skillName}/users - Find all users with a skill + skillsGlobalResource := api.Root().AddResource(jsii.String("skills"), nil) + skillNameResource := skillsGlobalResource.AddResource(jsii.String("{skillName}"), nil) + usersWithSkillResource := skillNameResource.AddResource(jsii.String("users"), nil) + usersWithSkillResource.AddMethod(jsii.String("GET"), 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 new file mode 100644 index 0000000..a757a0d --- /dev/null +++ b/docs/README-DYNAMODB-DESIGN.md @@ -0,0 +1,346 @@ +# 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/dynamodb-quick-reference.md b/docs/dynamodb-quick-reference.md new file mode 100644 index 0000000..c714ff8 --- /dev/null +++ b/docs/dynamodb-quick-reference.md @@ -0,0 +1,420 @@ +# 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 new file mode 100644 index 0000000..1c0481c --- /dev/null +++ b/docs/dynamodb-single-table-design-plan.md @@ -0,0 +1,481 @@ +# 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 new file mode 100644 index 0000000..16641d7 --- /dev/null +++ b/docs/entity-addition-protocol.md @@ -0,0 +1,607 @@ +# 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 db97e8337bf9979ef663bef0a372ef8bb43ae361 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 7 Dec 2025 20:15:22 +0200 Subject: [PATCH 3/3] chore: single table design configurations --- cmd/app/integration_test.go | 4 +- cmd/app/internal/database/dynamodb.go | 6 - cmd/app/internal/database/factory.go | 25 +++ cmd/app/internal/database/mock.go | 96 ++++----- cmd/app/internal/database/mock_test.go | 196 +++++++++++++++++- cmd/app/internal/handler/user_handler_test.go | 48 +++-- cmd/app/main.go | 2 +- 7 files changed, 288 insertions(+), 89 deletions(-) create mode 100644 cmd/app/internal/database/factory.go diff --git a/cmd/app/integration_test.go b/cmd/app/integration_test.go index 7417488..581d40c 100644 --- a/cmd/app/integration_test.go +++ b/cmd/app/integration_test.go @@ -45,8 +45,8 @@ func testConfig() *config.Config { // SetupIntegrationTest creates a test environment func SetupIntegrationTest() *IntegrationTestSuite { - userRepo := database.NewUserMockRepository() - userSkillsRepo := database.NewUserSkillsMockRepository() + userRepo := database.NewMockRepository() + userSkillsRepo := database.NewMockRepository() tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(userRepo, tokenService) userSkillsService := service.NewSkillService(userSkillsRepo) diff --git a/cmd/app/internal/database/dynamodb.go b/cmd/app/internal/database/dynamodb.go index 3f8849a..5c81cf8 100644 --- a/cmd/app/internal/database/dynamodb.go +++ b/cmd/app/internal/database/dynamodb.go @@ -15,12 +15,6 @@ const ( GSI1Name = "GSI1" ) -// Repository combines all repository interfaces -type Repository interface { - UserRepository - SkillRepository -} - // DynamoDBRepository implements Repository using DynamoDB single table design type DynamoDBRepository struct { client *dynamodb.DynamoDB diff --git a/cmd/app/internal/database/factory.go b/cmd/app/internal/database/factory.go new file mode 100644 index 0000000..5b75362 --- /dev/null +++ b/cmd/app/internal/database/factory.go @@ -0,0 +1,25 @@ +package database + +import ( + "github.com/hackmajoris/glad/pkg/config" + "github.com/hackmajoris/glad/pkg/logger" +) + +// Repository combines all repository interfaces for unified access +type Repository interface { + UserRepository + SkillRepository +} + +// NewRepository creates the appropriate repository implementation based on configuration +func NewRepository(cfg *config.Config) Repository { + log := logger.WithComponent("database") + + if cfg.LocalServer.Environment == "development" || cfg.LocalServer.Environment == "test" { + log.Info("Creating Mock repository for development/testing") + return NewMockRepository() + } + + log.Info("Creating DynamoDB repository for production") + return NewDynamoDBRepository() +} diff --git a/cmd/app/internal/database/mock.go b/cmd/app/internal/database/mock.go index b0ece79..9b5d928 100644 --- a/cmd/app/internal/database/mock.go +++ b/cmd/app/internal/database/mock.go @@ -9,45 +9,34 @@ import ( "github.com/hackmajoris/glad/pkg/logger" ) -// UserMockRepository implements UserRepository for testing -type UserMockRepository struct { - users map[string]*models.User - mutex sync.RWMutex +// MockRepository implements both UserRepository and SkillRepository 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 } -type UserSkillsMockRepository struct { - users map[string]*models.UserSkill - mutex sync.RWMutex -} - -// NewUserMockRepository creates a new mock repository -func NewUserMockRepository() *UserMockRepository { +// NewMockRepository creates a new unified mock repository +func NewMockRepository() *MockRepository { log := logger.WithComponent("database") - log.Info("Initializing Mock repository for local development") + log.Info("Initializing unified Mock repository for local development") - repo := &UserMockRepository{ - users: make(map[string]*models.User), + repo := &MockRepository{ + users: make(map[string]*models.User), + skills: make(map[string]*models.UserSkill), } - log.Info("Mock repository initialized successfully") + log.Info("Unified Mock repository initialized successfully") return repo } -// NewUserSkillsMockRepository NewUserMockRepository creates a new mock repository -func NewUserSkillsMockRepository() *UserSkillsMockRepository { - log := logger.WithComponent("database") - log.Info("Initializing Mock repository for local development") - - repo := &UserSkillsMockRepository{ - users: make(map[string]*models.UserSkill), - } - - log.Info("Mock repository initialized successfully") - return repo -} +// ============================================================================ +// USER REPOSITORY METHODS +// ============================================================================ // CreateUser creates a user in memory -func (m *UserMockRepository) CreateUser(user *models.User) error { +func (m *MockRepository) CreateUser(user *models.User) error { log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username, "repository", "mock") start := time.Now() @@ -67,7 +56,7 @@ func (m *UserMockRepository) CreateUser(user *models.User) error { } // GetUser retrieves a user from memory -func (m *UserMockRepository) GetUser(username string) (*models.User, error) { +func (m *MockRepository) GetUser(username string) (*models.User, error) { log := logger.WithComponent("database").With("operation", "GetUser", "username", username, "repository", "mock") start := time.Now() @@ -87,7 +76,7 @@ func (m *UserMockRepository) GetUser(username string) (*models.User, error) { } // UpdateUser updates a user in memory -func (m *UserMockRepository) UpdateUser(user *models.User) error { +func (m *MockRepository) UpdateUser(user *models.User) error { log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username, "repository", "mock") start := time.Now() @@ -107,7 +96,7 @@ func (m *UserMockRepository) UpdateUser(user *models.User) error { } // UserExists checks if a user exists in memory -func (m *UserMockRepository) UserExists(username string) (bool, error) { +func (m *MockRepository) UserExists(username string) (bool, error) { log := logger.WithComponent("database").With("operation", "UserExists", "username", username, "repository", "mock") start := time.Now() @@ -122,7 +111,7 @@ func (m *UserMockRepository) UserExists(username string) (bool, error) { } // ListUsers retrieves all users from memory -func (m *UserMockRepository) ListUsers() ([]*models.User, error) { +func (m *MockRepository) ListUsers() ([]*models.User, error) { log := logger.WithComponent("database").With("operation", "ListUsers", "repository", "mock") start := time.Now() @@ -140,8 +129,12 @@ func (m *UserMockRepository) ListUsers() ([]*models.User, error) { return users, nil } +// ============================================================================ +// SKILL REPOSITORY METHODS +// ============================================================================ + // CreateSkill creates a user skill in memory -func (m *UserSkillsMockRepository) CreateSkill(skill *models.UserSkill) error { +func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill", skill.SkillName, "repository", "mock") start := time.Now() @@ -151,18 +144,18 @@ func (m *UserSkillsMockRepository) CreateSkill(skill *models.UserSkill) error { defer m.mutex.Unlock() key := skill.Username + "#" + skill.SkillName - if _, exists := m.users[key]; exists { + if _, exists := m.skills[key]; exists { log.Debug("Skill already exists", "duration", time.Since(start)) return apperrors.ErrSkillAlreadyExists } - m.users[key] = skill - log.Info("Skill created successfully in mock repository", "total_skills", len(m.users), "duration", time.Since(start)) + 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 *UserSkillsMockRepository) GetSkill(username, skillName string) (*models.UserSkill, error) { +func (m *MockRepository) GetSkill(username, skillName string) (*models.UserSkill, error) { log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill", skillName, "repository", "mock") start := time.Now() @@ -172,7 +165,7 @@ func (m *UserSkillsMockRepository) GetSkill(username, skillName string) (*models defer m.mutex.RUnlock() key := username + "#" + skillName - skill, exists := m.users[key] + skill, exists := m.skills[key] if !exists { log.Debug("Skill not found in mock repository", "duration", time.Since(start)) return nil, apperrors.ErrSkillNotFound @@ -183,7 +176,7 @@ func (m *UserSkillsMockRepository) GetSkill(username, skillName string) (*models } // UpdateSkill updates a user skill in memory -func (m *UserSkillsMockRepository) UpdateSkill(skill *models.UserSkill) error { +func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill", skill.SkillName, "repository", "mock") start := time.Now() @@ -193,18 +186,18 @@ func (m *UserSkillsMockRepository) UpdateSkill(skill *models.UserSkill) error { defer m.mutex.Unlock() key := skill.Username + "#" + skill.SkillName - if _, exists := m.users[key]; !exists { + if _, exists := m.skills[key]; !exists { log.Debug("Skill not found for update", "duration", time.Since(start)) return apperrors.ErrSkillNotFound } - m.users[key] = skill + 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 *UserSkillsMockRepository) DeleteSkill(username, skillName string) error { +func (m *MockRepository) DeleteSkill(username, skillName string) error { log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill", skillName, "repository", "mock") start := time.Now() @@ -214,18 +207,18 @@ func (m *UserSkillsMockRepository) DeleteSkill(username, skillName string) error defer m.mutex.Unlock() key := username + "#" + skillName - if _, exists := m.users[key]; !exists { + if _, exists := m.skills[key]; !exists { log.Debug("Skill not found for deletion", "duration", time.Since(start)) return apperrors.ErrSkillNotFound } - delete(m.users, key) + 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 *UserSkillsMockRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { +func (m *MockRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username, "repository", "mock") start := time.Now() @@ -235,11 +228,10 @@ func (m *UserSkillsMockRepository) ListSkillsForUser(username string) ([]*models defer m.mutex.RUnlock() var skills []*models.UserSkill - for key, skill := range m.users { + for _, skill := range m.skills { if skill.Username == username { skills = append(skills, skill) } - _ = key } log.Info("Skills retrieved successfully for user from mock repository", "count", len(skills), "duration", time.Since(start)) @@ -247,7 +239,7 @@ func (m *UserSkillsMockRepository) ListSkillsForUser(username string) ([]*models } // ListUsersBySkill retrieves all users with a specific skill from memory -func (m *UserSkillsMockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { +func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") start := time.Now() @@ -257,11 +249,10 @@ func (m *UserSkillsMockRepository) ListUsersBySkill(skillName string) ([]*models defer m.mutex.RUnlock() var skills []*models.UserSkill - for key, skill := range m.users { + for _, skill := range m.skills { if skill.SkillName == skillName { skills = append(skills, skill) } - _ = key } log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) @@ -269,7 +260,7 @@ func (m *UserSkillsMockRepository) ListUsersBySkill(skillName string) ([]*models } // ListUsersBySkillAndLevel retrieves all users with a specific skill and proficiency level from memory -func (m *UserSkillsMockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { +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() @@ -279,11 +270,10 @@ func (m *UserSkillsMockRepository) ListUsersBySkillAndLevel(skillName string, pr defer m.mutex.RUnlock() var skills []*models.UserSkill - for key, skill := range m.users { + for _, skill := range m.skills { if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel { skills = append(skills, skill) } - _ = key } log.Info("Users retrieved successfully by skill and level from mock repository", "count", len(skills), "duration", time.Since(start)) diff --git a/cmd/app/internal/database/mock_test.go b/cmd/app/internal/database/mock_test.go index c75e7eb..af2e3ad 100644 --- a/cmd/app/internal/database/mock_test.go +++ b/cmd/app/internal/database/mock_test.go @@ -10,20 +10,26 @@ import ( ) func TestNewMockRepository(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() if repo == nil { t.Error("Expected non-nil repository") } if repo.users == nil { t.Error("Expected users map to be initialized") } + if repo.skills == nil { + t.Error("Expected skills map to be initialized") + } if len(repo.users) != 0 { t.Error("Expected empty users map") } + if len(repo.skills) != 0 { + t.Error("Expected empty skills map") + } } func TestMockRepository_CreateUser(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -44,7 +50,7 @@ func TestMockRepository_CreateUser(t *testing.T) { } func TestMockRepository_GetUser(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -74,7 +80,7 @@ func TestMockRepository_GetUser(t *testing.T) { } func TestMockRepository_UpdateUser(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -109,7 +115,7 @@ func TestMockRepository_UpdateUser(t *testing.T) { } func TestMockRepository_UserExists(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() user, err := models.NewUser("testuser", "Test User", "password123") if err != nil { @@ -139,7 +145,7 @@ func TestMockRepository_UserExists(t *testing.T) { } func TestMockRepository_ListUsers(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() // Test empty list users, err := repo.ListUsers() @@ -180,7 +186,7 @@ func TestMockRepository_ListUsers(t *testing.T) { } func TestMockRepository_ConcurrentAccess(t *testing.T) { - repo := NewUserMockRepository() + repo := NewMockRepository() var wg sync.WaitGroup concurrency := 10 @@ -221,3 +227,179 @@ func TestMockRepository_ConcurrentAccess(t *testing.T) { wg.Wait() } + +// ============================================================================ +// SKILL REPOSITORY TESTS +// ============================================================================ + +func TestMockRepository_CreateSkill(t *testing.T) { + repo := NewMockRepository() + + skill, err := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) + if err != nil { + t.Fatalf("Failed to create skill: %v", err) + } + + // Test successful creation + err = repo.CreateSkill(skill) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Test duplicate creation + err = repo.CreateSkill(skill) + if err != apperrors.ErrSkillAlreadyExists { + t.Errorf("Expected ErrSkillAlreadyExists, got %v", err) + } +} + +func TestMockRepository_GetSkill(t *testing.T) { + repo := NewMockRepository() + + skill, err := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) + if err != nil { + t.Fatalf("Failed to create skill: %v", err) + } + + // Create skill first + repo.CreateSkill(skill) + + // Test successful retrieval + retrieved, err := repo.GetSkill("testuser", "Go") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if retrieved.Username != "testuser" { + t.Errorf("Expected username testuser, got %s", retrieved.Username) + } + if retrieved.SkillName != "Go" { + t.Errorf("Expected skill name Go, got %s", retrieved.SkillName) + } + + // Test non-existent skill + _, err = repo.GetSkill("testuser", "nonexistent") + if err != apperrors.ErrSkillNotFound { + t.Errorf("Expected ErrSkillNotFound, got %v", err) + } +} + +func TestMockRepository_ListSkillsForUser(t *testing.T) { + repo := NewMockRepository() + + // Test empty list + skills, err := repo.ListSkillsForUser("testuser") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(skills) != 0 { + t.Errorf("Expected empty list, got %d skills", len(skills)) + } + + // 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) + + repo.CreateSkill(skill1) + repo.CreateSkill(skill2) + repo.CreateSkill(skill3) + + // Test list skills for testuser + skills, err = repo.ListSkillsForUser("testuser") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(skills) != 2 { + t.Errorf("Expected 2 skills, got %d", len(skills)) + } + + // Verify correct skills are returned + skillNames := make(map[string]bool) + for _, skill := range skills { + if skill.Username != "testuser" { + t.Errorf("Expected username testuser, got %s", skill.Username) + } + skillNames[skill.SkillName] = true + } + if !skillNames["Go"] { + t.Error("Expected Go skill to be in the list") + } + 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") + } +} + +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) + + repo.CreateSkill(skill1) + repo.CreateSkill(skill2) + repo.CreateSkill(skill3) + + // Test list users with Go skill + skills, err := repo.ListUsersBySkill("Go") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(skills) != 2 { + t.Errorf("Expected 2 users with Go skill, got %d", len(skills)) + } + + // Verify correct users are returned + usernames := make(map[string]bool) + for _, skill := range skills { + if skill.SkillName != "Go" { + t.Errorf("Expected skill name Go, got %s", skill.SkillName) + } + usernames[skill.Username] = true + } + if !usernames["user1"] { + t.Error("Expected user1 to be in the list") + } + if !usernames["user2"] { + t.Error("Expected user2 to be in the list") + } + if usernames["user3"] { + t.Error("Did not expect user3 to be in the list for Go skill") + } +} + +func TestMockRepository_UnifiedInterface(t *testing.T) { + // Test that the same repository instance implements both interfaces + repo := NewMockRepository() + + // Test as UserRepository + var userRepo UserRepository = repo + user, _ := models.NewUser("testuser", "Test User", "password123") + err := userRepo.CreateUser(user) + if err != nil { + t.Errorf("Failed to create user via UserRepository interface: %v", err) + } + + // Test as SkillRepository + var skillRepo SkillRepository = repo + skill, _ := models.NewUserSkill("testuser", "Go", models.ProficiencyIntermediate, 3) + err = skillRepo.CreateSkill(skill) + if err != nil { + t.Errorf("Failed to create skill via SkillRepository interface: %v", err) + } + + // Test as combined Repository interface + var combinedRepo Repository = repo + _, err = combinedRepo.GetUser("testuser") + if err != nil { + t.Errorf("Failed to get user via Repository interface: %v", err) + } + _, 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/handler/user_handler_test.go b/cmd/app/internal/handler/user_handler_test.go index 8b23a95..e06cf4c 100644 --- a/cmd/app/internal/handler/user_handler_test.go +++ b/cmd/app/internal/handler/user_handler_test.go @@ -28,18 +28,21 @@ func testConfig() *config.Config { func TestHandler_GetCurrentUser(t *testing.T) { tests := []struct { name string - setupRepo func(repo *database.UserMockRepository) + setupRepo func(repo *database.MockRepository) claims *auth.JWTClaims expectedStatus int validateBody func(t *testing.T, body string) }{ { name: "successful user retrieval", - setupRepo: func(repo *database.UserMockRepository) { + setupRepo: func(repo *database.MockRepository) { user, _ := models.NewUser("testuser", "Test User", "password123") user.CreatedAt = time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) user.UpdatedAt = time.Date(2025, 1, 2, 15, 30, 0, 0, time.UTC) - repo.CreateUser(user) + err := repo.CreateUser(user) + if err != nil { + return + } }, claims: &auth.JWTClaims{ Username: "testuser", @@ -67,7 +70,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { }, { name: "invalid token claims", - setupRepo: func(repo *database.UserMockRepository) { + setupRepo: func(repo *database.MockRepository) { // No setup needed }, claims: nil, @@ -84,7 +87,7 @@ func TestHandler_GetCurrentUser(t *testing.T) { }, { name: "user not found", - setupRepo: func(repo *database.UserMockRepository) { + setupRepo: func(repo *database.MockRepository) { // Don't create the user }, claims: &auth.JWTClaims{ @@ -105,18 +108,17 @@ func TestHandler_GetCurrentUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create mock repositories - userMockRepo := database.NewUserMockRepository() - skillMockRepo := database.NewUserSkillsMockRepository() + // Create unified mock repository + mockRepo := database.NewMockRepository() if tt.setupRepo != nil { - tt.setupRepo(userMockRepo) + tt.setupRepo(mockRepo) } - // Create services with mock repositories + // Create services with mock repository tokenService := auth.NewTokenService(testConfig()) - userService := service.NewUserService(userMockRepo, tokenService) - skillService := service.NewSkillService(skillMockRepo) + userService := service.NewUserService(mockRepo, tokenService) + skillService := service.NewSkillService(mockRepo) // Create handler h := New(userService, skillService) @@ -161,19 +163,22 @@ func TestHandler_GetCurrentUser(t *testing.T) { // TestHandler_GetCurrentUser_TimestampFormat verifies the timestamp format is ISO 8601 func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { - // Create mock repository and service - mockRepo := database.NewUserMockRepository() + // Create unified mock repository + mockRepo := database.NewMockRepository() // Create a user with specific timestamps user, _ := models.NewUser("testuser", "Test User", "password123") user.CreatedAt = time.Date(2025, 12, 7, 14, 30, 45, 0, time.FixedZone("EST", -5*3600)) user.UpdatedAt = time.Date(2025, 12, 7, 16, 45, 30, 0, time.FixedZone("PST", -8*3600)) - mockRepo.CreateUser(user) + err := mockRepo.CreateUser(user) + if err != nil { + return + } tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) - skillMockRepo := database.NewUserSkillsMockRepository() - skillService := service.NewSkillService(skillMockRepo) + mockRepository := database.NewMockRepository() + skillService := service.NewSkillService(mockRepository) h := New(userService, skillService) request := events.APIGatewayProxyRequest{ @@ -210,14 +215,17 @@ func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { // TestHandler_GetCurrentUser_DoesNotExposePassword verifies password hash is not included func TestHandler_GetCurrentUser_DoesNotExposePassword(t *testing.T) { // Create mock repository and service - mockRepo := database.NewUserMockRepository() + mockRepo := database.NewMockRepository() user, _ := models.NewUser("testuser", "Test User", "password123") - mockRepo.CreateUser(user) + err := mockRepo.CreateUser(user) + if err != nil { + return + } tokenService := auth.NewTokenService(testConfig()) userService := service.NewUserService(mockRepo, tokenService) - skillMockRepo := database.NewUserSkillsMockRepository() + skillMockRepo := database.NewMockRepository() skillService := service.NewSkillService(skillMockRepo) h := New(userService, skillService) diff --git a/cmd/app/main.go b/cmd/app/main.go index 25ee0f5..a61fe0d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -20,7 +20,7 @@ func main() { cfg := config.Load() // Initialize dependencies - repo := database.NewDynamoDBRepository() + repo := database.NewRepository(cfg) tokenService := auth.NewTokenService(cfg) // Initialize services