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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ Request → Router → Middleware → Handler → Service → Repository → Dat
- **DTO Pattern** - Separate request/response types from domain models
- **Service Layer** - Business logic isolated from HTTP concerns

## Data Model
The Single Table Design in modeled using new Dynamo feature: Multi-Keys(composite keys) for GSI approach.
Check: https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/

## API Endpoints

| Method | Path | Auth | Description |
Expand Down
18 changes: 14 additions & 4 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ tasks:
cdk:deploy:
desc: 'Deploy infrastructure to AWS'
dir: 'deployments/app'
deps: [build:lambda]
deps: [ build:lambda ]
cmds:
- echo 'Deploying to AWS...'
- cdk deploy --require-approval never
- echo 'Deploying to AWS with profile {{.profile | default "default"}}'
- cdk deploy --require-approval never --profile {{.profile | default "default"}}
- echo 'Deployment completed!'

cdk:destroy:
Expand All @@ -154,7 +154,17 @@ tasks:
# Full deployment workflow
deploy:
desc: 'Full deployment workflow (test, build, deploy)'
deps: [test, build:lambda, cdk:deploy]

cmds:
- echo "running command with args - {{.AWS_PROFILE}}"
- task: test
- task: build:lambda
- task: cdk:deploy
vars:
profile:
sh: echo {{.AWS_PROFILE | default "default"}}
requires:
vars: [AWS_PROFILE]

deploy:diff:
desc: 'Preview deployment changes'
Expand Down
6 changes: 5 additions & 1 deletion cmd/app/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func SetupIntegrationTest() *IntegrationTestSuite {
userSkillsRepo := database.NewMockRepository()
tokenService := auth.NewTokenService(testConfig())
userService := service.NewUserService(userRepo, tokenService)
userSkillsService := service.NewSkillService(userSkillsRepo)
userSkillsService := service.NewSkillService(userSkillsRepo, userSkillsRepo) // Pass both SkillRepository and MasterSkillRepository
apiHandler := handler.New(userService, userSkillsService)
authMiddleware := middleware.NewAuthMiddleware(tokenService)

Expand Down Expand Up @@ -112,14 +112,17 @@ func TestFullUserJourney(t *testing.T) {
"username": "testuser1",
"password": "password123",
}

loginResp := makeHTTPRequest(t, "POST", baseURL+"/login", loginPayload, "")

if loginResp.StatusCode != 200 {
t.Fatalf("Expected status 200 for login, got %d. Response: %s", loginResp.StatusCode, loginResp.Body)
}

// Extract token
var loginResponse map[string]interface{}
err := json.Unmarshal([]byte(loginResp.Body), &loginResponse)

if err != nil {
t.Fatalf("Failed to parse login response: %v", err)
}
Expand Down Expand Up @@ -164,6 +167,7 @@ func TestFullUserJourney(t *testing.T) {
// Step 5: List users
t.Log("5. Listing users...")
listResp := makeHTTPRequest(t, "GET", baseURL+"/users", nil, token)

if listResp.StatusCode != 200 {
t.Fatalf("Expected status 200 for list users, got %d. Response: %s", listResp.StatusCode, listResp.Body)
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/app/internal/database/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package database

const (
// TableName is the single table for all entities
TableName = "glad-entities"

GSIByUser = "ByUser"
GSISkillsByLevel = "SkillsByLevel"
GSIBySkillID = "BySkillID"
GSIByEntityType = "ByEntityType"
)
14 changes: 5 additions & 9 deletions cmd/app/internal/database/dynamodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,11 @@ import (
"github.com/aws/aws-sdk-go/service/dynamodb"
)

const (
// TableName is the single table for all entities
TableName = "glad-entities"

// GSI1Name GSI names
GSI1Name = "GSI1"
)

// DynamoDBRepository implements Repository using DynamoDB single table design
// DynamoDBRepository implements all repository interfaces using DynamoDB single table design
// It provides implementations for:
// - UserRepository (user management)
// - MasterSkillRepository (master skills)
// - SkillRepository (user skills)
type DynamoDBRepository struct {
client *dynamodb.DynamoDB
}
Expand Down
57 changes: 57 additions & 0 deletions cmd/app/internal/database/entity_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package database

import (
"fmt"
"strings"
)

// Entity ID utility functions for consistent key generation across the application.
// All entity IDs use "#" as the delimiter for better DynamoDB practices.

// BuildUserEntityID creates an entity ID for a User
// Format: USER#<username>
func BuildUserEntityID(username string) string {
return fmt.Sprintf("USER#%s", strings.ToLower(username))
}

// BuildUserSkillEntityID creates an entity ID for a UserSkill
// Format: USERSKILL#<username>#<skillID>
func BuildUserSkillEntityID(username, skillID string) string {
return fmt.Sprintf("USERSKILL#%s#%s", strings.ToLower(username), strings.ToLower(skillID))
}

// BuildMasterSkillEntityID creates an entity ID for a MasterSkill
// Format: SKILL#<skillID>
func BuildMasterSkillEntityID(skillID string) string {
return fmt.Sprintf("SKILL#%s", strings.ToLower(skillID))
}

// ParseUserEntityID extracts the username from a User entity ID
// Returns the username or empty string if invalid format
func ParseUserEntityID(entityID string) string {
parts := strings.Split(entityID, "#")
if len(parts) == 2 && parts[0] == "USER" {
return parts[1]
}
return ""
}

// ParseUserSkillEntityID extracts username and skillID from a UserSkill entity ID
// Returns username, skillID, or empty strings if invalid format
func ParseUserSkillEntityID(entityID string) (username, skillID string) {
parts := strings.Split(entityID, "#")
if len(parts) == 3 && parts[0] == "USERSKILL" {
return parts[1], parts[2]
}
return "", ""
}

// ParseMasterSkillEntityID extracts the skillID from a MasterSkill entity ID
// Returns the skillID or empty string if invalid format
func ParseMasterSkillEntityID(entityID string) string {
parts := strings.Split(entityID, "#")
if len(parts) == 2 && parts[0] == "SKILL" {
return parts[1]
}
return ""
}
1 change: 1 addition & 0 deletions cmd/app/internal/database/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
type Repository interface {
UserRepository
SkillRepository
MasterSkillRepository
}

// NewRepository creates the appropriate repository implementation based on configuration
Expand Down
187 changes: 187 additions & 0 deletions cmd/app/internal/database/master_skill_repository.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package database

import (
"time"

apperrors "github.com/hackmajoris/glad/cmd/app/internal/errors"
"github.com/hackmajoris/glad/cmd/app/internal/models"
"github.com/hackmajoris/glad/pkg/logger"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

// MasterSkillRepository defines operations for master skills
type MasterSkillRepository interface {
CreateMasterSkill(skill *models.Skill) error
GetMasterSkill(skillID string) (*models.Skill, error)
UpdateMasterSkill(skill *models.Skill) error
DeleteMasterSkill(skillID string) error
ListMasterSkills() ([]*models.Skill, error)
}

// CreateMasterSkill inserts a new master skill
func (r *DynamoDBRepository) CreateMasterSkill(skill *models.Skill) error {
log := logger.WithComponent("database").With("operation", "CreateMasterSkill", "skill_id", skill.SkillID)
start := time.Now()

log.Debug("Starting master skill creation")

skill.SetKeys()

item, err := dynamodbattribute.MarshalMap(skill)
if err != nil {
log.Error("Failed to marshal skill data", "error", err.Error(), "duration", time.Since(start))
return err
}

input := &dynamodb.PutItemInput{
TableName: aws.String(TableName),
Item: item,
ConditionExpression: aws.String("attribute_not_exists(entity_id)"),
}

_, err = r.client.PutItem(input)
if err != nil {
log.Error("Failed to create master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start))
return apperrors.ErrSkillAlreadyExists
}

log.Info("Master skill created successfully", "duration", time.Since(start))
return nil
}

// GetMasterSkill retrieves a master skill by ID
func (r *DynamoDBRepository) GetMasterSkill(skillID string) (*models.Skill, error) {
log := logger.WithComponent("database").With("operation", "GetMasterSkill", "skill_id", skillID)
start := time.Now()

log.Debug("Starting master skill retrieval")

entityID := BuildMasterSkillEntityID(skillID)

input := &dynamodb.GetItemInput{
TableName: aws.String(TableName),
Key: map[string]*dynamodb.AttributeValue{
"entity_id": {S: aws.String(entityID)},
},
}

result, err := r.client.GetItem(input)
if err != nil {
log.Error("Failed to get master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start))
return nil, err
}

if result.Item == nil {
log.Debug("Master skill not found", "duration", time.Since(start))
return nil, apperrors.ErrSkillNotFound
}

var skill models.Skill
err = dynamodbattribute.UnmarshalMap(result.Item, &skill)
if err != nil {
log.Error("Failed to unmarshal skill data", "error", err.Error(), "duration", time.Since(start))
return nil, err
}

log.Debug("Master skill retrieved successfully", "duration", time.Since(start))
return &skill, nil
}

// UpdateMasterSkill updates an existing master skill
func (r *DynamoDBRepository) UpdateMasterSkill(skill *models.Skill) error {
log := logger.WithComponent("database").With("operation", "UpdateMasterSkill", "skill_id", skill.SkillID)
start := time.Now()

log.Debug("Starting master skill update")

skill.SetKeys()
skill.UpdatedAt = time.Now()

item, err := dynamodbattribute.MarshalMap(skill)
if err != nil {
log.Error("Failed to marshal skill data for update", "error", err.Error(), "duration", time.Since(start))
return err
}

input := &dynamodb.PutItemInput{
TableName: aws.String(TableName),
Item: item,
ConditionExpression: aws.String("attribute_exists(entity_id)"),
}

_, err = r.client.PutItem(input)
if err != nil {
log.Error("Failed to update master skill in DynamoDB", "error", err.Error(), "duration", time.Since(start))
return apperrors.ErrSkillNotFound
}

log.Info("Master skill updated successfully", "duration", time.Since(start))
return nil
}

// DeleteMasterSkill removes a master skill
func (r *DynamoDBRepository) DeleteMasterSkill(skillID string) error {
log := logger.WithComponent("database").With("operation", "DeleteMasterSkill", "skill_id", skillID)
start := time.Now()

log.Debug("Starting master skill deletion")

entityID := BuildMasterSkillEntityID(skillID)

input := &dynamodb.DeleteItemInput{
TableName: aws.String(TableName),
Key: map[string]*dynamodb.AttributeValue{
"entity_id": {S: aws.String(entityID)},
},
ConditionExpression: aws.String("attribute_exists(entity_id)"),
}

_, err := r.client.DeleteItem(input)
if err != nil {
log.Error("Failed to delete master skill from DynamoDB", "error", err.Error(), "duration", time.Since(start))
return apperrors.ErrSkillNotFound
}

log.Info("Master skill deleted successfully", "duration", time.Since(start))
return nil
}

// ListMasterSkills retrieves all master skills
func (r *DynamoDBRepository) ListMasterSkills() ([]*models.Skill, error) {
log := logger.WithComponent("database").With("operation", "ListMasterSkills")
start := time.Now()

log.Debug("Starting master skills list retrieval")

// Use GSI for better performance instead of Scan
input := &dynamodb.QueryInput{
TableName: aws.String(TableName),
IndexName: aws.String(GSIByEntityType),
KeyConditionExpression: aws.String("EntityType = :entityType"),
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":entityType": {S: aws.String("Skill")},
},
}

result, err := r.client.Query(input)
if err != nil {
log.Error("Failed to query master skills", "error", err.Error(), "duration", time.Since(start))
return nil, err
}

var skills []*models.Skill
for i, item := range result.Items {
var skill models.Skill
if err := dynamodbattribute.UnmarshalMap(item, &skill); err != nil {
log.Error("Failed to unmarshal skill data", "error", err.Error(), "item_index", i, "duration", time.Since(start))
continue
}
skills = append(skills, &skill)
}

log.Info("Master skills retrieved successfully", "count", len(skills), "duration", time.Since(start))
return skills, nil
}
Loading
Loading