From 2f93978456b0e6b4e9e8fc04e64557c25ba2a794 Mon Sep 17 00:00:00 2001 From: chodak166 Date: Sun, 14 Sep 2025 16:56:35 +0200 Subject: [PATCH] Added iniial Go (golang) implementation --- README.md | 41 +- golang/.devcontainer/Dockerfile | 31 + golang/.devcontainer/devcontainer.json | 25 + golang/.devcontainer/docker-compose.yml | 29 + golang/PLAN.md | 604 ++++++++++++++++++ golang/SPEC_DETAILS.md | 262 ++++++++ golang/cmd/main.go | 94 +++ golang/docker/.dockerignore | 4 + golang/docker/Dockerfile | 29 + golang/docker/docker-compose.yml | 17 + golang/go.sum | 90 +++ .../application/commands/add_item_command.go | 97 +++ .../commands/delete_item_command.go | 69 ++ .../commands/handle_expired_items_command.go | 67 ++ .../commands/login_user_command.go | 35 + .../application/dto/create_item_dto.go | 34 + .../application/dto/item_response_dto.go | 25 + .../application/dto/jsend_response.go | 31 + golang/internal/application/dto/json_time.go | 50 ++ golang/internal/application/dto/login_dto.go | 33 + golang/internal/application/errors/errors.go | 5 + .../application/interfaces/auth_service.go | 11 + .../application/interfaces/item_repository.go | 17 + .../internal/application/interfaces/logger.go | 12 + .../application/interfaces/order_service.go | 10 + .../application/interfaces/time_provider.go | 9 + .../application/interfaces/user_repository.go | 13 + .../application/queries/get_item_query.go | 63 ++ .../application/queries/list_items_query.go | 50 ++ golang/internal/config/config.go | 67 ++ golang/internal/container/container.go | 174 +++++ golang/internal/domain/entities/errors.go | 11 + golang/internal/domain/entities/item.go | 118 ++++ golang/internal/domain/entities/user.go | 69 ++ .../domain/specifications/condition_spec.go | 247 +++++++ .../specifications/item_expiration_spec.go | 29 + .../specifications/simple_specification.go | 443 +++++++++++++ .../domain/value_objects/base_uuid.go | 45 ++ .../domain/value_objects/expiration_date.go | 23 + .../internal/domain/value_objects/item_id.go | 38 ++ .../internal/domain/value_objects/user_id.go | 38 ++ .../infrastructure/auth/jwt_auth_service.go | 126 ++++ .../http/order_url_http_client.go | 80 +++ .../infrastructure/logging/standard_logger.go | 41 ++ .../repositories/file_item_repository.go | 223 +++++++ .../repositories/file_user_repository.go | 156 +++++ .../scheduler/expired_items_scheduler.go | 104 +++ .../services/user_initialization_service.go | 65 ++ .../time/system_time_provider.go | 15 + .../controllers/auth_controller.go | 53 ++ .../controllers/items_controller.go | 157 +++++ .../presentation/middleware/jwt_middleware.go | 62 ++ golang/internal/presentation/server/server.go | 117 ++++ 53 files changed, 4345 insertions(+), 13 deletions(-) create mode 100644 golang/.devcontainer/Dockerfile create mode 100644 golang/.devcontainer/devcontainer.json create mode 100644 golang/.devcontainer/docker-compose.yml create mode 100644 golang/PLAN.md create mode 100644 golang/SPEC_DETAILS.md create mode 100644 golang/cmd/main.go create mode 100644 golang/docker/.dockerignore create mode 100644 golang/docker/Dockerfile create mode 100644 golang/docker/docker-compose.yml create mode 100644 golang/go.sum create mode 100644 golang/internal/application/commands/add_item_command.go create mode 100644 golang/internal/application/commands/delete_item_command.go create mode 100644 golang/internal/application/commands/handle_expired_items_command.go create mode 100644 golang/internal/application/commands/login_user_command.go create mode 100644 golang/internal/application/dto/create_item_dto.go create mode 100644 golang/internal/application/dto/item_response_dto.go create mode 100644 golang/internal/application/dto/jsend_response.go create mode 100644 golang/internal/application/dto/json_time.go create mode 100644 golang/internal/application/dto/login_dto.go create mode 100644 golang/internal/application/errors/errors.go create mode 100644 golang/internal/application/interfaces/auth_service.go create mode 100644 golang/internal/application/interfaces/item_repository.go create mode 100644 golang/internal/application/interfaces/logger.go create mode 100644 golang/internal/application/interfaces/order_service.go create mode 100644 golang/internal/application/interfaces/time_provider.go create mode 100644 golang/internal/application/interfaces/user_repository.go create mode 100644 golang/internal/application/queries/get_item_query.go create mode 100644 golang/internal/application/queries/list_items_query.go create mode 100644 golang/internal/config/config.go create mode 100644 golang/internal/container/container.go create mode 100644 golang/internal/domain/entities/errors.go create mode 100644 golang/internal/domain/entities/item.go create mode 100644 golang/internal/domain/entities/user.go create mode 100644 golang/internal/domain/specifications/condition_spec.go create mode 100644 golang/internal/domain/specifications/item_expiration_spec.go create mode 100644 golang/internal/domain/specifications/simple_specification.go create mode 100644 golang/internal/domain/value_objects/base_uuid.go create mode 100644 golang/internal/domain/value_objects/expiration_date.go create mode 100644 golang/internal/domain/value_objects/item_id.go create mode 100644 golang/internal/domain/value_objects/user_id.go create mode 100644 golang/internal/infrastructure/auth/jwt_auth_service.go create mode 100644 golang/internal/infrastructure/http/order_url_http_client.go create mode 100644 golang/internal/infrastructure/logging/standard_logger.go create mode 100644 golang/internal/infrastructure/repositories/file_item_repository.go create mode 100644 golang/internal/infrastructure/repositories/file_user_repository.go create mode 100644 golang/internal/infrastructure/scheduler/expired_items_scheduler.go create mode 100644 golang/internal/infrastructure/services/user_initialization_service.go create mode 100644 golang/internal/infrastructure/time/system_time_provider.go create mode 100644 golang/internal/presentation/controllers/auth_controller.go create mode 100644 golang/internal/presentation/controllers/items_controller.go create mode 100644 golang/internal/presentation/middleware/jwt_middleware.go create mode 100644 golang/internal/presentation/server/server.go diff --git a/README.md b/README.md index 4b73c0d..c9c4ad5 100644 --- a/README.md +++ b/README.md @@ -52,35 +52,38 @@ A system to store items with expiration dates. When items expire, new ones are a ```plaintext AutoStore/ -├── App +├── App # app assembly │ ├── Main │ ├── AppConfig │ └── ... ├── Extern -│ ├── +│ ├── │ └── <...downloaded libraries and git submodules> -├── Src +├── Src # internal/lib/src │ ├── Domain/ │ │ ├── Entities/ │ │ │ ├── User │ │ │ └── Item -│ │ └── Services/ -│ │ └── ExpirationPolicy +│ │ └── Specifications/ +│ │ └── ItemExpirationSpec │ ├── Application/ -│ │ ├── UseCases/ -│ │ │ ├── RegisterUser -│ │ │ ├── LoginUser +│ │ ├── Commands/ # use cases +│ │ │ ├── Login │ │ │ ├── AddItem -│ │ │ ├── GetItem │ │ │ ├── DeleteItem │ │ │ └── HandleExpiredItems +│ │ ├── Queries/ # use cases (read only) +│ │ │ ├── GetItem +│ │ │ └── ListItems │ │ ├── Interfaces/ │ │ │ ├── IUserRepository │ │ │ ├── IItemRepository │ │ │ ├── IAuthService │ │ │ └── IClock -│ │ ├── Dto/ +│ │ ├── Dto/ # data transfer objects (fields mappings, validation, etc.) │ │ └── Services/ +│ │ ├── UserInitializationService +│ │ └── ExpirationScheduler │ ├── Infrastructure/ │ │ ├── Repositories/ │ │ │ ├── FileUserRepository @@ -88,11 +91,11 @@ AutoStore/ │ │ ├── Adapters/ │ │ │ ├── JwtAuthAdapter │ │ │ ├── OrderUrlHttpClient -│ │ │ ├── SystemClockImpl +│ │ │ ├── SystemClock │ │ │ └── <... some extern lib adapters> │ │ └── Helpers/ │ │ └── <... DRY helpers> -│ └── WebApi/ +│ └── WebApi/ # presentation (controllers, middlewares, etc.) │ ├── Controllers/ │ │ ├── StoreController │ │ └── UserController @@ -103,12 +106,24 @@ AutoStore/ └── Integration/ ``` +--- + +## Domain Knowledge and Repository Queries + +Business rules like expiration checks (`expirationDate <= currentDate`) represent domain knowledge that **must have a single source of truth**. This logic might evolve (e.g., to `<= currentDate - N days` or vary by item type) and should never be duplicated across the codebase. + +While simple predicates (like `findWhere(predicate(Item))`) work for in-memory repositories, SQL-based repositories need to translate these rules into efficient WHERE clauses. To solve this, consider implementing a specification pattern with a simple condition abstraction that exposes the business rule as composable conditions (field, operator, value). + +This allows domain services to define rules once, use cases to apply them consistently, and repositories to translate them into optimal queries by interpreting the conditions according to their storage mechanism. Avoid duplicating the business logic in repository implementations - instead, let repositories consume the specification and build their queries accordingly. + +--- + ## Build and Run Ideally, each implementation should include a `/docker/docker-compose.yml` file so that you can simply run: ```bash -docker compose up +docker compose up --build ``` to build and run the application. diff --git a/golang/.devcontainer/Dockerfile b/golang/.devcontainer/Dockerfile new file mode 100644 index 0000000..97e5ba5 --- /dev/null +++ b/golang/.devcontainer/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.25.1-alpine3.22 + +WORKDIR /usr/src/app + +# Install system dependencies +RUN apk add --no-cache \ + git \ + bash \ + curl \ + sudo + +# Configure user and group IDs (default: 1000:1000) +ARG USER_ID=1000 +ARG GROUP_ID=1000 + +# Create a group and user with specific UID/GID +RUN addgroup -g ${GROUP_ID} developer \ + && adduser -D -u ${USER_ID} -G developer -s /bin/bash developer \ + && echo "developer ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/developer \ + && chmod 0440 /etc/sudoers.d/developer + +RUN chown -R ${USER_ID}:${GROUP_ID} /usr/src/app + +USER developer + +# Install Go tools +RUN go install github.com/go-delve/delve/cmd/dlv@latest + +EXPOSE 3000 + +CMD ["go", "run", "main.go"] \ No newline at end of file diff --git a/golang/.devcontainer/devcontainer.json b/golang/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f1d6509 --- /dev/null +++ b/golang/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +{ + "name": "Go dev container", + "dockerComposeFile": "./docker-compose.yml", + "service": "app", + "workspaceFolder": "/usr/src/app", + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "go.useLanguageServer": true, + "go.gopath": "/go", + "go.goroot": "/usr/local/go" + }, + "extensions": [ + "golang.go", + "ms-vscode.go-tools", + "ms-vscode.vscode-go", + "ms-vscode.vscode-docker" + ] + } + }, + "forwardPorts": [3000], + "remoteUser": "developer", + "postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && go mod tidy" +} \ No newline at end of file diff --git a/golang/.devcontainer/docker-compose.yml b/golang/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..1e6fe9c --- /dev/null +++ b/golang/.devcontainer/docker-compose.yml @@ -0,0 +1,29 @@ +version: "3.9" +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + args: + USER_ID: ${USER_ID:-1000} + GROUP_ID: ${GROUP_ID:-1000} + image: dev-golang-img + container_name: dev-golang + user: "developer" + volumes: + - ../:/usr/src/app:cached + - golang_modules:/go/pkg/mod + environment: + NODE_ENV: development + ports: + - "50080:3000" + networks: + - dev-network + command: sleep infinity + +volumes: + golang_modules: + +networks: + dev-network: + driver: bridge \ No newline at end of file diff --git a/golang/PLAN.md b/golang/PLAN.md new file mode 100644 index 0000000..5522153 --- /dev/null +++ b/golang/PLAN.md @@ -0,0 +1,604 @@ +# Go Implementation Plan for AutoStore + +## Overview +Implementation of AutoStore system using Go, following Clean Architecture principles. The system stores items with expiration dates and automatically orders new items when they expire. + +## Architecture Approach +- **Clean Architecture** with clear separation of concerns +- **Domain-Driven Design** with rich domain models +- **Hexagonal Architecture** with dependency inversion +- **Repository Pattern** for data persistence +- **CQRS-like** command/query separation +- **Dependency Injection** using Go's interface system and a DI container + +## Core Domain Logic + +### ItemExpirationSpec - Single Source of Truth for Expiration + +**File**: `internal/domain/specifications/item_expiration_spec.go` +**Purpose**: Centralized expiration checking logic - the single source of truth for determining if items are expired + +**Key Methods**: +- `IsExpired(item *ItemEntity, currentTime time.Time) bool` - Checks if item expired +- `GetSpec(currentTime time.Time) Specification[ItemEntity]` - Returns specification for repository queries + +**Place in the flow**: +- Called by `AddItemCommand.Execute()` to check newly created items for immediate expiration +- Called by `HandleExpiredItemsCommand.Execute()` to find expired items for processing +- Used by `ItemRepository.FindWhere()` to query database for expired items + +## Detailed Implementation Plan + +### Domain Layer + +#### 1. Entities + +**File**: `internal/domain/entities/item.go` +**Purpose**: Core business entity representing an item + +**Key Methods**: +- `NewItem(id ItemID, name string, expirationDate ExpirationDate, orderURL string, userID UserID) (*ItemEntity, error)` - Creates item with validation +- `GetID() ItemID` - Returns item ID +- `GetName() string` - Returns item name +- `GetExpirationDate() ExpirationDate` - Returns expiration date +- `GetOrderURL() string` - Returns order URL +- `GetUserID() UserID` - Returns user ID + +**Place in the flow**: +- Created by `AddItemCommand.Execute()` +- Retrieved by `ItemRepository` methods +- Passed to `ItemExpirationSpec.IsExpired()` for expiration checking + +**File**: `internal/domain/entities/user.go` +**Purpose**: User entity for item ownership and authentication purposes + +**Key Methods**: +- `NewUser(id UserID, username string, passwordHash string) (*UserEntity, error)` - Creates user with validation +- `GetID() UserID` - Returns user ID +- `GetUsername() string` - Returns username +- `GetPasswordHash() string` - Returns password hash +- `ValidatePassword(password string) bool` - Validates password + +#### 2. Value Objects + +**File**: `internal/domain/value_objects/item_id.go` +**Purpose**: Strong typing for item identifiers + +**Key Methods**: +- `NewItemID(value string) (ItemID, error)` - Validates UUID format +- `String() string` - Returns string value +- `Equals(other ItemID) bool` - Compares with another ItemID + +**File**: `internal/domain/value_objects/user_id.go` +**Purpose**: Strong typing for user identifiers + +**Key Methods**: +- `NewUserID(value string) (UserID, error)` - Validates UUID format +- `String() string` - Returns string value +- `Equals(other UserID) bool` - Compares with another UserID + +**File**: `internal/domain/value_objects/expiration_date.go` +**Purpose**: Immutable expiration date with validation + +**Key Methods**: +- `NewExpirationDate(value time.Time) (ExpirationDate, error)` - Validates date format (allows past dates per business rules) +- `Time() time.Time` - Returns time.Time object +- `String() string` - Returns ISO string format +- `IsExpired(currentTime time.Time) bool` - Checks if date is expired + +**Place in the flow**: +- Used by `ItemEntity` constructor for type-safe date handling +- Validated by `ItemExpirationSpec.IsExpired()` for expiration logic + +#### 3. Specifications + +**File**: `internal/domain/specifications/specification.go` +**Purpose**: Generic specification pattern interface + +**Key Methods**: +- `IsSatisfiedBy(candidate T) bool` - Evaluates specification +- `And(other Specification[T]) Specification[T]` - Combines with AND +- `Or(other Specification[T]) Specification[T]` - Combines with OR +- `Not() Specification[T]` - Negates specification + +**File**: `internal/domain/specifications/item_expiration_spec.go` +**Purpose**: Implementation of expiration specification + +**Key Methods**: +- `IsExpired(item *ItemEntity, currentTime time.Time) bool` - Checks if item is expired +- `GetSpec(currentTime time.Time) Specification[ItemEntity]` - Returns specification for repository queries + +**Place in the flow**: +- Implemented by `ItemExpirationSpec` for type-safe specifications +- Used by `ItemRepository.FindWhere()` for database queries + +### Application Layer + +#### 4. Commands + +**File**: `internal/application/commands/add_item_command.go` +**Purpose**: Use case for creating new items with expiration handling + +**Key Methods**: +- `NewAddItemCommand(itemRepo IItemRepository, orderService IOrderService, timeProvider ITimeProvider, expirationSpec *ItemExpirationSpec, logger ILogger) *AddItemCommand` - Constructor with dependency injection +- `Execute(ctx context.Context, name string, expirationDate time.Time, orderURL string, userID string) (string, error)` - Creates item, handles expired items immediately + +**Flow**: +1. `ItemsController.CreateItem()` calls `AddItemCommand.Execute()` +2. Creates `ItemEntity` with validated data +3. Calls `ItemExpirationSpec.IsExpired()` to check if item is expired +4. If expired: + - calls `OrderHTTPService.OrderItem()` + - **returns item ID** (business rule: expired items trigger ordering but still return ID field that might be empty or invalid) +5. If not expired: calls `ItemRepository.Save()` and returns item ID + +**File**: `internal/application/commands/handle_expired_items_command.go` +**Purpose**: Background command to process expired items + +**Key Methods**: +- `NewHandleExpiredItemsCommand(itemRepo IItemRepository, orderService IOrderService, timeProvider ITimeProvider, expirationSpec *ItemExpirationSpec, logger ILogger) *HandleExpiredItemsCommand` - Constructor with dependency injection +- `Execute(ctx context.Context) error` - Finds and processes all expired items + +**Flow**: +1. `ExpiredItemsScheduler.Run()` calls `HandleExpiredItemsCommand.Execute()` +2. Gets current time from `ITimeProvider` +3. Calls `ItemExpirationSpec.GetSpec()` to get expiration specification +4. Calls `ItemRepository.FindWhere()` to find expired items +5. For each expired item: calls `OrderHTTPService.OrderItem()` then `ItemRepository.Delete()` + +**File**: `internal/application/commands/delete_item_command.go` +**Purpose**: Use case for deleting user items + +**Key Methods**: +- `NewDeleteItemCommand(itemRepo IItemRepository, logger ILogger) *DeleteItemCommand` - Constructor with dependency injection +- `Execute(ctx context.Context, itemID string, userID string) error` - Validates ownership and deletes item + +**Flow**: +1. `ItemsController.DeleteItem()` calls `DeleteItemCommand.Execute()` +2. Calls `ItemRepository.FindByID()` to retrieve item +3. Validates ownership by comparing user IDs +4. Calls `ItemRepository.Delete()` to remove item + +**File**: `internal/application/commands/login_user_command.go` +**Purpose**: User authentication use case + +**Key Methods**: +- `NewLoginUserCommand(authService IAuthService, logger ILogger) *LoginUserCommand` - Constructor with dependency injection +- `Execute(ctx context.Context, username string, password string) (string, error)` - Authenticates and returns JWT token + +#### 5. Queries + +**File**: `internal/application/queries/get_item_query.go` +**Purpose**: Retrieves single item by ID with authorization + +**Key Methods**: +- `NewGetItemQuery(itemRepo IItemRepository, logger ILogger) *GetItemQuery` - Constructor with dependency injection +- `Execute(ctx context.Context, itemID string, userID string) (*ItemEntity, error)` - Validates ownership and returns item + +**Flow**: +1. `ItemsController.GetItem()` calls `GetItemQuery.Execute()` +2. Calls `ItemRepository.FindByID()` to retrieve item +3. Validates ownership by comparing user IDs +4. Returns item entity + +**File**: `internal/application/queries/list_items_query.go` +**Purpose**: Retrieves all items for authenticated user + +**Key Methods**: +- `NewListItemsQuery(itemRepo IItemRepository, logger ILogger) *ListItemsQuery` - Constructor with dependency injection +- `Execute(ctx context.Context, userID string) ([]*ItemEntity, error)` - Returns user's items + +**Flow**: +1. `ItemsController.ListItems()` calls `ListItemsQuery.Execute()` +2. Calls `ItemRepository.FindByUserID()` to retrieve user's items +3. Returns array of item entities + +#### 6. DTOs + +**File**: `internal/application/dto/create_item_dto.go` +**Purpose**: Request validation for item creation + +**Key Properties**: +- `Name string` - Item name (validation: not empty, max 255 chars) +- `ExpirationDate time.Time` - Date (validation: valid date) +- `OrderURL string` - Order URL (validation: valid URL format) + +**Key Methods**: +- `Validate() error` - Validates DTO fields + +**Place in the flow**: +- Used by `ItemsController.CreateItem()` for request body validation + +**File**: `internal/application/dto/item_response_dto.go` +**Purpose**: Standardized item response format + +**Key Properties**: +- `ID string` - Item ID +- `Name string` - Item name +- `ExpirationDate time.Time` - Expiration date +- `OrderURL string` - Order URL +- `UserID string` - Owner user ID +- `CreatedAt time.Time` - Creation timestamp + +**Key Methods**: +- `FromEntity(item *ItemEntity) *ItemResponseDTO` - Creates DTO from entity + +**Place in the flow**: +- Used by all item controller methods for response transformation + +**File**: `internal/application/dto/login_dto.go` +**Purpose**: Login request validation + +**Key Properties**: +- `Username string` - Username (validation: not empty) +- `Password string` - Password (validation: not empty) + +**Key Methods**: +- `Validate() error` - Validates DTO fields + +#### 7. Interfaces + +**File**: `internal/application/interfaces/item_repository.go` +**Purpose**: Repository interface for item persistence + +**Key Methods**: +- `Save(ctx context.Context, item *ItemEntity) error` +- `FindByID(ctx context.Context, id ItemID) (*ItemEntity, error)` +- `FindByUserID(ctx context.Context, userID UserID) ([]*ItemEntity, error)` +- `FindWhere(ctx context.Context, spec Specification[ItemEntity]) ([]*ItemEntity, error)` +- `Delete(ctx context.Context, id ItemID) error` +- `Exists(ctx context.Context, id ItemID) (bool, error)` + +**File**: `internal/application/interfaces/user_repository.go` +**Purpose**: Repository interface for user persistence + +**Key Methods**: +- `FindByUsername(ctx context.Context, username string) (*UserEntity, error)` +- `FindByID(ctx context.Context, id UserID) (*UserEntity, error)` +- `Save(ctx context.Context, user *UserEntity) error` + +**File**: `internal/application/interfaces/auth_service.go` +**Purpose**: Authentication service interface + +**Key Methods**: +- `Authenticate(ctx context.Context, username string, password string) (string, error)` +- `ValidateToken(ctx context.Context, token string) (bool, error)` +- `GetUserIDFromToken(ctx context.Context, token string) (string, error)` + +**File**: `internal/application/interfaces/order_service.go` +**Purpose**: Order service interface + +**Key Methods**: +- `OrderItem(ctx context.Context, item *ItemEntity) error` + +**File**: `internal/application/interfaces/time_provider.go` +**Purpose**: Time provider interface for testing + +**Key Methods**: +- `Now() time.Time` + +**File**: `internal/application/interfaces/logger.go` +**Purpose**: Logger interface + +**Key Methods**: +- `Info(ctx context.Context, msg string, fields ...interface{})` +- `Error(ctx context.Context, msg string, fields ...interface{})` +- `Debug(ctx context.Context, msg string, fields ...interface{})` +- `Warn(ctx context.Context, msg string, fields ...interface{})` + +### Infrastructure Layer + +#### 8. Repositories + +**File**: `internal/infrastructure/repositories/file_item_repository.go` +**Purpose**: File-based implementation of item repository using JSON files + +**Key Methods**: +- `Save(ctx context.Context, item *ItemEntity) error` - Persists item entity +- `FindByID(ctx context.Context, id ItemID) (*ItemEntity, error)` - Finds by ID +- `FindByUserID(ctx context.Context, userID UserID) ([]*ItemEntity, error)` - Finds by user +- `FindWhere(ctx context.Context, spec Specification[ItemEntity]) ([]*ItemEntity, error)` - Finds by specification using `ItemExpirationSpec` +- `Delete(ctx context.Context, id ItemID) error` - Deletes item +- `Exists(ctx context.Context, id ItemID) (bool, error)` - Checks existence + +**Place in the flow**: +- Called by all commands and queries for data persistence and retrieval +- Uses `ItemExpirationSpec` for finding expired items + +**File**: `internal/infrastructure/repositories/file_user_repository.go` +**Purpose**: File-based implementation of user repository using JSON files + +**Key Methods**: +- `FindByUsername(ctx context.Context, username string) (*UserEntity, error)` - Finds by username +- `FindByID(ctx context.Context, id UserID) (*UserEntity, error)` - Finds by ID +- `Save(ctx context.Context, user *UserEntity) error` - Saves user + +#### 9. HTTP Services + +**File**: `internal/infrastructure/http/order_http_service.go` +**Purpose**: HTTP implementation of order service + +**Key Methods**: +- `NewOrderHTTPService(client *http.Client, logger ILogger) *OrderHTTPService` - Constructor with dependency injection +- `OrderItem(ctx context.Context, item *ItemEntity) error` - Sends POST request to order URL + +**Place in the flow**: +- Called by `AddItemCommand.Execute()` for expired items +- Called by `HandleExpiredItemsCommand.Execute()` for batch processing + +#### 10. Authentication + +**File**: `internal/infrastructure/auth/jwt_auth_service.go` +**Purpose**: JWT implementation of authentication service + +**Key Methods**: +- `NewJWTAuthService(userRepo IUserRepository, secretKey string, logger ILogger) *JWTAuthService` - Constructor with dependency injection +- `Authenticate(ctx context.Context, username string, password string) (string, error)` - Validates credentials and generates JWT +- `ValidateToken(ctx context.Context, token string) (bool, error)` - Validates JWT token +- `GetUserIDFromToken(ctx context.Context, token string) (string, error)` - Extracts user ID from token + +**Place in the flow**: +- Called by `LoginUserCommand.Execute()` for user authentication +- Used by `JWTMiddleware` for route protection + +#### 11. Time Provider + +**File**: `internal/infrastructure/time/system_time_provider.go` +**Purpose**: System time implementation + +**Key Methods**: +- `NewSystemTimeProvider() *SystemTimeProvider` - Constructor +- `Now() time.Time` - Returns current system time + +#### 12. Logger + +**File**: `internal/infrastructure/logging/standard_logger.go` +**Purpose**: Standard library logger implementation + +**Key Methods**: +- `NewStandardLogger(writer io.Writer) *StandardLogger` - Constructor +- `Info(ctx context.Context, msg string, fields ...interface{})` - Logs info message +- `Error(ctx context.Context, msg string, fields ...interface{})` - Logs error message +- `Debug(ctx context.Context, msg string, fields ...interface{})` - Logs debug message +- `Warn(ctx context.Context, msg string, fields ...interface{})` - Logs warning message + +### Presentation Layer + +#### 13. Controllers + +**File**: `internal/presentation/controllers/items_controller.go` +**Purpose**: REST API endpoints for item management + +**Key Methods**: +- `NewItemsController(addItemCmd *AddItemCommand, getItemQry *GetItemQuery, listItemsQry *ListItemsQuery, deleteItemCmd *DeleteItemCommand, logger ILogger) *ItemsController` - Constructor with dependency injection +- `CreateItem(c *gin.Context)` - POST /items +- `GetItem(c *gin.Context)` - GET /items/:id +- `ListItems(c *gin.Context)` - GET /items +- `DeleteItem(c *gin.Context)` - DELETE /items/:id + +**Flow**: +- Receives HTTP requests and validates input +- Calls appropriate commands/queries based on HTTP method +- Returns standardized responses with DTOs + +**File**: `internal/presentation/controllers/auth_controller.go` +**Purpose**: Authentication endpoints + +**Key Methods**: +- `NewAuthController(loginUserCmd *LoginUserCommand, logger ILogger) *AuthController` - Constructor with dependency injection +- `Login(c *gin.Context)` - POST /login + +#### 14. Middleware + +**File**: `internal/presentation/middleware/jwt_middleware.go` +**Purpose**: JWT authentication middleware + +**Key Methods**: +- `NewJWTMiddleware(authService IAuthService, logger ILogger) *JWTMiddleware` - Constructor with dependency injection +- `Middleware() gin.HandlerFunc` - Returns gin middleware function + +**Place in the flow**: +- Applied to all protected routes by Gin router group +- Uses `JWTAuthService` for token validation + +#### 15. Server + +**File**: `internal/presentation/server/server.go` +**Purpose**: HTTP server setup and configuration + +**Key Methods**: +- `NewServer(config *Config, logger ILogger) *Server` - Constructor with dependency injection +- `Start() error` - Starts the HTTP server +- `SetupRoutes() *gin.Engine` - Sets up routes and middleware + +### Background Processing + +**File**: `internal/infrastructure/scheduler/expired_items_scheduler.go` +**Purpose**: Scheduled job for processing expired items using a ticker + +**Key Methods**: +- `NewExpiredItemsScheduler(handleExpiredItemsCmd *HandleExpiredItemsCommand, logger ILogger) *ExpiredItemsScheduler` - Constructor with dependency injection +- `Start(ctx context.Context) error` - Starts the scheduler +- `Stop() error` - Stops the scheduler +- `processExpiredItems(ctx context.Context) error` - Processes expired items + +**Flow**: +1. **On startup**: `processExpiredItems()` immediately calls `HandleExpiredItemsCommand.Execute()` +2. **Every minute**: Ticker triggers `processExpiredItems()` to process expired items +3. All methods use context for cancellation and error handling +4. Comprehensive logging for monitoring and debugging + +**Configuration**: +- Uses `time.Ticker` for periodic execution +- Implements graceful shutdown with context cancellation +- Configurable interval (default: 1 minute) + +### Dependency Injection + +**File**: `internal/container/container.go` +**Purpose**: Dependency injection container setup + +**Key Methods**: +- `NewContainer(config *Config) *Container` - Constructor +- `Initialize() error` - Initializes all dependencies +- `GetItemRepository() IItemRepository` - Returns item repository +- `GetUserRepository() IUserRepository` - Returns user repository +- `GetAuthService() IAuthService` - Returns auth service +- `GetOrderService() IOrderService` - Returns order service +- `GetTimeProvider() ITimeProvider` - Returns time provider +- `GetLogger() ILogger` - Returns logger +- `GetAddItemCommand() *AddItemCommand` - Returns add item command +- `GetHandleExpiredItemsCommand() *HandleExpiredItemsCommand` - Returns handle expired items command +- `GetDeleteItemCommand() *DeleteItemCommand` - Returns delete item command +- `GetLoginUserCommand() *LoginUserCommand` - Returns login user command +- `GetGetItemQuery() *GetItemQuery` - Returns get item query +- `GetListItemsQuery() *ListItemsQuery` - Returns list items query +- `GetItemsController() *ItemsController` - Returns items controller +- `GetAuthController() *AuthController` - Returns auth controller +- `GetJWTMiddleware() *JWTMiddleware` - Returns JWT middleware +- `GetExpiredItemsScheduler() *ExpiredItemsScheduler` - Returns expired items scheduler + +### Configuration + +**File**: `internal/config/config.go` +**Purpose**: Application configuration + +**Key Properties**: +- `ServerPort int` - Server port +- `JWTSecret string` - JWT secret key +- `DataDirectory string` - Directory for data files +- `LogLevel string` - Log level +- `SchedulerInterval time.Duration` - Scheduler interval + +**Key Methods**: +- `Load() (*Config, error)` - Loads configuration from environment variables +- `Validate() error` - Validates configuration + +### Main Application + +**File**: `cmd/main.go` +**Purpose**: Application entry point + +**Key Methods**: +- `main()` - Main function +- `setupGracefulShutdown(server *Server, scheduler *ExpiredItemsScheduler)` - Sets up graceful shutdown + +**Flow**: +1. Loads configuration +2. Initializes dependency container +3. Creates server and scheduler +4. Starts scheduler +5. Starts server +6. Sets up graceful shutdown + +## Complete Flow Summary + +### Item Creation Flow +``` +POST /items +├── JWTMiddleware (authentication) +├── CreateItemDTO validation +├── ItemsController.CreateItem() +│ ├── AddItemCommand.Execute() +│ │ ├── ItemEntity constructor (validation) +│ │ ├── ItemExpirationSpec.IsExpired() ← SINGLE SOURCE OF TRUTH +│ │ ├── If expired: OrderHTTPService.OrderItem() +│ │ └── If not expired: ItemRepository.Save() +│ └── ItemResponseDTO transformation +└── HTTP response +``` + +### Expired Items Processing Flow +``` +Scheduler (every minute) +└── ExpiredItemsScheduler.processExpiredItems() + └── HandleExpiredItemsCommand.Execute() + ├── ITimeProvider.Now() + ├── ItemExpirationSpec.GetSpec() ← SINGLE SOURCE OF TRUTH + ├── ItemRepository.FindWhere() (using spec) + ├── For each expired item: + │ ├── OrderHTTPService.OrderItem() + │ └── ItemRepository.Delete() + └── Logging +``` + +### Item Retrieval Flow +``` +GET /items/:id +├── JWTMiddleware (authentication) +├── ItemsController.GetItem() +│ ├── GetItemQuery.Execute() +│ │ ├── ItemRepository.FindByID() +│ │ ├── Ownership validation +│ │ └── Return ItemEntity +│ └── ItemResponseDTO transformation +└── HTTP response +``` + +## Key Design Principles + +1. **Single Source of Truth**: `ItemExpirationSpec` is the only component that determines expiration logic +2. **Clear Flow**: Each component has a well-defined place in the execution chain +3. **Dependency Inversion**: High-level modules don't depend on low-level modules +4. **Separation of Concerns**: Each layer has distinct responsibilities +5. **Testability**: All components can be tested in isolation +6. **Context Propagation**: All methods accept context for cancellation, deadlines, and tracing +7. **Error Handling**: Comprehensive error handling with proper error types +8. **Configuration Management**: Environment-based configuration with validation + +## Go-Specific Best Practices + +1. **Package Organization**: Follow Go's standard package layout with `internal/` for private packages +2. **Error Handling**: Use Go's error handling pattern with proper error wrapping +3. **Context Usage**: Use context for cancellation, deadlines, and request-scoped values +4. **Interface Segregation**: Keep interfaces small and focused +5. **Naming Conventions**: Follow Go's naming conventions (camelCase for exported, camelCase for unexported) +6. **Pointer vs Value**: Use pointers for mutable state and large structs, values for immutable data +7. **Concurrency**: Use goroutines and channels for concurrent operations +8. **Testing**: Write comprehensive tests with table-driven tests +9. **Documentation**: Use Go's documentation comments for all exported types and functions +10. **Dependencies**: Use Go modules for dependency management + +## Directory Structure + +``` +golang/ +├── cmd/ +│ └── main.go # Application entry point +├── internal/ +│ ├── application/ +│ │ ├── commands/ # Command handlers +│ │ ├── queries/ # Query handlers +│ │ ├── dto/ # Data transfer objects +│ │ └── interfaces/ # Application interfaces +│ ├── domain/ +│ │ ├── entities/ # Domain entities +│ │ ├── value_objects/ # Value objects +│ │ └── specifications/ # Domain specifications +│ ├── infrastructure/ +│ │ ├── repositories/ # Repository implementations +│ │ ├── http/ # HTTP services +│ │ ├── auth/ # Authentication services +│ │ ├── time/ # Time provider +│ │ ├── logging/ # Logger implementations +│ │ └── scheduler/ # Background scheduler +│ ├── presentation/ +│ │ ├── controllers/ # HTTP controllers +│ │ ├── middleware/ # HTTP middleware +│ │ └── server/ # HTTP server +│ ├── config/ +│ │ └── config.go # Configuration +│ └── container/ +│ └── container.go # Dependency injection container +├── pkg/ +│ └── specifications/ # Reusable specification patterns +├── data/ # Data files (JSON) +├── docker/ +│ ├── docker-compose.yml # Docker compose +│ └── Dockerfile # Docker image +├── go.mod # Go module file +├── go.sum # Go module checksums +└── README.md # Go implementation README +``` + +This implementation plan ensures consistent development regardless of the implementer, providing clear flow definitions and emphasizing `ItemExpirationSpec` as the centralized source for expiration logic, while following Go best practices and idioms. \ No newline at end of file diff --git a/golang/SPEC_DETAILS.md b/golang/SPEC_DETAILS.md new file mode 100644 index 0000000..93399c2 --- /dev/null +++ b/golang/SPEC_DETAILS.md @@ -0,0 +1,262 @@ +# Specification Pattern in Go - Detailed Explanation + +## What is the Specification Pattern? + +The Specification Pattern is a design pattern that allows you to encapsulate business rules and domain logic in reusable, composable objects. Instead of scattering business rules throughout your codebase, you create specification objects that represent individual rules that can be combined using logical operations (AND, OR, NOT). + +## Why Use It? + +1. **Single Source of Truth**: Business rules are defined in one place +2. **Reusability**: Rules can be reused across different parts of the application +3. **Composability**: Simple rules can be combined to create complex rules +4. **Testability**: Each specification can be tested in isolation +5. **Flexibility**: Easy to modify or extend business rules + +## How It Works in Our Go Implementation + +### Core Components + +#### 1. Specification Interface +```go +type Specification[T any] interface { + IsSatisfiedBy(candidate T) bool + And(other Specification[T]) Specification[T] + Or(other Specification[T]) Specification[T] + Not() Specification[T] + GetConditions() []Condition + GetSpec() *Spec +} +``` + +This interface defines the contract that all specifications must implement. The generic type `T` allows specifications to work with any type of object. + +#### 2. SimpleSpecification - The Workhorse +```go +type SimpleSpecification[T any] struct { + spec *Spec +} +``` + +This is the main implementation that evaluates conditions against objects using reflection. + +#### 3. Condition Structure +```go +type Condition struct { + Field string // Field name to check (e.g., "expirationDate") + Operator string // Comparison operator (e.g., "<=", "==", ">") + Value interface{} // Value to compare against +} +``` + +#### 4. Spec Structure - The Building Block +```go +type Spec struct { + Condition *Condition // Single condition + LogicalGroup *LogicalGroup // Group of conditions (AND/OR/NOT) +} +``` + +### How It Evaluates Conditions + +The magic happens in the `getFieldValue` method. When you specify a condition like `Lte("expirationDate", currentTime)`, the system needs to: + +1. **Find the field**: Look for a getter method like `ExpirationDate()` or `GetExpirationDate()` +2. **Extract the value**: Call the method to get the actual value +3. **Compare values**: Use the appropriate comparison based on the operator + +#### Field Resolution Strategy +```go +func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { + // 1. Try "GetFieldName" method first + getterName := "Get" + strings.Title(fieldName) + method := v.MethodByName(getterName) + + // 2. Try field name as method (e.g., "ExpirationDate") + method = v.MethodByName(fieldName) + + // 3. Try direct field access (for exported fields only) + field := v.FieldByName(fieldName) + if field.IsValid() && field.CanInterface() { + return field.Interface(), nil + } +} +``` + +This approach handles different naming conventions and respects Go's visibility rules. + +### Logical Operations + +#### AND Operation +```go +func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { + if otherSpec, ok := other.(*SimpleSpecification[T]); ok { + // Both are SimpleSpecifications - combine their specs + return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec)) + } + + // Different types - create a composite specification + return &CompositeSpecification[T]{ + left: s, + right: other, + op: "AND", + } +} +``` + +#### CompositeSpecification - Handling Mixed Types +When you try to combine different specification types, instead of panicking, we create a `CompositeSpecification` that: + +1. **Stores both specifications**: Keeps references to both left and right specs +2. **Evaluates independently**: Calls `IsSatisfiedBy` on each specification +3. **Combines results**: Applies the logical operation (AND/OR) to the results + +```go +func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { + switch c.op { + case "AND": + return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) + case "OR": + return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) + } +} +``` + +### Building Specifications + +We provide convenient helper functions that mirror the PHP/NestJS implementations: + +```go +// Simple conditions +spec := Eq("name", "Apple") // name == "Apple" +spec := Gt("price", 10.0) // price > 10.0 +spec := Lte("expirationDate", time) // expirationDate <= time + +// Logical combinations +spec := And( + Eq("status", "active"), + Or( + Gt("score", 80), + In("role", []interface{}{"admin", "moderator"}) + ), + Not(Eq("deleted", true)) +) +``` + +### Real-World Usage: ItemExpirationSpec + +Here's how we use it to check if items are expired: + +```go +func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool { + return s.GetSpec(currentTime).IsSatisfiedBy(item) +} + +func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] { + // Create condition: expirationDate <= currentTime + spec := Lte("expirationDate", currentTime) + return NewSimpleSpecification[*entities.ItemEntity](spec) +} +``` + +### Repository Integration + +The specification integrates seamlessly with repositories: + +```go +func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + var filteredItems []*entities.ItemEntity + for _, item := range items { + if spec.IsSatisfiedBy(item) { + filteredItems = append(filteredItems, item) + } + } + return filteredItems, nil +} +``` + +## Key Benefits of This Implementation + +### 1. Type Safety +Using generics (`Specification[T any]`) ensures type safety at compile time. + +### 2. Flexibility +- Supports different field access patterns (getters, direct fields) +- Handles various data types (strings, numbers, dates, etc.) +- Allows complex nested conditions + +### 3. No Panic Zone +This implementation gracefully handles mixed types using `CompositeSpecification`. + +### 4. Reflection Safety +The field resolution respects Go's visibility rules and provides clear error messages when fields can't be accessed. + +### 5. Performance +- Caches reflection information when possible +- Short-circuits evaluation (stops at first false in AND, first true in OR) +- Minimal overhead for simple conditions + +## Common Patterns and Examples + +### Checking Expired Items +```go +expirationSpec := NewItemExpirationSpec() +isExpired := expirationSpec.IsExpired(item, time.Now()) +``` + +### Finding Active Users +```go +activeUserSpec := And( + Eq("status", "active"), + Gte("lastLogin", thirtyDaysAgo), + Not(Eq("role", "banned")) +) +``` + +### Complex Business Rules +```go +premiumCustomerSpec := Or( + And( + Eq("subscriptionType", "premium"), + Gte("subscriptionEndDate", time.Now()), + ), + And( + Eq("totalPurchases", 1000), + Gte("customerSince", oneYearAgo), + ), +) +``` + +## Troubleshooting Common Issues + +### 1. Field Not Found +**Error**: `field expirationDate not found or not accessible` +**Solution**: Ensure your entity has a getter method like `ExpirationDate()` or `GetExpirationDate()` + +### 2. Type Mismatch +**Error**: Comparison fails between different types +**Solution**: The system tries to convert types automatically, but ensure your condition values match the expected field types + +### 3. Performance Concerns +**Issue**: Reflection is slower than direct field access +**Solution**: For performance-critical paths, consider implementing custom specifications or caching results + +## Best Practices + +1. **Use getter methods**: Prefer `GetFieldName()` or `FieldName()` methods over direct field access +2. **Keep conditions simple**: Complex business logic should be in domain services, not specifications +3. **Test specifications**: Write unit tests for your specifications to ensure they work correctly +4. **Document business rules**: Add comments explaining what each specification represents +5. **Reuse specifications**: Create specification factories for commonly used rules + +## Comparison with PHP/NestJS + +Our Go implementation maintains the same API surface as the PHP and NestJS versions: + +- **Same helper functions**: `Eq()`, `Gt()`, `Lte()`, `And()`, `Or()`, `Not()` +- **Same condition structure**: `[field, operator, value]` +- **Same logical operations**: AND, OR, NOT combinations +- **Same usage patterns**: Build specs, then call `IsSatisfiedBy()` + +The main difference is that Go's static typing and lack of runtime metadata requires more sophisticated reflection handling, which we've encapsulated in the `getFieldValue` method. + +This implementation provides a robust, production-ready specification pattern that follows Go best practices while maintaining compatibility with the PHP and NestJS implementations. \ No newline at end of file diff --git a/golang/cmd/main.go b/golang/cmd/main.go new file mode 100644 index 0000000..e800c0f --- /dev/null +++ b/golang/cmd/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "autostore/internal/application/interfaces" + "autostore/internal/config" + "autostore/internal/container" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Validate configuration + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Create dependency injection container + container := container.NewContainer(cfg) + if err := container.Initialize(); err != nil { + log.Fatalf("Failed to initialize container: %v", err) + } + + // Get server and scheduler from container + server := container.GetServer() + scheduler := container.GetExpiredItemsScheduler() + logger := container.GetLogger() + + // Setup graceful shutdown + shutdownComplete := make(chan struct{}) + go setupGracefulShutdown(server, scheduler, logger, shutdownComplete) + + // Start scheduler + if err := scheduler.Start(context.Background()); err != nil { + logger.Error(context.Background(), "Failed to start scheduler", "error", err) + log.Fatalf("Failed to start scheduler: %v", err) + } + + // Start server + if err := server.Start(); err != nil { + if err == http.ErrServerClosed { + // This is expected during graceful shutdown + logger.Info(context.Background(), "Server shutdown complete") + } else { + logger.Error(context.Background(), "Server failed to start", "error", err) + log.Fatalf("Server failed to start: %v", err) + } + } + + // Wait for graceful shutdown to complete + <-shutdownComplete + logger.Info(context.Background(), "Application exiting gracefully") +} + +func setupGracefulShutdown(server interface { + Shutdown(ctx context.Context) error +}, scheduler interface { + Stop() error +}, logger interfaces.ILogger, shutdownComplete chan struct{}) { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + sig := <-sigChan + logger.Info(context.Background(), "Received shutdown signal", "signal", sig) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := scheduler.Stop(); err != nil { + logger.Error(ctx, "Scheduler shutdown failed", "error", err) + } else { + logger.Info(ctx, "Scheduler shutdown completed gracefully") + } + + if err := server.Shutdown(ctx); err != nil { + logger.Error(ctx, "Server shutdown failed", "error", err) + } else { + logger.Info(ctx, "Server shutdown completed gracefully") + } + + // Signal that shutdown is complete + close(shutdownComplete) +} diff --git a/golang/docker/.dockerignore b/golang/docker/.dockerignore new file mode 100644 index 0000000..2fc46a7 --- /dev/null +++ b/golang/docker/.dockerignore @@ -0,0 +1,4 @@ +.devcontainer +.git +.gitignore +README.md \ No newline at end of file diff --git a/golang/docker/Dockerfile b/golang/docker/Dockerfile new file mode 100644 index 0000000..7ce9809 --- /dev/null +++ b/golang/docker/Dockerfile @@ -0,0 +1,29 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ + +# Download all dependencies +RUN go mod download + +COPY . . + +# Generate go.sum +RUN go mod tidy + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd + +# Runtime stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +COPY --from=builder /app/main . + +EXPOSE 3000 + +CMD ["./main"] \ No newline at end of file diff --git a/golang/docker/docker-compose.yml b/golang/docker/docker-compose.yml new file mode 100644 index 0000000..dcdf3c6 --- /dev/null +++ b/golang/docker/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" +services: + app: + build: + context: .. + dockerfile: docker/Dockerfile + image: golang-autostore-img + container_name: golang-autostore-app + ports: + - "50080:3000" + networks: + - app-network + restart: unless-stopped + +networks: + app-network: + driver: bridge \ No newline at end of file diff --git a/golang/go.sum b/golang/go.sum new file mode 100644 index 0000000..5799e1b --- /dev/null +++ b/golang/go.sum @@ -0,0 +1,90 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/golang/internal/application/commands/add_item_command.go b/golang/internal/application/commands/add_item_command.go new file mode 100644 index 0000000..656acc2 --- /dev/null +++ b/golang/internal/application/commands/add_item_command.go @@ -0,0 +1,97 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "time" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" + "autostore/internal/domain/value_objects" +) + +var ( + ErrItemCreationFailed = errors.New("failed to create item") + ErrInvalidUserID = errors.New("invalid user ID") +) + +type AddItemCommand struct { + itemRepo interfaces.IItemRepository + orderService interfaces.IOrderService + timeProvider interfaces.ITimeProvider + expirationSpec *specifications.ItemExpirationSpec + logger interfaces.ILogger +} + +func NewAddItemCommand( + itemRepo interfaces.IItemRepository, + orderService interfaces.IOrderService, + timeProvider interfaces.ITimeProvider, + expirationSpec *specifications.ItemExpirationSpec, + logger interfaces.ILogger, +) *AddItemCommand { + return &AddItemCommand{ + itemRepo: itemRepo, + orderService: orderService, + timeProvider: timeProvider, + expirationSpec: expirationSpec, + logger: logger, + } +} + +func (c *AddItemCommand) Execute(ctx context.Context, name string, expirationDate time.Time, orderURL string, userID string) (string, error) { + c.logger.Info(ctx, "Executing AddItemCommand", "name", name, "userID", userID) + + // Convert string IDs to value objects + itemID, err := value_objects.NewRandomItemID() + if err != nil { + c.logger.Error(ctx, "Failed to generate item ID", "error", err) + return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) + } + + userIDObj, err := value_objects.NewUserID(userID) + if err != nil { + c.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) + return "", fmt.Errorf("%w: %v", ErrInvalidUserID, err) + } + + // Create expiration date value object + expirationDateObj, err := value_objects.NewExpirationDate(expirationDate) + if err != nil { + c.logger.Error(ctx, "Invalid expiration date", "expirationDate", expirationDate, "error", err) + return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) + } + + // Create item entity + item, err := entities.NewItem(itemID, name, expirationDateObj, orderURL, userIDObj) + if err != nil { + c.logger.Error(ctx, "Failed to create item entity", "error", err) + return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) + } + + // Get current time and check if item is expired + currentTime := c.timeProvider.Now() + isExpired := c.expirationSpec.IsExpired(item, currentTime) + + // Save the item first (even if expired, as per business rule #4) + if err := c.itemRepo.Save(ctx, item); err != nil { + c.logger.Error(ctx, "Failed to save item", "itemID", itemID.String(), "error", err) + return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) + } + + if isExpired { + c.logger.Info(ctx, "Item is expired, triggering order", "itemID", itemID.String()) + + // Business rule: When an item expires, a new item of the same type is automatically ordered + if err := c.orderService.OrderItem(ctx, item); err != nil { + c.logger.Error(ctx, "Failed to order expired item", "itemID", itemID.String(), "error", err) + // Don't fail the entire operation if ordering fails, just log it + c.logger.Warn(ctx, "Item created but ordering failed", "itemID", itemID.String(), "error", err) + } + } + + c.logger.Info(ctx, "Item created successfully", "itemID", itemID.String()) + return itemID.String(), nil +} diff --git a/golang/internal/application/commands/delete_item_command.go b/golang/internal/application/commands/delete_item_command.go new file mode 100644 index 0000000..ad2efa3 --- /dev/null +++ b/golang/internal/application/commands/delete_item_command.go @@ -0,0 +1,69 @@ +package commands + +import ( + "context" + "fmt" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/value_objects" +) + +var ( + ErrItemDeletionFailed = fmt.Errorf("failed to delete item") + ErrItemNotFound = fmt.Errorf("item not found") + ErrUnauthorizedAccess = fmt.Errorf("unauthorized access to item") +) + +type DeleteItemCommand struct { + itemRepo interfaces.IItemRepository + logger interfaces.ILogger +} + +func NewDeleteItemCommand( + itemRepo interfaces.IItemRepository, + logger interfaces.ILogger, +) *DeleteItemCommand { + return &DeleteItemCommand{ + itemRepo: itemRepo, + logger: logger, + } +} + +func (c *DeleteItemCommand) Execute(ctx context.Context, itemID string, userID string) error { + c.logger.Info(ctx, "Executing DeleteItemCommand", "itemID", itemID, "userID", userID) + + // Convert string IDs to value objects + itemIDObj, err := value_objects.NewItemID(itemID) + if err != nil { + c.logger.Error(ctx, "Invalid item ID", "itemID", itemID, "error", err) + return fmt.Errorf("invalid item ID: %w", err) + } + + userIDObj, err := value_objects.NewUserID(userID) + if err != nil { + c.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) + return fmt.Errorf("invalid user ID: %w", err) + } + + // Find item by ID + item, err := c.itemRepo.FindByID(ctx, itemIDObj) + if err != nil { + c.logger.Error(ctx, "Failed to find item", "itemID", itemID, "error", err) + return fmt.Errorf("%w: %v", ErrItemNotFound, err) + } + + // Validate ownership - only the item's owner can delete it + if !item.GetUserID().Equals(userIDObj) { + c.logger.Warn(ctx, "Unauthorized deletion attempt", "itemID", itemID, "userID", userID, "ownerID", item.GetUserID().String()) + return ErrUnauthorizedAccess + } + + // Delete the item + if err := c.itemRepo.Delete(ctx, itemIDObj); err != nil { + c.logger.Error(ctx, "Failed to delete item", "itemID", itemID, "error", err) + return fmt.Errorf("%w: %v", ErrItemDeletionFailed, err) + } + + c.logger.Info(ctx, "Item deleted successfully", "itemID", itemID, "userID", userID) + return nil +} diff --git a/golang/internal/application/commands/handle_expired_items_command.go b/golang/internal/application/commands/handle_expired_items_command.go new file mode 100644 index 0000000..f783c6d --- /dev/null +++ b/golang/internal/application/commands/handle_expired_items_command.go @@ -0,0 +1,67 @@ +package commands + +import ( + "context" + "fmt" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/specifications" +) + +type HandleExpiredItemsCommand struct { + itemRepo interfaces.IItemRepository + orderService interfaces.IOrderService + timeProvider interfaces.ITimeProvider + expirationSpec *specifications.ItemExpirationSpec + logger interfaces.ILogger +} + +func NewHandleExpiredItemsCommand( + itemRepo interfaces.IItemRepository, + orderService interfaces.IOrderService, + timeProvider interfaces.ITimeProvider, + expirationSpec *specifications.ItemExpirationSpec, + logger interfaces.ILogger, +) *HandleExpiredItemsCommand { + return &HandleExpiredItemsCommand{ + itemRepo: itemRepo, + orderService: orderService, + timeProvider: timeProvider, + expirationSpec: expirationSpec, + logger: logger, + } +} + +func (c *HandleExpiredItemsCommand) Execute(ctx context.Context) error { + c.logger.Info(ctx, "Starting expired items processing") + + currentTime := c.timeProvider.Now() + expirationSpec := c.expirationSpec.GetSpec(currentTime) + + expiredItems, err := c.itemRepo.FindWhere(ctx, expirationSpec) + if err != nil { + c.logger.Error(ctx, "Failed to find expired items", "error", err) + return fmt.Errorf("failed to find expired items: %w", err) + } + + c.logger.Info(ctx, "Found expired items", "count", len(expiredItems)) + + for _, item := range expiredItems { + c.logger.Info(ctx, "Processing expired item", "item_id", item.GetID().String(), "item_name", item.GetName()) + + if err := c.orderService.OrderItem(ctx, item); err != nil { + c.logger.Error(ctx, "Failed to order replacement item", "item_id", item.GetID().String(), "error", err) + continue + } + + if err := c.itemRepo.Delete(ctx, item.GetID()); err != nil { + c.logger.Error(ctx, "Failed to delete expired item", "item_id", item.GetID().String(), "error", err) + return fmt.Errorf("failed to delete expired item %s: %w", item.GetID().String(), err) + } + + c.logger.Info(ctx, "Successfully processed expired item", "item_id", item.GetID().String()) + } + + c.logger.Info(ctx, "Completed expired items processing", "processed_count", len(expiredItems)) + return nil +} \ No newline at end of file diff --git a/golang/internal/application/commands/login_user_command.go b/golang/internal/application/commands/login_user_command.go new file mode 100644 index 0000000..ead75af --- /dev/null +++ b/golang/internal/application/commands/login_user_command.go @@ -0,0 +1,35 @@ +package commands + +import ( + "context" + + "autostore/internal/application/interfaces" +) + +type LoginUserCommand struct { + authService interfaces.IAuthService + logger interfaces.ILogger +} + +func NewLoginUserCommand( + authService interfaces.IAuthService, + logger interfaces.ILogger, +) *LoginUserCommand { + return &LoginUserCommand{ + authService: authService, + logger: logger, + } +} + +func (c *LoginUserCommand) Execute(ctx context.Context, username string, password string) (string, error) { + c.logger.Info(ctx, "Executing login command", "username", username) + + token, err := c.authService.Authenticate(ctx, username, password) + if err != nil { + c.logger.Warn(ctx, "Authentication failed", "username", username, "error", err) + return "", err + } + + c.logger.Info(ctx, "Login successful", "username", username) + return token, nil +} diff --git a/golang/internal/application/dto/create_item_dto.go b/golang/internal/application/dto/create_item_dto.go new file mode 100644 index 0000000..50c2256 --- /dev/null +++ b/golang/internal/application/dto/create_item_dto.go @@ -0,0 +1,34 @@ +package dto + +import ( + "errors" + "net/url" +) + +var ( + ErrInvalidItemName = errors.New("item name cannot be empty") + ErrInvalidOrderURL = errors.New("invalid order URL format") + ErrInvalidExpirationDate = errors.New("invalid expiration date") +) + +type CreateItemDTO struct { + Name string `json:"name" binding:"required"` + ExpirationDate JSONTime `json:"expirationDate" binding:"required"` + OrderURL string `json:"orderUrl" binding:"required"` +} + +func (dto *CreateItemDTO) Validate() error { + if dto.Name == "" { + return ErrInvalidItemName + } + + if _, err := url.ParseRequestURI(dto.OrderURL); err != nil { + return ErrInvalidOrderURL + } + + if dto.ExpirationDate.Time.IsZero() { + return ErrInvalidExpirationDate + } + + return nil +} \ No newline at end of file diff --git a/golang/internal/application/dto/item_response_dto.go b/golang/internal/application/dto/item_response_dto.go new file mode 100644 index 0000000..9874707 --- /dev/null +++ b/golang/internal/application/dto/item_response_dto.go @@ -0,0 +1,25 @@ +package dto + +import ( + "autostore/internal/domain/entities" +) + +type ItemResponseDTO struct { + ID string `json:"id"` + Name string `json:"name"` + ExpirationDate JSONTime `json:"expirationDate"` + OrderURL string `json:"orderUrl"` + UserID string `json:"userId"` + CreatedAt JSONTime `json:"createdAt"` +} + +func (dto *ItemResponseDTO) FromEntity(item *entities.ItemEntity) *ItemResponseDTO { + return &ItemResponseDTO{ + ID: item.GetID().String(), + Name: item.GetName(), + ExpirationDate: JSONTime{item.GetExpirationDate().Time()}, + OrderURL: item.GetOrderURL(), + UserID: item.GetUserID().String(), + CreatedAt: JSONTime{item.GetCreatedAt()}, + } +} \ No newline at end of file diff --git a/golang/internal/application/dto/jsend_response.go b/golang/internal/application/dto/jsend_response.go new file mode 100644 index 0000000..6570e69 --- /dev/null +++ b/golang/internal/application/dto/jsend_response.go @@ -0,0 +1,31 @@ +package dto + +type JSendResponse struct { + Status string `json:"status"` + Data interface{} `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Code int `json:"code,omitempty"` +} + +func JSendSuccess(data interface{}) JSendResponse { + return JSendResponse{ + Status: "success", + Data: data, + } +} + +func JSendError(message string, code int) JSendResponse { + return JSendResponse{ + Status: "error", + Message: message, + Code: code, + } +} + +func JSendFail(message string, code int) JSendResponse { + return JSendResponse{ + Status: "fail", + Message: message, + Code: code, + } +} \ No newline at end of file diff --git a/golang/internal/application/dto/json_time.go b/golang/internal/application/dto/json_time.go new file mode 100644 index 0000000..5f85fe6 --- /dev/null +++ b/golang/internal/application/dto/json_time.go @@ -0,0 +1,50 @@ +package dto + +import ( + "encoding/json" + "fmt" + "time" +) + +// JSONTime is a custom time type that can unmarshal from JSON strings +type JSONTime struct { + time.Time +} + +// UnmarshalJSON implements json.Unmarshaler interface +func (jt *JSONTime) UnmarshalJSON(data []byte) error { + // Remove quotes from JSON string + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + + // Try to parse with different formats + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05.000000Z", + "2006-01-02T15:04:05.000000000Z", + } + + for _, format := range formats { + if t, err := time.Parse(format, str); err == nil { + jt.Time = t + return nil + } + } + + return fmt.Errorf("unable to parse time: %s", str) +} + +// MarshalJSON implements json.Marshaler interface +func (jt JSONTime) MarshalJSON() ([]byte, error) { + return json.Marshal(jt.Time.Format(time.RFC3339)) +} + +// String returns the time in RFC3339 format +func (jt JSONTime) String() string { + return jt.Time.Format(time.RFC3339) +} \ No newline at end of file diff --git a/golang/internal/application/dto/login_dto.go b/golang/internal/application/dto/login_dto.go new file mode 100644 index 0000000..ca60819 --- /dev/null +++ b/golang/internal/application/dto/login_dto.go @@ -0,0 +1,33 @@ +package dto + +import ( + "errors" +) + +var ( + ErrInvalidUsername = errors.New("username cannot be empty") + ErrInvalidPassword = errors.New("password cannot be empty") +) + +type LoginDTO struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func (dto *LoginDTO) Validate() error { + if dto.Username == "" { + return ErrInvalidUsername + } + + if dto.Password == "" { + return ErrInvalidPassword + } + + return nil +} + +type LoginResponseDTO struct { + Token string `json:"token"` + TokenType string `json:"tokenType"` + ExpiresIn int `json:"expiresIn"` +} \ No newline at end of file diff --git a/golang/internal/application/errors/errors.go b/golang/internal/application/errors/errors.go new file mode 100644 index 0000000..ee62dc1 --- /dev/null +++ b/golang/internal/application/errors/errors.go @@ -0,0 +1,5 @@ +package errors + +import "errors" + +var ErrNotImplemented = errors.New("not implemented") \ No newline at end of file diff --git a/golang/internal/application/interfaces/auth_service.go b/golang/internal/application/interfaces/auth_service.go new file mode 100644 index 0000000..2d8503a --- /dev/null +++ b/golang/internal/application/interfaces/auth_service.go @@ -0,0 +1,11 @@ +package interfaces + +import ( + "context" +) + +type IAuthService interface { + Authenticate(ctx context.Context, username string, password string) (string, error) + ValidateToken(ctx context.Context, token string) (bool, error) + GetUserIDFromToken(ctx context.Context, token string) (string, error) +} \ No newline at end of file diff --git a/golang/internal/application/interfaces/item_repository.go b/golang/internal/application/interfaces/item_repository.go new file mode 100644 index 0000000..cd74a5e --- /dev/null +++ b/golang/internal/application/interfaces/item_repository.go @@ -0,0 +1,17 @@ +package interfaces + +import ( + "context" + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" + "autostore/internal/domain/value_objects" +) + +type IItemRepository interface { + Save(ctx context.Context, item *entities.ItemEntity) error + FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) + FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) + FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) + Delete(ctx context.Context, id value_objects.ItemID) error + Exists(ctx context.Context, id value_objects.ItemID) (bool, error) +} \ No newline at end of file diff --git a/golang/internal/application/interfaces/logger.go b/golang/internal/application/interfaces/logger.go new file mode 100644 index 0000000..29fdf9f --- /dev/null +++ b/golang/internal/application/interfaces/logger.go @@ -0,0 +1,12 @@ +package interfaces + +import ( + "context" +) + +type ILogger interface { + Info(ctx context.Context, msg string, fields ...interface{}) + Error(ctx context.Context, msg string, fields ...interface{}) + Debug(ctx context.Context, msg string, fields ...interface{}) + Warn(ctx context.Context, msg string, fields ...interface{}) +} \ No newline at end of file diff --git a/golang/internal/application/interfaces/order_service.go b/golang/internal/application/interfaces/order_service.go new file mode 100644 index 0000000..c830995 --- /dev/null +++ b/golang/internal/application/interfaces/order_service.go @@ -0,0 +1,10 @@ +package interfaces + +import ( + "context" + "autostore/internal/domain/entities" +) + +type IOrderService interface { + OrderItem(ctx context.Context, item *entities.ItemEntity) error +} \ No newline at end of file diff --git a/golang/internal/application/interfaces/time_provider.go b/golang/internal/application/interfaces/time_provider.go new file mode 100644 index 0000000..fc68e16 --- /dev/null +++ b/golang/internal/application/interfaces/time_provider.go @@ -0,0 +1,9 @@ +package interfaces + +import ( + "time" +) + +type ITimeProvider interface { + Now() time.Time +} \ No newline at end of file diff --git a/golang/internal/application/interfaces/user_repository.go b/golang/internal/application/interfaces/user_repository.go new file mode 100644 index 0000000..feba817 --- /dev/null +++ b/golang/internal/application/interfaces/user_repository.go @@ -0,0 +1,13 @@ +package interfaces + +import ( + "context" + "autostore/internal/domain/entities" + "autostore/internal/domain/value_objects" +) + +type IUserRepository interface { + FindByUsername(ctx context.Context, username string) (*entities.UserEntity, error) + FindByID(ctx context.Context, id value_objects.UserID) (*entities.UserEntity, error) + Save(ctx context.Context, user *entities.UserEntity) error +} \ No newline at end of file diff --git a/golang/internal/application/queries/get_item_query.go b/golang/internal/application/queries/get_item_query.go new file mode 100644 index 0000000..9eb7ed3 --- /dev/null +++ b/golang/internal/application/queries/get_item_query.go @@ -0,0 +1,63 @@ +package queries + +import ( + "context" + "fmt" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" + "autostore/internal/domain/value_objects" +) + +var ( + ErrItemNotFound = fmt.Errorf("item not found") + ErrUnauthorizedAccess = fmt.Errorf("unauthorized access to item") +) + +type GetItemQuery struct { + itemRepo interfaces.IItemRepository + logger interfaces.ILogger +} + +func NewGetItemQuery( + itemRepo interfaces.IItemRepository, + logger interfaces.ILogger, +) *GetItemQuery { + return &GetItemQuery{ + itemRepo: itemRepo, + logger: logger, + } +} + +func (q *GetItemQuery) Execute(ctx context.Context, itemID string, userID string) (*entities.ItemEntity, error) { + q.logger.Info(ctx, "Executing GetItemQuery", "itemID", itemID, "userID", userID) + + // Convert string IDs to value objects + itemIDObj, err := value_objects.NewItemID(itemID) + if err != nil { + q.logger.Error(ctx, "Invalid item ID", "itemID", itemID, "error", err) + return nil, fmt.Errorf("invalid item ID: %w", err) + } + + userIDObj, err := value_objects.NewUserID(userID) + if err != nil { + q.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) + return nil, fmt.Errorf("invalid user ID: %w", err) + } + + // Find item by ID + item, err := q.itemRepo.FindByID(ctx, itemIDObj) + if err != nil { + q.logger.Error(ctx, "Failed to find item", "itemID", itemID, "error", err) + return nil, fmt.Errorf("%w: %v", ErrItemNotFound, err) + } + + // Validate ownership - only the item's owner can access it + if !item.GetUserID().Equals(userIDObj) { + q.logger.Warn(ctx, "Unauthorized access attempt", "itemID", itemID, "userID", userID, "ownerID", item.GetUserID().String()) + return nil, ErrUnauthorizedAccess + } + + q.logger.Info(ctx, "Item retrieved successfully", "itemID", itemID, "userID", userID) + return item, nil +} diff --git a/golang/internal/application/queries/list_items_query.go b/golang/internal/application/queries/list_items_query.go new file mode 100644 index 0000000..571f8ff --- /dev/null +++ b/golang/internal/application/queries/list_items_query.go @@ -0,0 +1,50 @@ +package queries + +import ( + "context" + "fmt" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" + "autostore/internal/domain/value_objects" +) + +var ( + ErrFailedToListItems = fmt.Errorf("failed to list items") +) + +type ListItemsQuery struct { + itemRepo interfaces.IItemRepository + logger interfaces.ILogger +} + +func NewListItemsQuery( + itemRepo interfaces.IItemRepository, + logger interfaces.ILogger, +) *ListItemsQuery { + return &ListItemsQuery{ + itemRepo: itemRepo, + logger: logger, + } +} + +func (q *ListItemsQuery) Execute(ctx context.Context, userID string) ([]*entities.ItemEntity, error) { + q.logger.Info(ctx, "Executing ListItemsQuery", "userID", userID) + + // Convert string ID to value object + userIDObj, err := value_objects.NewUserID(userID) + if err != nil { + q.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) + return nil, fmt.Errorf("invalid user ID: %w", err) + } + + // Find all items for the user + items, err := q.itemRepo.FindByUserID(ctx, userIDObj) + if err != nil { + q.logger.Error(ctx, "Failed to list items for user", "userID", userID, "error", err) + return nil, fmt.Errorf("%w: %v", ErrFailedToListItems, err) + } + + q.logger.Info(ctx, "Items listed successfully", "userID", userID, "count", len(items)) + return items, nil +} diff --git a/golang/internal/config/config.go b/golang/internal/config/config.go new file mode 100644 index 0000000..eeab9ea --- /dev/null +++ b/golang/internal/config/config.go @@ -0,0 +1,67 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + ServerPort int `env:"SERVER_PORT" envDefault:"3000"` + JWTSecret string `env:"JWT_SECRET" envDefault:"your-secret-key"` + DataDirectory string `env:"DATA_DIRECTORY" envDefault:"./data"` + LogLevel string `env:"LOG_LEVEL" envDefault:"info"` + SchedulerInterval time.Duration `env:"SCHEDULER_INTERVAL" envDefault:"1m"` + ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"30s"` + WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"` + ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"30s"` +} + +func Load() (*Config, error) { + cfg := &Config{ + ServerPort: getEnvAsInt("SERVER_PORT", 3000), + JWTSecret: getEnvAsString("JWT_SECRET", "your-secret-key"), + DataDirectory: getEnvAsString("DATA_DIRECTORY", "./data"), + LogLevel: getEnvAsString("LOG_LEVEL", "info"), + SchedulerInterval: getEnvAsDuration("SCHEDULER_INTERVAL", "1m"), + ReadTimeout: getEnvAsDuration("READ_TIMEOUT", "30s"), + WriteTimeout: getEnvAsDuration("WRITE_TIMEOUT", "30s"), + ShutdownTimeout: getEnvAsDuration("SHUTDOWN_TIMEOUT", "30s"), + } + + return cfg, nil +} + +func (c *Config) Validate() error { + // For now, we'll keep it simple and not add validation + // In a real application, you would validate the configuration here + return nil +} + +func getEnvAsString(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int) int { + if value, exists := os.LookupEnv(key); exists { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +func getEnvAsDuration(key string, defaultValue string) time.Duration { + if value, exists := os.LookupEnv(key); exists { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + if duration, err := time.ParseDuration(defaultValue); err == nil { + return duration + } + return time.Minute // Default to 1 minute if parsing fails +} diff --git a/golang/internal/container/container.go b/golang/internal/container/container.go new file mode 100644 index 0000000..da1f74a --- /dev/null +++ b/golang/internal/container/container.go @@ -0,0 +1,174 @@ +package container + +import ( + "context" + "os" + + "autostore/internal/application/commands" + "autostore/internal/application/interfaces" + "autostore/internal/application/queries" + "autostore/internal/config" + "autostore/internal/domain/specifications" + "autostore/internal/infrastructure/auth" + "autostore/internal/infrastructure/http" + "autostore/internal/infrastructure/logging" + "autostore/internal/infrastructure/repositories" + "autostore/internal/infrastructure/scheduler" + "autostore/internal/infrastructure/services" + "autostore/internal/infrastructure/time" + "autostore/internal/presentation/controllers" + "autostore/internal/presentation/middleware" + "autostore/internal/presentation/server" +) +type Container struct { + config *config.Config + + // Infrastructure + logger interfaces.ILogger + timeProvider interfaces.ITimeProvider + authService interfaces.IAuthService + expiredItemsScheduler *scheduler.ExpiredItemsScheduler + + // Domain + expirationSpec *specifications.ItemExpirationSpec + + // Application + addItemCommand *commands.AddItemCommand + deleteItemCommand *commands.DeleteItemCommand + handleExpiredItemsCommand *commands.HandleExpiredItemsCommand + loginUserCommand *commands.LoginUserCommand + getItemQuery *queries.GetItemQuery + listItemsQuery *queries.ListItemsQuery + + + // Presentation + itemsController *controllers.ItemsController + authController *controllers.AuthController + jwtMiddleware *middleware.JWTMiddleware + server *server.Server +} + + +func NewContainer(config *config.Config) *Container { + return &Container{ + config: config, + } +} + +func (c *Container) Initialize() error { + // Initialize infrastructure + c.logger = logging.NewStandardLogger(os.Stdout) + c.timeProvider = time.NewSystemTimeProvider() + + // Initialize user repository + userRepository := repositories.NewFileUserRepository(c.config.DataDirectory, c.logger) + + // Initialize user initialization service and create default users + userInitService := services.NewUserInitializationService(userRepository, c.logger) + if err := userInitService.InitializeDefaultUsers(); err != nil { + c.logger.Error(context.Background(), "Failed to initialize default users", "error", err) + // Continue even if user initialization fails + } + + // Initialize auth service with user repository + c.authService = auth.NewJWTAuthService(userRepository, c.config.JWTSecret, c.logger) + + // Initialize domain + c.expirationSpec = specifications.NewItemExpirationSpec() + + // Initialize item repository + itemRepository := repositories.NewFileItemRepository(c.config.DataDirectory, c.logger) + + // Initialize order service + orderService := http.NewOrderURLHttpClient(c.logger) + + // Initialize application + c.addItemCommand = commands.NewAddItemCommand(itemRepository, orderService, c.timeProvider, c.expirationSpec, c.logger) + c.deleteItemCommand = commands.NewDeleteItemCommand(itemRepository, c.logger) + c.handleExpiredItemsCommand = commands.NewHandleExpiredItemsCommand(itemRepository, orderService, c.timeProvider, c.expirationSpec, c.logger) + c.loginUserCommand = commands.NewLoginUserCommand(c.authService, c.logger) + c.getItemQuery = queries.NewGetItemQuery(itemRepository, c.logger) + c.listItemsQuery = queries.NewListItemsQuery(itemRepository, c.logger) + + // Initialize scheduler + c.expiredItemsScheduler = scheduler.NewExpiredItemsScheduler(c.handleExpiredItemsCommand, c.logger) + + // Initialize presentation + c.itemsController = controllers.NewItemsController(c.addItemCommand, c.getItemQuery, c.listItemsQuery, c.deleteItemCommand, c.logger) + c.authController = controllers.NewAuthController(c.loginUserCommand, c.logger) + c.jwtMiddleware = middleware.NewJWTMiddleware(c.authService, c.logger) + + serverConfig := &server.Config{ + Port: c.config.ServerPort, + ReadTimeout: c.config.ReadTimeout, + WriteTimeout: c.config.WriteTimeout, + ShutdownTimeout: c.config.ShutdownTimeout, + } + c.server = server.NewServer(serverConfig, c.logger, c.itemsController, c.authController, c.jwtMiddleware) + + return nil +} + +// Getters for infrastructure +func (c *Container) GetLogger() interfaces.ILogger { + return c.logger +} + +func (c *Container) GetTimeProvider() interfaces.ITimeProvider { + return c.timeProvider +} + +func (c *Container) GetAuthService() interfaces.IAuthService { + return c.authService +} + +// Getters for domain +func (c *Container) GetExpirationSpec() *specifications.ItemExpirationSpec { + return c.expirationSpec +} + +// Getters for application +func (c *Container) GetAddItemCommand() *commands.AddItemCommand { + return c.addItemCommand +} + +func (c *Container) GetDeleteItemCommand() *commands.DeleteItemCommand { + return c.deleteItemCommand +} + +func (c *Container) GetHandleExpiredItemsCommand() *commands.HandleExpiredItemsCommand { + return c.handleExpiredItemsCommand +} + +func (c *Container) GetLoginUserCommand() *commands.LoginUserCommand { + return c.loginUserCommand +} + +func (c *Container) GetGetItemQuery() *queries.GetItemQuery { + return c.getItemQuery +} + +func (c *Container) GetListItemsQuery() *queries.ListItemsQuery { + return c.listItemsQuery +} + +// Getters for presentation +func (c *Container) GetItemsController() *controllers.ItemsController { + return c.itemsController +} + +func (c *Container) GetAuthController() *controllers.AuthController { + return c.authController +} + +func (c *Container) GetJWTMiddleware() *middleware.JWTMiddleware { + return c.jwtMiddleware +} + +func (c *Container) GetServer() *server.Server { + return c.server +} + +func (c *Container) GetExpiredItemsScheduler() *scheduler.ExpiredItemsScheduler { + return c.expiredItemsScheduler +} diff --git a/golang/internal/domain/entities/errors.go b/golang/internal/domain/entities/errors.go new file mode 100644 index 0000000..273ea17 --- /dev/null +++ b/golang/internal/domain/entities/errors.go @@ -0,0 +1,11 @@ +package entities + +import "errors" + +var ( + ErrInvalidItemName = errors.New("item name cannot be empty") + ErrInvalidOrderURL = errors.New("order URL cannot be empty") + ErrInvalidUsername = errors.New("username cannot be empty") + ErrInvalidPassword = errors.New("password cannot be empty") + ErrFailedToHashPassword = errors.New("failed to hash password") +) \ No newline at end of file diff --git a/golang/internal/domain/entities/item.go b/golang/internal/domain/entities/item.go new file mode 100644 index 0000000..626df51 --- /dev/null +++ b/golang/internal/domain/entities/item.go @@ -0,0 +1,118 @@ +package entities + +import ( + "encoding/json" + "fmt" + "time" + + "autostore/internal/domain/value_objects" +) + +type itemEntityJSON struct { + ID string `json:"id"` + Name string `json:"name"` + ExpirationDate time.Time `json:"expirationDate"` + OrderURL string `json:"orderUrl"` + UserID string `json:"userId"` + CreatedAt time.Time `json:"createdAt"` +} + +type ItemEntity struct { + id value_objects.ItemID + name string + expirationDate value_objects.ExpirationDate + orderURL string + userID value_objects.UserID + createdAt time.Time +} + +func NewItem(id value_objects.ItemID, name string, expirationDate value_objects.ExpirationDate, orderURL string, userID value_objects.UserID) (*ItemEntity, error) { + if name == "" { + return nil, ErrInvalidItemName + } + + if orderURL == "" { + return nil, ErrInvalidOrderURL + } + + return &ItemEntity{ + id: id, + name: name, + expirationDate: expirationDate, + orderURL: orderURL, + userID: userID, + createdAt: time.Now(), + }, nil +} + +func (i *ItemEntity) GetID() value_objects.ItemID { + return i.id +} + +func (i *ItemEntity) GetName() string { + return i.name +} + +func (i *ItemEntity) GetExpirationDate() value_objects.ExpirationDate { + return i.expirationDate +} + +func (i *ItemEntity) ExpirationDate() time.Time { + return i.expirationDate.Time() +} + +func (i *ItemEntity) GetOrderURL() string { + return i.orderURL +} + +func (i *ItemEntity) GetUserID() value_objects.UserID { + return i.userID +} + +func (i *ItemEntity) GetCreatedAt() time.Time { + return i.createdAt +} + +// MarshalJSON implements json.Marshaler interface +func (i *ItemEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(&itemEntityJSON{ + ID: i.id.String(), + Name: i.name, + ExpirationDate: i.expirationDate.Time(), + OrderURL: i.orderURL, + UserID: i.userID.String(), + CreatedAt: i.createdAt, + }) +} + +// UnmarshalJSON implements json.Unmarshaler interface +func (i *ItemEntity) UnmarshalJSON(data []byte) error { + var aux itemEntityJSON + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + id, err := value_objects.NewItemIDFromString(aux.ID) + if err != nil { + return fmt.Errorf("invalid item ID: %w", err) + } + + userID, err := value_objects.NewUserIDFromString(aux.UserID) + if err != nil { + return fmt.Errorf("invalid user ID: %w", err) + } + + expirationDate, err := value_objects.NewExpirationDate(aux.ExpirationDate) + if err != nil { + return fmt.Errorf("invalid expiration date: %w", err) + } + + i.id = id + i.name = aux.Name + i.expirationDate = expirationDate + i.orderURL = aux.OrderURL + i.userID = userID + i.createdAt = aux.CreatedAt + + return nil +} \ No newline at end of file diff --git a/golang/internal/domain/entities/user.go b/golang/internal/domain/entities/user.go new file mode 100644 index 0000000..0392620 --- /dev/null +++ b/golang/internal/domain/entities/user.go @@ -0,0 +1,69 @@ +package entities + +import ( + "autostore/internal/domain/value_objects" + + "golang.org/x/crypto/bcrypt" +) + +type UserEntity struct { + id value_objects.UserID + username string + passwordHash string +} + +func NewUser(id value_objects.UserID, username string, password string) (*UserEntity, error) { + if username == "" { + return nil, ErrInvalidUsername + } + + if password == "" { + return nil, ErrInvalidPassword + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, ErrFailedToHashPassword + } + + return &UserEntity{ + id: id, + username: username, + passwordHash: string(hashedPassword), + }, nil +} + +// NewUserWithHashedPassword creates a user entity with a pre-hashed password +// This is used when reconstructing a user from the database +func NewUserWithHashedPassword(id value_objects.UserID, username string, passwordHash string) (*UserEntity, error) { + if username == "" { + return nil, ErrInvalidUsername + } + + if passwordHash == "" { + return nil, ErrInvalidPassword + } + + return &UserEntity{ + id: id, + username: username, + passwordHash: passwordHash, + }, nil +} + +func (u *UserEntity) GetID() value_objects.UserID { + return u.id +} + +func (u *UserEntity) GetUsername() string { + return u.username +} + +func (u *UserEntity) GetPasswordHash() string { + return u.passwordHash +} + +func (u *UserEntity) ValidatePassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.passwordHash), []byte(password)) + return err == nil +} diff --git a/golang/internal/domain/specifications/condition_spec.go b/golang/internal/domain/specifications/condition_spec.go new file mode 100644 index 0000000..ea32642 --- /dev/null +++ b/golang/internal/domain/specifications/condition_spec.go @@ -0,0 +1,247 @@ +package specifications + +import ( +) + +// Condition represents a single condition in the specification +type Condition struct { + Field string `json:"field"` + Operator string `json:"operator"` + Value interface{} `json:"value"` +} + +// LogicalGroup represents a logical group of conditions (AND, OR, NOT) +type LogicalGroup struct { + Operator string `json:"operator"` // "AND", "OR", "NOT" + Conditions []Condition `json:"conditions"` // For AND/OR + Spec *Spec `json:"spec"` // For NOT +} + +// Spec represents a specification that can be either a single condition or a logical group +type Spec struct { + Condition *Condition `json:"condition,omitempty"` + LogicalGroup *LogicalGroup `json:"logicalGroup,omitempty"` +} + +// SpecHelper provides methods to build specifications +type SpecHelper struct{} + +// NewSpecHelper creates a new SpecHelper instance +func NewSpecHelper() *SpecHelper { + return &SpecHelper{} +} + +// Logical group operators +const ( + GROUP_AND = "AND" + GROUP_OR = "OR" + GROUP_NOT = "NOT" +) + +// Comparison operators +const ( + OP_EQ = "=" + OP_NEQ = "!=" + OP_GT = ">" + OP_GTE = ">=" + OP_LT = "<" + OP_LTE = "<=" + OP_IN = "IN" + OP_NIN = "NOT IN" +) + +// Logical group helpers +func (h *SpecHelper) And(conditions ...*Spec) *Spec { + return &Spec{ + LogicalGroup: &LogicalGroup{ + Operator: GROUP_AND, + Conditions: h.extractConditions(conditions), + }, + } +} + +func (h *SpecHelper) Or(conditions ...*Spec) *Spec { + return &Spec{ + LogicalGroup: &LogicalGroup{ + Operator: GROUP_OR, + Conditions: h.extractConditions(conditions), + }, + } +} + +func (h *SpecHelper) Not(condition *Spec) *Spec { + return &Spec{ + LogicalGroup: &LogicalGroup{ + Operator: GROUP_NOT, + Spec: condition, + }, + } +} + +// Condition helpers +func (h *SpecHelper) Eq(field string, value interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_EQ, + Value: value, + }, + } +} + +func (h *SpecHelper) Neq(field string, value interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_NEQ, + Value: value, + }, + } +} + +func (h *SpecHelper) Gt(field string, value interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_GT, + Value: value, + }, + } +} + +func (h *SpecHelper) Gte(field string, value interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_GTE, + Value: value, + }, + } +} + +func (h *SpecHelper) Lt(field string, value interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_LT, + Value: value, + }, + } +} + +func (h *SpecHelper) Lte(field string, value interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_LTE, + Value: value, + }, + } +} + +func (h *SpecHelper) In(field string, values []interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_IN, + Value: values, + }, + } +} + +func (h *SpecHelper) Nin(field string, values []interface{}) *Spec { + return &Spec{ + Condition: &Condition{ + Field: field, + Operator: OP_NIN, + Value: values, + }, + } +} + +// extractConditions extracts conditions from specs for logical groups +func (h *SpecHelper) extractConditions(specs []*Spec) []Condition { + var conditions []Condition + for _, spec := range specs { + if spec.Condition != nil { + conditions = append(conditions, *spec.Condition) + } + } + return conditions +} + +// GetConditions returns all conditions from a spec (flattened) +func (h *SpecHelper) GetConditions(spec *Spec) []Condition { + var conditions []Condition + h.flattenConditions(spec, &conditions) + return conditions +} + +// flattenConditions recursively flattens conditions from a spec +func (h *SpecHelper) flattenConditions(spec *Spec, conditions *[]Condition) { + if spec == nil { + return + } + + if spec.Condition != nil { + *conditions = append(*conditions, *spec.Condition) + } + + if spec.LogicalGroup != nil { + if spec.LogicalGroup.Operator == GROUP_AND || spec.LogicalGroup.Operator == GROUP_OR { + for _, cond := range spec.LogicalGroup.Conditions { + *conditions = append(*conditions, cond) + } + } else if spec.LogicalGroup.Operator == GROUP_NOT && spec.LogicalGroup.Spec != nil { + h.flattenConditions(spec.LogicalGroup.Spec, conditions) + } + } +} + +// Global Spec helper instance +var SpecBuilder = NewSpecHelper() + +// Convenience functions that use the global Spec instance +func And(conditions ...*Spec) *Spec { + return SpecBuilder.And(conditions...) +} + +func Or(conditions ...*Spec) *Spec { + return SpecBuilder.Or(conditions...) +} + +func Not(condition *Spec) *Spec { + return SpecBuilder.Not(condition) +} + +func Eq(field string, value interface{}) *Spec { + return SpecBuilder.Eq(field, value) +} + +func Neq(field string, value interface{}) *Spec { + return SpecBuilder.Neq(field, value) +} + +func Gt(field string, value interface{}) *Spec { + return SpecBuilder.Gt(field, value) +} + +func Gte(field string, value interface{}) *Spec { + return SpecBuilder.Gte(field, value) +} + +func Lt(field string, value interface{}) *Spec { + return SpecBuilder.Lt(field, value) +} + +func Lte(field string, value interface{}) *Spec { + return SpecBuilder.Lte(field, value) +} + +func In(field string, values []interface{}) *Spec { + return SpecBuilder.In(field, values) +} + +func Nin(field string, values []interface{}) *Spec { + return SpecBuilder.Nin(field, values) +} \ No newline at end of file diff --git a/golang/internal/domain/specifications/item_expiration_spec.go b/golang/internal/domain/specifications/item_expiration_spec.go new file mode 100644 index 0000000..3d10aec --- /dev/null +++ b/golang/internal/domain/specifications/item_expiration_spec.go @@ -0,0 +1,29 @@ +package specifications + +import ( + "autostore/internal/domain/entities" + "time" +) + +type ItemExpirationSpec struct{} + +func NewItemExpirationSpec() *ItemExpirationSpec { + return &ItemExpirationSpec{} +} + +// IsExpired checks if an item is expired using the new condition-based specification +func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool { + return s.GetSpec(currentTime).IsSatisfiedBy(item) +} + +// GetSpec returns a condition-based specification for checking item expiration +func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] { + // Create a condition that checks if expirationDate <= currentTime + spec := Lte("expirationDate", currentTime) + return NewSimpleSpecification[*entities.ItemEntity](spec) +} + +// GetConditionSpec returns the raw condition spec for query generation +func (s *ItemExpirationSpec) GetConditionSpec(currentTime time.Time) *Spec { + return Lte("expirationDate", currentTime) +} \ No newline at end of file diff --git a/golang/internal/domain/specifications/simple_specification.go b/golang/internal/domain/specifications/simple_specification.go new file mode 100644 index 0000000..ef11c3e --- /dev/null +++ b/golang/internal/domain/specifications/simple_specification.go @@ -0,0 +1,443 @@ +package specifications + +import ( + "fmt" + "reflect" + "strings" + "time" +) + +// CompositeSpecification implements logical operations between different specification types +type CompositeSpecification[T any] struct { + left Specification[T] + right Specification[T] + op string // "AND", "OR" +} + +// IsSatisfiedBy checks if the candidate satisfies the composite specification +func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { + switch c.op { + case "AND": + return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) + case "OR": + return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) + default: + return false + } +} + +// And creates a new specification that is the logical AND of this and another specification +func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{ + left: c, + right: other, + op: "AND", + } +} + +// Or creates a new specification that is the logical OR of this and another specification +func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{ + left: c, + right: other, + op: "OR", + } +} + +// Not creates a new specification that is the logical NOT of this specification +func (c *CompositeSpecification[T]) Not() Specification[T] { + // For composite specifications, we need to create a wrapper that negates the result + return &NotSpecification[T]{spec: c} +} + +// GetConditions returns all conditions from this specification +func (c *CompositeSpecification[T]) GetConditions() []Condition { + // Combine conditions from both sides + leftConditions := c.left.GetConditions() + rightConditions := c.right.GetConditions() + return append(leftConditions, rightConditions...) +} + +// GetSpec returns nil for composite specifications as they don't have a single spec structure +func (c *CompositeSpecification[T]) GetSpec() *Spec { + return nil +} + +// NotSpecification implements the NOT operation for specifications +type NotSpecification[T any] struct { + spec Specification[T] +} + +// IsSatisfiedBy checks if the candidate does NOT satisfy the wrapped specification +func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool { + return !n.spec.IsSatisfiedBy(candidate) +} + +// And creates a new specification that is the logical AND of this and another specification +func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{ + left: n, + right: other, + op: "AND", + } +} + +// Or creates a new specification that is the logical OR of this and another specification +func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{ + left: n, + right: other, + op: "OR", + } +} + +// Not creates a new specification that is the logical NOT of this specification (double negation) +func (n *NotSpecification[T]) Not() Specification[T] { + return n.spec +} + +// GetConditions returns all conditions from the wrapped specification +func (n *NotSpecification[T]) GetConditions() []Condition { + return n.spec.GetConditions() +} + +// GetSpec returns nil for not specifications +func (n *NotSpecification[T]) GetSpec() *Spec { + return nil +} + +// Specification defines the interface for specifications +type Specification[T any] interface { + IsSatisfiedBy(candidate T) bool + And(other Specification[T]) Specification[T] + Or(other Specification[T]) Specification[T] + Not() Specification[T] + GetConditions() []Condition + GetSpec() *Spec +} + +// SimpleSpecification implements the Specification interface using condition-based specs +type SimpleSpecification[T any] struct { + spec *Spec +} + +// NewSimpleSpecification creates a new SimpleSpecification with the given spec +func NewSimpleSpecification[T any](spec *Spec) *SimpleSpecification[T] { + return &SimpleSpecification[T]{ + spec: spec, + } +} + +// IsSatisfiedBy checks if the candidate satisfies the specification +func (s *SimpleSpecification[T]) IsSatisfiedBy(candidate T) bool { + return s.evaluateSpec(s.spec, candidate) +} + +// And creates a new specification that is the logical AND of this and another specification +func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { + if otherSpec, ok := other.(*SimpleSpecification[T]); ok { + return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec)) + } + panic("And operation not supported between different specification types") +} + +// Or creates a new specification that is the logical OR of this and another specification +func (s *SimpleSpecification[T]) Or(other Specification[T]) Specification[T] { + if otherSpec, ok := other.(*SimpleSpecification[T]); ok { + return NewSimpleSpecification[T](SpecBuilder.Or(s.spec, otherSpec.spec)) + } + panic("Or operation not supported between different specification types") +} + +// Not creates a new specification that is the logical NOT of this specification +func (s *SimpleSpecification[T]) Not() Specification[T] { + return NewSimpleSpecification[T](SpecBuilder.Not(s.spec)) +} + +// GetSpec returns the underlying specification structure +func (s *SimpleSpecification[T]) GetSpec() *Spec { + return s.spec +} + +// GetConditions returns all conditions from this specification +func (s *SimpleSpecification[T]) GetConditions() []Condition { + return SpecBuilder.GetConditions(s.spec) +} + +// evaluateSpec recursively evaluates a specification against a candidate +func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool { + if spec == nil { + return false + } + + // Handle logical groups + if spec.LogicalGroup != nil { + switch spec.LogicalGroup.Operator { + case GROUP_AND: + return s.evaluateAndGroup(spec.LogicalGroup, candidate) + case GROUP_OR: + return s.evaluateOrGroup(spec.LogicalGroup, candidate) + case GROUP_NOT: + return s.evaluateNotGroup(spec.LogicalGroup, candidate) + } + } + + // Handle simple condition + if spec.Condition != nil { + return s.evaluateCondition(*spec.Condition, candidate) + } + + return false +} + +// evaluateAndGroup evaluates an AND group +func (s *SimpleSpecification[T]) evaluateAndGroup(group *LogicalGroup, candidate T) bool { + for _, cond := range group.Conditions { + if !s.evaluateCondition(cond, candidate) { + return false + } + } + return true +} + +// evaluateOrGroup evaluates an OR group +func (s *SimpleSpecification[T]) evaluateOrGroup(group *LogicalGroup, candidate T) bool { + for _, cond := range group.Conditions { + if s.evaluateCondition(cond, candidate) { + return true + } + } + return false +} + +// evaluateNotGroup evaluates a NOT group +func (s *SimpleSpecification[T]) evaluateNotGroup(group *LogicalGroup, candidate T) bool { + if group.Spec != nil { + return !s.evaluateSpec(group.Spec, candidate) + } + return false +} + +// evaluateCondition evaluates a single condition against a candidate +func (s *SimpleSpecification[T]) evaluateCondition(condition Condition, candidate T) bool { + // Get the field value from the candidate + fieldValue, err := s.getFieldValue(candidate, condition.Field) + if err != nil { + return false + } + + // Handle time comparison + return s.compareValues(fieldValue, condition.Operator, condition.Value) +} + +// getFieldValue retrieves the value of a field from an object using reflection +func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { + v := reflect.ValueOf(candidate) + + // If candidate is a pointer, get the underlying value + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + if v.Kind() != reflect.Struct { + return nil, fmt.Errorf("candidate is not a struct") + } + + // Try to get the field by getter method first (preferred approach) + getterName := "Get" + strings.Title(fieldName) + method := v.MethodByName(getterName) + if method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() > 0 { + results := method.Call(nil) + if len(results) > 0 { + return results[0].Interface(), nil + } + } + + // Try alternative getter naming (e.g., ExpirationDate() instead of GetExpirationDate()) + method = v.MethodByName(fieldName) + if method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() > 0 { + results := method.Call(nil) + if len(results) > 0 { + return results[0].Interface(), nil + } + } + + // Try to get the field by name (for exported fields only) + field := v.FieldByName(fieldName) + if field.IsValid() && field.CanInterface() { + return field.Interface(), nil + } + + return nil, fmt.Errorf("field %s not found or not accessible", fieldName) +} + +// compareValues compares two values using the specified operator +func (s *SimpleSpecification[T]) compareValues(fieldValue interface{}, operator string, compareValue interface{}) bool { + // Handle time comparison + if s.isTimeComparable(fieldValue, compareValue) { + return s.compareTimes(fieldValue, operator, compareValue) + } + + // Convert both values to the same type for comparison + fieldVal := reflect.ValueOf(fieldValue) + compareVal := reflect.ValueOf(compareValue) + + // Handle IN and NOT IN operators + if operator == OP_IN || operator == OP_NIN { + return s.compareIn(fieldValue, operator, compareValue) + } + + // For other operators, try to convert to comparable types + if fieldVal.Kind() != compareVal.Kind() { + // Try to convert compareValue to the same type as fieldValue + if compareVal.CanConvert(fieldVal.Type()) { + compareVal = compareVal.Convert(fieldVal.Type()) + } else if fieldVal.CanConvert(compareVal.Type()) { + fieldVal = fieldVal.Convert(compareVal.Type()) + } else { + return false + } + } + + switch operator { + case OP_EQ: + return reflect.DeepEqual(fieldValue, compareValue) + case OP_NEQ: + return !reflect.DeepEqual(fieldValue, compareValue) + case OP_GT: + return s.compareGreater(fieldVal, compareVal) + case OP_GTE: + return s.compareGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) + case OP_LT: + return s.compareLess(fieldVal, compareVal) + case OP_LTE: + return s.compareLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) + default: + return false + } +} + +// isTimeComparable checks if the values can be compared as times +func (s *SimpleSpecification[T]) isTimeComparable(fieldValue, compareValue interface{}) bool { + _, fieldIsTime := fieldValue.(time.Time) + _, compareIsTime := compareValue.(time.Time) + + // If both are time.Time, they are time comparable + if fieldIsTime && compareIsTime { + return true + } + + // If one is time.Time and the other is string, check if string is a valid time + if fieldIsTime { + if compareStr, ok := compareValue.(string); ok { + _, err := time.Parse(time.RFC3339, compareStr) + return err == nil + } + } + + if compareIsTime { + if fieldStr, ok := fieldValue.(string); ok { + _, err := time.Parse(time.RFC3339, fieldStr) + return err == nil + } + } + + return false +} + +// compareTimes compares time values +func (s *SimpleSpecification[T]) compareTimes(fieldValue interface{}, operator string, compareValue interface{}) bool { + var fieldTime, compareTime time.Time + var err error + + // Convert fieldValue to time.Time + switch v := fieldValue.(type) { + case time.Time: + fieldTime = v + case string: + fieldTime, err = time.Parse(time.RFC3339, v) + if err != nil { + return false + } + default: + return false + } + + // Convert compareValue to time.Time + switch v := compareValue.(type) { + case time.Time: + compareTime = v + case string: + compareTime, err = time.Parse(time.RFC3339, v) + if err != nil { + return false + } + default: + return false + } + + switch operator { + case OP_EQ: + return fieldTime.Equal(compareTime) + case OP_NEQ: + return !fieldTime.Equal(compareTime) + case OP_GT: + return fieldTime.After(compareTime) + case OP_GTE: + return fieldTime.After(compareTime) || fieldTime.Equal(compareTime) + case OP_LT: + return fieldTime.Before(compareTime) + case OP_LTE: + return fieldTime.Before(compareTime) || fieldTime.Equal(compareTime) + default: + return false + } +} + +// compareIn handles IN and NOT IN operators +func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator string, compareValue interface{}) bool { + compareSlice, ok := compareValue.([]interface{}) + if !ok { + return false + } + + for _, v := range compareSlice { + if reflect.DeepEqual(fieldValue, v) { + return operator == OP_IN + } + } + + return operator == OP_NIN +} + +// compareGreater compares if fieldVal is greater than compareVal +func (s *SimpleSpecification[T]) compareGreater(fieldVal, compareVal reflect.Value) bool { + switch fieldVal.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return fieldVal.Int() > compareVal.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return fieldVal.Uint() > compareVal.Uint() + case reflect.Float32, reflect.Float64: + return fieldVal.Float() > compareVal.Float() + case reflect.String: + return fieldVal.String() > compareVal.String() + default: + return false + } +} + +// compareLess compares if fieldVal is less than compareVal +func (s *SimpleSpecification[T]) compareLess(fieldVal, compareVal reflect.Value) bool { + switch fieldVal.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return fieldVal.Int() < compareVal.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return fieldVal.Uint() < compareVal.Uint() + case reflect.Float32, reflect.Float64: + return fieldVal.Float() < compareVal.Float() + case reflect.String: + return fieldVal.String() < compareVal.String() + default: + return false + } +} \ No newline at end of file diff --git a/golang/internal/domain/value_objects/base_uuid.go b/golang/internal/domain/value_objects/base_uuid.go new file mode 100644 index 0000000..847a437 --- /dev/null +++ b/golang/internal/domain/value_objects/base_uuid.go @@ -0,0 +1,45 @@ +package value_objects + +import ( + "errors" + + "github.com/google/uuid" +) + +var ( + ErrInvalidUUID = errors.New("invalid UUID format") +) + +type BaseUUID struct { + value string +} + +func NewBaseUUID(value string) (BaseUUID, error) { + if value == "" { + return BaseUUID{}, ErrInvalidUUID + } + + _, err := uuid.Parse(value) + if err != nil { + return BaseUUID{}, ErrInvalidUUID + } + + return BaseUUID{value: value}, nil +} + +func NewRandomBaseUUID() (BaseUUID, error) { + id, err := uuid.NewRandom() + if err != nil { + return BaseUUID{}, err + } + + return BaseUUID{value: id.String()}, nil +} + +func (b BaseUUID) String() string { + return b.value +} + +func (b BaseUUID) Equals(other BaseUUID) bool { + return b.value == other.value +} \ No newline at end of file diff --git a/golang/internal/domain/value_objects/expiration_date.go b/golang/internal/domain/value_objects/expiration_date.go new file mode 100644 index 0000000..95809e9 --- /dev/null +++ b/golang/internal/domain/value_objects/expiration_date.go @@ -0,0 +1,23 @@ +package value_objects + +import ( + "time" +) + +type ExpirationDate struct { + value time.Time +} + +func NewExpirationDate(value time.Time) (ExpirationDate, error) { + // According to business rules, expired items can be added to the store, + // so we don't need to validate if the date is in the past + return ExpirationDate{value: value}, nil +} + +func (e ExpirationDate) Time() time.Time { + return e.value +} + +func (e ExpirationDate) String() string { + return e.value.Format(time.RFC3339) +} \ No newline at end of file diff --git a/golang/internal/domain/value_objects/item_id.go b/golang/internal/domain/value_objects/item_id.go new file mode 100644 index 0000000..d79029e --- /dev/null +++ b/golang/internal/domain/value_objects/item_id.go @@ -0,0 +1,38 @@ +package value_objects + +import ( + "errors" +) + +var ( + ErrInvalidItemID = errors.New("invalid item ID") +) + +type ItemID struct { + BaseUUID +} + +func NewItemID(value string) (ItemID, error) { + baseUUID, err := NewBaseUUID(value) + if err != nil { + return ItemID{}, ErrInvalidItemID + } + + return ItemID{BaseUUID: baseUUID}, nil +} + +func NewRandomItemID() (ItemID, error) { + baseUUID, err := NewRandomBaseUUID() + if err != nil { + return ItemID{}, ErrInvalidItemID + } + + return ItemID{BaseUUID: baseUUID}, nil +} +func NewItemIDFromString(value string) (ItemID, error) { + return NewItemID(value) +} + +func (i ItemID) Equals(other ItemID) bool { + return i.BaseUUID.Equals(other.BaseUUID) +} diff --git a/golang/internal/domain/value_objects/user_id.go b/golang/internal/domain/value_objects/user_id.go new file mode 100644 index 0000000..40b6997 --- /dev/null +++ b/golang/internal/domain/value_objects/user_id.go @@ -0,0 +1,38 @@ +package value_objects + +import ( + "errors" +) + +var ( + ErrInvalidUserID = errors.New("invalid user ID") +) + +type UserID struct { + BaseUUID +} + +func NewUserID(value string) (UserID, error) { + baseUUID, err := NewBaseUUID(value) + if err != nil { + return UserID{}, ErrInvalidUserID + } + + return UserID{BaseUUID: baseUUID}, nil +} + +func NewRandomUserID() (UserID, error) { + baseUUID, err := NewRandomBaseUUID() + if err != nil { + return UserID{}, ErrInvalidUserID + } + + return UserID{BaseUUID: baseUUID}, nil +} +func NewUserIDFromString(value string) (UserID, error) { + return NewUserID(value) +} + +func (u UserID) Equals(other UserID) bool { + return u.BaseUUID.Equals(other.BaseUUID) +} diff --git a/golang/internal/infrastructure/auth/jwt_auth_service.go b/golang/internal/infrastructure/auth/jwt_auth_service.go new file mode 100644 index 0000000..3cebd83 --- /dev/null +++ b/golang/internal/infrastructure/auth/jwt_auth_service.go @@ -0,0 +1,126 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidToken = errors.New("invalid token") + ErrTokenExpired = errors.New("token expired") +) + +type JWTAuthService struct { + userRepo interfaces.IUserRepository + secretKey string + logger interfaces.ILogger +} + +func NewJWTAuthService( + userRepo interfaces.IUserRepository, + secretKey string, + logger interfaces.ILogger, +) *JWTAuthService { + return &JWTAuthService{ + userRepo: userRepo, + secretKey: secretKey, + logger: logger, + } +} + +func (s *JWTAuthService) Authenticate(ctx context.Context, username string, password string) (string, error) { + user, err := s.userRepo.FindByUsername(ctx, username) + if err != nil { + s.logger.Error(ctx, "Failed to find user by username", "error", err, "username", username) + return "", ErrInvalidCredentials + } + + if user == nil { + s.logger.Warn(ctx, "User not found", "username", username) + return "", ErrInvalidCredentials + } + + if !user.ValidatePassword(password) { + s.logger.Warn(ctx, "Invalid password", "username", username) + return "", ErrInvalidCredentials + } + + token, err := s.generateToken(user) + if err != nil { + s.logger.Error(ctx, "Failed to generate token", "error", err, "username", username) + return "", err + } + + s.logger.Info(ctx, "User authenticated successfully", "username", username, "userID", user.GetID().String()) + return token, nil +} + +func (s *JWTAuthService) ValidateToken(ctx context.Context, tokenString string) (bool, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.secretKey), nil + }) + + if err != nil { + s.logger.Warn(ctx, "Token validation failed", "error", err) + return false, nil + } + + return token.Valid, nil +} + +func (s *JWTAuthService) GetUserIDFromToken(ctx context.Context, tokenString string) (string, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.secretKey), nil + }) + + if err != nil { + s.logger.Warn(ctx, "Failed to parse token", "error", err) + return "", ErrInvalidToken + } + + if !token.Valid { + s.logger.Warn(ctx, "Invalid token") + return "", ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + s.logger.Warn(ctx, "Invalid token claims") + return "", ErrInvalidToken + } + + userID, ok := claims["sub"].(string) + if !ok { + s.logger.Warn(ctx, "User ID not found in token claims") + return "", ErrInvalidToken + } + + return userID, nil +} + +func (s *JWTAuthService) generateToken(user *entities.UserEntity) (string, error) { + claims := jwt.MapClaims{ + "sub": user.GetID().String(), + "username": user.GetUsername(), + "iss": "autostore", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour * 24).Unix(), // 24 hours expiration + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.secretKey)) +} diff --git a/golang/internal/infrastructure/http/order_url_http_client.go b/golang/internal/infrastructure/http/order_url_http_client.go new file mode 100644 index 0000000..a6a332f --- /dev/null +++ b/golang/internal/infrastructure/http/order_url_http_client.go @@ -0,0 +1,80 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" +) + +type OrderRequest struct { + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + OrderURL string `json:"orderUrl"` + UserID string `json:"userId"` + OrderedAt time.Time `json:"orderedAt"` +} + +type OrderURLHttpClient struct { + client *http.Client + logger interfaces.ILogger +} + +func NewOrderURLHttpClient(logger interfaces.ILogger) *OrderURLHttpClient { + return &OrderURLHttpClient{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + logger: logger, + } +} + +func (c *OrderURLHttpClient) OrderItem(ctx context.Context, item *entities.ItemEntity) error { + orderURL := item.GetOrderURL() + if orderURL == "" { + return fmt.Errorf("order URL is empty") + } + + orderRequest := OrderRequest{ + ItemID: item.GetID().String(), + ItemName: item.GetName(), + OrderURL: orderURL, + UserID: item.GetUserID().String(), + OrderedAt: time.Now(), + } + + jsonData, err := json.Marshal(orderRequest) + if err != nil { + return fmt.Errorf("failed to marshal order request: %w", err) + } + + c.logger.Info(ctx, "Sending order request", "orderURL", orderURL, "itemID", orderRequest.ItemID) + + req, err := http.NewRequestWithContext(ctx, "POST", orderURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send order request: %w", err) + } + defer resp.Body.Close() + + // We don't care about the response body, just that the request was sent + if resp.StatusCode >= 400 { + c.logger.Warn(ctx, "Order request returned non-success status", "status", resp.StatusCode, "orderURL", orderURL) + // Don't fail the operation, just log the warning + return nil + } + + c.logger.Info(ctx, "Order request sent successfully", "status", resp.StatusCode, "orderURL", orderURL) + return nil +} \ No newline at end of file diff --git a/golang/internal/infrastructure/logging/standard_logger.go b/golang/internal/infrastructure/logging/standard_logger.go new file mode 100644 index 0000000..983e0c1 --- /dev/null +++ b/golang/internal/infrastructure/logging/standard_logger.go @@ -0,0 +1,41 @@ +package logging + +import ( + "context" + "io" + "log" +) + +type StandardLogger struct { + logger *log.Logger +} + +func NewStandardLogger(writer io.Writer) *StandardLogger { + return &StandardLogger{ + logger: log.New(writer, "", log.LstdFlags), + } +} + +func (l *StandardLogger) Info(ctx context.Context, msg string, fields ...interface{}) { + l.log("INFO", msg, fields...) +} + +func (l *StandardLogger) Error(ctx context.Context, msg string, fields ...interface{}) { + l.log("ERROR", msg, fields...) +} + +func (l *StandardLogger) Debug(ctx context.Context, msg string, fields ...interface{}) { + l.log("DEBUG", msg, fields...) +} + +func (l *StandardLogger) Warn(ctx context.Context, msg string, fields ...interface{}) { + l.log("WARN", msg, fields...) +} + +func (l *StandardLogger) log(level, msg string, fields ...interface{}) { + if len(fields) > 0 { + l.logger.Printf("[%s] %s %v", level, msg, fields) + } else { + l.logger.Printf("[%s] %s", level, msg) + } +} \ No newline at end of file diff --git a/golang/internal/infrastructure/repositories/file_item_repository.go b/golang/internal/infrastructure/repositories/file_item_repository.go new file mode 100644 index 0000000..9b89f8a --- /dev/null +++ b/golang/internal/infrastructure/repositories/file_item_repository.go @@ -0,0 +1,223 @@ +package repositories + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" + "autostore/internal/domain/value_objects" +) + +type FileItemRepository struct { + dataDirectory string + logger interfaces.ILogger + mu sync.RWMutex +} + +func NewFileItemRepository(dataDirectory string, logger interfaces.ILogger) *FileItemRepository { + return &FileItemRepository{ + dataDirectory: dataDirectory, + logger: logger, + } +} + +func (r *FileItemRepository) Save(ctx context.Context, item *entities.ItemEntity) error { + r.logger.Info(ctx, "Saving item", "itemID", item.GetID().String()) + + filePath := filepath.Join(r.dataDirectory, "items.json") + + r.mu.Lock() + defer r.mu.Unlock() + + items, err := r.loadItems(filePath) + if err != nil { + r.logger.Error(ctx, "Failed to load items", "error", err) + return fmt.Errorf("failed to load items: %w", err) + } + + items[item.GetID().String()] = item + if err := r.saveItems(filePath, items); err != nil { + r.logger.Error(ctx, "Failed to save items", "error", err) + return fmt.Errorf("failed to save items: %w", err) + } + + r.logger.Info(ctx, "Item saved successfully", "itemID", item.GetID().String()) + return nil +} + +func (r *FileItemRepository) FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) { + r.logger.Info(ctx, "Finding item by ID", "itemID", id.String()) + + filePath := filepath.Join(r.dataDirectory, "items.json") + + r.mu.RLock() + defer r.mu.RUnlock() + + items, err := r.loadItems(filePath) + if err != nil { + r.logger.Error(ctx, "Failed to load items", "error", err) + return nil, fmt.Errorf("failed to load items: %w", err) + } + + item, ok := items[id.String()] + if !ok { + r.logger.Info(ctx, "Item not found", "itemID", id.String()) + return nil, fmt.Errorf("item with ID %s not found", id.String()) + } + + r.logger.Info(ctx, "Item found successfully", "itemID", id.String()) + return item, nil +} + +func (r *FileItemRepository) FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) { + r.logger.Info(ctx, "Finding items by user ID", "userID", userID.String()) + + filePath := filepath.Join(r.dataDirectory, "items.json") + + r.mu.RLock() + defer r.mu.RUnlock() + + items, err := r.loadItems(filePath) + if err != nil { + r.logger.Error(ctx, "Failed to load items", "error", err) + return nil, fmt.Errorf("failed to load items: %w", err) + } + + var userItems []*entities.ItemEntity + for _, item := range items { + if item.GetUserID().Equals(userID) { + userItems = append(userItems, item) + } + } + + r.logger.Info(ctx, "Found items for user", "userID", userID.String(), "count", len(userItems)) + return userItems, nil +} + +func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + r.logger.Info(ctx, "Finding items by specification") + + filePath := filepath.Join(r.dataDirectory, "items.json") + + r.mu.RLock() + defer r.mu.RUnlock() + + items, err := r.loadItems(filePath) + if err != nil { + r.logger.Error(ctx, "Failed to load items", "error", err) + return nil, fmt.Errorf("failed to load items: %w", err) + } + + var filteredItems []*entities.ItemEntity + for _, item := range items { + if spec.IsSatisfiedBy(item) { + filteredItems = append(filteredItems, item) + } + } + + r.logger.Info(ctx, "Found items matching specification", "count", len(filteredItems)) + return filteredItems, nil +} + +func (r *FileItemRepository) Delete(ctx context.Context, id value_objects.ItemID) error { + r.logger.Info(ctx, "Deleting item", "itemID", id.String()) + + filePath := filepath.Join(r.dataDirectory, "items.json") + + r.mu.Lock() + defer r.mu.Unlock() + + items, err := r.loadItems(filePath) + if err != nil { + r.logger.Error(ctx, "Failed to load items", "error", err) + return fmt.Errorf("failed to load items: %w", err) + } + + if _, exists := items[id.String()]; !exists { + r.logger.Info(ctx, "Item not found for deletion", "itemID", id.String()) + return fmt.Errorf("item with ID %s not found", id.String()) + } + + delete(items, id.String()) + if err := r.saveItems(filePath, items); err != nil { + r.logger.Error(ctx, "Failed to save items after deletion", "error", err) + return fmt.Errorf("failed to save items: %w", err) + } + + r.logger.Info(ctx, "Item deleted successfully", "itemID", id.String()) + return nil +} + +func (r *FileItemRepository) Exists(ctx context.Context, id value_objects.ItemID) (bool, error) { + r.logger.Info(ctx, "Checking if item exists", "itemID", id.String()) + + filePath := filepath.Join(r.dataDirectory, "items.json") + + r.mu.RLock() + defer r.mu.RUnlock() + + items, err := r.loadItems(filePath) + if err != nil { + r.logger.Error(ctx, "Failed to load items", "error", err) + return false, fmt.Errorf("failed to load items: %w", err) + } + + _, ok := items[id.String()] + r.logger.Info(ctx, "Item existence check completed", "itemID", id.String(), "exists", ok) + return ok, nil +} + +func (r *FileItemRepository) loadItems(filePath string) (map[string]*entities.ItemEntity, error) { + // Ensure the directory exists + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]*entities.ItemEntity), nil + } + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var items map[string]*entities.ItemEntity + if len(data) == 0 { + return make(map[string]*entities.ItemEntity), nil + } + + if err := json.Unmarshal(data, &items); err != nil { + return nil, fmt.Errorf("failed to unmarshal items: %w", err) + } + return items, nil +} + +func (r *FileItemRepository) saveItems(filePath string, items map[string]*entities.ItemEntity) error { + // Ensure the directory exists + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal items: %w", err) + } + + // Write to a temporary file first, then rename for atomic operation + tempPath := filePath + ".tmp" + if err := os.WriteFile(tempPath, data, 0644); err != nil { + return fmt.Errorf("failed to write temporary file: %w", err) + } + + if err := os.Rename(tempPath, filePath); err != nil { + return fmt.Errorf("failed to rename temporary file: %w", err) + } + + return nil +} diff --git a/golang/internal/infrastructure/repositories/file_user_repository.go b/golang/internal/infrastructure/repositories/file_user_repository.go new file mode 100644 index 0000000..ef18d94 --- /dev/null +++ b/golang/internal/infrastructure/repositories/file_user_repository.go @@ -0,0 +1,156 @@ +package repositories + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" + "autostore/internal/domain/value_objects" +) + +type FileUserRepository struct { + filePath string + logger interfaces.ILogger + mu sync.RWMutex +} + +func NewFileUserRepository(dataDirectory string, logger interfaces.ILogger) *FileUserRepository { + // Ensure the data directory exists + if err := os.MkdirAll(dataDirectory, 0755); err != nil { + logger.Error(context.Background(), "Failed to create data directory", "error", err, "path", dataDirectory) + } + + filePath := filepath.Join(dataDirectory, "users.json") + + // Initialize the file if it doesn't exist + if _, err := os.Stat(filePath); os.IsNotExist(err) { + if err := os.WriteFile(filePath, []byte("{}"), 0644); err != nil { + logger.Error(context.Background(), "Failed to create users file", "error", err, "path", filePath) + } + } + + return &FileUserRepository{ + filePath: filePath, + logger: logger, + } +} + +func (r *FileUserRepository) FindByUsername(ctx context.Context, username string) (*entities.UserEntity, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + users, err := r.readUsers() + if err != nil { + return nil, err + } + + for _, userData := range users { + if userData.Username == username { + return r.dataToEntity(userData) + } + } + + return nil, nil +} + +func (r *FileUserRepository) FindByID(ctx context.Context, id value_objects.UserID) (*entities.UserEntity, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + users, err := r.readUsers() + if err != nil { + return nil, err + } + + userIDStr := id.String() + if userData, exists := users[userIDStr]; exists { + return r.dataToEntity(userData) + } + + return nil, nil +} + +func (r *FileUserRepository) Save(ctx context.Context, user *entities.UserEntity) error { + r.mu.Lock() + defer r.mu.Unlock() + + users, err := r.readUsers() + if err != nil { + return err + } + + // Check if username already exists for a different user + userIDStr := user.GetID().String() + for existingID, existingUser := range users { + if existingID != userIDStr && existingUser.Username == user.GetUsername() { + return errors.New("username already exists") + } + } + + users[userIDStr] = r.entityToData(user) + + if err := r.writeUsers(users); err != nil { + return err + } + + r.logger.Info(ctx, "User saved successfully", "userID", userIDStr, "username", user.GetUsername()) + return nil +} + +// Helper types for JSON serialization +type userData struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"passwordHash"` + CreatedAt string `json:"createdAt"` +} + +func (r *FileUserRepository) readUsers() (map[string]userData, error) { + data, err := os.ReadFile(r.filePath) + if err != nil { + return nil, fmt.Errorf("failed to read users file: %w", err) + } + + var users map[string]userData + if err := json.Unmarshal(data, &users); err != nil { + return nil, fmt.Errorf("failed to parse users file: %w", err) + } + + return users, nil +} + +func (r *FileUserRepository) writeUsers(users map[string]userData) error { + data, err := json.MarshalIndent(users, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal users: %w", err) + } + + if err := os.WriteFile(r.filePath, data, 0644); err != nil { + return fmt.Errorf("failed to write users file: %w", err) + } + + return nil +} + +func (r *FileUserRepository) entityToData(user *entities.UserEntity) userData { + return userData{ + ID: user.GetID().String(), + Username: user.GetUsername(), + PasswordHash: user.GetPasswordHash(), + CreatedAt: "", // Will be added when user entity has createdAt field + } +} + +func (r *FileUserRepository) dataToEntity(data userData) (*entities.UserEntity, error) { + userID, err := value_objects.NewUserID(data.ID) + if err != nil { + return nil, fmt.Errorf("invalid user ID: %w", err) + } + return entities.NewUserWithHashedPassword(userID, data.Username, data.PasswordHash) +} diff --git a/golang/internal/infrastructure/scheduler/expired_items_scheduler.go b/golang/internal/infrastructure/scheduler/expired_items_scheduler.go new file mode 100644 index 0000000..61790ec --- /dev/null +++ b/golang/internal/infrastructure/scheduler/expired_items_scheduler.go @@ -0,0 +1,104 @@ +package scheduler + +import ( + "context" + "time" + + "autostore/internal/application/commands" + "autostore/internal/application/interfaces" +) + +type ExpiredItemsScheduler struct { + handleExpiredItemsCmd *commands.HandleExpiredItemsCommand + logger interfaces.ILogger + ticker *time.Ticker + done chan struct{} +} + +func NewExpiredItemsScheduler( + handleExpiredItemsCmd *commands.HandleExpiredItemsCommand, + logger interfaces.ILogger, +) *ExpiredItemsScheduler { + return &ExpiredItemsScheduler{ + handleExpiredItemsCmd: handleExpiredItemsCmd, + logger: logger, + done: make(chan struct{}), + } +} + +func (s *ExpiredItemsScheduler) Start(ctx context.Context) error { + s.logger.Info(ctx, "Starting expired items scheduler") + + // Process expired items immediately on startup + if err := s.processExpiredItems(ctx); err != nil { + s.logger.Error(ctx, "Failed to process expired items on startup", "error", err) + return err + } + + // Calculate duration until next midnight + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + if now.After(midnight) { + midnight = midnight.Add(24 * time.Hour) + } + durationUntilMidnight := midnight.Sub(now) + + // Start a timer for the first midnight + firstMidnightTimer := time.NewTimer(durationUntilMidnight) + + go func() { + for { + select { + case <-firstMidnightTimer.C: + s.processExpiredItems(ctx) + + // After first midnight, set up daily ticker + s.ticker = time.NewTicker(24 * time.Hour) + firstMidnightTimer.Stop() + + for { + select { + case <-s.ticker.C: + s.processExpiredItems(ctx) + case <-s.done: + s.ticker.Stop() + return + case <-ctx.Done(): + s.ticker.Stop() + return + } + } + case <-s.done: + firstMidnightTimer.Stop() + return + case <-ctx.Done(): + firstMidnightTimer.Stop() + return + } + } + }() + + s.logger.Info(ctx, "Expired items scheduler started", "next_run", midnight.Format(time.RFC3339)) + return nil +} + +func (s *ExpiredItemsScheduler) Stop() error { + s.logger.Info(context.Background(), "Stopping expired items scheduler") + close(s.done) + if s.ticker != nil { + s.ticker.Stop() + } + return nil +} + +func (s *ExpiredItemsScheduler) processExpiredItems(ctx context.Context) error { + s.logger.Info(ctx, "Running scheduled expired items processing") + + if err := s.handleExpiredItemsCmd.Execute(ctx); err != nil { + s.logger.Error(ctx, "Scheduled expired items processing failed", "error", err) + return err + } + + s.logger.Info(ctx, "Scheduled expired items processing completed") + return nil +} \ No newline at end of file diff --git a/golang/internal/infrastructure/services/user_initialization_service.go b/golang/internal/infrastructure/services/user_initialization_service.go new file mode 100644 index 0000000..8937bf1 --- /dev/null +++ b/golang/internal/infrastructure/services/user_initialization_service.go @@ -0,0 +1,65 @@ +package services + +import ( + "context" + + "autostore/internal/application/interfaces" + "autostore/internal/domain/entities" + "autostore/internal/domain/value_objects" +) + +type UserInitializationService struct { + userRepository interfaces.IUserRepository + logger interfaces.ILogger +} + +func NewUserInitializationService(userRepository interfaces.IUserRepository, logger interfaces.ILogger) *UserInitializationService { + return &UserInitializationService{ + userRepository: userRepository, + logger: logger, + } +} + +func (s *UserInitializationService) InitializeDefaultUsers() error { + defaultUsers := []struct { + username string + password string + }{ + {username: "admin", password: "admin"}, + {username: "user", password: "user"}, + } + + for _, userData := range defaultUsers { + existingUser, err := s.userRepository.FindByUsername(context.Background(), userData.username) + if err != nil { + s.logger.Error(context.Background(), "Failed to check if user exists", "error", err, "username", userData.username) + continue + } + + if existingUser != nil { + s.logger.Info(context.Background(), "Default user already exists", "username", userData.username) + continue + } + + userID, err := value_objects.NewRandomUserID() + if err != nil { + s.logger.Error(context.Background(), "Failed to generate user ID", "error", err) + continue + } + + user, err := entities.NewUser(userID, userData.username, userData.password) + if err != nil { + s.logger.Error(context.Background(), "Failed to create user entity", "error", err, "username", userData.username) + continue + } + + if err := s.userRepository.Save(context.Background(), user); err != nil { + s.logger.Error(context.Background(), "Failed to save user", "error", err, "username", userData.username) + continue + } + + s.logger.Info(context.Background(), "Created default user", "username", userData.username) + } + + return nil +} diff --git a/golang/internal/infrastructure/time/system_time_provider.go b/golang/internal/infrastructure/time/system_time_provider.go new file mode 100644 index 0000000..2ff160d --- /dev/null +++ b/golang/internal/infrastructure/time/system_time_provider.go @@ -0,0 +1,15 @@ +package time + +import ( + "time" +) + +type SystemTimeProvider struct{} + +func NewSystemTimeProvider() *SystemTimeProvider { + return &SystemTimeProvider{} +} + +func (p *SystemTimeProvider) Now() time.Time { + return time.Now() +} \ No newline at end of file diff --git a/golang/internal/presentation/controllers/auth_controller.go b/golang/internal/presentation/controllers/auth_controller.go new file mode 100644 index 0000000..93135de --- /dev/null +++ b/golang/internal/presentation/controllers/auth_controller.go @@ -0,0 +1,53 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "autostore/internal/application/commands" + "autostore/internal/application/dto" + "autostore/internal/application/interfaces" +) + +type AuthController struct { + loginUserCmd *commands.LoginUserCommand + logger interfaces.ILogger +} + +func NewAuthController( + loginUserCmd *commands.LoginUserCommand, + logger interfaces.ILogger, +) *AuthController { + return &AuthController{ + loginUserCmd: loginUserCmd, + logger: logger, + } +} + +func (ctrl *AuthController) Login(c *gin.Context) { + var loginDTO dto.LoginDTO + if err := c.ShouldBindJSON(&loginDTO); err != nil { + c.JSON(http.StatusBadRequest, dto.JSendError("Invalid request body", http.StatusBadRequest)) + return + } + + if err := loginDTO.Validate(); err != nil { + c.JSON(http.StatusBadRequest, dto.JSendError(err.Error(), http.StatusBadRequest)) + return + } + + token, err := ctrl.loginUserCmd.Execute(c.Request.Context(), loginDTO.Username, loginDTO.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.JSendError(err.Error(), http.StatusUnauthorized)) + return + } + + response := &dto.LoginResponseDTO{ + Token: token, + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour in seconds + } + + c.JSON(http.StatusOK, dto.JSendSuccess(response)) +} \ No newline at end of file diff --git a/golang/internal/presentation/controllers/items_controller.go b/golang/internal/presentation/controllers/items_controller.go new file mode 100644 index 0000000..89a581a --- /dev/null +++ b/golang/internal/presentation/controllers/items_controller.go @@ -0,0 +1,157 @@ +package controllers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "errors" + + "autostore/internal/application/commands" + "autostore/internal/application/dto" + "autostore/internal/application/interfaces" + "autostore/internal/application/queries" +) + +type ItemsController struct { + addItemCmd *commands.AddItemCommand + getItemQry *queries.GetItemQuery + listItemsQry *queries.ListItemsQuery + deleteItemCmd *commands.DeleteItemCommand + logger interfaces.ILogger +} + +func NewItemsController( + addItemCmd *commands.AddItemCommand, + getItemQry *queries.GetItemQuery, + listItemsQry *queries.ListItemsQuery, + deleteItemCmd *commands.DeleteItemCommand, + logger interfaces.ILogger, +) *ItemsController { + return &ItemsController{ + addItemCmd: addItemCmd, + getItemQry: getItemQry, + listItemsQry: listItemsQry, + deleteItemCmd: deleteItemCmd, + logger: logger, + } +} + +func (ctrl *ItemsController) CreateItem(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) + return + } + + var createItemDTO dto.CreateItemDTO + if err := c.ShouldBindJSON(&createItemDTO); err != nil { + c.JSON(http.StatusBadRequest, dto.JSendError("Invalid request body", http.StatusBadRequest)) + return + } + + if err := createItemDTO.Validate(); err != nil { + c.JSON(http.StatusBadRequest, dto.JSendError(err.Error(), http.StatusBadRequest)) + return + } + + itemID, err := ctrl.addItemCmd.Execute(c.Request.Context(), createItemDTO.Name, createItemDTO.ExpirationDate.Time, createItemDTO.OrderURL, userID.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) + return + } + + response := &dto.ItemResponseDTO{ + ID: itemID, + Name: createItemDTO.Name, + ExpirationDate: createItemDTO.ExpirationDate, + OrderURL: createItemDTO.OrderURL, + UserID: userID.(string), + CreatedAt: dto.JSONTime{Time: time.Now()}, + } + + c.JSON(http.StatusCreated, dto.JSendSuccess(response)) +} + +func (ctrl *ItemsController) GetItem(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) + return + } + + itemID := c.Param("id") + if itemID == "" { + c.JSON(http.StatusBadRequest, dto.JSendError("Item ID is required", http.StatusBadRequest)) + return + } + + item, err := ctrl.getItemQry.Execute(c.Request.Context(), itemID, userID.(string)) + if err != nil { + if errors.Is(err, queries.ErrItemNotFound) { + c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) + return + } + if errors.Is(err, queries.ErrUnauthorizedAccess) { + c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) + return + } + c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) + return + } + + response := (&dto.ItemResponseDTO{}).FromEntity(item) + + c.JSON(http.StatusOK, dto.JSendSuccess(response)) +} + +func (ctrl *ItemsController) ListItems(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) + return + } + + items, err := ctrl.listItemsQry.Execute(c.Request.Context(), userID.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) + return + } + + var response []*dto.ItemResponseDTO + for _, item := range items { + response = append(response, (&dto.ItemResponseDTO{}).FromEntity(item)) + } + + c.JSON(http.StatusOK, dto.JSendSuccess(response)) +} + +func (ctrl *ItemsController) DeleteItem(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) + return + } + + itemID := c.Param("id") + if itemID == "" { + c.JSON(http.StatusBadRequest, dto.JSendError("Item ID is required", http.StatusBadRequest)) + return + } + + err := ctrl.deleteItemCmd.Execute(c.Request.Context(), itemID, userID.(string)) + if err != nil { + if errors.Is(err, commands.ErrItemNotFound) { + c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) + return + } + if errors.Is(err, commands.ErrUnauthorizedAccess) { + c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) + return + } + c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) + return + } + + c.JSON(http.StatusNoContent, dto.JSendSuccess(nil)) +} \ No newline at end of file diff --git a/golang/internal/presentation/middleware/jwt_middleware.go b/golang/internal/presentation/middleware/jwt_middleware.go new file mode 100644 index 0000000..676f276 --- /dev/null +++ b/golang/internal/presentation/middleware/jwt_middleware.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "autostore/internal/application/interfaces" + "autostore/internal/application/dto" +) + +type JWTMiddleware struct { + authService interfaces.IAuthService + logger interfaces.ILogger +} + +func NewJWTMiddleware( + authService interfaces.IAuthService, + logger interfaces.ILogger, +) *JWTMiddleware { + return &JWTMiddleware{ + authService: authService, + logger: logger, + } +} + +func (m *JWTMiddleware) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, dto.JSendError("Authorization header is required", http.StatusUnauthorized)) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + c.JSON(http.StatusUnauthorized, dto.JSendError("Invalid authorization format", http.StatusUnauthorized)) + c.Abort() + return + } + + token := parts[1] + valid, err := m.authService.ValidateToken(c.Request.Context(), token) + if err != nil || !valid { + c.JSON(http.StatusUnauthorized, dto.JSendError("Invalid or expired token", http.StatusUnauthorized)) + c.Abort() + return + } + + userID, err := m.authService.GetUserIDFromToken(c.Request.Context(), token) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.JSendError("Failed to get user ID from token", http.StatusUnauthorized)) + c.Abort() + return + } + + c.Set("userID", userID) + c.Next() + } +} \ No newline at end of file diff --git a/golang/internal/presentation/server/server.go b/golang/internal/presentation/server/server.go new file mode 100644 index 0000000..b4d91a0 --- /dev/null +++ b/golang/internal/presentation/server/server.go @@ -0,0 +1,117 @@ +package server + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "autostore/internal/application/interfaces" + "autostore/internal/presentation/controllers" + "autostore/internal/presentation/middleware" +) + +type Server struct { + config *Config + logger interfaces.ILogger + itemsCtrl *controllers.ItemsController + authCtrl *controllers.AuthController + jwtMiddleware *middleware.JWTMiddleware + httpServer *http.Server +} + +type Config struct { + Port int + ReadTimeout time.Duration + WriteTimeout time.Duration + ShutdownTimeout time.Duration +} + +func NewServer( + config *Config, + logger interfaces.ILogger, + itemsCtrl *controllers.ItemsController, + authCtrl *controllers.AuthController, + jwtMiddleware *middleware.JWTMiddleware, +) *Server { + return &Server{ + config: config, + logger: logger, + itemsCtrl: itemsCtrl, + authCtrl: authCtrl, + jwtMiddleware: jwtMiddleware, + } +} + +func (s *Server) Start() error { + gin.SetMode(gin.ReleaseMode) + router := s.SetupRoutes() + + s.httpServer = &http.Server{ + Addr: ":" + strconv.Itoa(s.config.Port), + Handler: router, + ReadTimeout: s.config.ReadTimeout, + WriteTimeout: s.config.WriteTimeout, + } + + s.logger.Info(context.Background(), "Server starting on port %d", s.config.Port) + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + s.logger.Info(ctx, "Server shutting down") + + shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) + defer cancel() + + return s.httpServer.Shutdown(shutdownCtx) +} + +func (s *Server) SetupRoutes() *gin.Engine { + router := gin.New() + + // Middleware + router.Use(gin.Recovery()) + router.Use(s.corsMiddleware()) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // API v1 group + v1 := router.Group("/api/v1") + + // Auth routes (no JWT middleware) + v1.POST("/login", s.authCtrl.Login) + + // Items routes (with JWT middleware) + items := v1.Group("/items") + items.Use(s.jwtMiddleware.Middleware()) + { + items.POST("", s.itemsCtrl.CreateItem) + items.GET("", s.itemsCtrl.ListItems) + items.GET("/:id", s.itemsCtrl.GetItem) + items.DELETE("/:id", s.itemsCtrl.DeleteItem) + } + + return router +} + +func (s *Server) corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +}