diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 43f8e16..8ab8c98 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # GLAD Stack - Go, Lambda, ApiGateway, DynamoDB -A comprehensive serverless API platform built with Go, demonstrating modern cloud-native architecture using AWS serverless technologies and production ready API. +A comprehensive serverless API platform built with Go, +demonstrating modern Cloud-Native architecture using AWS serverless technologies and production-ready API. ## What is GLAD? @@ -8,24 +9,45 @@ A comprehensive serverless API platform built with Go, demonstrating modern clou - **G**o - Modern, efficient programming language with excellent performance and concurrency - **L**ambda - AWS serverless compute platform for running code without managing servers - **A**piGateway - AWS managed API gateway service for creating, deploying, and managing REST APIs -- **D**ynamoDB - AWS NoSQL database(Single Table Design) service providing fast and predictable performance with seamless scalability +- **D**ynamoDB - AWS NoSQL database (Single Table Design) service providing fast and predictable performance with seamless scalability -This project showcases how these four technologies work together to create a production-ready, scalable, and cost-effective serverless API platform that can handle millions of requests while maintaining low latency and high availability. +This project showcases how these four technologies work together to create a production-ready, scalable, and cost-effective serverless API platform +that can handle millions of requests while maintaining low latency and high availability. -[![Tests](https://img.shields.io/badge/tests-passing-brightgreen)]() [![Go Version](https://img.shields.io/badge/go-1.24.0-blue)]() -[![Production Ready](https://img.shields.io/badge/status-production%20ready-success)]() ## Features -- **RESTful API** with user authentication and management -- **JWT Authentication** with token-based authorization -- **Serverless Architecture** using AWS Lambda + API Gateway -- **DynamoDB Integration** for data persistence -- **Clean Architecture** with layered design (Handler → Service → Repository) -- **Comprehensive Testing** with unit and integration tests -- **Structured Logging** using Go's slog package -- **Infrastructure as Code** with AWS CDK +### User Management +- ✅ User registration with validation (username, name, password) +- ✅ User authentication with JWT tokens +- ✅ User profile updates (name, password changes) +- ✅ List all users in the system +- ✅ Bcrypt password hashing for security +- ✅ Get current authenticated user info + +### Skills Management +- ✅ **Master Skills Catalog** - Centralized skill definitions +- ✅ **User Skills** - Assign skills to users with proficiency tracking +- ✅ **Proficiency Levels** - Beginner, Intermediate, Advanced, Expert +- ✅ **Years of Experience** - Track experience per skill +- ✅ **Skill Categories** - Organize skills by category (Programming, DevOps, etc.) +- ✅ **Endorsements** - Track skill endorsement counts +- ✅ **Last Used Date** - Track when skill was last used +- ✅ **Skill Notes** - Add custom notes/comments to user skills +- ✅ **Cross-User Queries** - Find all users with a specific skill +- ✅ **Filter by Proficiency** - Query users by skill and proficiency level + +### Architecture & Infrastructure +- ✅ **Serverless Architecture** using AWS Lambda + API Gateway +- ✅ **Single Table DynamoDB Design** with Multi-Key GSI pattern +- ✅ **Clean Architecture** with layered design (Handler → Service → Repository) +- ✅ **Repository Pattern** with DynamoDB and Mock implementations +- ✅ **Comprehensive Testing** - unit, integration, and API tests +- ✅ **Structured Logging** using Go's slog package with component tracking +- ✅ **Infrastructure as Code** with AWS CDK (Go) +- ✅ **JWT Authentication** with configurable token expiry +- ✅ **Automatic Mock/Production** repository switching ## Project Structure @@ -36,9 +58,8 @@ glad/ │ ├── main.go # Lambda entry point │ ├── integration_test.go # Integration tests │ ├── testdata/ # Test data files -│ │ └── sample_request.json # Sample API requests │ └── internal/ # App-specific code -│ ├── database/ # Repository implementations +│ ├── database/ # Repository layer (see Database Layer Organization) │ ├── dto/ # Request/Response DTOs │ ├── errors/ # App-specific errors │ ├── handler/ # HTTP handlers (thin layer) @@ -46,8 +67,6 @@ glad/ │ ├── router/ # Router abstraction │ ├── service/ # Business logic │ └── validation/ # Input validation -├── internal/ # Shared private code -│ └── errors/ # Domain error definitions ├── pkg/ # Shared public packages │ ├── auth/ # JWT token service │ ├── config/ # Configuration management @@ -56,14 +75,7 @@ glad/ │ └── middleware/ # HTTP middleware ├── deployments/ │ └── app/ # AWS CDK infrastructure -│ ├── cdk.json # CDK configuration -│ └── cdk.out/ # CDK build output -├── scripts/ # Utility scripts -├── site/ # Documentation/website -├── .github/ -│ ├── workflows/ -│ │ └── ci.yml # GitHub Actions CI -│ └── dependabot.yml # Dependency updates +│ ├── cdk.go # CDK stack definition ├── Taskfile.yml # Task runner configuration ├── .golangci.yml # Go linter configuration └── README.md # This file @@ -79,34 +91,133 @@ Request → Router → Middleware → Handler → Service → Repository → Dat ### Layers -1. **Router** - Route matching and middleware chaining +1. **Router** - Route matching and middleware chaining for Lambda 2. **Middleware** - JWT validation, logging, CORS 3. **Handler** - HTTP layer (JSON marshaling/unmarshaling) 4. **Service** - Business logic and validation -5. **Repository** - Data access abstraction -6. **Database** - DynamoDB or Mock implementation +5. **Repository** - Data access abstraction (interface-based) +6. **Database** - DynamoDB (production) or Mock (development/testing) ### Design Patterns - **Layered Architecture** - Clear separation of concerns -- **Repository Pattern** - Interface-based data access -- **Dependency Injection** - Config-based initialization +- **Repository Pattern** - Interface-based data access with factory +- **Dependency Injection** - Constructor injection throughout - **DTO Pattern** - Separate request/response types from domain models - **Service Layer** - Business logic isolated from HTTP concerns +- **Factory Pattern** - Auto-selects Mock vs DynamoDB based on environment +- **Single Responsibility** - Each layer has one clear purpose -## Data Model -The Single Table Design in modeled using new Dynamo feature: Multi-Keys(composite keys) for GSI approach. -Check: https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/ +## Database Layer Organization + +The database package follows a scalable file organization pattern designed for growth to 10+ repositories: + +``` +cmd/app/internal/database/ +├── client.go # Repository struct definitions +├── constants.go # Table names, GSI constants +├── entity_keys.go # Entity ID builders and parsers +├── factory.go # Repository factory + unified interface +│ +├── user_repository.go # UserRepository interface +├── user_repository_dynamodb.go # DynamoDB implementation +├── user_repository_mock.go # Mock implementation +│ +├── user_skill_repository.go # SkillRepository interface +├── user_skill_repository_dynamodb.go # DynamoDB implementation +├── user_skill_repository_mock.go # Mock implementation +│ +├── master_skill_repository.go # MasterSkillRepository interface +├── master_skill_repository_dynamodb.go # DynamoDB implementation +└── master_skill_repository_mock.go # Mock implementation +``` + +**File Naming Pattern**: `{entity}_repository.go`, `{entity}_repository_{implementation}.go` + +This pattern ensures: +- Files are grouped by entity when sorted alphabetically +- Clear separation between interface and implementation +- Easy to locate code: interface → DynamoDB impl → Mock impl +- Scales to 10+ repositories without confusion + +**Repository Pattern**: Each entity has: +1. An interface defining operations (e.g., `UserRepository`) +2. DynamoDB implementation using single-table design +3. Mock implementation for local development and testing + +The unified `Repository` interface composes all entity repositories, allowing both `DynamoDBRepository` and `MockRepository` to implement the same interface. + +**Auto-Selection Logic**: +- If `AWS_LAMBDA_FUNCTION_NAME` environment variable exists → DynamoDB +- If `ENVIRONMENT=production` → DynamoDB +- If `ENVIRONMENT=development` or `DB_MOCK=true` → Mock +- Default: DynamoDB + +## Data Model - Single Table Design with Multi-Key GSI + +The Single Table Design is modeled using DynamoDB's Multi-Key (composite keys) for GSI feature. +Read more: https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/ + +### Table: `glad-entities` +- **Partition Key**: `entity_id` (STRING) + +### Entity ID Format (using `#` delimiter): +- **Users**: `USER#` (e.g., `USER#john`) +- **User Skills**: `USERSKILL##` (e.g., `USERSKILL#john#python`) +- **Master Skills**: `SKILL#` (e.g., `SKILL#python`) + +### Global Secondary Indexes (5 GSIs): + +1. **SkillsByLevel** - Query users by skill and proficiency level + - PK: `SkillName`, SK: `ProficiencyLevel` +2. **ByUser** - Get all entities for a user + - PK: `Username`, SK: `EntityType` +3. **SkillsByCategory** - Find skills by category + - PK: `EntityType`, SK: `Category` +4. **ByEntityType** - Query all entities of a type + - PK: `EntityType`, SK: `SkillName` +5. **BySkillID** - Find all users with a specific skill + - PK: `skill_id`, SK: `Username` ## API Endpoints +### Authentication (Public) | Method | Path | Auth | Description | |--------|------------|------|------------------------------| | POST | /register | No | User registration | | POST | /login | No | Authentication (returns JWT) | -| GET | /protected | JWT | Protected resource demo | -| PUT | /user | JWT | Update user profile | + +### User Management (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|------------|------|------------------------------| +| GET | /me | JWT | Get current user info | | GET | /users | JWT | List all users | +| PUT | /user | JWT | Update user profile | +| GET | /protected | JWT | Protected resource demo | + +### User Skills (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|------------------------------------|------|--------------------------| +| POST | /users/{username}/skills | JWT | Add skill to user | +| GET | /users/{username}/skills | JWT | List all skills for user | +| GET | /users/{username}/skills/{skillID} | JWT | Get specific user skill | +| PUT | /users/{username}/skills/{skillID} | JWT | Update user skill | +| DELETE | /users/{username}/skills/{skillID} | JWT | Delete user skill | + +### Master Skills (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|--------------------------|------|---------------------------| +| POST | /master-skills | JWT | Create master skill | +| GET | /master-skills | JWT | List all master skills | +| GET | /master-skills/{skillID} | JWT | Get specific master skill | +| PUT | /master-skills/{skillID} | JWT | Update master skill | +| DELETE | /master-skills/{skillID} | JWT | Delete master skill | + +### Cross-User Skill Queries (Protected - JWT Required) +| Method | Path | Auth | Description | +|--------|----------------------------------------|------|--------------------------------| +| GET | /skills/{skillName}/users | JWT | Find all users with skill | +| GET | /skills/{skillName}/users?level=Expert | JWT | Find users with skill at level | ## Getting Started @@ -114,8 +225,8 @@ Check: https://aws.amazon.com/blogs/database/multi-key-support-for-global-second - Go 1.21+ (tested with 1.24.0) - [Task](https://taskfile.dev/) installed (`brew install go-task/tap/go-task`) -- AWS CLI configured -- AWS CDK installed (for deployment) +- AWS CLI configured with credentials +- AWS CDK installed (for deployment): `npm install -g aws-cdk` ### Local Development @@ -138,25 +249,29 @@ task lint # Format code task fmt -# Full development test cycle +# Quick test cycle (format + test) +task dev:quick-test + +# Full development test cycle (format + lint + test + build) task dev:full-test ``` ### Testing the API +#### User Registration & Authentication ```bash # Register a user curl -X POST http://localhost:8080/register \ -H "Content-Type: application/json" \ -d '{"username":"testuser","name":"Test User","password":"password123"}' -# Login +# Login (returns JWT token) curl -X POST http://localhost:8080/login \ -H "Content-Type: application/json" \ -d '{"username":"testuser","password":"password123"}' -# Access protected route (use token from login) -curl -X GET http://localhost:8080/protected \ +# Get current user info +curl -X GET http://localhost:8080/me \ -H "Authorization: Bearer YOUR_TOKEN_HERE" # Update user profile @@ -165,11 +280,55 @@ curl -X PUT http://localhost:8080/user \ -H "Content-Type: application/json" \ -d '{"name":"Updated Name"}' -# List users +# List all users curl -X GET http://localhost:8080/users \ -H "Authorization: Bearer YOUR_TOKEN_HERE" ``` +#### Master Skills Management +```bash +# Create a master skill +curl -X POST http://localhost:8080/master-skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{ + "skill_id":"python", + "skill_name":"Python", + "category":"Programming" + }' + +# List all master skills +curl -X GET http://localhost:8080/master-skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +#### User Skills Management +```bash +# Add skill to user +curl -X POST http://localhost:8080/users/testuser/skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{ + "skill_id":"python", + "skill_name":"Python", + "category":"Programming", + "proficiency_level":"Intermediate", + "years_of_experience":3 + }' + +# Get user's skills +curl -X GET http://localhost:8080/users/testuser/skills \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" + +# Find all users with Python skill +curl -X GET http://localhost:8080/skills/Python/users \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" + +# Find Expert Python developers +curl -X GET "http://localhost:8080/skills/Python/users?level=Expert" \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + ### Building for Lambda ```bash @@ -182,16 +341,16 @@ task build:lambda ### Deploying to AWS ```bash -# Bootstrap CDK (first time only) +# Bootstrap CDK (first time only, per account/region) task cdk:bootstrap +# Preview deployment changes +task cdk:diff + # Deploy infrastructure and application task cdk:deploy -# Preview deployment changes -task deploy:diff - -# Full deployment workflow (test, build, deploy) +# Full deployment workflow (test → build → deploy) task deploy # Destroy stack (use with caution!) @@ -202,14 +361,17 @@ task cdk:destroy Set environment variables for configuration: -| Variable | Description | Default | -|------------------|-------------------------------|----------------------| -| `JWT_SECRET` | JWT signing secret | "default-secret-key" | -| `JWT_EXPIRY` | Token expiry duration | 24h | -| `DYNAMODB_TABLE` | DynamoDB table name | "users" | -| `AWS_REGION` | AWS region | "us-east-1" | -| `ENVIRONMENT` | "production" or "development" | "development" | -| `PORT` | Server port (local only) | 8080 | +| Variable | Description | Default | +|----------------------------|-------------------------------|----------------------| +| `JWT_SECRET` | JWT signing secret | "default-secret-key" | +| `JWT_EXPIRY` | Token expiry duration | 24h | +| `JWT_SIGNING_ALG` | JWT signing algorithm | "HS256" | +| `DYNAMODB_TABLE` | DynamoDB table name | "users" | +| `AWS_REGION` | AWS region for DynamoDB | "us-east-1" | +| `ENVIRONMENT` | "production" or "development" | "development" | +| `PORT` | Server port (local only) | 8080 | +| `DB_MOCK` | Force mock DB usage | (not set) | +| `AWS_LAMBDA_FUNCTION_NAME` | Auto-detected in Lambda | (auto) | ## Testing @@ -224,6 +386,9 @@ go test -v ./... # Run specific package tests go test ./pkg/auth/... + +# Run with race detection +go test -race ./... ``` ### Integration Tests @@ -242,71 +407,141 @@ task test:database # Database tests only task test:models # Model tests only ``` +### Test Coverage + +```bash +# Generate coverage report +task test:coverage + +# View coverage in browser +go tool cover -html=coverage.out +``` + Test coverage includes: -- ✅ Handler layer tests +- ✅ Handler layer tests (user, skill, master skill) - ✅ Service layer tests -- ✅ Database layer tests (mock) +- ✅ Database layer tests (Mock repository) - ✅ Authentication & middleware tests - ✅ Full user journey integration tests - ✅ Security & authorization tests +- ✅ Domain model validation tests ## Key Components ### Config (`pkg/config/`) -Centralized configuration loading from environment variables with typed structs. - -### Errors (`internal/errors/` & `pkg/errors/`) -Domain-specific and reusable error definitions with proper error wrapping. - -### Validation (`internal/validation/`) -Shared validation logic for user input with clear error messages. +Centralized configuration loading from environment variables with typed structs and defaults. + +### Errors (`cmd/app/internal/errors/` & `pkg/errors/`) +- Domain-specific error definitions +- Reusable error utilities +- HTTP status code mapping +- Proper error wrapping with context + +### Validation (`cmd/app/internal/validation/`) +- Username validation (3-50 chars, alphanumeric + underscore) +- Password validation (min 6 chars) +- Name validation (non-empty) +- Skill ID validation (lowercase alphanumeric + dashes) +- Proficiency level enum validation +- Years of experience validation (non-negative) ### Authentication (`pkg/auth/`) -JWT token generation and validation with configurable expiry. +- JWT token generation with configurable expiry +- Token validation and claims extraction +- HS256 signing algorithm +- Username embedded in token claims ### Middleware (`pkg/middleware/`) -JWT authentication middleware for protecting routes. +- JWT authentication middleware +- Bearer token extraction from Authorization header +- Route protection +- Error handling in auth flow ### Logging (`pkg/logger/`) -Structured logging with slog, including request duration tracking. +- Structured logging with Go's slog package +- Component-based logging (e.g., "database", "handler") +- Operation tracking +- Duration tracking for performance monitoring +- Levels: Info (✅), Debug (🔍), Error (❌), Warn (⚠️) +- JSON format for production, text for development ## AWS Infrastructure -Deployed resources (via CDK): - -- **DynamoDB Table**: `glad-entities` table -- **Lambda Function**: Go 1.x runtime with provided.al2023 -- **API Gateway**: REST API with CORS enabled -- **IAM Roles**: Least-privilege access for Lambda +Deployed resources (via AWS CDK in Go): + +### DynamoDB Table +- **Name**: `glad-entities` +- **Single table** with 5 Global Secondary Indexes +- **DynamoDB Streams**: Enabled (NEW_AND_OLD_IMAGES) +- **Capacity**: On-demand billing mode +- **Point-in-time recovery**: Disabled (dev-friendly) +- **Removal policy**: DESTROY (dev-friendly) + +### Lambda Function +- **Runtime**: provided.al2023 (custom Go runtime) +- **Handler**: bootstrap binary +- **Architecture**: AMD64 +- **Timeout**: 30 seconds +- **Environment**: ENVIRONMENT=production +- **Permissions**: DynamoDB read/write on `glad-entities` + +### API Gateway +- **Type**: REST API +- **Name**: glad-api-gateway +- **CORS**: Enabled (* origins) +- **Throttling**: 100 RPS, 200 burst +- **Deployment**: Production stage +- **Usage Plan**: Attached with rate limiting + +### IAM Roles +- Lambda execution role with DynamoDB permissions +- Least-privilege access pattern ## Development Workflow -1. Write code following the layered architecture -2. Add unit tests for new functionality -3. Format and lint code (`task dev:quick-test`) -4. Run full test suite (`task dev:full-test`) -5. Run integration tests (`task test:integration`) -6. Build Lambda package (`task build:lambda`) -7. Deploy to AWS (`task deploy`) +1. **Write code** following the layered architecture +2. **Add unit tests** for new functionality +3. **Format and lint** code (`task dev:quick-test`) +4. **Run full test suite** (`task dev:full-test`) +5. **Run integration tests** (`task test:integration`) +6. **Build Lambda package** (`task build:lambda`) +7. **Preview changes** (`task cdk:diff`) +8. **Deploy to AWS** (`task deploy`) ## Code Quality -- **Linting**: golangci-lint with 10+ linters -- **Test Coverage**: Unit and integration tests -- **CI/CD**: GitHub Actions workflow -- **Security**: JWT authentication, bcrypt password hashing +- **Linting**: golangci-lint via `task lint` +- **Test Coverage**: Unit and integration tests across all layers +- **CI/CD**: GitHub Actions workflow (see `.github/workflows/`) +- **Security**: + - JWT authentication with Bearer tokens + - Bcrypt password hashing (cost: 10) + - Input validation on all endpoints + - Proper error handling without leaking sensitive data - **Logging**: Structured logging throughout all layers +- **Error Handling**: Domain-specific errors with HTTP mapping + +## Dependencies + +### Go Packages +- `github.com/aws/aws-lambda-go` - Lambda runtime +- `github.com/aws/aws-sdk-go` - DynamoDB client +- `github.com/golang-jwt/jwt/v5` - JWT token handling +- `golang.org/x/crypto` - Bcrypt password hashing + +### AWS Services +- AWS Lambda (serverless compute) +- API Gateway (REST API management) +- DynamoDB (NoSQL database with single-table design) +- CloudFormation (infrastructure via CDK) +- IAM (permissions management) ## Contributing -This is a learning project. Feel free to explore, fork, and experiment! +This is a learning project demonstrating serverless Go architecture. Feel free to explore, fork, and experiment! ## License This project is for educational purposes. ---- - -**Status**: Production Ready ✅ - -All tests passing. Clean architecture. Comprehensive error handling. Security best practices implemented. \ No newline at end of file +--- \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 7336720..f967c07 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -42,6 +42,7 @@ tasks: - go test -coverprofile=coverage.out ./... - go tool cover -html=coverage.out -o coverage.html - echo 'Coverage report generated - coverage.html' + - go tool cover -html=coverage.out test:suite: desc: 'Run comprehensive test suite with detailed reporting' diff --git a/cmd/app/internal/database/client.go b/cmd/app/internal/database/client.go new file mode 100644 index 0000000..9e6f0ae --- /dev/null +++ b/cmd/app/internal/database/client.go @@ -0,0 +1,58 @@ +package database + +import ( + "sync" + + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/dynamodb" +) + +// DynamoDBRepository implements all repository interfaces using DynamoDB single table design +// It provides implementations for: +// - UserRepository (user management) +// - MasterSkillRepository (master skills) +// - SkillRepository (user skills) +type DynamoDBRepository struct { + client *dynamodb.DynamoDB +} + +// NewDynamoDBRepository creates a new DynamoDB repository +func NewDynamoDBRepository() *DynamoDBRepository { + log := logger.WithComponent("database") + log.Info("Initializing DynamoDB repository", "table", TableName) + + sess := session.Must(session.NewSession()) + repo := &DynamoDBRepository{ + client: dynamodb.New(sess), + } + + log.Info("DynamoDB repository initialized successfully") + return repo +} + +// MockRepository implements UserRepository, SkillRepository, and MasterSkillRepository for testing +// This matches the DynamoDBRepository structure with unified implementation +type MockRepository struct { + users map[string]*models.User // key: username + skills map[string]*models.UserSkill // key: "username#skillname" + masterSkills map[string]*models.Skill // key: skill_id + mutex sync.RWMutex +} + +// NewMockRepository creates a new unified mock repository +func NewMockRepository() *MockRepository { + log := logger.WithComponent("database") + log.Info("Initializing unified Mock repository for local development") + + repo := &MockRepository{ + users: make(map[string]*models.User), + skills: make(map[string]*models.UserSkill), + masterSkills: make(map[string]*models.Skill), + } + + log.Info("Unified Mock repository initialized successfully") + return repo +} diff --git a/cmd/app/internal/database/mock_test.go b/cmd/app/internal/database/client_test.go similarity index 100% rename from cmd/app/internal/database/mock_test.go rename to cmd/app/internal/database/client_test.go diff --git a/cmd/app/internal/database/dynamodb.go b/cmd/app/internal/database/dynamodb.go deleted file mode 100644 index 1a66067..0000000 --- a/cmd/app/internal/database/dynamodb.go +++ /dev/null @@ -1,31 +0,0 @@ -package database - -import ( - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/dynamodb" -) - -// DynamoDBRepository implements all repository interfaces using DynamoDB single table design -// It provides implementations for: -// - UserRepository (user management) -// - MasterSkillRepository (master skills) -// - SkillRepository (user skills) -type DynamoDBRepository struct { - client *dynamodb.DynamoDB -} - -// NewDynamoDBRepository creates a new DynamoDB repository -func NewDynamoDBRepository() *DynamoDBRepository { - log := logger.WithComponent("database") - log.Info("Initializing DynamoDB repository", "table", TableName) - - sess := session.Must(session.NewSession()) - repo := &DynamoDBRepository{ - client: dynamodb.New(sess), - } - - log.Info("DynamoDB repository initialized successfully") - return repo -} diff --git a/cmd/app/internal/database/factory.go b/cmd/app/internal/database/factory.go index 49bed0e..c926a3d 100644 --- a/cmd/app/internal/database/factory.go +++ b/cmd/app/internal/database/factory.go @@ -7,7 +7,8 @@ import ( "github.com/hackmajoris/glad/pkg/logger" ) -// Repository combines all repository interfaces for unified access +// Repository combines all repository interfaces for unified access. +// Both DynamoDBRepository and MockRepository implement this interface. type Repository interface { UserRepository SkillRepository diff --git a/cmd/app/internal/database/master_skill_repository.go b/cmd/app/internal/database/master_skill_repository.go index 75f8a75..30e3f73 100644 --- a/cmd/app/internal/database/master_skill_repository.go +++ b/cmd/app/internal/database/master_skill_repository.go @@ -1,16 +1,6 @@ package database -import ( - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) +import "github.com/hackmajoris/glad/cmd/app/internal/models" // MasterSkillRepository defines operations for master skills type MasterSkillRepository interface { @@ -20,168 +10,3 @@ type MasterSkillRepository interface { DeleteMasterSkill(skillID string) error ListMasterSkills() ([]*models.Skill, error) } - -// CreateMasterSkill inserts a new master skill -func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting master skill creation") - - skill.SetKeys() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - log.Info("Master skill created successfully", "duration", time.Since(start)) - return nil -} - -// GetMasterSkill retrieves a master skill by ID -func (r *DynamoDBRepository) GetMasterSkill(skillID string) (*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID) - start := time.Now() - - log.Debug("Starting master skill retrieval") - - entityID := BuildMasterSkillEntityID(skillID) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Debug("Master skill not found", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - var skill models.Skill - err = dynamodbattribute.UnmarshalMap(result.Item, &skill) - if err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("Master skill retrieved successfully", "duration", time.Since(start)) - return &skill, nil -} - -// UpdateMasterSkill updates an existing master skill -func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting master skill update") - - skill.SetKeys() - skill.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Master skill updated successfully", "duration", time.Since(start)) - return nil -} - -// DeleteMasterSkill removes a master skill -func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID) - start := time.Now() - - log.Debug("Starting master skill deletion") - - entityID := BuildMasterSkillEntityID(skillID) - - input := &dynamodb.DeleteItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err := r.client.DeleteItem(input) - if err != nil { - log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Master skill deleted successfully", "duration", time.Since(start)) - return nil -} - -// ListMasterSkills retrieves all master skills -func (r *DynamoDBRepository) ListMasterSkills() ([]*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "ListMasterSkills") - start := time.Now() - - log.Debug("Starting master skills list retrieval") - - // Use GSI for better performance instead of Scan - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIByEntityType), - KeyConditionExpression: aws.String("EntityType = :entityType"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":entityType": {S: aws.String("Skill")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query master skills", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.Skill - for i, item := range result.Items { - var skill models.Skill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Master skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} diff --git a/cmd/app/internal/database/master_skill_repository_dynamodb.go b/cmd/app/internal/database/master_skill_repository_dynamodb.go new file mode 100644 index 0000000..99397c1 --- /dev/null +++ b/cmd/app/internal/database/master_skill_repository_dynamodb.go @@ -0,0 +1,178 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// CreateMasterSkill inserts a new master skill +func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting master skill creation") + + skill.SetKeys() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Master skill created successfully", "duration", time.Since(start)) + return nil +} + +// GetMasterSkill retrieves a master skill by ID +func (r *DynamoDBRepository) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting master skill retrieval") + + entityID := BuildMasterSkillEntityID(skillID) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("Master skill not found", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + var skill models.Skill + err = dynamodbattribute.UnmarshalMap(result.Item, &skill) + if err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Master skill retrieved successfully", "duration", time.Since(start)) + return &skill, nil +} + +// UpdateMasterSkill updates an existing master skill +func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting master skill update") + + skill.SetKeys() + skill.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Master skill updated successfully", "duration", time.Since(start)) + return nil +} + +// DeleteMasterSkill removes a master skill +func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting master skill deletion") + + entityID := BuildMasterSkillEntityID(skillID) + + input := &dynamodb.DeleteItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err := r.client.DeleteItem(input) + if err != nil { + log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Master skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills +func (r *DynamoDBRepository) ListMasterSkills() ([]*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "ListMasterSkills") + start := time.Now() + + log.Debug("Starting master skills list retrieval") + + // Use GSI for better performance instead of Scan + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByEntityType), + KeyConditionExpression: aws.String("EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":entityType": {S: aws.String("Skill")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query master skills", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.Skill + for i, item := range result.Items { + var skill models.Skill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Master skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/master_skill_repository_mock.go b/cmd/app/internal/database/master_skill_repository_mock.go new file mode 100644 index 0000000..e8125a5 --- /dev/null +++ b/cmd/app/internal/database/master_skill_repository_mock.go @@ -0,0 +1,108 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// CreateMasterSkill creates a master skill in memory +func (m *MockRepository) CreateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skill.SkillID]; exists { + log.Debug("Master skill already exists", "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + m.masterSkills[skill.SkillID] = skill + log.Info("Master skill created successfully in mock repository", "total_master_skills", len(m.masterSkills), "duration", time.Since(start)) + return nil +} + +// GetMasterSkill retrieves a master skill from memory +func (m *MockRepository) GetMasterSkill(skillID string) (*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + skill, exists := m.masterSkills[skillID] + if !exists { + log.Debug("Master skill not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + log.Debug("Master skill retrieved successfully from mock repository", "duration", time.Since(start)) + return skill, nil +} + +// UpdateMasterSkill updates a master skill in memory +func (m *MockRepository) UpdateMasterSkill(skill *models.Skill) error { + log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skill.SkillID]; !exists { + log.Debug("Master skill not found for update", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + m.masterSkills[skill.SkillID] = skill + log.Info("Master skill updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// DeleteMasterSkill deletes a master skill from memory +func (m *MockRepository) DeleteMasterSkill(skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting master skill deletion from mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.masterSkills[skillID]; !exists { + log.Debug("Master skill not found for deletion", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + delete(m.masterSkills, skillID) + log.Info("Master skill deleted successfully from mock repository", "duration", time.Since(start)) + return nil +} + +// ListMasterSkills retrieves all master skills from memory +func (m *MockRepository) ListMasterSkills() ([]*models.Skill, error) { + log := logger.WithComponent("database").With("operation", "ListMasterSkills", "repository", "mock") + start := time.Now() + + log.Debug("Starting master skills list retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.Skill + for _, skill := range m.masterSkills { + skills = append(skills, skill) + } + + log.Info("Master skills retrieved successfully from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/mock.go b/cmd/app/internal/database/mock.go deleted file mode 100644 index b655bb6..0000000 --- a/cmd/app/internal/database/mock.go +++ /dev/null @@ -1,407 +0,0 @@ -package database - -import ( - "sync" - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" -) - -// MockRepository implements UserRepository, SkillRepository, and MasterSkillRepository for testing -// This matches the DynamoDBRepository structure with unified implementation -type MockRepository struct { - users map[string]*models.User // key: username - skills map[string]*models.UserSkill // key: "username#skillname" - masterSkills map[string]*models.Skill // key: skill_id - mutex sync.RWMutex -} - -// NewMockRepository creates a new unified mock repository -func NewMockRepository() *MockRepository { - log := logger.WithComponent("database") - log.Info("Initializing unified Mock repository for local development") - - repo := &MockRepository{ - users: make(map[string]*models.User), - skills: make(map[string]*models.UserSkill), - masterSkills: make(map[string]*models.Skill), - } - - log.Info("Unified Mock repository initialized successfully") - return repo -} - -// ============================================================================ -// USER REPOSITORY METHODS -// ============================================================================ - -// CreateUser creates a user in memory -func (m *MockRepository) CreateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username, "repository", "mock") - start := time.Now() - - log.Debug("Starting user creation in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.users[user.Username]; exists { - log.Debug("User already exists", "duration", time.Since(start)) - return apperrors.ErrUserExists - } - - m.users[user.Username] = user - log.Info("User created successfully in mock repository", "total_users", len(m.users), "duration", time.Since(start)) - return nil -} - -// GetUser retrieves a user from memory -func (m *MockRepository) GetUser(username string) (*models.User, error) { - log := logger.WithComponent("database").With("operation", "GetUser", "username", username, "repository", "mock") - start := time.Now() - - log.Debug("Starting user retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - user, exists := m.users[username] - if !exists { - log.Debug("User not found in mock repository", "duration", time.Since(start)) - return nil, apperrors.ErrUserNotFound - } - - log.Debug("User retrieved successfully from mock repository", "duration", time.Since(start)) - return user, nil -} - -// UpdateUser updates a user in memory -func (m *MockRepository) UpdateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username, "repository", "mock") - start := time.Now() - - log.Debug("Starting user update in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.users[user.Username]; !exists { - log.Debug("User not found for update", "duration", time.Since(start)) - return apperrors.ErrUserNotFound - } - - m.users[user.Username] = user - log.Info("User updated successfully in mock repository", "duration", time.Since(start)) - return nil -} - -// UserExists checks if a user exists in memory -func (m *MockRepository) UserExists(username string) (bool, error) { - log := logger.WithComponent("database").With("operation", "UserExists", "username", username, "repository", "mock") - start := time.Now() - - log.Debug("Checking if user exists in mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - _, exists := m.users[username] - log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) - return exists, nil -} - -// ListUsers retrieves all users from memory -func (m *MockRepository) ListUsers() ([]*models.User, error) { - log := logger.WithComponent("database").With("operation", "ListUsers", "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var users []*models.User - for _, user := range m.users { - users = append(users, user) - } - - log.Info("Users retrieved successfully from mock repository", "count", len(users), "duration", time.Since(start)) - return users, nil -} - -// ============================================================================ -// SKILL REPOSITORY METHODS -// ============================================================================ - -// CreateSkill creates a user skill in memory -func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill creation in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) - if _, exists := m.skills[key]; exists { - log.Debug("Skill already exists", "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - m.skills[key] = skill - log.Info("Skill created successfully in mock repository", "total_skills", len(m.skills), "duration", time.Since(start)) - return nil -} - -// GetSkill retrieves a user skill from memory -func (m *MockRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - key := models.BuildUserSkillEntityID(username, skillID) - skill, exists := m.skills[key] - if !exists { - log.Debug("Skill not found in mock repository", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - log.Debug("Skill retrieved successfully from mock repository", "duration", time.Since(start)) - return skill, nil -} - -// UpdateSkill updates a user skill in memory -func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill update in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) - if _, exists := m.skills[key]; !exists { - log.Debug("Skill not found for update", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - m.skills[key] = skill - log.Info("Skill updated successfully in mock repository", "duration", time.Since(start)) - return nil -} - -// DeleteSkill deletes a user skill from memory -func (m *MockRepository) DeleteSkill(username, skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting skill deletion from mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - key := models.BuildUserSkillEntityID(username, skillID) - if _, exists := m.skills[key]; !exists { - log.Debug("Skill not found for deletion", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - delete(m.skills, key) - log.Info("Skill deleted successfully from mock repository", "duration", time.Since(start)) - return nil -} - -// ListSkillsForUser retrieves all skills for a specific user from memory -func (m *MockRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username, "repository", "mock") - start := time.Now() - - log.Debug("Starting skills list retrieval for user from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.Username == username { - skills = append(skills, skill) - } - } - - log.Info("Skills retrieved successfully for user from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkill retrieves all users with a specific skill from memory -func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval by skill from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.SkillName == skillName { - skills = append(skills, skill) - } - } - - log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// QueryUserSkillsBySkillID retrieves all users with a specific skill from memory -func (m *MockRepository) QueryUserSkillsBySkillID(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval by skill from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.SkillName == skillName { - skills = append(skills, skill) - } - } - - log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkillAndLevel retrieves all users with a specific skill and proficiency level from memory -func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel, "repository", "mock") - start := time.Now() - - log.Debug("Starting users list retrieval by skill and level from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.UserSkill - for _, skill := range m.skills { - if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel { - skills = append(skills, skill) - } - } - - log.Info("Users retrieved successfully by skill and level from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ============================================================================ -// MASTER SKILL REPOSITORY METHODS -// ============================================================================ - -// CreateMasterSkill creates a master skill in memory -func (m *MockRepository) CreateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill creation in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.masterSkills[skill.SkillID]; exists { - log.Debug("Master skill already exists", "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - m.masterSkills[skill.SkillID] = skill - log.Info("Master skill created successfully in mock repository", "total_master_skills", len(m.masterSkills), "duration", time.Since(start)) - return nil -} - -// GetMasterSkill retrieves a master skill from memory -func (m *MockRepository) GetMasterSkill(skillID string) (*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - skill, exists := m.masterSkills[skillID] - if !exists { - log.Debug("Master skill not found in mock repository", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - log.Debug("Master skill retrieved successfully from mock repository", "duration", time.Since(start)) - return skill, nil -} - -// UpdateMasterSkill updates a master skill in memory -func (m *MockRepository) UpdateMasterSkill(skill *models.Skill) error { - log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill update in mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.masterSkills[skill.SkillID]; !exists { - log.Debug("Master skill not found for update", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - m.masterSkills[skill.SkillID] = skill - log.Info("Master skill updated successfully in mock repository", "duration", time.Since(start)) - return nil -} - -// DeleteMasterSkill deletes a master skill from memory -func (m *MockRepository) DeleteMasterSkill(skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID, "repository", "mock") - start := time.Now() - - log.Debug("Starting master skill deletion from mock repository") - - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.masterSkills[skillID]; !exists { - log.Debug("Master skill not found for deletion", "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - delete(m.masterSkills, skillID) - log.Info("Master skill deleted successfully from mock repository", "duration", time.Since(start)) - return nil -} - -// ListMasterSkills retrieves all master skills from memory -func (m *MockRepository) ListMasterSkills() ([]*models.Skill, error) { - log := logger.WithComponent("database").With("operation", "ListMasterSkills", "repository", "mock") - start := time.Now() - - log.Debug("Starting master skills list retrieval from mock repository") - - m.mutex.RLock() - defer m.mutex.RUnlock() - - var skills []*models.Skill - for _, skill := range m.masterSkills { - skills = append(skills, skill) - } - - log.Info("Master skills retrieved successfully from mock repository", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} diff --git a/cmd/app/internal/database/skill_repository.go b/cmd/app/internal/database/skill_repository.go deleted file mode 100644 index 60455d0..0000000 --- a/cmd/app/internal/database/skill_repository.go +++ /dev/null @@ -1,9 +0,0 @@ -package database - -// DEPRECATED: This file is kept for backwards compatibility. -// Repository implementations have been moved to: -// - master_skill_repository.go (MasterSkillRepository) -// - user_skill_repository.go (SkillRepository) -// - constants.go (GSI constants) -// -// This file will be removed in a future version. diff --git a/cmd/app/internal/database/user_repository.go b/cmd/app/internal/database/user_repository.go index 9acd381..893309b 100644 --- a/cmd/app/internal/database/user_repository.go +++ b/cmd/app/internal/database/user_repository.go @@ -1,20 +1,6 @@ package database -import ( - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) - -// ============================================================================ -// USER REPOSITORY METHODS -// ============================================================================ +import "github.com/hackmajoris/glad/cmd/app/internal/models" // UserRepository defines the interface for user data operations type UserRepository interface { @@ -24,172 +10,3 @@ type UserRepository interface { UserExists(username string) (bool, error) ListUsers() ([]*models.User, error) } - -// CreateUser inserts a new user into DynamoDB -func (r *DynamoDBRepository) CreateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username) - start := time.Now() - - log.Debug("Starting user creation") - - // Ensure keys are set - user.SetKeys() - - item, err := dynamodbattribute.MarshalMap(user) - if err != nil { - log.Error("Failed to marshal user data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return err - } - - log.Info("User created successfully", "duration", time.Since(start)) - return nil -} - -// GetUser retrieves a user by username from DynamoDB -func (r *DynamoDBRepository) GetUser(username string) (*models.User, error) { - log := logger.WithComponent("database").With("operation", "GetUser", "username", username) - start := time.Now() - - log.Debug("Starting user retrieval") - - entityID := models.BuildUserEntityID(username) - log.Info("Attempting to retrieve user", "entity_id", entityID, "table", TableName) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get user from DynamoDB", "error", err.Error(), "entity_id", entityID, "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Info("User not found in DynamoDB", "entity_id", entityID, "duration", time.Since(start)) - return nil, apperrors.ErrUserNotFound - } - - var user models.User - err = dynamodbattribute.UnmarshalMap(result.Item, &user) - if err != nil { - log.Error("Failed to unmarshal user data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("User retrieved successfully", "duration", time.Since(start)) - return &user, nil -} - -// UserExists checks if a user exists in DynamoDB -func (r *DynamoDBRepository) UserExists(username string) (bool, error) { - log := logger.WithComponent("database").With("operation", "UserExists", "username", username) - start := time.Now() - - log.Debug("Checking if user exists") - - entityID := models.BuildUserEntityID(username) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - ProjectionExpression: aws.String("entity_id"), - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to check user existence", "error", err.Error(), "duration", time.Since(start)) - return false, err - } - - exists := result.Item != nil - log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) - return exists, nil -} - -// UpdateUser updates an existing user in DynamoDB -func (r *DynamoDBRepository) UpdateUser(user *models.User) error { - log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username) - start := time.Now() - - log.Debug("Starting user update") - - // Ensure keys are set - user.SetKeys() - user.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(user) - if err != nil { - log.Error("Failed to marshal user data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return err - } - - log.Info("User updated successfully", "duration", time.Since(start)) - return nil -} - -// ListUsers retrieves all users from DynamoDB using Query on EntityType -func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { - log := logger.WithComponent("database").With("operation", "ListUsers") - start := time.Now() - - log.Debug("Starting users list retrieval") - - // Use Scan with filter for EntityType = "User" - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIByEntityType), - KeyConditionExpression: aws.String("EntityType = :entityType"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":entityType": {S: aws.String("User")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to scan users table", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var users []*models.User - for i, item := range result.Items { - var user models.User - if err := dynamodbattribute.UnmarshalMap(item, &user); err != nil { - log.Error("Failed to unmarshal user data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - return nil, err - } - users = append(users, &user) - } - - log.Info("Users retrieved successfully", "count", len(users), "scanned_count", *result.ScannedCount, "duration", time.Since(start)) - return users, nil -} diff --git a/cmd/app/internal/database/user_repository_dynamodb.go b/cmd/app/internal/database/user_repository_dynamodb.go new file mode 100644 index 0000000..6410dfb --- /dev/null +++ b/cmd/app/internal/database/user_repository_dynamodb.go @@ -0,0 +1,182 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// CreateUser inserts a new user into DynamoDB +func (r *DynamoDBRepository) CreateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username) + start := time.Now() + + log.Debug("Starting user creation") + + // Ensure keys are set + user.SetKeys() + + item, err := dynamodbattribute.MarshalMap(user) + if err != nil { + log.Error("Failed to marshal user data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("User created successfully", "duration", time.Since(start)) + return nil +} + +// GetUser retrieves a user by username from DynamoDB +func (r *DynamoDBRepository) GetUser(username string) (*models.User, error) { + log := logger.WithComponent("database").With("operation", "GetUser", "username", username) + start := time.Now() + + log.Debug("Starting user retrieval") + + entityID := models.BuildUserEntityID(username) + log.Info("Attempting to retrieve user", "entity_id", entityID, "table", TableName) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get user from DynamoDB", "error", err.Error(), "entity_id", entityID, "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Info("User not found in DynamoDB", "entity_id", entityID, "duration", time.Since(start)) + return nil, apperrors.ErrUserNotFound + } + + var user models.User + err = dynamodbattribute.UnmarshalMap(result.Item, &user) + if err != nil { + log.Error("Failed to unmarshal user data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("User retrieved successfully", "duration", time.Since(start)) + return &user, nil +} + +// UserExists checks if a user exists in DynamoDB +func (r *DynamoDBRepository) UserExists(username string) (bool, error) { + log := logger.WithComponent("database").With("operation", "UserExists", "username", username) + start := time.Now() + + log.Debug("Checking if user exists") + + entityID := models.BuildUserEntityID(username) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ProjectionExpression: aws.String("entity_id"), + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to check user existence", "error", err.Error(), "duration", time.Since(start)) + return false, err + } + + exists := result.Item != nil + log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) + return exists, nil +} + +// UpdateUser updates an existing user in DynamoDB +func (r *DynamoDBRepository) UpdateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username) + start := time.Now() + + log.Debug("Starting user update") + + // Ensure keys are set + user.SetKeys() + user.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(user) + if err != nil { + log.Error("Failed to marshal user data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update user in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("User updated successfully", "duration", time.Since(start)) + return nil +} + +// ListUsers retrieves all users from DynamoDB using Query on EntityType +func (r *DynamoDBRepository) ListUsers() ([]*models.User, error) { + log := logger.WithComponent("database").With("operation", "ListUsers") + start := time.Now() + + log.Debug("Starting users list retrieval") + + // Use Query on GSI for EntityType = "User" + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByEntityType), + KeyConditionExpression: aws.String("EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":entityType": {S: aws.String("User")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users table", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var users []*models.User + for i, item := range result.Items { + var user models.User + if err := dynamodbattribute.UnmarshalMap(item, &user); err != nil { + log.Error("Failed to unmarshal user data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + return nil, err + } + users = append(users, &user) + } + + log.Info("Users retrieved successfully", "count", len(users), "scanned_count", *result.ScannedCount, "duration", time.Since(start)) + return users, nil +} diff --git a/cmd/app/internal/database/user_repository_mock.go b/cmd/app/internal/database/user_repository_mock.go new file mode 100644 index 0000000..481bcf0 --- /dev/null +++ b/cmd/app/internal/database/user_repository_mock.go @@ -0,0 +1,103 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// CreateUser creates a user in memory +func (m *MockRepository) CreateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "CreateUser", "username", user.Username, "repository", "mock") + start := time.Now() + + log.Debug("Starting user creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.users[user.Username]; exists { + log.Debug("User already exists", "duration", time.Since(start)) + return apperrors.ErrUserExists + } + + m.users[user.Username] = user + log.Info("User created successfully in mock repository", "total_users", len(m.users), "duration", time.Since(start)) + return nil +} + +// GetUser retrieves a user from memory +func (m *MockRepository) GetUser(username string) (*models.User, error) { + log := logger.WithComponent("database").With("operation", "GetUser", "username", username, "repository", "mock") + start := time.Now() + + log.Debug("Starting user retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + user, exists := m.users[username] + if !exists { + log.Debug("User not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrUserNotFound + } + + log.Debug("User retrieved successfully from mock repository", "duration", time.Since(start)) + return user, nil +} + +// UpdateUser updates a user in memory +func (m *MockRepository) UpdateUser(user *models.User) error { + log := logger.WithComponent("database").With("operation", "UpdateUser", "username", user.Username, "repository", "mock") + start := time.Now() + + log.Debug("Starting user update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + if _, exists := m.users[user.Username]; !exists { + log.Debug("User not found for update", "duration", time.Since(start)) + return apperrors.ErrUserNotFound + } + + m.users[user.Username] = user + log.Info("User updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// UserExists checks if a user exists in memory +func (m *MockRepository) UserExists(username string) (bool, error) { + log := logger.WithComponent("database").With("operation", "UserExists", "username", username, "repository", "mock") + start := time.Now() + + log.Debug("Checking if user exists in mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + _, exists := m.users[username] + log.Debug("User existence check completed", "exists", exists, "duration", time.Since(start)) + return exists, nil +} + +// ListUsers retrieves all users from memory +func (m *MockRepository) ListUsers() ([]*models.User, error) { + log := logger.WithComponent("database").With("operation", "ListUsers", "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var users []*models.User + for _, user := range m.users { + users = append(users, user) + } + + log.Info("Users retrieved successfully from mock repository", "count", len(users), "duration", time.Since(start)) + return users, nil +} diff --git a/cmd/app/internal/database/user_skill_repository.go b/cmd/app/internal/database/user_skill_repository.go index 40b72df..d22e79a 100644 --- a/cmd/app/internal/database/user_skill_repository.go +++ b/cmd/app/internal/database/user_skill_repository.go @@ -1,16 +1,6 @@ package database -import ( - "time" - - apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" - "github.com/hackmajoris/glad/cmd/app/internal/models" - "github.com/hackmajoris/glad/pkg/logger" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" -) +import "github.com/hackmajoris/glad/cmd/app/internal/models" // SkillRepository defines operations for user skills type SkillRepository interface { @@ -23,281 +13,3 @@ type SkillRepository interface { ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) } - -// CreateSkill inserts a new user skill into DynamoDB -func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting skill creation") - - // Ensure keys are set - skill.SetKeys() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_not_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillAlreadyExists - } - - log.Info("Skill created successfully", "duration", time.Since(start)) - return nil -} - -// GetSkill retrieves a specific skill for a user by skill_id -func (r *DynamoDBRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID) - start := time.Now() - - log.Debug("Starting skill retrieval") - - entityID := BuildUserSkillEntityID(username, skillID) - - input := &dynamodb.GetItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - } - - result, err := r.client.GetItem(input) - if err != nil { - log.Error("Failed to get skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - if result.Item == nil { - log.Debug("Skill not found", "duration", time.Since(start)) - return nil, apperrors.ErrSkillNotFound - } - - var skill models.UserSkill - err = dynamodbattribute.UnmarshalMap(result.Item, &skill) - if err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - log.Debug("Skill retrieved successfully", "duration", time.Since(start)) - return &skill, nil -} - -// UpdateSkill updates an existing skill -func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { - log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID) - start := time.Now() - - log.Debug("Starting skill update") - - // Ensure keys are set - skill.SetKeys() - skill.UpdatedAt = time.Now() - - item, err := dynamodbattribute.MarshalMap(skill) - if err != nil { - log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) - return err - } - - input := &dynamodb.PutItemInput{ - TableName: aws.String(TableName), - Item: item, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err = r.client.PutItem(input) - if err != nil { - log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Skill updated successfully", "duration", time.Since(start)) - return nil -} - -// DeleteSkill removes a skill from a user -func (r *DynamoDBRepository) DeleteSkill(username, skillID string) error { - log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID) - start := time.Now() - - log.Debug("Starting skill deletion") - - entityID := BuildUserSkillEntityID(username, skillID) - - input := &dynamodb.DeleteItemInput{ - TableName: aws.String(TableName), - Key: map[string]*dynamodb.AttributeValue{ - "entity_id": {S: aws.String(entityID)}, - }, - ConditionExpression: aws.String("attribute_exists(entity_id)"), - } - - _, err := r.client.DeleteItem(input) - if err != nil { - log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) - return apperrors.ErrSkillNotFound - } - - log.Info("Skill deleted successfully", "duration", time.Since(start)) - return nil -} - -// ListSkillsForUser retrieves all skills for a specific user using GSI ByUser -func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username) - start := time.Now() - - log.Debug("Starting skills list retrieval for user") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIByUser), - KeyConditionExpression: aws.String("Username = :username AND EntityType = :entityType"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":username": {S: aws.String(username)}, - ":entityType": {S: aws.String("UserSkill")}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query skills for user", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkill retrieves all users who have a specific skill using GSI SkillsByLevel -func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName) - start := time.Now() - - log.Debug("Starting users list retrieval by skill") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSISkillsByLevel), - KeyConditionExpression: aws.String("SkillName = :skillName"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":skillName": {S: aws.String(skillName)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query users by skill", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level -// Uses composite partition key on SkillName + ProficiencyLevel -func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) - start := time.Now() - - log.Debug("Starting users list retrieval by skill and level") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSISkillsByLevel), - KeyConditionExpression: aws.String("SkillName = :skillName AND ProficiencyLevel = :level"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":skillName": {S: aws.String(skillName)}, - ":level": {S: aws.String(string(proficiencyLevel))}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query users by skill and level", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} - -// QueryUserSkillsBySkillID retrieves all UserSkills that reference a specific skill_id -// Used when syncing denormalized data after master skill updates -func (r *DynamoDBRepository) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) { - log := logger.WithComponent("database").With("operation", "QueryUserSkillsBySkillID", "skill_id", skillID) - start := time.Now() - - log.Debug("Starting UserSkills retrieval by skill_id") - - input := &dynamodb.QueryInput{ - TableName: aws.String(TableName), - IndexName: aws.String(GSIBySkillID), - KeyConditionExpression: aws.String("skill_id = :skillID"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":skillID": {S: aws.String(skillID)}, - }, - } - - result, err := r.client.Query(input) - if err != nil { - log.Error("Failed to query UserSkills by skill_id", "error", err.Error(), "duration", time.Since(start)) - return nil, err - } - - var skills []*models.UserSkill - for i, item := range result.Items { - var skill models.UserSkill - if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { - log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) - continue - } - skills = append(skills, &skill) - } - - log.Info("UserSkills by skill_id retrieved successfully", "skill_id", skillID, "count", len(skills), "duration", time.Since(start)) - return skills, nil -} diff --git a/cmd/app/internal/database/user_skill_repository_dynamodb.go b/cmd/app/internal/database/user_skill_repository_dynamodb.go new file mode 100644 index 0000000..4dc25ec --- /dev/null +++ b/cmd/app/internal/database/user_skill_repository_dynamodb.go @@ -0,0 +1,292 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" +) + +// CreateSkill inserts a new user skill into DynamoDB +func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting skill creation") + + // Ensure keys are set + skill.SetKeys() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Skill created successfully", "duration", time.Since(start)) + return nil +} + +// GetSkill retrieves a specific skill for a user by skill_id +func (r *DynamoDBRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID) + start := time.Now() + + log.Debug("Starting skill retrieval") + + entityID := BuildUserSkillEntityID(username, skillID) + + input := &dynamodb.GetItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + } + + result, err := r.client.GetItem(input) + if err != nil { + log.Error("Failed to get skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + if result.Item == nil { + log.Debug("Skill not found", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + var skill models.UserSkill + err = dynamodbattribute.UnmarshalMap(result.Item, &skill) + if err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + log.Debug("Skill retrieved successfully", "duration", time.Since(start)) + return &skill, nil +} + +// UpdateSkill updates an existing skill +func (r *DynamoDBRepository) UpdateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID) + start := time.Now() + + log.Debug("Starting skill update") + + // Ensure keys are set + skill.SetKeys() + skill.UpdatedAt = time.Now() + + item, err := dynamodbattribute.MarshalMap(skill) + if err != nil { + log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start)) + return err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(TableName), + Item: item, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err = r.client.PutItem(input) + if err != nil { + log.Error("Failed to update skill in DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Skill updated successfully", "duration", time.Since(start)) + return nil +} + +// DeleteSkill removes a skill from a user +func (r *DynamoDBRepository) DeleteSkill(username, skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID) + start := time.Now() + + log.Debug("Starting skill deletion") + + entityID := BuildUserSkillEntityID(username, skillID) + + input := &dynamodb.DeleteItemInput{ + TableName: aws.String(TableName), + Key: map[string]*dynamodb.AttributeValue{ + "entity_id": {S: aws.String(entityID)}, + }, + ConditionExpression: aws.String("attribute_exists(entity_id)"), + } + + _, err := r.client.DeleteItem(input) + if err != nil { + log.Error("Failed to delete skill from DynamoDB", "error", err.Error(), "duration", time.Since(start)) + return err + } + + log.Info("Skill deleted successfully", "duration", time.Since(start)) + return nil +} + +// ListSkillsForUser retrieves all skills for a specific user using GSI ByUser +func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username) + start := time.Now() + + log.Debug("Starting skills list retrieval for user") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIByUser), + KeyConditionExpression: aws.String("Username = :username AND EntityType = :entityType"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":username": {S: aws.String(username)}, + ":entityType": {S: aws.String("UserSkill")}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query skills for user", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Skills retrieved successfully", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkill retrieves all users who have a specific skill using GSI SkillsByLevel +func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName) + start := time.Now() + + log.Debug("Starting users list retrieval by skill") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSISkillsByLevel), + KeyConditionExpression: aws.String("SkillName = :skillName"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillName": {S: aws.String(skillName)}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users by skill", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level +// Uses composite partition key on SkillName + ProficiencyLevel +func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel) + start := time.Now() + + log.Debug("Starting users list retrieval by skill and level") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSISkillsByLevel), + KeyConditionExpression: aws.String("SkillName = :skillName AND ProficiencyLevel = :level"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillName": {S: aws.String(skillName)}, + ":level": {S: aws.String(string(proficiencyLevel))}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query users by skill and level", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// QueryUserSkillsBySkillID retrieves all UserSkills that reference a specific skill_id +// Used when syncing denormalized data after master skill updates +func (r *DynamoDBRepository) QueryUserSkillsBySkillID(skillID string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "QueryUserSkillsBySkillID", "skill_id", skillID) + start := time.Now() + + log.Debug("Starting UserSkills retrieval by skill_id") + + input := &dynamodb.QueryInput{ + TableName: aws.String(TableName), + IndexName: aws.String(GSIBySkillID), + KeyConditionExpression: aws.String("skill_id = :skillID"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":skillID": {S: aws.String(skillID)}, + }, + } + + result, err := r.client.Query(input) + if err != nil { + log.Error("Failed to query UserSkills by skill_id", "error", err.Error(), "duration", time.Since(start)) + return nil, err + } + + var skills []*models.UserSkill + for i, item := range result.Items { + var skill models.UserSkill + if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil { + log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start)) + continue + } + skills = append(skills, &skill) + } + + log.Info("UserSkills by skill_id retrieved successfully", "skill_id", skillID, "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/database/user_skill_repository_mock.go b/cmd/app/internal/database/user_skill_repository_mock.go new file mode 100644 index 0000000..97b920b --- /dev/null +++ b/cmd/app/internal/database/user_skill_repository_mock.go @@ -0,0 +1,177 @@ +package database + +import ( + "time" + + apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/pkg/logger" +) + +// CreateSkill creates a user skill in memory +func (m *MockRepository) CreateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "CreateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill creation in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) + if _, exists := m.skills[key]; exists { + log.Debug("Skill already exists", "duration", time.Since(start)) + return apperrors.ErrSkillAlreadyExists + } + + m.skills[key] = skill + log.Info("Skill created successfully in mock repository", "total_skills", len(m.skills), "duration", time.Since(start)) + return nil +} + +// GetSkill retrieves a user skill from memory +func (m *MockRepository) GetSkill(username, skillID string) (*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "GetSkill", "username", username, "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill retrieval from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + key := models.BuildUserSkillEntityID(username, skillID) + skill, exists := m.skills[key] + if !exists { + log.Debug("Skill not found in mock repository", "duration", time.Since(start)) + return nil, apperrors.ErrSkillNotFound + } + + log.Debug("Skill retrieved successfully from mock repository", "duration", time.Since(start)) + return skill, nil +} + +// UpdateSkill updates a user skill in memory +func (m *MockRepository) UpdateSkill(skill *models.UserSkill) error { + log := logger.WithComponent("database").With("operation", "UpdateSkill", "username", skill.Username, "skill_id", skill.SkillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill update in mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := models.BuildUserSkillEntityID(skill.Username, skill.SkillID) + if _, exists := m.skills[key]; !exists { + log.Debug("Skill not found for update", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + m.skills[key] = skill + log.Info("Skill updated successfully in mock repository", "duration", time.Since(start)) + return nil +} + +// DeleteSkill deletes a user skill from memory +func (m *MockRepository) DeleteSkill(username, skillID string) error { + log := logger.WithComponent("database").With("operation", "DeleteSkill", "username", username, "skill_id", skillID, "repository", "mock") + start := time.Now() + + log.Debug("Starting skill deletion from mock repository") + + m.mutex.Lock() + defer m.mutex.Unlock() + + key := models.BuildUserSkillEntityID(username, skillID) + if _, exists := m.skills[key]; !exists { + log.Debug("Skill not found for deletion", "duration", time.Since(start)) + return apperrors.ErrSkillNotFound + } + + delete(m.skills, key) + log.Info("Skill deleted successfully from mock repository", "duration", time.Since(start)) + return nil +} + +// ListSkillsForUser retrieves all skills for a specific user from memory +func (m *MockRepository) ListSkillsForUser(username string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListSkillsForUser", "username", username, "repository", "mock") + start := time.Now() + + log.Debug("Starting skills list retrieval for user from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.Username == username { + skills = append(skills, skill) + } + } + + log.Info("Skills retrieved successfully for user from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkill retrieves all users with a specific skill from memory +func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// QueryUserSkillsBySkillID retrieves all users with a specific skill from memory +func (m *MockRepository) QueryUserSkillsBySkillID(skillName string) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} + +// ListUsersBySkillAndLevel retrieves all users with a specific skill and proficiency level from memory +func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) { + log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel, "repository", "mock") + start := time.Now() + + log.Debug("Starting users list retrieval by skill and level from mock repository") + + m.mutex.RLock() + defer m.mutex.RUnlock() + + var skills []*models.UserSkill + for _, skill := range m.skills { + if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel { + skills = append(skills, skill) + } + } + + log.Info("Users retrieved successfully by skill and level from mock repository", "count", len(skills), "duration", time.Since(start)) + return skills, nil +} diff --git a/cmd/app/internal/models/keys.go b/cmd/app/internal/models/utils.go similarity index 100% rename from cmd/app/internal/models/keys.go rename to cmd/app/internal/models/utils.go diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29