Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,6 @@ Deployed resources (via AWS CDK in Go):
- **Name**: `glad-entities`
- **Optimized single table** with 1 Global Secondary Index
- **Table Keys**: `EntityType` (PK) + `entity_id` (SK)
- **DynamoDB Streams**: Enabled (NEW_AND_OLD_IMAGES)
- **Capacity**: On-demand billing mode
- **Point-in-time recovery**: Disabled (dev-friendly)
- **Removal policy**: DESTROY (dev-friendly)
Expand Down
4 changes: 2 additions & 2 deletions cmd/app/internal/database/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,8 @@ func TestMockRepository_ListUsersBySkill(t *testing.T) {
repo.CreateSkill(skill2)
repo.CreateSkill(skill3)

// Test list users with Go skill
skills, err := repo.ListUsersBySkill("Go")
// Test list users with Go skill in Programming category
skills, err := repo.ListUsersBySkill("Programming", "Go")
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
Expand Down
6 changes: 4 additions & 2 deletions cmd/app/internal/database/user_skill_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type SkillRepository interface {
UpdateSkill(skill *models.UserSkill) error
DeleteSkill(username, skillID string) error
ListSkillsForUser(username string) ([]*models.UserSkill, error)
ListUsersBySkill(skillName string) ([]*models.UserSkill, error)
ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error)
// ListUsersBySkill queries the BySkill GSI with Category + SkillName
ListUsersBySkill(category, skillName string) ([]*models.UserSkill, error)
// ListUsersBySkillAndLevel queries the BySkill GSI with Category + SkillName + ProficiencyLevel
ListUsersBySkillAndLevel(category, skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error)
}
22 changes: 13 additions & 9 deletions cmd/app/internal/database/user_skill_repository_dynamodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ func (r *DynamoDBRepository) CreateSkill(skill *models.UserSkill) error {
ConditionExpression: aws.String("attribute_not_exists(entity_id)"),
}

_, err = r.client.PutItem(input)
Comment thread
hackmajoris marked this conversation as resolved.
_, err = r.client.PutItem(input)
if err != nil {
log.Error("Failed to create skill in DynamoDB", "error", err.Error(), "duration", time.Since(start))
Expand Down Expand Up @@ -182,17 +181,19 @@ func (r *DynamoDBRepository) ListSkillsForUser(username string) ([]*models.UserS
}

// ListUsersBySkill retrieves all users who have a specific skill using GSI BySkill
func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill, error) {
log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "skill", skillName)
// GSI BySkill structure: PK=Category, SK=SkillName+ProficiencyLevel+YearsOfExperience+Username
func (r *DynamoDBRepository) ListUsersBySkill(category, skillName string) ([]*models.UserSkill, error) {
log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "category", category, "skill", skillName)
start := time.Now()

log.Debug("Starting users list retrieval by skill")

input := &dynamodb.QueryInput{
TableName: aws.String(TableName),
IndexName: aws.String(GSIBySkill),
KeyConditionExpression: aws.String("SkillName = :skillName"),
KeyConditionExpression: aws.String("Category = :category AND SkillName = :skillName"),
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":category": {S: aws.String(category)},
":skillName": {S: aws.String(skillName)},
},
}
Expand All @@ -213,22 +214,25 @@ func (r *DynamoDBRepository) ListUsersBySkill(skillName string) ([]*models.UserS
skills = append(skills, &skill)
}

log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(skills), "duration", time.Since(start))
log.Info("Users with skill retrieved successfully", "category", category, "skill", skillName, "count", len(skills), "duration", time.Since(start))
return skills, nil
}

// ListUsersBySkillAndLevel retrieves users with a specific skill at a specific proficiency level
func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) {
log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel)
// GSI BySkill structure: PK=Category, SK=SkillName+ProficiencyLevel+YearsOfExperience+Username
// Uses composite sort key matching: Category + SkillName + ProficiencyLevel (left-to-right)
func (r *DynamoDBRepository) ListUsersBySkillAndLevel(category, skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) {
log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "category", category, "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(GSIBySkill),
KeyConditionExpression: aws.String("SkillName = :skillName AND ProficiencyLevel = :level"),
KeyConditionExpression: aws.String("Category = :category AND SkillName = :skillName AND ProficiencyLevel = :level"),
Comment thread
hackmajoris marked this conversation as resolved.
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":category": {S: aws.String(category)},
":skillName": {S: aws.String(skillName)},
":level": {S: aws.String(string(proficiencyLevel))},
},
Expand All @@ -250,6 +254,6 @@ func (r *DynamoDBRepository) ListUsersBySkillAndLevel(skillName string, proficie
skills = append(skills, &skill)
}

log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start))
log.Info("Users with skill and level retrieved successfully", "category", category, "skill", skillName, "level", proficiencyLevel, "count", len(skills), "duration", time.Since(start))
return skills, nil
}
12 changes: 6 additions & 6 deletions cmd/app/internal/database/user_skill_repository_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ func (m *MockRepository) ListSkillsForUser(username string) ([]*models.UserSkill
}

// 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")
func (m *MockRepository) ListUsersBySkill(category, skillName string) ([]*models.UserSkill, error) {
log := logger.WithComponent("database").With("operation", "ListUsersBySkill", "category", category, "skill", skillName, "repository", "mock")
start := time.Now()

log.Debug("Starting users list retrieval by skill from mock repository")
Expand All @@ -125,7 +125,7 @@ func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill

var skills []*models.UserSkill
for _, skill := range m.skills {
if skill.SkillName == skillName {
if skill.Category == category && skill.SkillName == skillName {
skills = append(skills, skill)
}
}
Expand All @@ -135,8 +135,8 @@ func (m *MockRepository) ListUsersBySkill(skillName string) ([]*models.UserSkill
}

// 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")
func (m *MockRepository) ListUsersBySkillAndLevel(category, skillName string, proficiencyLevel models.ProficiencyLevel) ([]*models.UserSkill, error) {
log := logger.WithComponent("database").With("operation", "ListUsersBySkillAndLevel", "category", category, "skill", skillName, "level", proficiencyLevel, "repository", "mock")
start := time.Now()

log.Debug("Starting users list retrieval by skill and level from mock repository")
Expand All @@ -146,7 +146,7 @@ func (m *MockRepository) ListUsersBySkillAndLevel(skillName string, proficiencyL

var skills []*models.UserSkill
for _, skill := range m.skills {
if skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel {
if skill.Category == category && skill.SkillName == skillName && skill.ProficiencyLevel == proficiencyLevel {
skills = append(skills, skill)
}
}
Expand Down
12 changes: 9 additions & 3 deletions cmd/app/internal/handler/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,28 +313,34 @@ func (h *Handler) DeleteSkill(request events.APIGatewayProxyRequest) (events.API
}

// ListUsersBySkill handles finding all users with a specific skill
// GET /skills/{skillName}/users
// GET /skills/{skillName}/users?category=<category>&level=<level>
func (h *Handler) ListUsersBySkill(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Get skill name from path parameter
skillName, ok := request.PathParameters["skillName"]
if !ok || skillName == "" {
return errorResponse(http.StatusBadRequest, "Skill name is required"), nil
}

// Get category from query parameters (required for multi-key GSI)
category, ok := request.QueryStringParameters["category"]
if !ok || category == "" {
return errorResponse(http.StatusBadRequest, "Category is required"), nil
}

// Check for proficiency level filter in query parameters
proficiencyLevel, ok := request.QueryStringParameters["level"]
if ok && proficiencyLevel != "" {
// Query with level filter
level := models.ProficiencyLevel(proficiencyLevel)
users, err := h.skillService.ListUsersBySkillAndLevel(skillName, level)
users, err := h.skillService.ListUsersBySkillAndLevel(category, skillName, level)
if err != nil {
return h.handleServiceError(err), nil
}
return successResponse(http.StatusOK, users), nil
}

// Query all users with skill
users, err := h.skillService.ListUsersBySkill(skillName)
users, err := h.skillService.ListUsersBySkill(category, skillName)
if err != nil {
return h.handleServiceError(err), nil
}
Expand Down
2 changes: 0 additions & 2 deletions cmd/app/internal/models/user_skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,6 @@ func (s *UserSkill) SetKeys() {
// Base table key: Unique identifier
s.EntityID = BuildUserSkillEntityID(s.Username, s.SkillID)
s.EntityType = "UserSkill"
// Composite sort key: ProficiencyLevel#Username
s.SkillCompositeSort = string(s.ProficiencyLevel) + "#" + s.Username
}

// UpdateProficiency updates the skill proficiency level
Expand Down
20 changes: 10 additions & 10 deletions cmd/app/internal/service/skill_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,14 @@ func (s *SkillService) ListSkillsForUser(username string) ([]dto.SkillResponse,
return result, nil
}

// ListUsersBySkill retrieves all users who have a specific skill
func (s *SkillService) ListUsersBySkill(skillName string) ([]dto.UserSkillResponse, error) {
log := logger.WithComponent("service").With("operation", "ListUsersBySkill", "skill", skillName)
// ListUsersBySkill retrieves all users who have a specific skill in a category
func (s *SkillService) ListUsersBySkill(category, skillName string) ([]dto.UserSkillResponse, error) {
log := logger.WithComponent("service").With("operation", "ListUsersBySkill", "category", category, "skill", skillName)
start := time.Now()

log.Info("Retrieving users by skill")

skills, err := s.repo.ListUsersBySkill(skillName)
skills, err := s.repo.ListUsersBySkill(category, skillName)
if err != nil {
log.Error("Failed to retrieve users by skill", "error", err.Error(), "duration", time.Since(start))
return nil, err
Expand All @@ -205,18 +205,18 @@ func (s *SkillService) ListUsersBySkill(skillName string) ([]dto.UserSkillRespon
}
}

log.Info("Users with skill retrieved successfully", "skill", skillName, "count", len(result), "duration", time.Since(start))
log.Info("Users with skill retrieved successfully", "category", category, "skill", skillName, "count", len(result), "duration", time.Since(start))
return result, nil
}

// ListUsersBySkillAndLevel retrieves users with a skill at a specific proficiency level
func (s *SkillService) ListUsersBySkillAndLevel(skillName string, proficiencyLevel models.ProficiencyLevel) ([]dto.UserSkillResponse, error) {
log := logger.WithComponent("service").With("operation", "ListUsersBySkillAndLevel", "skill", skillName, "level", proficiencyLevel)
// ListUsersBySkillAndLevel retrieves users with a skill at a specific proficiency level in a category
func (s *SkillService) ListUsersBySkillAndLevel(category, skillName string, proficiencyLevel models.ProficiencyLevel) ([]dto.UserSkillResponse, error) {
log := logger.WithComponent("service").With("operation", "ListUsersBySkillAndLevel", "category", category, "skill", skillName, "level", proficiencyLevel)
start := time.Now()

log.Info("Retrieving users by skill and level")

skills, err := s.repo.ListUsersBySkillAndLevel(skillName, proficiencyLevel)
skills, err := s.repo.ListUsersBySkillAndLevel(category, skillName, proficiencyLevel)
if err != nil {
log.Error("Failed to retrieve users by skill and level", "error", err.Error(), "duration", time.Since(start))
return nil, err
Expand All @@ -235,6 +235,6 @@ func (s *SkillService) ListUsersBySkillAndLevel(skillName string, proficiencyLev
}
}

log.Info("Users with skill and level retrieved successfully", "skill", skillName, "level", proficiencyLevel, "count", len(result), "duration", time.Since(start))
log.Info("Users with skill and level retrieved successfully", "category", category, "skill", skillName, "level", proficiencyLevel, "count", len(result), "duration", time.Since(start))
return result, nil
}
19 changes: 17 additions & 2 deletions deployments/app/cdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,33 @@ func createEntitiesTable(stack awscdk.Stack, id *string, environment string) aws
},

GlobalSecondaryIndexes: &[]*awsdynamodb.GlobalSecondaryIndexPropsV2{
// GSI for skill-only queries
// GSI for flexible category/skill/proficiency queries
// Single PK: Category (allows broad queries)
// Composite SK: SkillName + ProficiencyLevel + YearsOfExperience + Username
// This design provides maximum query flexibility:
// - Query by Category alone
// - Query by Category + SkillName
// - Query by Category + SkillName + ProficiencyLevel
// - Query by Category + SkillName + ProficiencyLevel + YearsOfExperience (with range)
{
IndexName: jsii.String("BySkill"),
PartitionKey: &awsdynamodb.Attribute{
Name: jsii.String("SkillName"),
Name: jsii.String("Category"),
Type: awsdynamodb.AttributeType_STRING,
},
SortKeys: &[]*awsdynamodb.Attribute{
{
Name: jsii.String("SkillName"),
Type: awsdynamodb.AttributeType_STRING,
},
{
Name: jsii.String("ProficiencyLevel"),
Type: awsdynamodb.AttributeType_STRING,
},
{
Name: jsii.String("YearsOfExperience"),
Type: awsdynamodb.AttributeType_NUMBER,
},
{
Name: jsii.String("Username"),
Type: awsdynamodb.AttributeType_STRING,
Comment thread
hackmajoris marked this conversation as resolved.
Expand Down
37 changes: 19 additions & 18 deletions docs/api-testing/api-test.http
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Content-Type: application/json

{
"username": "jane_doe",
"password": "secure123"
"password": "newPassword123"
}

### 1.7 Login - Alice
Expand Down Expand Up @@ -331,7 +331,7 @@ Authorization: Bearer {{token}}
Content-Type: application/json

{
"skill_name": "JavaScripdt",
"skill_name": "JavaScript",
"proficiency_level": "Advanced",
"years_of_experience": 5,
"notes": "Frontend development with React"
Expand Down Expand Up @@ -421,50 +421,51 @@ Authorization: Bearer {{token}}

###############################################################################
### 5. CROSS-USER SKILL QUERIES (Protected Routes)
### NOTE: category parameter is REQUIRED for multi-key GSI queries
###############################################################################

### 5.1 Find All Users with Python
GET {{API_URL}}/skills/Python/users
### 5.1 Find All Users with Python (Programming category)
GET {{API_URL}}/skills/Python/users?category=Programming
Authorization: Bearer {{token}}

### 5.2 Find All Users with JavaScript
GET {{API_URL}}/skills/JavaScript/users
### 5.2 Find All Users with JavaScript (Programming category)
GET {{API_URL}}/skills/JavaScript/users?category=Programming
Authorization: Bearer {{token}}

### 5.3 Find All Users with Go
GET {{API_URL}}/skills/Go/users
### 5.3 Find All Users with Go (Programming category)
GET {{API_URL}}/skills/Go/users?category=Programming
Authorization: Bearer {{token}}

### 5.4 Find Expert Python Developers
GET {{API_URL}}/skills/Python/users?level=Expert
GET {{API_URL}}/skills/Python/users?category=Programming&level=Expert
Authorization: Bearer {{token}}

### 5.5 Find Intermediate Python Developers
GET {{API_URL}}/skills/Python/users?level=Intermediate
GET {{API_URL}}/skills/Python/users?category=Programming&level=Intermediate
Authorization: Bearer {{token}}

### 5.6 Find Advanced Python Developers
GET {{API_URL}}/skills/Python/users?level=Advanced
GET {{API_URL}}/skills/Python/users?category=Programming&level=Advanced
Authorization: Bearer {{token}}

### 5.7 Find Beginner Python Developers
GET {{API_URL}}/skills/Python/users?level=Beginner
GET {{API_URL}}/skills/Python/users?category=Programming&level=Beginner
Authorization: Bearer {{token}}

### 5.8 Find Expert JavaScript Developers
GET {{API_URL}}/skills/JavaScript/users?level=Expert
GET {{API_URL}}/skills/JavaScript/users?category=Programming&level=Expert
Authorization: Bearer {{token}}

### 5.9 Find Advanced JavaScript Developers
GET {{API_URL}}/skills/JavaScript/users?level=Advanced
GET {{API_URL}}/skills/JavaScript/users?category=Programming&level=Advanced
Authorization: Bearer {{token}}

### 5.10 Find Expert Go Developers
GET {{API_URL}}/skills/Go/users?level=Expert
GET {{API_URL}}/skills/Go/users?category=Programming&level=Expert
Authorization: Bearer {{token}}

### 5.11 Find Intermediate Go Developers
GET {{API_URL}}/skills/Go/users?level=Intermediate
GET {{API_URL}}/skills/Go/users?category=Programming&level=Intermediate
Authorization: Bearer {{token}}

###############################################################################
Expand Down Expand Up @@ -703,8 +704,8 @@ Content-Type: application/json
"notes": "Making good progress with Rust"
}

### 8.8 WORKFLOW Step 8 - Find All Rust Developers
GET {{API_URL}}/skills/Rust/users
### 8.8 WORKFLOW Step 8 - Find All Rust Developers (Programming category)
GET {{API_URL}}/skills/Rust/users?category=Programming
Authorization: Bearer {{token}}

### 8.9 WORKFLOW Step 9 - Update User Profile
Expand Down
Loading
Loading