Initialize base
This commit is contained in:
@@ -0,0 +1,717 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cursor Rules for Tender Management Go Backend
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
This is a Go backend API for a tender management system following Clean Architecture principles with minimal complexity, designed to be AI-friendly and easy to maintain.
|
||||||
|
|
||||||
|
## 🏗️ Architecture Guidelines
|
||||||
|
|
||||||
|
### Clean Architecture Layers
|
||||||
|
- **Domain Layer** (`internal/domain/`): Business entities, interfaces, and core business rules
|
||||||
|
- **Service Layer** (`internal/service/`): Business logic and use cases
|
||||||
|
- **Handler Layer** (`internal/handler/`): HTTP controllers and request/response handling
|
||||||
|
- **Repository Layer** (`internal/repository/`): Data access implementations
|
||||||
|
- **Infrastructure Layer** (`internal/infrastructure/`): External dependencies (DB, cache, queues)
|
||||||
|
|
||||||
|
### Dependency Rules
|
||||||
|
- Always depend on interfaces, not concrete implementations
|
||||||
|
- Use dependency injection through constructors
|
||||||
|
- Inner layers should never depend on outer layers
|
||||||
|
- All dependencies flow inward toward the domain
|
||||||
|
|
||||||
|
## 💻 Go Best Practices
|
||||||
|
|
||||||
|
### Code Structure
|
||||||
|
- Use standard Go project layout with `internal/` for private code
|
||||||
|
- Group related functionality in packages
|
||||||
|
- Keep packages focused on single responsibility
|
||||||
|
- Use meaningful package names (not generic like `utils` or `helpers`)
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- Use Go naming conventions: camelCase for private, PascalCase for public
|
||||||
|
- Interface names should describe what they do (e.g., `UserRepository`, `AuthService`)
|
||||||
|
- Use descriptive variable names, avoid abbreviations
|
||||||
|
- Constants should be in ALL_CAPS with underscores
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Always handle errors explicitly, never ignore them
|
||||||
|
- Use structured error messages with context
|
||||||
|
- Wrap errors with additional context using `fmt.Errorf("context: %w", err)`
|
||||||
|
- Log errors at the point they occur with relevant fields
|
||||||
|
- Return domain-specific error types when appropriate
|
||||||
|
|
||||||
|
### Function Design
|
||||||
|
- Keep functions small and focused (max 20-30 lines)
|
||||||
|
- Use early returns to reduce nesting
|
||||||
|
- Validate inputs at function entry points
|
||||||
|
- Use descriptive parameter names
|
||||||
|
- Limit function parameters (max 3-4, use structs for more)
|
||||||
|
|
||||||
|
## 🔌 Interface Design
|
||||||
|
|
||||||
|
### Repository Interfaces
|
||||||
|
- Always create interfaces in the domain layer
|
||||||
|
- Use context.Context as first parameter for all methods
|
||||||
|
- Return domain entities, not database models
|
||||||
|
- Include common operations: Create, GetByID, Update, Delete, List
|
||||||
|
- Use specific query methods (e.g., `GetByEmail`, `GetActiveUsers`)
|
||||||
|
|
||||||
|
### Service Interfaces
|
||||||
|
- Define business operations clearly
|
||||||
|
- Use request/response DTOs for complex operations
|
||||||
|
- Include validation in service methods
|
||||||
|
- Handle business logic, not infrastructure concerns
|
||||||
|
|
||||||
|
## 📝 Logging Standards
|
||||||
|
|
||||||
|
### Using Zap Logger
|
||||||
|
- Always use structured logging with fields
|
||||||
|
- Include relevant context (user_id, request_id, etc.)
|
||||||
|
- Use appropriate log levels:
|
||||||
|
- `Debug`: Detailed debugging information
|
||||||
|
- `Info`: General operational messages
|
||||||
|
- `Warn`: Warning conditions that should be addressed
|
||||||
|
- `Error`: Error conditions that need attention
|
||||||
|
- `Fatal`: Critical errors that cause program termination
|
||||||
|
|
||||||
|
### Logging Examples
|
||||||
|
```go
|
||||||
|
// Good
|
||||||
|
log.Info("User authenticated successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"ip": clientIP,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
log.Info("User login")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Logging
|
||||||
|
- Log errors with full context
|
||||||
|
- Include error details and relevant fields
|
||||||
|
- Don't log the same error multiple times in the call stack
|
||||||
|
|
||||||
|
## 🗄️ Database & Repository Patterns
|
||||||
|
|
||||||
|
### Repository Implementation
|
||||||
|
- Implement repository interfaces in `internal/repository/`
|
||||||
|
- Use proper database transactions for related operations
|
||||||
|
- Handle database errors gracefully
|
||||||
|
- Use prepared statements or ORM query builders
|
||||||
|
- Implement proper pagination
|
||||||
|
|
||||||
|
### Data Models
|
||||||
|
- Separate database models from domain entities
|
||||||
|
- Convert between database models and domain entities in repositories
|
||||||
|
- Use proper database field tags (bson, json)
|
||||||
|
- Include audit fields (created_at, updated_at)
|
||||||
|
|
||||||
|
## 🌐 HTTP Handler Guidelines
|
||||||
|
|
||||||
|
### Request Handling
|
||||||
|
- Validate all incoming requests using struct tags
|
||||||
|
- Use proper HTTP status codes
|
||||||
|
- Return consistent API response format using `pkg/response`
|
||||||
|
- Handle CORS appropriately
|
||||||
|
- Include request timeout handling
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
- Always use standardized response structure from `pkg/response`
|
||||||
|
- Include appropriate metadata for paginated responses
|
||||||
|
- Provide meaningful error messages
|
||||||
|
- Never expose internal errors to clients
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Validate and sanitize all inputs
|
||||||
|
- Use proper authentication middleware
|
||||||
|
- Implement rate limiting
|
||||||
|
- Never log sensitive information (passwords, tokens)
|
||||||
|
- Use HTTPS in production
|
||||||
|
|
||||||
|
## 🧪 Testing Guidelines
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
- Write unit tests for all business logic
|
||||||
|
- Use table-driven tests for multiple scenarios
|
||||||
|
- Mock external dependencies using interfaces
|
||||||
|
- Test error cases, not just happy paths
|
||||||
|
- Use meaningful test names that describe the scenario
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
- Place tests in same package as code being tested
|
||||||
|
- Use `_test.go` suffix for test files
|
||||||
|
- Create test utilities in `testutil` package if needed
|
||||||
|
- Use setup/teardown functions for common test data
|
||||||
|
|
||||||
|
## 📋 Configuration Management
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
- Use YAML for configuration files
|
||||||
|
- Support environment variable overrides
|
||||||
|
- Validate configuration on startup
|
||||||
|
- Use default values for optional settings
|
||||||
|
- Document all configuration options
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- Use consistent naming convention (e.g., `TM_DATABASE_URI`)
|
||||||
|
- Never commit secrets in configuration files
|
||||||
|
- Use different configs for different environments
|
||||||
|
|
||||||
|
## 🚀 Performance Guidelines
|
||||||
|
|
||||||
|
### Optimization
|
||||||
|
- Use database indexes appropriately
|
||||||
|
- Implement caching for frequently accessed data
|
||||||
|
- Use connection pooling for databases
|
||||||
|
- Avoid N+1 queries
|
||||||
|
- Profile performance bottlenecks
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
- Close resources properly (database connections, files)
|
||||||
|
- Use context for cancellation and timeouts
|
||||||
|
- Avoid memory leaks in goroutines
|
||||||
|
- Use sync.Pool for frequently allocated objects
|
||||||
|
|
||||||
|
## 📚 Documentation Standards
|
||||||
|
|
||||||
|
### Code Documentation
|
||||||
|
- Write clear comments for complex business logic
|
||||||
|
- Document all public interfaces and types
|
||||||
|
- Use Go doc comments format
|
||||||
|
- Include examples for complex functions
|
||||||
|
- Keep comments up to date with code changes
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
- Use OpenAPI/Swagger annotations
|
||||||
|
- Document all endpoints with examples
|
||||||
|
- Include error response examples
|
||||||
|
- Document authentication requirements
|
||||||
|
|
||||||
|
## 🔐 Security Best Practices
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use JWT tokens with proper expiration
|
||||||
|
- Implement refresh token rotation
|
||||||
|
- Use bcrypt for password hashing
|
||||||
|
- Validate token claims properly
|
||||||
|
- Implement proper RBAC
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
- Validate all inputs at API boundaries
|
||||||
|
- Use whitelist validation, not blacklist
|
||||||
|
- Sanitize inputs to prevent injection attacks
|
||||||
|
- Validate file uploads properly
|
||||||
|
|
||||||
|
## 📦 Dependency Management
|
||||||
|
|
||||||
|
### Go Modules
|
||||||
|
- Keep go.mod clean and organized
|
||||||
|
- Pin major versions for stability
|
||||||
|
- Regular dependency updates with testing
|
||||||
|
- Remove unused dependencies
|
||||||
|
- Use vendor directory for critical dependencies
|
||||||
|
|
||||||
|
### External Libraries
|
||||||
|
- Prefer standard library when possible
|
||||||
|
- Choose well-maintained libraries
|
||||||
|
- Avoid dependencies with security issues
|
||||||
|
- Keep dependencies minimal
|
||||||
|
|
||||||
|
## 🔄 Code Review Guidelines
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
- Run `go fmt`, `go vet`, and linting tools
|
||||||
|
- Ensure all tests pass
|
||||||
|
- Check for proper error handling
|
||||||
|
- Verify logging is appropriate
|
||||||
|
- Confirm no sensitive data is exposed
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Follow single responsibility principle
|
||||||
|
- Avoid code duplication
|
||||||
|
- Use meaningful variable names
|
||||||
|
- Keep functions and files reasonably sized
|
||||||
|
- Maintain consistent code style
|
||||||
|
|
||||||
|
## 🚨 Error Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
- Don't use panic for regular error handling
|
||||||
|
- Avoid global variables for application state
|
||||||
|
- Don't ignore context cancellation
|
||||||
|
- Avoid deeply nested if statements
|
||||||
|
- Don't use reflection unless absolutely necessary
|
||||||
|
|
||||||
|
### Common Mistakes
|
||||||
|
- Not handling database connection errors
|
||||||
|
- Forgetting to close resources
|
||||||
|
- Not validating user inputs
|
||||||
|
- Exposing internal errors to clients
|
||||||
|
- Not using proper HTTP status codes
|
||||||
|
|
||||||
|
## 📋 Checklist for New Features
|
||||||
|
|
||||||
|
### Before Implementation
|
||||||
|
- [ ] Define interfaces in domain layer
|
||||||
|
- [ ] Create proper request/response DTOs
|
||||||
|
- [ ] Plan error handling strategy
|
||||||
|
- [ ] Design database schema if needed
|
||||||
|
- [ ] Consider caching requirements
|
||||||
|
|
||||||
|
### During Implementation
|
||||||
|
- [ ] Follow clean architecture layers
|
||||||
|
- [ ] Use dependency injection
|
||||||
|
- [ ] Implement proper logging
|
||||||
|
- [ ] Add input validation
|
||||||
|
- [ ] Handle errors gracefully
|
||||||
|
|
||||||
|
### After Implementation
|
||||||
|
- [ ] Write unit tests
|
||||||
|
- [ ] Update API documentation
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
- [ ] Verify security implications
|
||||||
|
- [ ] Check performance impact
|
||||||
|
|
||||||
|
## 🎯 Code Examples
|
||||||
|
|
||||||
|
### Service Implementation Template
|
||||||
|
```go
|
||||||
|
type UserServiceImpl struct {
|
||||||
|
userRepo domain.UserRepository
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(userRepo domain.UserRepository, logger logger.Logger) domain.UserService {
|
||||||
|
return &UserServiceImpl{
|
||||||
|
userRepo: userRepo,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) CreateUser(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) {
|
||||||
|
// Validate input
|
||||||
|
if err := validateCreateUserRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business logic
|
||||||
|
user := &domain.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: req.Email,
|
||||||
|
// ... other fields
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository call
|
||||||
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
|
s.logger.Error("Failed to create user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User created successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handler Implementation Template
|
||||||
|
```go
|
||||||
|
func (h *UserHandler) CreateUser(c echo.Context) error {
|
||||||
|
var req domain.CreateUserRequest
|
||||||
|
|
||||||
|
// Bind and validate request
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
user, err := h.userService.CreateUser(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Create user failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.InternalServerError(c, "Failed to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Created(c, user, "User created successfully")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Remember
|
||||||
|
- Prioritize simplicity and readability over cleverness
|
||||||
|
- Make code self-documenting through clear naming
|
||||||
|
- Always consider the person who will maintain this code
|
||||||
|
- Test your code thoroughly
|
||||||
|
- Security and performance are not optional # Cursor Rules for Tender Management Go Backend
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
This is a Go backend API for a tender management system following Clean Architecture principles with minimal complexity, designed to be AI-friendly and easy to maintain.
|
||||||
|
|
||||||
|
## 🏗️ Architecture Guidelines
|
||||||
|
|
||||||
|
### Clean Architecture Layers
|
||||||
|
- **Domain Layer** (`internal/domain/`): Business entities, interfaces, and core business rules
|
||||||
|
- **Service Layer** (`internal/service/`): Business logic and use cases
|
||||||
|
- **Handler Layer** (`internal/handler/`): HTTP controllers and request/response handling
|
||||||
|
- **Repository Layer** (`internal/repository/`): Data access implementations
|
||||||
|
- **Infrastructure Layer** (`internal/infrastructure/`): External dependencies (DB, cache, queues)
|
||||||
|
|
||||||
|
### Dependency Rules
|
||||||
|
- Always depend on interfaces, not concrete implementations
|
||||||
|
- Use dependency injection through constructors
|
||||||
|
- Inner layers should never depend on outer layers
|
||||||
|
- All dependencies flow inward toward the domain
|
||||||
|
|
||||||
|
## 💻 Go Best Practices
|
||||||
|
|
||||||
|
### Code Structure
|
||||||
|
- Use standard Go project layout with `internal/` for private code
|
||||||
|
- Group related functionality in packages
|
||||||
|
- Keep packages focused on single responsibility
|
||||||
|
- Use meaningful package names (not generic like `utils` or `helpers`)
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- Use Go naming conventions: camelCase for private, PascalCase for public
|
||||||
|
- Interface names should describe what they do (e.g., `UserRepository`, `AuthService`)
|
||||||
|
- Use descriptive variable names, avoid abbreviations
|
||||||
|
- Constants should be in ALL_CAPS with underscores
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Always handle errors explicitly, never ignore them
|
||||||
|
- Use structured error messages with context
|
||||||
|
- Wrap errors with additional context using `fmt.Errorf("context: %w", err)`
|
||||||
|
- Log errors at the point they occur with relevant fields
|
||||||
|
- Return domain-specific error types when appropriate
|
||||||
|
|
||||||
|
### Function Design
|
||||||
|
- Keep functions small and focused (max 20-30 lines)
|
||||||
|
- Use early returns to reduce nesting
|
||||||
|
- Validate inputs at function entry points
|
||||||
|
- Use descriptive parameter names
|
||||||
|
- Limit function parameters (max 3-4, use structs for more)
|
||||||
|
|
||||||
|
## 🔌 Interface Design
|
||||||
|
|
||||||
|
### Repository Interfaces
|
||||||
|
- Always create interfaces in the domain layer
|
||||||
|
- Use context.Context as first parameter for all methods
|
||||||
|
- Return domain entities, not database models
|
||||||
|
- Include common operations: Create, GetByID, Update, Delete, List
|
||||||
|
- Use specific query methods (e.g., `GetByEmail`, `GetActiveUsers`)
|
||||||
|
|
||||||
|
### Service Interfaces
|
||||||
|
- Define business operations clearly
|
||||||
|
- Use request/response DTOs for complex operations
|
||||||
|
- Include validation in service methods
|
||||||
|
- Handle business logic, not infrastructure concerns
|
||||||
|
|
||||||
|
## 📝 Logging Standards
|
||||||
|
|
||||||
|
### Using Zap Logger
|
||||||
|
- Always use structured logging with fields
|
||||||
|
- Include relevant context (user_id, request_id, etc.)
|
||||||
|
- Use appropriate log levels:
|
||||||
|
- `Debug`: Detailed debugging information
|
||||||
|
- `Info`: General operational messages
|
||||||
|
- `Warn`: Warning conditions that should be addressed
|
||||||
|
- `Error`: Error conditions that need attention
|
||||||
|
- `Fatal`: Critical errors that cause program termination
|
||||||
|
|
||||||
|
### Logging Examples
|
||||||
|
```go
|
||||||
|
// Good
|
||||||
|
log.Info("User authenticated successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"ip": clientIP,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
log.Info("User login")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Logging
|
||||||
|
- Log errors with full context
|
||||||
|
- Include error details and relevant fields
|
||||||
|
- Don't log the same error multiple times in the call stack
|
||||||
|
|
||||||
|
## 🗄️ Database & Repository Patterns
|
||||||
|
|
||||||
|
### Repository Implementation
|
||||||
|
- Implement repository interfaces in `internal/repository/`
|
||||||
|
- Use proper database transactions for related operations
|
||||||
|
- Handle database errors gracefully
|
||||||
|
- Use prepared statements or ORM query builders
|
||||||
|
- Implement proper pagination
|
||||||
|
|
||||||
|
### Data Models
|
||||||
|
- Separate database models from domain entities
|
||||||
|
- Convert between database models and domain entities in repositories
|
||||||
|
- Use proper database field tags (bson, json)
|
||||||
|
- Include audit fields (created_at, updated_at)
|
||||||
|
|
||||||
|
## 🌐 HTTP Handler Guidelines
|
||||||
|
|
||||||
|
### Request Handling
|
||||||
|
- Validate all incoming requests using struct tags
|
||||||
|
- Use proper HTTP status codes
|
||||||
|
- Return consistent API response format using `pkg/response`
|
||||||
|
- Handle CORS appropriately
|
||||||
|
- Include request timeout handling
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
- Always use standardized response structure from `pkg/response`
|
||||||
|
- Include appropriate metadata for paginated responses
|
||||||
|
- Provide meaningful error messages
|
||||||
|
- Never expose internal errors to clients
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Validate and sanitize all inputs
|
||||||
|
- Use proper authentication middleware
|
||||||
|
- Implement rate limiting
|
||||||
|
- Never log sensitive information (passwords, tokens)
|
||||||
|
- Use HTTPS in production
|
||||||
|
|
||||||
|
## 🧪 Testing Guidelines
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
- Write unit tests for all business logic
|
||||||
|
- Use table-driven tests for multiple scenarios
|
||||||
|
- Mock external dependencies using interfaces
|
||||||
|
- Test error cases, not just happy paths
|
||||||
|
- Use meaningful test names that describe the scenario
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
- Place tests in same package as code being tested
|
||||||
|
- Use `_test.go` suffix for test files
|
||||||
|
- Create test utilities in `testutil` package if needed
|
||||||
|
- Use setup/teardown functions for common test data
|
||||||
|
|
||||||
|
## 📋 Configuration Management
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
- Use YAML for configuration files
|
||||||
|
- Support environment variable overrides
|
||||||
|
- Validate configuration on startup
|
||||||
|
- Use default values for optional settings
|
||||||
|
- Document all configuration options
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- Use consistent naming convention (e.g., `TM_DATABASE_URI`)
|
||||||
|
- Never commit secrets in configuration files
|
||||||
|
- Use different configs for different environments
|
||||||
|
|
||||||
|
## 🚀 Performance Guidelines
|
||||||
|
|
||||||
|
### Optimization
|
||||||
|
- Use database indexes appropriately
|
||||||
|
- Implement caching for frequently accessed data
|
||||||
|
- Use connection pooling for databases
|
||||||
|
- Avoid N+1 queries
|
||||||
|
- Profile performance bottlenecks
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
- Close resources properly (database connections, files)
|
||||||
|
- Use context for cancellation and timeouts
|
||||||
|
- Avoid memory leaks in goroutines
|
||||||
|
- Use sync.Pool for frequently allocated objects
|
||||||
|
|
||||||
|
## 📚 Documentation Standards
|
||||||
|
|
||||||
|
### Code Documentation
|
||||||
|
- Write clear comments for complex business logic
|
||||||
|
- Document all public interfaces and types
|
||||||
|
- Use Go doc comments format
|
||||||
|
- Include examples for complex functions
|
||||||
|
- Keep comments up to date with code changes
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
- Use OpenAPI/Swagger annotations
|
||||||
|
- Document all endpoints with examples
|
||||||
|
- Include error response examples
|
||||||
|
- Document authentication requirements
|
||||||
|
|
||||||
|
## 🔐 Security Best Practices
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use JWT tokens with proper expiration
|
||||||
|
- Implement refresh token rotation
|
||||||
|
- Use bcrypt for password hashing
|
||||||
|
- Validate token claims properly
|
||||||
|
- Implement proper RBAC
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
- Validate all inputs at API boundaries
|
||||||
|
- Use whitelist validation, not blacklist
|
||||||
|
- Sanitize inputs to prevent injection attacks
|
||||||
|
- Validate file uploads properly
|
||||||
|
|
||||||
|
## 📦 Dependency Management
|
||||||
|
|
||||||
|
### Go Modules
|
||||||
|
- Keep go.mod clean and organized
|
||||||
|
- Pin major versions for stability
|
||||||
|
- Regular dependency updates with testing
|
||||||
|
- Remove unused dependencies
|
||||||
|
- Use vendor directory for critical dependencies
|
||||||
|
|
||||||
|
### External Libraries
|
||||||
|
- Prefer standard library when possible
|
||||||
|
- Choose well-maintained libraries
|
||||||
|
- Avoid dependencies with security issues
|
||||||
|
- Keep dependencies minimal
|
||||||
|
|
||||||
|
## 🔄 Code Review Guidelines
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
- Run `go fmt`, `go vet`, and linting tools
|
||||||
|
- Ensure all tests pass
|
||||||
|
- Check for proper error handling
|
||||||
|
- Verify logging is appropriate
|
||||||
|
- Confirm no sensitive data is exposed
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Follow single responsibility principle
|
||||||
|
- Avoid code duplication
|
||||||
|
- Use meaningful variable names
|
||||||
|
- Keep functions and files reasonably sized
|
||||||
|
- Maintain consistent code style
|
||||||
|
|
||||||
|
## 🚨 Error Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
- Don't use panic for regular error handling
|
||||||
|
- Avoid global variables for application state
|
||||||
|
- Don't ignore context cancellation
|
||||||
|
- Avoid deeply nested if statements
|
||||||
|
- Don't use reflection unless absolutely necessary
|
||||||
|
|
||||||
|
### Common Mistakes
|
||||||
|
- Not handling database connection errors
|
||||||
|
- Forgetting to close resources
|
||||||
|
- Not validating user inputs
|
||||||
|
- Exposing internal errors to clients
|
||||||
|
- Not using proper HTTP status codes
|
||||||
|
|
||||||
|
## 📋 Checklist for New Features
|
||||||
|
|
||||||
|
### Before Implementation
|
||||||
|
- [ ] Define interfaces in domain layer
|
||||||
|
- [ ] Create proper request/response DTOs
|
||||||
|
- [ ] Plan error handling strategy
|
||||||
|
- [ ] Design database schema if needed
|
||||||
|
- [ ] Consider caching requirements
|
||||||
|
|
||||||
|
### During Implementation
|
||||||
|
- [ ] Follow clean architecture layers
|
||||||
|
- [ ] Use dependency injection
|
||||||
|
- [ ] Implement proper logging
|
||||||
|
- [ ] Add input validation
|
||||||
|
- [ ] Handle errors gracefully
|
||||||
|
|
||||||
|
### After Implementation
|
||||||
|
- [ ] Write unit tests
|
||||||
|
- [ ] Update API documentation
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
- [ ] Verify security implications
|
||||||
|
- [ ] Check performance impact
|
||||||
|
|
||||||
|
## 🎯 Code Examples
|
||||||
|
|
||||||
|
### Service Implementation Template
|
||||||
|
```go
|
||||||
|
type UserServiceImpl struct {
|
||||||
|
userRepo domain.UserRepository
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(userRepo domain.UserRepository, logger logger.Logger) domain.UserService {
|
||||||
|
return &UserServiceImpl{
|
||||||
|
userRepo: userRepo,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) CreateUser(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) {
|
||||||
|
// Validate input
|
||||||
|
if err := validateCreateUserRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business logic
|
||||||
|
user := &domain.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: req.Email,
|
||||||
|
// ... other fields
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository call
|
||||||
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
|
s.logger.Error("Failed to create user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User created successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handler Implementation Template
|
||||||
|
```go
|
||||||
|
func (h *UserHandler) CreateUser(c echo.Context) error {
|
||||||
|
var req domain.CreateUserRequest
|
||||||
|
|
||||||
|
// Bind and validate request
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
user, err := h.userService.CreateUser(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Create user failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.InternalServerError(c, "Failed to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Created(c, user, "User created successfully")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Remember
|
||||||
|
- Prioritize simplicity and readability over cleverness
|
||||||
|
- Make code self-documenting through clear naming
|
||||||
|
- Always consider the person who will maintain this code
|
||||||
|
- Test your code thoroughly
|
||||||
|
- Security and performance are not optional
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# Configuration files with secrets
|
||||||
|
config.local.yaml
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
docker-data/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
#
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
# Development Specification
|
||||||
|
|
||||||
|
# Tender Management
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
## **For AEC**
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
# **Document Metadata** {#document-metadata}
|
||||||
|
|
||||||
|
| Field | Information |
|
||||||
|
| ----- | ----- |
|
||||||
|
| **Document Name** | Tender Management Development Specification |
|
||||||
|
| **Version** | 1.0 |
|
||||||
|
| **Created By** | Niki Sagharidooz |
|
||||||
|
| **Creation Date** | Jun 09, 2025 |
|
||||||
|
| **Last Updated** | Jun 28, 2025 |
|
||||||
|
| **Status** | Draft |
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
# Table of Content
|
||||||
|
|
||||||
|
[**Document Metadata 2**](#document-metadata)
|
||||||
|
|
||||||
|
[**Project Overview 5**](#project-overview)
|
||||||
|
|
||||||
|
[Purpose 5](#purpose)
|
||||||
|
|
||||||
|
[Problem Statement: Why This System Is Needed 6](#problem-statement:-why-this-system-is-needed)
|
||||||
|
|
||||||
|
[Scope 8](#scope)
|
||||||
|
|
||||||
|
[Technology Stack 11](#technology-stack)
|
||||||
|
|
||||||
|
[Step-by-Step Workflow 12](#step-by-step-workflow)
|
||||||
|
|
||||||
|
[Technical Implementation 16](#technical-implementation)
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
# **Project Overview** {#project-overview}
|
||||||
|
|
||||||
|
## **Purpose** {#purpose}
|
||||||
|
|
||||||
|
The purpose of this project is to develop a smart system that:
|
||||||
|
|
||||||
|
* Uses AI and machine learning to identify tenders that are relevant to a company’s products, services, or interests.
|
||||||
|
* Automatically extracts and interprets tender deadlines and required documents.
|
||||||
|
* Sends real-time notifications to customers based on their favorite topics.
|
||||||
|
* Downloads all tender-related documents automatically, including from websites requiring login, reducing the need for manual monitoring and data entry.
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
## **Problem Statement: Why This System Is Needed** {#problem-statement:-why-this-system-is-needed}
|
||||||
|
|
||||||
|
Organizations often face major inefficiencies and missed opportunities when trying to stay updated with relevant tenders. These challenges include:
|
||||||
|
|
||||||
|
### **1\. Manual Tender Discovery**
|
||||||
|
|
||||||
|
* Companies must monitor various tender websites manually—each with different formats, layouts, and login requirements.
|
||||||
|
* Most tender platforms are designed for desktop, making them inconvenient for mobile-first customers.
|
||||||
|
* This results in delayed access and increased risk of missing relevant tenders.
|
||||||
|
|
||||||
|
### **2\. Language Barriers**
|
||||||
|
|
||||||
|
* Tenders are often published in local or foreign languages, requiring manual translation for teams to understand the content.
|
||||||
|
* This slows down decision-making and increases dependency on translators.
|
||||||
|
|
||||||
|
### **3\. Unstructured and Scattered Documents**
|
||||||
|
|
||||||
|
* Tender documents are scattered across platforms and hidden behind logins.
|
||||||
|
* Required documents may include PDFs, scanned images, Excel sheets, and multi-part attachments.
|
||||||
|
* Manual download and sorting wastes time and increases the risk of incomplete or incorrect submissions.
|
||||||
|
|
||||||
|
### **4\. Irrelevant Notifications**
|
||||||
|
|
||||||
|
* Existing systems typically send bulk notifications, regardless of a company’s business focus.
|
||||||
|
* Without AI to understand company profiles or documents, users receive low-value and unrelated tenders, causing disengagement.
|
||||||
|
|
||||||
|
### **5\. Missed Deadlines**
|
||||||
|
|
||||||
|
* Deadlines are often hidden deep in documents, not always in structured form.
|
||||||
|
* Manual review means companies often respond too late.
|
||||||
|
|
||||||
|
### **6\. Missing Legal Documents or Partnership Requirements**
|
||||||
|
|
||||||
|
* Some tenders require specific legal registrations or partner documentation that the company may lack.
|
||||||
|
* Without guidance or support, companies are **disqualified** or discouraged from applying.
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
## **Scope** {#scope}
|
||||||
|
|
||||||
|
### **Tender Discovery & Matching**
|
||||||
|
|
||||||
|
* Automatically gather and classify tenders from various portals using scraping and APIs.
|
||||||
|
* Use NLP to categorize tenders by industry, keywords, and relevance.
|
||||||
|
* Enable users to filter and search based on personalized criteria.
|
||||||
|
|
||||||
|
### **Document Management & Automation**
|
||||||
|
|
||||||
|
* Automate downloading of tender documents from multiple portals, even behind logins.
|
||||||
|
* Securely store and organize documents, avoiding duplicates using checksums.
|
||||||
|
* Support uploads via dashboard or email for company files like catalogs or contracts.
|
||||||
|
|
||||||
|
### **Company & Business Knowledge Integration**
|
||||||
|
|
||||||
|
* Extract structured insights from business documents using AI and NLP.
|
||||||
|
* Understand products, services, and offerings to inform tender matching.
|
||||||
|
* Continuously enrich the system’s contextual knowledge of each company.
|
||||||
|
|
||||||
|
### **Tender Recommendation Engine**
|
||||||
|
|
||||||
|
* Match tenders with company offerings and user interests using advanced AI models.
|
||||||
|
* Use semantic similarity, document analysis, and saved preferences to score relevance.
|
||||||
|
* Provide tailored recommendations and allow user feedback for improved results.
|
||||||
|
|
||||||
|
### **Notification system**
|
||||||
|
|
||||||
|
* Send email and mobile alerts for new and relevant tenders.
|
||||||
|
* Support customizable frequency settings (real-time, daily, weekly).
|
||||||
|
* Highlight high-priority tenders and deadline reminders automatically.
|
||||||
|
|
||||||
|
### **Mobile and desktop application**
|
||||||
|
|
||||||
|
* Offer a responsive web and mobile app experience for viewing tenders and alerts.
|
||||||
|
* Allow users to review, filter, and act on tenders anytime, anywhere.
|
||||||
|
* Ensure mobile-first UI for busy professionals on the go.
|
||||||
|
|
||||||
|
### **Legal & Compliance Assistance**
|
||||||
|
|
||||||
|
* Identify legal requirements and missing documents for specific tenders.
|
||||||
|
* Recommend document preparation or suggest potential partners for compliance.
|
||||||
|
* Support multi-party collaboration for joint tender submissions.
|
||||||
|
|
||||||
|
### **User & Company Profiles**
|
||||||
|
|
||||||
|
* Enable users to save interests, keywords, and preferred industries.
|
||||||
|
* Store company-specific information to personalize tender discovery.
|
||||||
|
* Adapt AI recommendations based on evolving preferences and behavior.
|
||||||
|
|
||||||
|
### **Dashboard & Administration**
|
||||||
|
|
||||||
|
* Provide an intuitive dashboard for tender matches, documents, and timelines.
|
||||||
|
* Include admin tools for managing users, scrapers, and system performance.
|
||||||
|
* Offer insights and analytics on matching accuracy and user activity.
|
||||||
|
|
||||||
|
### **System Integration & APIs**
|
||||||
|
|
||||||
|
* Expose APIs for integration with external CRMs, ERPs, and tender management tools.
|
||||||
|
* Use token-based authentication and modular services for extensibility.
|
||||||
|
* Prepare the system for white-labeling or SaaS partnerships.
|
||||||
|
|
||||||
|
### **Reliability, Monitoring & Deployment**
|
||||||
|
|
||||||
|
* Ensure stable operation with automated testing, error monitoring, and alerts.
|
||||||
|
* Deploy with Docker on secure cloud infrastructure.
|
||||||
|
* Log system behavior and provide real-time status for key components.
|
||||||
|
|
||||||
|
### **Continuous Improvement & Scaling**
|
||||||
|
|
||||||
|
* Retrain AI models with new data to increase accuracy over time.
|
||||||
|
* Update scrapers to adapt to changes in tender portal structures.
|
||||||
|
* Evolve features based on feedback, business needs, and user behavior.
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
## **Technology Stack** {#technology-stack}
|
||||||
|
|
||||||
|
| Component | Technology Choices |
|
||||||
|
| :---- | :---- |
|
||||||
|
| **Frontend** | React Native (admin) |
|
||||||
|
| **Mobile** | Flutter |
|
||||||
|
| **Backend** | GoLang (Echo) |
|
||||||
|
| **AI & NLP** | ? |
|
||||||
|
| **Database** | MongoDB / Redis / RabbitMQ / Elasticsearch |
|
||||||
|
|
||||||
|
## **Step-by-Step Workflow** {#step-by-step-workflow}
|
||||||
|
|
||||||
|
### **1\. Tender Source Monitoring**
|
||||||
|
|
||||||
|
**Objective:** Identify potential tender opportunities in real-time.
|
||||||
|
|
||||||
|
* Maintain a list of trusted tender sources (URLs, portals, APIs like OPIC) in the database.
|
||||||
|
* Schedule regular checks for each source (via scraping or API).
|
||||||
|
* Detect and fetch new tenders based on publication date and update frequency.
|
||||||
|
|
||||||
|
### **2\. Tender Discovery & Data Collection**
|
||||||
|
|
||||||
|
**Objective:** Extract structured data from multiple tender platforms.
|
||||||
|
|
||||||
|
* Automatically navigate tender websites or APIs to collect metadata:
|
||||||
|
* Title, deadline, description, type (RFI, RFP, Tender…), category codes, region, and budget.
|
||||||
|
* For documents and deep content:
|
||||||
|
* Use AI-assisted login handling.
|
||||||
|
* Retrieve attachments (PDFs, Word, Excel) such as tender documents, terms of reference, eligibility criteria.
|
||||||
|
* Automatically translate non-English documents into English using AI-based translation tools (e.g., DeepL API, open-source models).
|
||||||
|
* Store raw and normalized tender data in the local database (MongoDB).
|
||||||
|
|
||||||
|
### **3\. Tender Data Normalization & Categorization**
|
||||||
|
|
||||||
|
**Objective:** Make tenders searchable, sortable, and ready for AI matching.
|
||||||
|
|
||||||
|
* Clean and preprocess texts (tokenization, language detection, stemming).
|
||||||
|
* Use NLP/ML models to:
|
||||||
|
* Classify tenders by industry, sector, or CPV codes.
|
||||||
|
* Extract keywords and technical/legal requirement phrases.
|
||||||
|
* Assign tags for internal indexing and filtering.
|
||||||
|
|
||||||
|
### **4\. Company Onboarding & Knowledge Extraction**
|
||||||
|
|
||||||
|
**Objective:** Understand the company’s business, products, and capabilities.
|
||||||
|
|
||||||
|
* Allow companies to upload documents:
|
||||||
|
* Product catalogs, brochures, project history, certificates, etc.
|
||||||
|
* Accept uploads via dashboard or email parser.
|
||||||
|
* Apply NLP to extract and structure key information:
|
||||||
|
* Keywords, product types, service areas, past experience, legal readiness.
|
||||||
|
|
||||||
|
### **5\. Interest Modeling (Company Preferences)**
|
||||||
|
|
||||||
|
**Objective:** Understand and learn company-specific tender interests.
|
||||||
|
|
||||||
|
* Build a dynamic interest profile using:
|
||||||
|
* Onboarding data
|
||||||
|
* Interactions (liked/disliked tenders)
|
||||||
|
* Previously applied tenders
|
||||||
|
* Use vector similarity models (e.g., SBERT, embedding-based matching) to align tender features with company profiles.
|
||||||
|
|
||||||
|
### **6\. AI-Powered Tender Matching & Feedback Loop**
|
||||||
|
|
||||||
|
**Objective:** Suggest best-fit tenders and refine recommendations.
|
||||||
|
|
||||||
|
* Propose tenders on web and mobile interface
|
||||||
|
* Allow users to like/dislike tenders (swipe or button).
|
||||||
|
* Log actions and train ML models continuously to refine relevance.
|
||||||
|
* Use feedback to:
|
||||||
|
* Improve the similarity score model
|
||||||
|
* Adjust tagging/weighting for interests
|
||||||
|
|
||||||
|
### **7\. Tender Detail View & Document Requirement Analysis**
|
||||||
|
|
||||||
|
**Objective:** Prepare for successful application.
|
||||||
|
|
||||||
|
* Each tender has a detail page showing:
|
||||||
|
* Full description, documents, deadline, eligibility
|
||||||
|
* Analyze attached files (requirements, checklists) using AI.
|
||||||
|
* Compare against company documents to identify what’s missing:
|
||||||
|
* Legal documents (e.g., licenses, certificates)
|
||||||
|
* Technical docs (e.g., specs, experience, financials)
|
||||||
|
* Show completion progress bar and give upload recommendations.
|
||||||
|
|
||||||
|
### **8\. Document Finalization & Submission Preparation**
|
||||||
|
|
||||||
|
**Objective:** Automate preparation of a complete tender submission package.
|
||||||
|
|
||||||
|
* Once all required documents are available:
|
||||||
|
* Convert to required formats
|
||||||
|
* Merge, compress, and sign if needed
|
||||||
|
Check for compliance or special formatting rules
|
||||||
|
* Use submission method:
|
||||||
|
* Self-Apply: Package is downloaded or submitted via tender portal.
|
||||||
|
* Partnership-Apply: System suggests or connects with partners to complete missing parts (e.g., financial eligibility).
|
||||||
|
|
||||||
|
### **9\. Post-Submission Tracking & Follow-Up (Optional Future Phase)**
|
||||||
|
|
||||||
|
**Objective:** Extend lifecycle tracking for transparency and engagement.
|
||||||
|
|
||||||
|
* Allow companies to track status (submitted, under review, won/lost).
|
||||||
|
* Gather outcome data to train models on winning patterns.
|
||||||
|
* Enable reminders for re-submission, future similar tenders.
|
||||||
|
|
||||||
|
## **Technical Implementation** {#technical-implementation}
|
||||||
|
|
||||||
|
### **1\. Tender Source Monitoring**
|
||||||
|
|
||||||
|
Automatically monitor and extract tender metadata and documents from diverse sources (static sites, dynamic pages, APIs), normalize it, and store it in the internal database for further AI processing.
|
||||||
|
|
||||||
|
Goal: Keep track of trusted tender sources and detect the availability of new tenders in near real-time.
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **1\. Tender Source Configuration Table**
|
||||||
|
|
||||||
|
Maintain a registry of sources to monitor.
|
||||||
|
|
||||||
|
* **Sources (for example:)**
|
||||||
|
* [**https://app.mercell.com/**](https://app.mercell.com/)
|
||||||
|
* [**https://ted.europa.eu/**](https://ted.europa.eu/)
|
||||||
|
* **Schema fields:**
|
||||||
|
* id, name, type (API, HTML, JS, PDF)
|
||||||
|
* base\_url
|
||||||
|
* auth\_required (bool)
|
||||||
|
* credentials\_id (link to secure store)
|
||||||
|
* category\_tags, region, language
|
||||||
|
* check\_interval
|
||||||
|
* Last\_checked\_at
|
||||||
|
* status
|
||||||
|
|
||||||
|
##### **2\. Job Scheduler (Dynamic Source Polling)**
|
||||||
|
|
||||||
|
* Use approaches for scheduling and tracking.
|
||||||
|
* Each source is polled based on:
|
||||||
|
* sync\_frequency (e.g., hourly, daily)
|
||||||
|
* last\_checked\_at timestamp
|
||||||
|
* Historical success/failure log
|
||||||
|
* Jobs are batched and distributed to workers based on source type.
|
||||||
|
|
||||||
|
##### **4\. Logging, Monitoring & Recovery**
|
||||||
|
|
||||||
|
* Use ELK Stack or Prometheus \+ Grafana to monitor:
|
||||||
|
* Job failures
|
||||||
|
* Source availability
|
||||||
|
* Scraping errors
|
||||||
|
* Retry logic:
|
||||||
|
* Exponential backoff on failures
|
||||||
|
* Email alerts on consecutive errors per source
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### **2\. Tender Discovery & Data Collection**
|
||||||
|
|
||||||
|
Automatically discover tenders from multiple trusted sources (public APIs, web portals, data feeds), collect and structure their metadata, extract/download documents, and store them in a standardized format for downstream AI processing.
|
||||||
|
|
||||||
|
Goal: Deep-dive into flagged sources, extract structured tender metadata and documents.
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **1\. Scraper Engines**
|
||||||
|
|
||||||
|
* For each source, use appropriate adapter:
|
||||||
|
* **API-based**: REST/SOAP client with pagination and filtering
|
||||||
|
* **HTML-based**: Headless browser automation (e.g., Playwright)
|
||||||
|
* Authenticate if required (Basic Auth, OAuth, or Form login via AI-based login engine)
|
||||||
|
|
||||||
|
##### **2\. Metadata & Document Extraction**
|
||||||
|
|
||||||
|
* Parse listings to extract structured tender metadata:
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| :---- | :---- |
|
||||||
|
| title | Billing system |
|
||||||
|
| Summary | **Risks & contract compliance** Service Level Agreements (SLAs): Support and operational support should be available on weekdays, excluding holidays, where the lowest level should be 6 hours between 7 am and 5 pm Swedish time. Fines and fees: A penalty is payable for delays in commissioning according to the implementation plan. Compliance and Intellectual Property: The Supplier grants free, non-exclusive and unlimited rights of use for the licenses that are part of the Supplier's commitment. **Scope of delivery** Delivery Timelines \- Expected Delivery: 2025-12-01 |
|
||||||
|
| type | RFP, RFI, Tender, etc. |
|
||||||
|
| publication\_date | 2025-08-01T00:00:00Z |
|
||||||
|
| deadline | 2025-08-15T00:00:00Z |
|
||||||
|
| category\_codes | 48000000-8 Software and information systems, 48444100-3 Billing system |
|
||||||
|
| region | Europe, Asia, etc. |
|
||||||
|
| budget | $500,000 USD |
|
||||||
|
| source\_link | Tender detail page:[https://www.opic.com/upphandling/debiteringssystem-(laholmsbuktens-va-ab-halmstad)-aid40481e6db4dfa80c3ead781d5a3fe3a9/?p=8](https://www.opic.com/upphandling/debiteringssystem-\(laholmsbuktens-va-ab-halmstad\)-aid40481e6db4dfa80c3ead781d5a3fe3a9/?p=8) |
|
||||||
|
| document\_links | Tender document pages (Generate SHA256 for deduplication) |
|
||||||
|
|
||||||
|
#####
|
||||||
|
|
||||||
|
##### **3\. OCR & Translation Pipeline**
|
||||||
|
|
||||||
|
* Detect document language (via langdetect, fastText)
|
||||||
|
* If not English:
|
||||||
|
* Use AI for translation
|
||||||
|
* Store both original and translated versions with versioning.
|
||||||
|
|
||||||
|
##### **4\. Normalization & Storage**
|
||||||
|
|
||||||
|
* Store in PostgreSQL or MongoDB:
|
||||||
|
* **PostgreSQL** for structured metadata (good for querying, indexing)
|
||||||
|
* **MongoDB** for semi-structured documents and text
|
||||||
|
* Normalize dates, categorize tenders using tags and CPV codes
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### **3\. Tender Data Normalization & Categorization**
|
||||||
|
|
||||||
|
Transform raw tender data into structured, consistent, and semantically enriched records to enable precise search, filtering, and intelligent matching. This step prepares tenders for downstream AI models by cleaning, classifying, and tagging their content.
|
||||||
|
|
||||||
|
Goal: Convert unstructured tender data into a clean, searchable, and categorized format, and enable accurate AI-powered tender matching through classification, tagging, and keyword extraction.
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Text Preprocessing Pipeline**
|
||||||
|
|
||||||
|
* Language Detection: Identify the language; translate non-English tenders.
|
||||||
|
* Text Cleaning: Remove HTML, special characters, and noise.
|
||||||
|
* Tokenization: Break text into individual words/phrases.
|
||||||
|
* Stemming/Lemmatization: Reduce words to their root forms for consistency.
|
||||||
|
* Stopword Removal: Exclude irrelevant filler words to improve keyword clarity.
|
||||||
|
|
||||||
|
##### **Tender Classification (Industry, Sector, CPV Code)**
|
||||||
|
|
||||||
|
* Model Training: Train classification models using labeled tender datasets with CPV codes.
|
||||||
|
* Model Inference: Apply the trained model to new tenders to auto-assign categories and codes.
|
||||||
|
* Confidence Scoring: Assign a probability score to each classification for accuracy evaluation or fallback logic.
|
||||||
|
|
||||||
|
##### **Keyword & Requirement Extraction**
|
||||||
|
|
||||||
|
* NER (Named Entity Recognition): Extract names, organizations, locations, and monetary amounts.
|
||||||
|
* Keyphrase Extraction: Use tools to identify key requirements and tender focus areas.
|
||||||
|
* Pattern Matching: detect technical/legal requirements (e.g., “ISO 9001”).
|
||||||
|
|
||||||
|
##### **Tagging & Indexing**
|
||||||
|
|
||||||
|
* Tag Assignment: Auto-generate tags (e.g., “LMS”, “CRM”) based on keywords, industry, and tender type.
|
||||||
|
* Synonym Mapping: Normalize terminology (e.g., “solar” → “renewable energy”) using internal thesauri or mapping tables.
|
||||||
|
* Elasticsearch Indexing: Store all structured and tagged tenders in a search engine like Elasticsearch for fast retrieval.
|
||||||
|
|
||||||
|
##### **Data Storage Format**
|
||||||
|
|
||||||
|
* Save normalized tenders in a JSON schema with fields like title, industry, cpv\_code, tags, keywords, description, and deadline.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### **4\. Company Onboarding & Knowledge Extraction**
|
||||||
|
|
||||||
|
This step enables companies to easily onboard by uploading their business-related documents. Using AI, we extract structured knowledge about their products, services, experience, and legal readiness to enhance tender matching accuracy.
|
||||||
|
|
||||||
|
Goal: Build a structured profile of each company based on uploaded documents and business data, and enable personalized tender recommendations based on real company capabilities and past experience.
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Document Intake & Upload**
|
||||||
|
|
||||||
|
* Dashboard Upload: Support uploading files directly via web interface (drag & drop or file selector).
|
||||||
|
* Email Parser Integration: Set up a dedicated email and use IMAP to retrieve and parse incoming attachments.
|
||||||
|
* Supported File Types: PDF, DOCX, XLSX, CSV, images (with OCR), and plain text.
|
||||||
|
|
||||||
|
##### **File Preprocessing**
|
||||||
|
|
||||||
|
* Document Parsing:
|
||||||
|
* Use tools for text extraction.
|
||||||
|
* OCR support for scanned/image-based documents.
|
||||||
|
* Language Detection & Translation: Auto-translate non-English documents.
|
||||||
|
|
||||||
|
##### **NLP-Based Information Extraction**
|
||||||
|
|
||||||
|
* Named Entity Recognition (NER): Extract key entities such as product names, certifications, partner companies, dates, locations.
|
||||||
|
* Keyphrase Extraction:
|
||||||
|
* Product categories
|
||||||
|
* Service areas
|
||||||
|
* Experience keywords (e.g., “government projects”, “international tenders”)
|
||||||
|
* Legal/readiness terms (e.g., “ISO”, “compliance”, “bond”, “bid security”)
|
||||||
|
* Classification & Tagging: Categorize the company’s domain (e.g., construction, IT services) using multi-label classifiers.
|
||||||
|
|
||||||
|
##### **Structured Data Output**
|
||||||
|
|
||||||
|
* Create Company Profile Object: Normalize and store extracted data in structured format.
|
||||||
|
* Store in Database: Save in MongoDB or PostgreSQL for relational access.
|
||||||
|
|
||||||
|
##### **Dashboard Visualization**
|
||||||
|
|
||||||
|
* Preview Extracted Info: Allow users to review and correct extracted info in their profile dashboard.
|
||||||
|
* Editable Tags & Interests: Companies can refine their expertise or interests to improve recommendations.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### **5\. Interest Modeling (Company Preferences)**
|
||||||
|
|
||||||
|
This step creates a dynamic, evolving interest profile for each company based on onboarding data, behavior, and tender interaction history. It allows the system to tailor tender recommendations using AI-powered semantic understanding.
|
||||||
|
|
||||||
|
Goal: Accurately predict and recommend relevant tenders to each company using machine learning and semantic similarity and Continuously refine recommendations based on real-time feedback like likes, dislikes, and application history.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Interest Profile Construction**
|
||||||
|
|
||||||
|
* Initial Profile Creation:
|
||||||
|
* Extract from onboarding data (e.g., keywords, sectors, product types).
|
||||||
|
* Store as structured embeddings using models or OpenAI embeddings.
|
||||||
|
* Behavioral Signals:
|
||||||
|
* Track:
|
||||||
|
* Tenders the company liked/disliked (via UI interaction).
|
||||||
|
* Tenders viewed in detail.
|
||||||
|
* Tenders the company applied to.
|
||||||
|
* Assign implicit weights to actions:
|
||||||
|
* Applied \> Liked \> Viewed \> Ignored \> Disliked.
|
||||||
|
|
||||||
|
##### **Embedding & Vector Space Modeling**
|
||||||
|
|
||||||
|
* Embed Tender Metadata:
|
||||||
|
* Use models or OpenAI to embed:
|
||||||
|
* Title \+ Description \+ Category \+ Keywords of each tender.
|
||||||
|
* Embed Company Profile:
|
||||||
|
* Combine onboarding profile \+ interacted tenders to form a composite embedding.
|
||||||
|
* Update periodically using weighted average of embeddings and attention mechanisms.
|
||||||
|
|
||||||
|
##### **Matching via Similarity**
|
||||||
|
|
||||||
|
* Calculate Similarity Scores:
|
||||||
|
* Use similarity between tender vectors and the company interest vector.
|
||||||
|
* Rank tenders based on similarity scores.
|
||||||
|
* Dynamic Thresholding:
|
||||||
|
* Auto-adjust thresholds to optimize for engagement.
|
||||||
|
|
||||||
|
##### **Continuous Learning**
|
||||||
|
|
||||||
|
* Feedback Loop:
|
||||||
|
* After each interaction, retrain/update interest vectors with new signals.
|
||||||
|
* Use weighting (e.g., recent feedback has higher influence).
|
||||||
|
* Cold Start Handling:
|
||||||
|
* For new users: rely more on onboarding data and sector-based popularity.
|
||||||
|
|
||||||
|
##### **Data Storage**
|
||||||
|
|
||||||
|
* Store embeddings in vector databases.
|
||||||
|
* Maintain a link between company\_id and interest vectors.
|
||||||
|
|
||||||
|
##### **Dashboard Integration**
|
||||||
|
|
||||||
|
* Allow companies to optionally adjust their visible “interest tags.”
|
||||||
|
* Show feedback insights: “Why this tender was recommended” using explainable AI labels.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### **6\. AI-Powered Tender Matching & Feedback Loop**
|
||||||
|
|
||||||
|
This step delivers personalized tender recommendations to users via a user-friendly interface and continuously improves accuracy by learning from their actions (like, dislike, apply). The system refines its AI model in real-time to better align future matches with user preferences.
|
||||||
|
|
||||||
|
Goal: Display top-matching tenders to companies using semantic similarity and preference learning, and continuously refine matching logic using interaction data to improve recommendation precision.
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Tender Recommendation Engine**
|
||||||
|
|
||||||
|
* Input:
|
||||||
|
* Vector embeddings of tenders (from metadata and documents).
|
||||||
|
* Company interest profile (from onboarding and behavioral history).
|
||||||
|
* Processing:
|
||||||
|
* Calculate similarity between tender vectors and company interest vectors.
|
||||||
|
* Apply filters (e.g., deadlines, budget thresholds, region) as post-processing.
|
||||||
|
* Return top N matches with ranking score.
|
||||||
|
* Output:
|
||||||
|
* List of tenders with scores, tags, and explanations (“matched based on your interest in X”).
|
||||||
|
|
||||||
|
##### **Frontend Integration (Web & Mobile)**
|
||||||
|
|
||||||
|
* Display Recommendations:
|
||||||
|
* Swipe interface (Right \= Like, Left \= Dislike).
|
||||||
|
* Tender Details Page:
|
||||||
|
* View all tender information, documents, requirements.
|
||||||
|
* Show AI-generated tags and relevance explanation.
|
||||||
|
* UX Enhancements:
|
||||||
|
* Save, share, and bookmark options.
|
||||||
|
* Quick apply or partner apply triggers.
|
||||||
|
|
||||||
|
##### **Logging & Feedback Capture**
|
||||||
|
|
||||||
|
* Log every user interaction:
|
||||||
|
* Swipe/Like/Dislike/Apply/Open/View duration.
|
||||||
|
* Tag feedback with tender ID and company ID.
|
||||||
|
* Store in an event table or analytics service.
|
||||||
|
|
||||||
|
##### **Feedback Loop for Model Refinement**
|
||||||
|
|
||||||
|
* Model Update Strategy:
|
||||||
|
* Use positive actions (like/apply) to reinforce embeddings.
|
||||||
|
* Use negative actions (dislike) to reduce weight for similar tenders.
|
||||||
|
* Training Pipeline:
|
||||||
|
* Batch trains daily or weekly on logged feedback.
|
||||||
|
* Fine-tune vector weighting, tagging priorities, and scoring thresholds.
|
||||||
|
* Use techniques like contrastive learning or reinforcement-based re-ranking.
|
||||||
|
* Model Versions:
|
||||||
|
* Track multiple versions of recommendation models.
|
||||||
|
* Monitor engagement metrics for each.
|
||||||
|
|
||||||
|
##### **Continuous Learning Architecture**
|
||||||
|
|
||||||
|
* Maintain:
|
||||||
|
* Embeddings index.
|
||||||
|
* Interaction history per company.
|
||||||
|
* Real-time scoring microservice for fast tender matching.
|
||||||
|
* Periodic retraining with growing interaction dataset.
|
||||||
|
|
||||||
|
### **7\. Tender Detail View & Document Requirement Analysis**
|
||||||
|
|
||||||
|
This feature ensures companies are well-prepared to submit complete, compliant applications by analyzing tender requirements and comparing them against existing company documents. It surfaces gaps and guides users through completion with AI-powered recommendations.
|
||||||
|
|
||||||
|
Goal: Provide an intelligent tender detail view that highlights all requirements and tracks preparation status, and automatically detect missing documents and guide users to complete their application package efficiently.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Tender Detail Page Interface**
|
||||||
|
|
||||||
|
* Display Components:
|
||||||
|
* Tender metadata: Title, type (RFI/RFP), deadline, budget, country, CPV codes.
|
||||||
|
* Downloadable tender documents (with preview).
|
||||||
|
* Eligibility criteria, required legal & technical documents.
|
||||||
|
* AI-suggested tags and summary (extracted from content).
|
||||||
|
* “Like / Dislike” or “Apply Now” buttons.
|
||||||
|
* Progress Features:
|
||||||
|
* Upload area with drag-and-drop or document selection.
|
||||||
|
* Visual progress bar (% of required docs completed).
|
||||||
|
* Real-time upload recommendations: “You’re missing X document for eligibility.”
|
||||||
|
|
||||||
|
##### **Document Content Analysis**
|
||||||
|
|
||||||
|
* Extraction Pipeline:
|
||||||
|
* Automatically parse PDFs, Word, and Excel files from tenders.
|
||||||
|
* Use NLP models to detect requirement sections, deadlines, eligibility criteria, and file checklists.
|
||||||
|
* Extract named entities (licenses, certifications, formats).
|
||||||
|
* Identify legal, financial, and technical expectations using keyword and pattern detection.
|
||||||
|
|
||||||
|
##### **Company Document Matching**
|
||||||
|
|
||||||
|
* Cross-check Requirements vs. Company Assets:
|
||||||
|
* Use company's previously uploaded/onboarded files.
|
||||||
|
* Apply classification to each file (license, portfolio, certificate, etc.).
|
||||||
|
* Match against tender needs using semantic similarity.
|
||||||
|
* Identify missing or outdated documents (based on expiration or file type).
|
||||||
|
* Output:
|
||||||
|
* List of matched vs. missing docs with confidence scores.
|
||||||
|
* Suggested templates or examples if the required doc is not available.
|
||||||
|
* Button to request generation or partner document upload if needed.
|
||||||
|
|
||||||
|
##### **Completion Progress Engine**
|
||||||
|
|
||||||
|
* Logic:
|
||||||
|
* Calculate completeness score as (Matched docs / Required docs).
|
||||||
|
* Show color-coded progress bar (e.g., red \<50%, yellow 50–90%, green 90–100%).
|
||||||
|
* Optional: provide estimated readiness time based on historical upload pace.
|
||||||
|
* Notifications:
|
||||||
|
* Trigger alerts for missing documents with deadlines.
|
||||||
|
* Recommend upload or request from partners.
|
||||||
|
|
||||||
|
##### **Smart Upload Assistant (Optional AI enhancement)**
|
||||||
|
|
||||||
|
* Auto-suggest appropriate files from user’s document repository.
|
||||||
|
* Pre-fill metadata for uploaded documents (e.g., expiry date, type).
|
||||||
|
* Offer translation option for non-English documents before submission.
|
||||||
|
|
||||||
|
### **8\. Document Finalization & Submission Preparation**
|
||||||
|
|
||||||
|
This stage ensures that once all tender requirements are fulfilled, the system automatically assembles a submission-ready package, following each tender’s specific formatting, compliance, and submission rules. Whether submitting solo or through a partner, companies receive a polished, validated package.
|
||||||
|
|
||||||
|
Goal: Streamline and automate the final preparation of all tender documents into a compliant, complete submission package, and support both self-application and partnership-based submission paths
|
||||||
|
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Document Aggregation & Validation**
|
||||||
|
|
||||||
|
* Trigger: Once all required documents are marked as “uploaded” and “valid.”
|
||||||
|
* Actions:
|
||||||
|
* Gather documents from company repository or upload history.
|
||||||
|
* Validate document types, names, and formats against tender requirements.
|
||||||
|
* Check for required elements (e.g., stamps, signatures, date fields, headers).
|
||||||
|
|
||||||
|
##### **Format Conversion & Compilation**
|
||||||
|
|
||||||
|
* Auto-conversion:
|
||||||
|
* Convert all documents to required formats (e.g., PDF/A, DOCX, XLSX).
|
||||||
|
* File merging & compression:
|
||||||
|
* Merge into a single file or grouped ZIP, based on tender rules.
|
||||||
|
* Compress files (e.g., using zip) to meet size limits.
|
||||||
|
|
||||||
|
##### **Compliance & Formatting Checks**
|
||||||
|
|
||||||
|
* Use rule-based validation for each tender (stored in tender metadata):
|
||||||
|
* File size limits
|
||||||
|
* Naming conventions
|
||||||
|
* Required fields (e.g., bid amount, bidder name)
|
||||||
|
* Format-specific checks (password protection, watermarking)
|
||||||
|
* Highlight compliance issues and block submission until resolved.
|
||||||
|
|
||||||
|
##### **Submission Path Handling**
|
||||||
|
|
||||||
|
* Self-Apply Path:
|
||||||
|
* Show final review screen with submission checklist.
|
||||||
|
* Provide “Download Submission Package” or “Auto-submit” via portal login (if credentials provided).
|
||||||
|
* Save submission receipt if submitted automatically.
|
||||||
|
* Partnership-Apply Path:
|
||||||
|
* Check which documents are missing or out of scope (e.g., large financial guarantees).
|
||||||
|
* Initiate request to partner with progress tracker.
|
||||||
|
* Once all parts are covered, follow same compilation and submission flow.
|
||||||
|
|
||||||
|
##### **Logging & Auditing**
|
||||||
|
|
||||||
|
* Save submission timestamp, method, and document hash for auditing.
|
||||||
|
* Generate a PDF summary page: tender info, included documents, company details.
|
||||||
|
|
||||||
|
### **9\. Post-Submission Tracking & Follow-Up**
|
||||||
|
|
||||||
|
This step extends the tender lifecycle beyond submission by allowing companies to monitor outcomes, receive updates, and learn from past results. It also feeds valuable outcome data back into the AI to improve future tender matching and interest modeling.
|
||||||
|
|
||||||
|
Goal: Provide full visibility into the status of submitted tenders and enable intelligent follow-ups, and collect feedback data to refine AI recommendations and improve success rates over time
|
||||||
|
|
||||||
|
|
||||||
|
#### **Implementation Steps:**
|
||||||
|
|
||||||
|
##### **Submission Status Tracking**
|
||||||
|
|
||||||
|
* Integration points:
|
||||||
|
* Where supported, integrate with tender portals via API or email scraping to fetch status updates (e.g., “Under Review”, “Awarded”, “Rejected”).
|
||||||
|
* Otherwise, allow companies to manually update status.
|
||||||
|
* Statuses supported:
|
||||||
|
* Submitted
|
||||||
|
* Under Review
|
||||||
|
* Clarification Requested
|
||||||
|
* Won
|
||||||
|
* Lost
|
||||||
|
* Implementation:
|
||||||
|
* Use webhook listeners or scheduled API polling to monitor external status (where available).
|
||||||
|
* NLP-based email parser to extract outcome data from official tender authority emails.
|
||||||
|
|
||||||
|
##### **Outcome Logging & Analytics**
|
||||||
|
|
||||||
|
* After a result is confirmed:
|
||||||
|
* Log result, including winning bidder info (if public), reason for win/loss (if available).
|
||||||
|
* Store structured outcome data in database (linked to tender ID and company ID).
|
||||||
|
* Trigger learning module: feed result into AI model to improve future predictions.
|
||||||
|
|
||||||
|
##### **Feedback to AI Models**
|
||||||
|
|
||||||
|
* Use outcomes to:
|
||||||
|
* Refine tender-to-company matching algorithm (reward winning features).
|
||||||
|
* Update company interest vectors to reflect real wins/losses.
|
||||||
|
* Adjust tender classification/weighting based on what tends to succeed.
|
||||||
|
|
||||||
|
##### **Follow-Up Reminders & Re-engagement**
|
||||||
|
|
||||||
|
* Features:
|
||||||
|
* Show follow-up prompts like:
|
||||||
|
* “This tender reopens annually – set a reminder?”
|
||||||
|
* “Missed this tender? 3 similar ones are now open.”
|
||||||
|
* Schedule email/SMS reminders based on historical tender dates.
|
||||||
|
* Implementation:
|
||||||
|
* Use clustering or similarity search to suggest future relevant tenders.
|
||||||
|
* Store and query tender metadata to detect recurring patterns (e.g., same title/code).
|
||||||
|
|
||||||
|
##### **Auditable History & Notifications**
|
||||||
|
|
||||||
|
* Display a full lifecycle log in the user dashboard:
|
||||||
|
* Dates of submission, updates, outcome, actions taken.
|
||||||
|
* Allow download of submission receipts and audit logs.
|
||||||
|
* Notify teams of key status changes via web app and email.
|
||||||
|
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
|
||||||
|
# Install git and ca-certificates (needed for go mod download)
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN addgroup -g 1001 -S appgroup && \
|
||||||
|
adduser -u 1001 -S appuser -G appgroup
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags='-w -s -extldflags "-static"' \
|
||||||
|
-a -installsuffix cgo \
|
||||||
|
-o bin/api \
|
||||||
|
cmd/api/main.go
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
# Copy ca-certificates from builder
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
|
||||||
|
# Copy timezone data
|
||||||
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
|
||||||
|
# Copy user information from builder
|
||||||
|
COPY --from=builder /etc/passwd /etc/passwd
|
||||||
|
COPY --from=builder /etc/group /etc/group
|
||||||
|
|
||||||
|
# Copy the binary
|
||||||
|
COPY --from=builder /app/bin/api /api
|
||||||
|
|
||||||
|
# Copy configuration
|
||||||
|
COPY --from=builder /app/configs /configs
|
||||||
|
|
||||||
|
# Use non-root user
|
||||||
|
USER appuser:appgroup
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/api", "-health-check"] || exit 1
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/api"]
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Logging System Upgrade: Logrus → Zap + Lumberjack
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The tender management system has been upgraded from Logrus to **Zap** with **Lumberjack** for high-performance structured logging with automatic log rotation.
|
||||||
|
|
||||||
|
## 🚀 **Improvements**
|
||||||
|
|
||||||
|
### **Performance**
|
||||||
|
- **10x faster** logging performance with Zap
|
||||||
|
- Zero-allocation logging in hot paths
|
||||||
|
- Structured field processing optimized for speed
|
||||||
|
|
||||||
|
### **Log Rotation**
|
||||||
|
- **Automatic log rotation** based on file size, age, and backup count
|
||||||
|
- **Compression** of rotated log files to save disk space
|
||||||
|
- **Configurable retention** policies
|
||||||
|
|
||||||
|
### **Flexibility**
|
||||||
|
- **Multi-output** logging (file + stdout simultaneously)
|
||||||
|
- **Multiple formats** (JSON, console, text)
|
||||||
|
- **Environment-specific** configuration
|
||||||
|
|
||||||
|
## 📁 **Configuration**
|
||||||
|
|
||||||
|
### **YAML Configuration**
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
level: "info" # debug, info, warn, error, fatal
|
||||||
|
format: "json" # json, console, text
|
||||||
|
output: "file" # stdout, stderr, file
|
||||||
|
file:
|
||||||
|
path: "./logs/app.log" # Path to log file
|
||||||
|
max_size: 100 # Max size in MB before rotation
|
||||||
|
max_backups: 5 # Number of backup files to keep
|
||||||
|
max_age: 30 # Max age in days to keep log files
|
||||||
|
compress: true # Compress rotated files
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Environment Variables**
|
||||||
|
```bash
|
||||||
|
export TM_LOGGING_LEVEL="info"
|
||||||
|
export TM_LOGGING_OUTPUT="file"
|
||||||
|
export TM_LOGGING_FILE_PATH="./logs/app.log"
|
||||||
|
export TM_LOGGING_FILE_MAX_SIZE=100
|
||||||
|
export TM_LOGGING_FILE_MAX_BACKUPS=5
|
||||||
|
export TM_LOGGING_FILE_MAX_AGE=30
|
||||||
|
export TM_LOGGING_FILE_COMPRESS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 **Usage**
|
||||||
|
|
||||||
|
### **Same Interface**
|
||||||
|
The logger interface remains the same - **no code changes required** in existing handlers and services:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// All existing code continues to work
|
||||||
|
log.Info("User logged in", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Structured logging with fields
|
||||||
|
logger := log.WithFields(map[string]interface{}{
|
||||||
|
"component": "auth",
|
||||||
|
"request_id": requestID,
|
||||||
|
})
|
||||||
|
logger.Error("Authentication failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### **New Features**
|
||||||
|
```go
|
||||||
|
// Flush logs before shutdown
|
||||||
|
logger.Sync()
|
||||||
|
|
||||||
|
// Or use the global function
|
||||||
|
import "tm/pkg/logger"
|
||||||
|
logger.Sync()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 **Log Rotation Behavior**
|
||||||
|
|
||||||
|
### **File Naming Pattern**
|
||||||
|
```
|
||||||
|
logs/
|
||||||
|
├── app.log # Current log file
|
||||||
|
├── app.log.1 # Most recent backup
|
||||||
|
├── app.log.2 # Older backup
|
||||||
|
├── app.log.3.gz # Compressed older backup
|
||||||
|
├── app.log.4.gz # Compressed older backup
|
||||||
|
└── app.log.5.gz # Oldest backup (will be deleted on next rotation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Rotation Triggers**
|
||||||
|
- **Size-based**: When `app.log` reaches 100MB
|
||||||
|
- **Time-based**: Daily rotation (if configured)
|
||||||
|
- **Manual**: Can be triggered programmatically
|
||||||
|
|
||||||
|
### **Cleanup**
|
||||||
|
- Keeps maximum 5 backup files
|
||||||
|
- Deletes files older than 30 days
|
||||||
|
- Compresses old files to save space
|
||||||
|
|
||||||
|
## 🛠️ **Development Commands**
|
||||||
|
|
||||||
|
### **Log Management**
|
||||||
|
```bash
|
||||||
|
# View live logs
|
||||||
|
make logs
|
||||||
|
|
||||||
|
# Follow logs in real-time
|
||||||
|
make logs-live
|
||||||
|
|
||||||
|
# Clean all log files
|
||||||
|
make logs-clean
|
||||||
|
|
||||||
|
# Clean everything including logs
|
||||||
|
make clean-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Log File Locations**
|
||||||
|
- **Development**: `./logs/app.log`
|
||||||
|
- **Docker**: `/logs/app.log` (mounted volume)
|
||||||
|
- **Production**: Configurable via environment variables
|
||||||
|
|
||||||
|
## 🐳 **Docker Integration**
|
||||||
|
|
||||||
|
### **Volume Mounting**
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
volumes:
|
||||||
|
- ./logs:/logs # Host logs directory mounted to container
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Log Persistence**
|
||||||
|
- Logs persist on the host machine
|
||||||
|
- Rotation works across container restarts
|
||||||
|
- Easy log analysis and monitoring
|
||||||
|
|
||||||
|
## 🔍 **Log Format Examples**
|
||||||
|
|
||||||
|
### **JSON Format (Default)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2024-01-15T10:30:45.123Z",
|
||||||
|
"level": "info",
|
||||||
|
"caller": "handler/auth_handler.go:45",
|
||||||
|
"msg": "User logged in successfully",
|
||||||
|
"user_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"request_id": "req_abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Console Format (Development)**
|
||||||
|
```
|
||||||
|
2024-01-15T10:30:45.123Z INFO handler/auth_handler.go:45 User logged in successfully
|
||||||
|
user_id=123e4567-e89b-12d3-a456-426614174000
|
||||||
|
email=user@example.com
|
||||||
|
request_id=req_abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 **Migration Notes**
|
||||||
|
|
||||||
|
### **Breaking Changes**
|
||||||
|
- **None** - The logger interface remains compatible
|
||||||
|
|
||||||
|
### **Dependencies Updated**
|
||||||
|
```go
|
||||||
|
// Removed
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
|
||||||
|
// Added
|
||||||
|
go.uber.org/zap v1.26.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Configuration Changes**
|
||||||
|
- Added `file` section to logging configuration
|
||||||
|
- `output: "file"` now enables file logging with rotation
|
||||||
|
|
||||||
|
## 📈 **Performance Comparison**
|
||||||
|
|
||||||
|
| Metric | Logrus | Zap | Improvement |
|
||||||
|
|--------|--------|-----|-------------|
|
||||||
|
| **Throughput** | ~100k/sec | ~1M+/sec | **10x faster** |
|
||||||
|
| **Allocations** | High | Zero (hot path) | **Much lower memory** |
|
||||||
|
| **CPU Usage** | Higher | Lower | **Better efficiency** |
|
||||||
|
| **Structured Fields** | Reflection-based | Type-safe | **Faster processing** |
|
||||||
|
|
||||||
|
## 🔧 **Advanced Configuration**
|
||||||
|
|
||||||
|
### **Custom Log Levels**
|
||||||
|
```go
|
||||||
|
// Set different levels for different components
|
||||||
|
config := &infrastructure.LoggingConfig{
|
||||||
|
Level: "debug", // Global level
|
||||||
|
// Component-specific levels can be added later
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Multiple Outputs**
|
||||||
|
```go
|
||||||
|
// Logs go to both file and stdout when output is "file"
|
||||||
|
// Perfect for development - see logs in terminal AND save to file
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Programmatic Control**
|
||||||
|
```go
|
||||||
|
// Flush logs before application shutdown
|
||||||
|
defer logger.Cleanup()
|
||||||
|
|
||||||
|
// Or manually sync
|
||||||
|
if err := logger.Sync(); err != nil {
|
||||||
|
// Handle sync error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 **Best Practices**
|
||||||
|
|
||||||
|
1. **Use structured fields** for better log analysis
|
||||||
|
2. **Set appropriate log levels** (info for production, debug for development)
|
||||||
|
3. **Monitor log file sizes** in production
|
||||||
|
4. **Use log aggregation** tools like ELK stack for production
|
||||||
|
5. **Flush logs** before application shutdown
|
||||||
|
|
||||||
|
## 📚 **Resources**
|
||||||
|
|
||||||
|
- [Zap Documentation](https://pkg.go.dev/go.uber.org/zap)
|
||||||
|
- [Lumberjack Documentation](https://pkg.go.dev/gopkg.in/natefinch/lumberjack.v2)
|
||||||
|
- [Go Logging Best Practices](https://dave.cheney.net/2015/11/05/lets-talk-about-logging)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
.PHONY: help build test clean run docker-build docker-run dev-up dev-down lint fmt vet
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo 'Usage: make [target]'
|
||||||
|
@echo ''
|
||||||
|
@echo 'Targets:'
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
# Build Configuration
|
||||||
|
BINARY_NAME=tm-api
|
||||||
|
MAIN_PATH=./cmd/api/main.go
|
||||||
|
BUILD_DIR=./bin
|
||||||
|
|
||||||
|
# Go Configuration
|
||||||
|
GOCMD=go
|
||||||
|
GOBUILD=$(GOCMD) build
|
||||||
|
GOCLEAN=$(GOCMD) clean
|
||||||
|
GOTEST=$(GOCMD) test
|
||||||
|
GOGET=$(GOCMD) get
|
||||||
|
GOMOD=$(GOCMD) mod
|
||||||
|
GOFMT=gofmt
|
||||||
|
GOVET=$(GOCMD) vet
|
||||||
|
|
||||||
|
# Build flags
|
||||||
|
LDFLAGS=-ldflags "-w -s"
|
||||||
|
BUILD_FLAGS=-a -installsuffix cgo
|
||||||
|
|
||||||
|
# Development
|
||||||
|
dev-up: ## Start development environment with Docker Compose
|
||||||
|
docker-compose up -d mongodb redis rabbitmq elasticsearch
|
||||||
|
@echo "Development services started. Waiting for services to be ready..."
|
||||||
|
@sleep 10
|
||||||
|
@echo "Services should be ready. Run 'make run' to start the API."
|
||||||
|
|
||||||
|
dev-down: ## Stop development environment
|
||||||
|
docker-compose down
|
||||||
|
docker-compose down --volumes --remove-orphans
|
||||||
|
|
||||||
|
dev-logs: ## Show logs from development services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
dev-status: ## Show status of development services
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Building
|
||||||
|
build: ## Build the application
|
||||||
|
mkdir -p $(BUILD_DIR)
|
||||||
|
CGO_ENABLED=0 GOOS=linux $(GOBUILD) $(LDFLAGS) $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PATH)
|
||||||
|
|
||||||
|
build-local: ## Build for local development
|
||||||
|
mkdir -p $(BUILD_DIR)
|
||||||
|
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PATH)
|
||||||
|
|
||||||
|
# Running
|
||||||
|
run: ## Run the application locally
|
||||||
|
$(GOCMD) run $(MAIN_PATH)
|
||||||
|
|
||||||
|
run-binary: build-local ## Build and run the binary
|
||||||
|
$(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
test: ## Run tests
|
||||||
|
$(GOTEST) -v ./...
|
||||||
|
|
||||||
|
test-coverage: ## Run tests with coverage
|
||||||
|
$(GOTEST) -v -coverprofile=coverage.out ./...
|
||||||
|
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||||
|
@echo "Coverage report generated: coverage.html"
|
||||||
|
|
||||||
|
test-race: ## Run tests with race detection
|
||||||
|
$(GOTEST) -race -v ./...
|
||||||
|
|
||||||
|
benchmark: ## Run benchmarks
|
||||||
|
$(GOTEST) -bench=. -benchmem ./...
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
lint: ## Run golangci-lint
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
fmt: ## Format Go code
|
||||||
|
$(GOFMT) -s -w .
|
||||||
|
$(GOCMD) fmt ./...
|
||||||
|
|
||||||
|
vet: ## Run go vet
|
||||||
|
$(GOVET) ./...
|
||||||
|
|
||||||
|
check: fmt vet lint test ## Run all code quality checks
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
deps: ## Download dependencies
|
||||||
|
$(GOMOD) download
|
||||||
|
|
||||||
|
deps-update: ## Update dependencies
|
||||||
|
$(GOMOD) tidy
|
||||||
|
$(GOGET) -u ./...
|
||||||
|
$(GOMOD) tidy
|
||||||
|
|
||||||
|
deps-vendor: ## Vendor dependencies
|
||||||
|
$(GOMOD) vendor
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-build: ## Build Docker image
|
||||||
|
docker build -t $(BINARY_NAME):latest .
|
||||||
|
|
||||||
|
docker-run: ## Run Docker container
|
||||||
|
docker run -p 8080:8080 --name $(BINARY_NAME) $(BINARY_NAME):latest
|
||||||
|
|
||||||
|
docker-stop: ## Stop Docker container
|
||||||
|
docker stop $(BINARY_NAME) || true
|
||||||
|
docker rm $(BINARY_NAME) || true
|
||||||
|
|
||||||
|
docker-push: ## Push Docker image (requires DOCKER_REGISTRY env var)
|
||||||
|
@if [ -z "$(DOCKER_REGISTRY)" ]; then \
|
||||||
|
echo "Error: DOCKER_REGISTRY environment variable is not set"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
docker tag $(BINARY_NAME):latest $(DOCKER_REGISTRY)/$(BINARY_NAME):latest
|
||||||
|
docker push $(DOCKER_REGISTRY)/$(BINARY_NAME):latest
|
||||||
|
|
||||||
|
# Docker Compose
|
||||||
|
compose-up: ## Start all services with Docker Compose
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
compose-up-d: ## Start all services in background
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
compose-down: ## Stop all services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
compose-logs: ## Show logs from all services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
compose-restart: ## Restart all services
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Database
|
||||||
|
db-migrate: ## Run database migrations (TODO: implement)
|
||||||
|
@echo "Database migration not implemented yet"
|
||||||
|
|
||||||
|
db-seed: ## Seed database with test data (TODO: implement)
|
||||||
|
@echo "Database seeding not implemented yet"
|
||||||
|
|
||||||
|
db-reset: ## Reset database (TODO: implement)
|
||||||
|
@echo "Database reset not implemented yet"
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
logs: ## Show application logs (when running locally)
|
||||||
|
tail -f ./logs/app.log
|
||||||
|
|
||||||
|
logs-live: ## Follow live application logs
|
||||||
|
tail -f ./logs/app.log
|
||||||
|
|
||||||
|
logs-clean: ## Clean application logs
|
||||||
|
rm -f ./logs/*.log
|
||||||
|
rm -f ./logs/*.log.gz
|
||||||
|
|
||||||
|
health: ## Check application health
|
||||||
|
curl -f http://localhost:8080/health || echo "Health check failed"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
$(GOCLEAN)
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
|
rm -f coverage.out coverage.html
|
||||||
|
|
||||||
|
clean-all: logs-clean clean ## Clean everything including logs
|
||||||
|
@echo "All artifacts cleaned"
|
||||||
|
|
||||||
|
clean-docker: ## Clean Docker images and containers
|
||||||
|
docker-compose down --volumes --remove-orphans
|
||||||
|
docker system prune -af
|
||||||
|
|
||||||
|
# Release (TODO: implement proper versioning)
|
||||||
|
release: check build docker-build ## Build release version
|
||||||
|
@echo "Release build completed"
|
||||||
|
|
||||||
|
# Production deployment (example)
|
||||||
|
deploy-staging: ## Deploy to staging environment
|
||||||
|
@echo "Deploying to staging..."
|
||||||
|
# Add your staging deployment commands here
|
||||||
|
|
||||||
|
deploy-prod: ## Deploy to production environment
|
||||||
|
@echo "Deploying to production..."
|
||||||
|
# Add your production deployment commands here
|
||||||
|
|
||||||
|
# Generate documentation
|
||||||
|
docs: ## Generate API documentation
|
||||||
|
@echo "Generating API documentation..."
|
||||||
|
# Add swagger/openapi generation here
|
||||||
|
|
||||||
|
# Security
|
||||||
|
security-scan: ## Run security scan on dependencies
|
||||||
|
$(GOCMD) list -json -deps ./... | nancy sleuth
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
install: ## Install the application globally
|
||||||
|
$(GOCMD) install $(MAIN_PATH)
|
||||||
|
|
||||||
|
# Environment setup for new developers
|
||||||
|
setup: ## Setup development environment
|
||||||
|
@echo "Setting up development environment..."
|
||||||
|
$(GOMOD) download
|
||||||
|
@echo "Installing development tools..."
|
||||||
|
$(GOCMD) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
@echo "Setup completed! Run 'make dev-up' to start services and 'make run' to start the API."
|
||||||
|
|
||||||
|
# Show build information
|
||||||
|
info: ## Show build information
|
||||||
|
@echo "Binary Name: $(BINARY_NAME)"
|
||||||
|
@echo "Main Path: $(MAIN_PATH)"
|
||||||
|
@echo "Build Directory: $(BUILD_DIR)"
|
||||||
|
@echo "Go Version: $(shell $(GOCMD) version)"
|
||||||
|
@echo "Git Commit: $(shell git rev-parse --short HEAD 2>/dev/null || echo 'N/A')"
|
||||||
|
@echo "Build Time: $(shell date)"
|
||||||
|
|
||||||
|
# All-in-one development start
|
||||||
|
dev: deps dev-up ## Setup and start complete development environment
|
||||||
|
@echo "Development environment is ready!"
|
||||||
|
@echo "API will be available at: http://localhost:8080"
|
||||||
|
@echo "MongoDB: localhost:27017"
|
||||||
|
@echo "Redis: localhost:6379"
|
||||||
|
@echo "RabbitMQ Management: http://localhost:15672 (admin/password)"
|
||||||
|
@echo "Elasticsearch: http://localhost:9200"
|
||||||
|
@echo "Kibana: http://localhost:5601"
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Tender Management System - Dual User Architecture
|
||||||
|
|
||||||
|
A comprehensive tender management backend system built with Go, featuring separate authentication and authorization for mobile app customers and panel admin users.
|
||||||
|
|
||||||
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
|
This system implements **Clean Architecture** principles with a dual user system:
|
||||||
|
|
||||||
|
### User Types
|
||||||
|
|
||||||
|
1. **Customers (Mobile App Users)**
|
||||||
|
- End users who interact through mobile applications
|
||||||
|
- Can register, view tenders, manage company profiles
|
||||||
|
- Role-based access: `customer_user`, `customer_manager`
|
||||||
|
|
||||||
|
2. **Panel Users (Admin/Staff)**
|
||||||
|
- Administrative users who manage the system
|
||||||
|
- Can manage customers, tenders, companies, and system settings
|
||||||
|
- Role-based access: `admin`, `moderator`, `support`, `analyst`
|
||||||
|
|
||||||
|
### Layer Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Handler Layer │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Customer Auth │ │ User Auth │ │ Legacy Auth │ │
|
||||||
|
│ │ Handler │ │ Handler │ │ Handler │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Service Layer │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Customer Auth │ │ User Auth │ │ Customer │ │
|
||||||
|
│ │ Service │ │ Service │ │ Service │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Repository Layer │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Customer │ │ User │ │ Company │ │
|
||||||
|
│ │ Repository │ │ Repository │ │ Repository │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Domain Layer │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Customer │ │ User │ │ Interfaces │ │
|
||||||
|
│ │ Entity │ │ Entity │ │ & DTOs │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.23+
|
||||||
|
- MongoDB 4.4+
|
||||||
|
- Valid MongoDB connection
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd tm
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure the application**
|
||||||
|
```bash
|
||||||
|
cp configs/config.example.yaml configs/config.yaml
|
||||||
|
# Edit configs/config.yaml with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run database migration**
|
||||||
|
```bash
|
||||||
|
go run migrations/001_dual_user_system.go
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Start the server**
|
||||||
|
```bash
|
||||||
|
go run cmd/api/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### Mobile App (Customer) Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|
|--------|----------|-------------|---------------|
|
||||||
|
| POST | `/api/v1/mobile/auth/register` | Customer registration | No |
|
||||||
|
| POST | `/api/v1/mobile/auth/login` | Customer login | No |
|
||||||
|
| POST | `/api/v1/mobile/auth/refresh` | Refresh tokens | No |
|
||||||
|
| POST | `/api/v1/mobile/auth/verify-email` | Email verification | Yes |
|
||||||
|
| POST | `/api/v1/mobile/auth/change-password` | Change password | Yes |
|
||||||
|
| GET | `/api/v1/mobile/auth/profile` | Get customer profile | Yes |
|
||||||
|
| POST | `/api/v1/mobile/auth/logout` | Customer logout | Yes |
|
||||||
|
|
||||||
|
### Admin Panel (User) Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|
|--------|----------|-------------|---------------|
|
||||||
|
| POST | `/api/v1/admin/auth/login` | Panel user login | No |
|
||||||
|
| POST | `/api/v1/admin/auth/refresh` | Refresh tokens | No |
|
||||||
|
| POST | `/api/v1/admin/auth/change-password` | Change password | Yes |
|
||||||
|
| GET | `/api/v1/admin/auth/profile` | Get user profile | Yes |
|
||||||
|
| POST | `/api/v1/admin/users` | Create panel user | Admin only |
|
||||||
|
| PUT | `/api/v1/admin/users/:id/permissions` | Update permissions | Admin only |
|
||||||
|
|
||||||
|
### Legacy Endpoints (Backward Compatible)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/auth/register` | Generic registration (defaults to customer) |
|
||||||
|
| POST | `/auth/login` | Generic login (tries both types) |
|
||||||
|
| POST | `/auth/refresh` | Generic token refresh |
|
||||||
|
|
||||||
|
## 🔐 Authentication & Authorization
|
||||||
|
|
||||||
|
### Token Structure
|
||||||
|
|
||||||
|
#### Customer Tokens
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer_id": "uuid",
|
||||||
|
"email": "customer@example.com",
|
||||||
|
"role": "customer_user",
|
||||||
|
"company_id": "uuid",
|
||||||
|
"user_type": "customer",
|
||||||
|
"type": "access"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Panel User Tokens
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "uuid",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"role": "admin",
|
||||||
|
"department": "IT",
|
||||||
|
"permissions": ["manage_users", "manage_tenders"],
|
||||||
|
"user_type": "user",
|
||||||
|
"type": "access"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Middleware Options
|
||||||
|
|
||||||
|
- `CustomerOnlyMiddleware()` - Mobile users only
|
||||||
|
- `UserOnlyMiddleware()` - Panel users only
|
||||||
|
- `CustomerRoleMiddleware(roles...)` - Customer role-based access
|
||||||
|
- `UserRoleMiddleware(roles...)` - Panel user role-based access
|
||||||
|
- `UserPermissionMiddleware(permissions...)` - Fine-grained permissions
|
||||||
|
|
||||||
|
### Permission System
|
||||||
|
|
||||||
|
Panel users have granular permissions:
|
||||||
|
- `manage_users` - Create/modify panel users
|
||||||
|
- `manage_tenders` - Tender management
|
||||||
|
- `manage_companies` - Company management
|
||||||
|
- `manage_customers` - Customer management
|
||||||
|
- `view_reports` - Access reporting
|
||||||
|
- `manage_system` - System configuration
|
||||||
|
|
||||||
|
## 📊 Database Schema
|
||||||
|
|
||||||
|
### Collections
|
||||||
|
|
||||||
|
- `customers` - Mobile app users
|
||||||
|
- `users` - Panel users (admin/staff)
|
||||||
|
- `companies` - Company profiles
|
||||||
|
- `tenders` - Tender opportunities
|
||||||
|
- `notifications` - System notifications
|
||||||
|
|
||||||
|
### Sample Data
|
||||||
|
|
||||||
|
The migration creates sample accounts:
|
||||||
|
|
||||||
|
**Admin User:**
|
||||||
|
- Email: `admin@tendermanagement.com`
|
||||||
|
- Password: `admin123!`
|
||||||
|
- Role: `admin`
|
||||||
|
|
||||||
|
**Customer:**
|
||||||
|
- Email: `customer@example.com`
|
||||||
|
- Password: `customer123!`
|
||||||
|
- Role: `customer_user`
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tm/
|
||||||
|
├── cmd/api/ # Application entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── domain/ # Business entities & interfaces
|
||||||
|
│ ├── service/ # Business logic layer
|
||||||
|
│ ├── handler/ # HTTP handlers
|
||||||
|
│ ├── repository/ # Data access layer
|
||||||
|
│ └── infrastructure/ # External dependencies
|
||||||
|
├── pkg/
|
||||||
|
│ ├── logger/ # Logging utilities
|
||||||
|
│ ├── middleware/ # HTTP middleware
|
||||||
|
│ └── response/ # API response utilities
|
||||||
|
├── configs/ # Configuration files
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
└── docs/ # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
1. **Define interfaces in domain layer**
|
||||||
|
2. **Implement business logic in service layer**
|
||||||
|
3. **Create HTTP handlers**
|
||||||
|
4. **Add repository implementations**
|
||||||
|
5. **Wire up in main.go**
|
||||||
|
6. **Add appropriate middleware to routes**
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
Key configuration sections:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
mongodb:
|
||||||
|
uri: "mongodb://localhost:27017"
|
||||||
|
name: "tender_management"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt:
|
||||||
|
secret: "your-jwt-secret"
|
||||||
|
access_token_duration: "15m"
|
||||||
|
refresh_token_duration: "7d"
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "localhost"
|
||||||
|
port: 8080
|
||||||
|
timeout: "30s"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Security Features
|
||||||
|
|
||||||
|
- **Separate Authentication**: Isolated customer and panel user auth
|
||||||
|
- **JWT Tokens**: Stateless authentication with refresh tokens
|
||||||
|
- **Password Hashing**: bcrypt with configurable cost
|
||||||
|
- **Role-Based Access**: Granular role and permission system
|
||||||
|
- **Input Validation**: Comprehensive request validation
|
||||||
|
- **CORS Support**: Configurable cross-origin policies
|
||||||
|
|
||||||
|
## 📈 Monitoring & Logging
|
||||||
|
|
||||||
|
- **Structured Logging**: JSON-formatted logs with context
|
||||||
|
- **Request Logging**: HTTP request/response logging
|
||||||
|
- **Error Tracking**: Contextual error information
|
||||||
|
- **Health Checks**: `/health` endpoint for monitoring
|
||||||
|
|
||||||
|
## 🎯 Key Benefits
|
||||||
|
|
||||||
|
1. **Clear Separation**: Mobile and panel users are completely isolated
|
||||||
|
2. **Security**: Each user type has appropriate authentication and authorization
|
||||||
|
3. **Scalability**: Clean architecture allows easy feature additions
|
||||||
|
4. **Maintainability**: Well-structured code with clear dependencies
|
||||||
|
5. **Backward Compatibility**: Legacy endpoints continue to work
|
||||||
|
6. **Developer Friendly**: Comprehensive logging and error handling
|
||||||
|
|
||||||
|
## 📚 Next Steps
|
||||||
|
|
||||||
|
- [ ] Implement CompanyRepository
|
||||||
|
- [ ] Add tender management endpoints
|
||||||
|
- [ ] Create notification system
|
||||||
|
- [ ] Add file upload functionality
|
||||||
|
- [ ] Implement email verification
|
||||||
|
- [ ] Add rate limiting
|
||||||
|
- [ ] Create API documentation with Swagger
|
||||||
|
- [ ] Add comprehensive test coverage
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/internal/handler"
|
||||||
|
"tm/internal/infrastructure"
|
||||||
|
"tm/internal/repository"
|
||||||
|
"tm/internal/service"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
customMiddleware "tm/pkg/middleware"
|
||||||
|
"tm/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration
|
||||||
|
config, err := infrastructure.LoadConfig("./configs")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to load config: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
logger.InitLogger(&config.Logging)
|
||||||
|
log := logger.GetLogger()
|
||||||
|
|
||||||
|
log.Info("Starting Tender Management API Server", map[string]interface{}{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"port": config.Server.Port,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize MongoDB connection
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
mongoClient, err := mongo.Connect(ctx, options.Client().ApplyURI(config.Database.MongoDB.URI))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to connect to MongoDB", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping MongoDB to verify connection
|
||||||
|
if err = mongoClient.Ping(ctx, nil); err != nil {
|
||||||
|
log.Fatal("Failed to ping MongoDB", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Connected to MongoDB successfully", map[string]interface{}{
|
||||||
|
"database": config.Database.MongoDB.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get MongoDB database
|
||||||
|
db := mongoClient.Database(config.Database.MongoDB.Name)
|
||||||
|
|
||||||
|
// Initialize validator
|
||||||
|
validate := validator.New()
|
||||||
|
|
||||||
|
// Initialize repositories
|
||||||
|
customerRepo := repository.NewCustomerRepository(db, log)
|
||||||
|
userRepo := repository.NewUserRepository(db, log)
|
||||||
|
companyRepo := repository.NewCompanyRepository(db, log)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
customerAuthService := service.NewCustomerAuthService(customerRepo, companyRepo, &config.Auth, log)
|
||||||
|
userAuthService := service.NewUserAuthService(userRepo, &config.Auth, log)
|
||||||
|
// TODO: Initialize customer service when needed for admin customer management
|
||||||
|
// customerService := service.NewCustomerService(customerRepo, log)
|
||||||
|
|
||||||
|
// Backward compatibility - create generic auth service
|
||||||
|
authService := handler.NewAuthHandler(customerAuthService, userAuthService, validate, log)
|
||||||
|
|
||||||
|
// Initialize handlers
|
||||||
|
customerAuthHandler := handler.NewCustomerAuthHandler(customerAuthService, validate, log)
|
||||||
|
userAuthHandler := handler.NewUserAuthHandler(userAuthService, validate, log)
|
||||||
|
|
||||||
|
// Initialize Echo
|
||||||
|
e := echo.New()
|
||||||
|
|
||||||
|
// Configure Echo
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
|
||||||
|
// Add built-in middleware
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
e.Use(middleware.CORS())
|
||||||
|
e.Use(middleware.RequestID())
|
||||||
|
|
||||||
|
// Add custom middleware for logging
|
||||||
|
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
err := next(c)
|
||||||
|
|
||||||
|
log.Info("HTTP Request", map[string]interface{}{
|
||||||
|
"method": c.Request().Method,
|
||||||
|
"path": c.Request().URL.Path,
|
||||||
|
"status": c.Response().Status,
|
||||||
|
"duration": time.Since(start).String(),
|
||||||
|
"request_id": c.Response().Header().Get(echo.HeaderXRequestID),
|
||||||
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add authentication middleware
|
||||||
|
e.Use(customMiddleware.AuthMiddleware(customerAuthService, userAuthService, log))
|
||||||
|
|
||||||
|
// Setup routes
|
||||||
|
setupRoutes(e, authService, customerAuthHandler, userAuthHandler)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
e.GET("/health", func(c echo.Context) error {
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": time.Now().UTC(),
|
||||||
|
"version": "1.0.0",
|
||||||
|
}, "Service is healthy")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server in a goroutine
|
||||||
|
go func() {
|
||||||
|
address := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
|
||||||
|
log.Info("Server starting", map[string]interface{}{
|
||||||
|
"address": address,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := e.Start(address); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal("Failed to start server", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for interrupt signal to gracefully shutdown the server
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Info("Server is shutting down...", nil)
|
||||||
|
|
||||||
|
// Create a deadline for graceful shutdown
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), config.Server.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Gracefully shutdown the server
|
||||||
|
if err := e.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatal("Server forced to shutdown", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close MongoDB connection
|
||||||
|
if err := mongoClient.Disconnect(context.Background()); err != nil {
|
||||||
|
log.Error("Failed to disconnect from MongoDB", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Server stopped gracefully", nil)
|
||||||
|
|
||||||
|
// Cleanup logger to flush any remaining logs
|
||||||
|
logger.Cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRoutes configures all API routes
|
||||||
|
func setupRoutes(
|
||||||
|
e *echo.Echo,
|
||||||
|
authHandler *handler.AuthHandler,
|
||||||
|
customerAuthHandler *handler.CustomerAuthHandler,
|
||||||
|
userAuthHandler *handler.UserAuthHandler,
|
||||||
|
) {
|
||||||
|
// Legacy/Generic authentication routes (backward compatibility)
|
||||||
|
auth := e.Group("/auth")
|
||||||
|
auth.POST("/register", authHandler.Register)
|
||||||
|
auth.POST("/login", authHandler.Login)
|
||||||
|
auth.POST("/refresh", authHandler.RefreshToken)
|
||||||
|
auth.POST("/change-password", authHandler.ChangePassword)
|
||||||
|
auth.GET("/profile", authHandler.GetProfile)
|
||||||
|
auth.POST("/logout", authHandler.Logout)
|
||||||
|
|
||||||
|
// API version group
|
||||||
|
api := e.Group("/api/v1")
|
||||||
|
|
||||||
|
// Mobile app routes (customers)
|
||||||
|
mobile := api.Group("/mobile")
|
||||||
|
mobileAuth := mobile.Group("/auth")
|
||||||
|
mobileAuth.POST("/register", customerAuthHandler.Register)
|
||||||
|
mobileAuth.POST("/login", customerAuthHandler.Login)
|
||||||
|
mobileAuth.POST("/refresh", customerAuthHandler.RefreshToken)
|
||||||
|
mobileAuth.POST("/verify-email", customerAuthHandler.VerifyEmail)
|
||||||
|
mobileAuth.POST("/resend-verification", customerAuthHandler.ResendVerification)
|
||||||
|
|
||||||
|
// Protected mobile routes
|
||||||
|
mobileAuth.POST("/change-password", customerAuthHandler.ChangePassword, customMiddleware.CustomerOnlyMiddleware())
|
||||||
|
mobileAuth.GET("/profile", customerAuthHandler.GetProfile, customMiddleware.CustomerOnlyMiddleware())
|
||||||
|
mobileAuth.POST("/logout", customerAuthHandler.Logout, customMiddleware.CustomerOnlyMiddleware())
|
||||||
|
|
||||||
|
// Mobile app tenders routes
|
||||||
|
mobileTenders := mobile.Group("/tenders", customMiddleware.CustomerOnlyMiddleware())
|
||||||
|
// TODO: Add tender routes for customers
|
||||||
|
_ = mobileTenders
|
||||||
|
|
||||||
|
// Mobile app companies routes
|
||||||
|
mobileCompanies := mobile.Group("/companies", customMiddleware.CustomerOnlyMiddleware())
|
||||||
|
// TODO: Add company routes for customers
|
||||||
|
_ = mobileCompanies
|
||||||
|
|
||||||
|
// Admin panel routes (panel users)
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
adminAuth := admin.Group("/auth")
|
||||||
|
adminAuth.POST("/login", userAuthHandler.Login)
|
||||||
|
adminAuth.POST("/refresh", userAuthHandler.RefreshToken)
|
||||||
|
|
||||||
|
// Protected admin auth routes
|
||||||
|
adminAuth.POST("/change-password", userAuthHandler.ChangePassword, customMiddleware.UserOnlyMiddleware())
|
||||||
|
adminAuth.GET("/profile", userAuthHandler.GetProfile, customMiddleware.UserOnlyMiddleware())
|
||||||
|
adminAuth.POST("/logout", userAuthHandler.Logout, customMiddleware.UserOnlyMiddleware())
|
||||||
|
|
||||||
|
// Admin user management routes (admin only)
|
||||||
|
adminUsers := admin.Group("/users", customMiddleware.UserRoleMiddleware(domain.UserRoleAdmin))
|
||||||
|
adminUsers.POST("", userAuthHandler.CreatePanelUser)
|
||||||
|
adminUsers.PUT("/:user_id/permissions", userAuthHandler.UpdateUserPermissions)
|
||||||
|
|
||||||
|
// Admin customer management routes
|
||||||
|
adminCustomers := admin.Group("/customers", customMiddleware.UserRoleMiddleware(domain.UserRoleAdmin, domain.UserRoleModerator))
|
||||||
|
// TODO: Add customer management routes for admins
|
||||||
|
_ = adminCustomers
|
||||||
|
|
||||||
|
// Admin tender management routes
|
||||||
|
adminTenders := admin.Group("/tenders", customMiddleware.UserPermissionMiddleware("manage_tenders"))
|
||||||
|
// TODO: Add tender management routes for admins
|
||||||
|
_ = adminTenders
|
||||||
|
|
||||||
|
// Admin company management routes
|
||||||
|
adminCompanies := admin.Group("/companies", customMiddleware.UserPermissionMiddleware("manage_companies"))
|
||||||
|
// TODO: Add company management routes for admins
|
||||||
|
_ = adminCompanies
|
||||||
|
|
||||||
|
// Admin reports routes
|
||||||
|
adminReports := admin.Group("/reports", customMiddleware.UserPermissionMiddleware("view_reports"))
|
||||||
|
// TODO: Add reporting routes for admins
|
||||||
|
_ = adminReports
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
timeout: 30s
|
||||||
|
read_timeout: 10s
|
||||||
|
write_timeout: 10s
|
||||||
|
|
||||||
|
database:
|
||||||
|
mongodb:
|
||||||
|
uri: "mongodb://localhost:27017"
|
||||||
|
name: "tender_management"
|
||||||
|
timeout: 10s
|
||||||
|
max_pool_size: 100
|
||||||
|
|
||||||
|
cache:
|
||||||
|
redis:
|
||||||
|
host: "localhost"
|
||||||
|
port: 6379
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
pool_size: 10
|
||||||
|
|
||||||
|
queue:
|
||||||
|
rabbitmq:
|
||||||
|
url: "amqp://guest:guest@localhost:5672/"
|
||||||
|
exchange: "tender_exchange"
|
||||||
|
queues:
|
||||||
|
scraping: "scraping_queue"
|
||||||
|
processing: "processing_queue"
|
||||||
|
notifications: "notifications_queue"
|
||||||
|
|
||||||
|
search:
|
||||||
|
elasticsearch:
|
||||||
|
urls: ["http://localhost:9200"]
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
index_prefix: "tender_"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt:
|
||||||
|
secret: "your-super-secret-key-change-in-production"
|
||||||
|
access_token_duration: "24h"
|
||||||
|
refresh_token_duration: "168h" # 7 days
|
||||||
|
|
||||||
|
ai:
|
||||||
|
openai:
|
||||||
|
api_key: "your-openai-api-key"
|
||||||
|
model: "gpt-3.5-turbo"
|
||||||
|
max_tokens: 1000
|
||||||
|
|
||||||
|
scraping:
|
||||||
|
user_agent: "TenderBot/1.0"
|
||||||
|
timeout: 30s
|
||||||
|
max_retries: 3
|
||||||
|
delay_between_requests: 1s
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info" # debug, info, warn, error, fatal
|
||||||
|
format: "json" # json, console, text
|
||||||
|
output: "file" # stdout, stderr, file
|
||||||
|
file:
|
||||||
|
path: "./logs/app.log" # Path to log file
|
||||||
|
max_size: 100 # Max size in MB before rotation
|
||||||
|
max_backups: 5 # Number of backup files to keep
|
||||||
|
max_age: 30 # Max age in days to keep log files
|
||||||
|
compress: true # Compress rotated files
|
||||||
|
|
||||||
|
rate_limiting:
|
||||||
|
requests_per_minute: 100
|
||||||
|
burst: 20
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
timeout: 30s
|
||||||
|
read_timeout: 10s
|
||||||
|
write_timeout: 10s
|
||||||
|
|
||||||
|
database:
|
||||||
|
mongodb:
|
||||||
|
uri: "mongodb://localhost:27017"
|
||||||
|
name: "tender_management"
|
||||||
|
timeout: 10s
|
||||||
|
max_pool_size: 100
|
||||||
|
|
||||||
|
cache:
|
||||||
|
redis:
|
||||||
|
host: "localhost"
|
||||||
|
port: 6379
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
pool_size: 10
|
||||||
|
|
||||||
|
queue:
|
||||||
|
rabbitmq:
|
||||||
|
url: "amqp://guest:guest@localhost:5672/"
|
||||||
|
exchange: "tender_exchange"
|
||||||
|
queues:
|
||||||
|
scraping: "scraping_queue"
|
||||||
|
processing: "processing_queue"
|
||||||
|
notifications: "notifications_queue"
|
||||||
|
|
||||||
|
search:
|
||||||
|
elasticsearch:
|
||||||
|
urls: ["http://localhost:9200"]
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
index_prefix: "tender_"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt:
|
||||||
|
secret: "your-super-secret-key"
|
||||||
|
access_token_duration: "24h"
|
||||||
|
refresh_token_duration: "168h" # 7 days
|
||||||
|
|
||||||
|
ai:
|
||||||
|
openai:
|
||||||
|
api_key: ""
|
||||||
|
model: "gpt-3.5-turbo"
|
||||||
|
max_tokens: 1000
|
||||||
|
|
||||||
|
scraping:
|
||||||
|
user_agent: "TenderBot/1.0"
|
||||||
|
timeout: 30s
|
||||||
|
max_retries: 3
|
||||||
|
delay_between_requests: 1s
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
format: "json"
|
||||||
|
output: "file" # stdout, stderr, file
|
||||||
|
file:
|
||||||
|
path: "./logs/app.log"
|
||||||
|
max_size: 100 # Max size in MB before rotation
|
||||||
|
max_backups: 5 # Number of backup files to keep
|
||||||
|
max_age: 30 # Max age in days to keep log files
|
||||||
|
compress: true # Compress rotated files
|
||||||
|
|
||||||
|
rate_limiting:
|
||||||
|
requests_per_minute: 100
|
||||||
|
burst: 20
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# MongoDB Database
|
||||||
|
mongodb:
|
||||||
|
image: mongo:6.0
|
||||||
|
container_name: tm_mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: admin
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: password
|
||||||
|
MONGO_INITDB_DATABASE: tender_management
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
- ./scripts/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
|
||||||
|
# Redis Cache
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tm_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass password
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
|
||||||
|
# RabbitMQ Message Queue
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3-management-alpine
|
||||||
|
container_name: tm_rabbitmq
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: admin
|
||||||
|
RABBITMQ_DEFAULT_PASS: password
|
||||||
|
RABBITMQ_DEFAULT_VHOST: /
|
||||||
|
ports:
|
||||||
|
- "5672:5672" # AMQP port
|
||||||
|
- "15672:15672" # Management UI
|
||||||
|
volumes:
|
||||||
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
|
||||||
|
# Elasticsearch Search Engine
|
||||||
|
elasticsearch:
|
||||||
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
|
||||||
|
container_name: tm_elasticsearch
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- discovery.type=single-node
|
||||||
|
- xpack.security.enabled=false
|
||||||
|
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||||
|
ports:
|
||||||
|
- "9200:9200"
|
||||||
|
- "9300:9300"
|
||||||
|
volumes:
|
||||||
|
- elasticsearch_data:/usr/share/elasticsearch/data
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
|
||||||
|
# Kibana (Optional - for Elasticsearch management)
|
||||||
|
kibana:
|
||||||
|
image: docker.elastic.co/kibana/kibana:8.11.0
|
||||||
|
container_name: tm_kibana
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
|
||||||
|
ports:
|
||||||
|
- "5601:5601"
|
||||||
|
depends_on:
|
||||||
|
- elasticsearch
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
|
||||||
|
# Tender Management API
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: tm_api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TM_SERVER_HOST=0.0.0.0
|
||||||
|
- TM_SERVER_PORT=8080
|
||||||
|
- TM_DATABASE_MONGODB_URI=mongodb://admin:password@mongodb:27017/tender_management?authSource=admin
|
||||||
|
- TM_CACHE_REDIS_HOST=redis
|
||||||
|
- TM_CACHE_REDIS_PASSWORD=password
|
||||||
|
- TM_QUEUE_RABBITMQ_URL=amqp://admin:password@rabbitmq:5672/
|
||||||
|
- TM_SEARCH_ELASTICSEARCH_URLS=http://elasticsearch:9200
|
||||||
|
- TM_AUTH_JWT_SECRET=super-secret-jwt-key-change-in-production
|
||||||
|
- TM_LOGGING_LEVEL=info
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
- elasticsearch
|
||||||
|
volumes:
|
||||||
|
- ./configs:/configs:ro
|
||||||
|
- ./logs:/logs
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Background Worker (Optional)
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: tm_worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["/worker"] # Assuming we'll create a worker binary
|
||||||
|
environment:
|
||||||
|
- TM_DATABASE_MONGODB_URI=mongodb://admin:password@mongodb:27017/tender_management?authSource=admin
|
||||||
|
- TM_CACHE_REDIS_HOST=redis
|
||||||
|
- TM_CACHE_REDIS_PASSWORD=password
|
||||||
|
- TM_QUEUE_RABBITMQ_URL=amqp://admin:password@rabbitmq:5672/
|
||||||
|
- TM_LOGGING_LEVEL=info
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
volumes:
|
||||||
|
- ./configs:/configs:ro
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
profiles:
|
||||||
|
- worker # Only start with: docker-compose --profile worker up
|
||||||
|
|
||||||
|
# Tender Scraper (Optional)
|
||||||
|
scraper:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: tm_scraper
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["/scraper"] # Assuming we'll create a scraper binary
|
||||||
|
environment:
|
||||||
|
- TM_DATABASE_MONGODB_URI=mongodb://admin:password@mongodb:27017/tender_management?authSource=admin
|
||||||
|
- TM_CACHE_REDIS_HOST=redis
|
||||||
|
- TM_CACHE_REDIS_PASSWORD=password
|
||||||
|
- TM_QUEUE_RABBITMQ_URL=amqp://admin:password@rabbitmq:5672/
|
||||||
|
- TM_LOGGING_LEVEL=info
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
volumes:
|
||||||
|
- ./configs:/configs:ro
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
profiles:
|
||||||
|
- scraper # Only start with: docker-compose --profile scraper up
|
||||||
|
|
||||||
|
# Nginx Reverse Proxy (Optional - for production)
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: tm_nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- tender_network
|
||||||
|
profiles:
|
||||||
|
- production
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongodb_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
rabbitmq_data:
|
||||||
|
driver: local
|
||||||
|
elasticsearch_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tender_network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
module tm
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||||
|
github.com/google/uuid v1.4.0
|
||||||
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
github.com/spf13/viper v1.18.2
|
||||||
|
go.mongodb.org/mongo-driver v1.17.4
|
||||||
|
go.uber.org/zap v1.26.0
|
||||||
|
golang.org/x/crypto v0.38.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.0 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
|
golang.org/x/net v0.40.0 // indirect
|
||||||
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
golang.org/x/time v0.11.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||||
|
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||||
|
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||||
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
|
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||||
|
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||||
|
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.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
|
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
|
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||||
|
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
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=
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Customer represents a mobile app user (customer)
|
||||||
|
type Customer struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
Email string `json:"email" bson:"email"`
|
||||||
|
Password string `json:"-" bson:"password"` // Never serialize password
|
||||||
|
FirstName string `json:"first_name" bson:"first_name"`
|
||||||
|
LastName string `json:"last_name" bson:"last_name"`
|
||||||
|
Phone string `json:"phone" bson:"phone"`
|
||||||
|
Role CustomerRole `json:"role" bson:"role"`
|
||||||
|
CompanyID uuid.UUID `json:"company_id" bson:"company_id"`
|
||||||
|
IsActive bool `json:"is_active" bson:"is_active"`
|
||||||
|
IsVerified bool `json:"is_verified" bson:"is_verified"`
|
||||||
|
DeviceTokens []string `json:"device_tokens" bson:"device_tokens"` // For push notifications
|
||||||
|
Preferences CustomerPreferences `json:"preferences" bson:"preferences"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty" bson:"last_login_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerPreferences represents customer-specific preferences
|
||||||
|
type CustomerPreferences struct {
|
||||||
|
Language string `json:"language" bson:"language"`
|
||||||
|
NotificationSettings NotificationSettings `json:"notification_settings" bson:"notification_settings"`
|
||||||
|
TenderAlerts bool `json:"tender_alerts" bson:"tender_alerts"`
|
||||||
|
EmailNotifications bool `json:"email_notifications" bson:"email_notifications"`
|
||||||
|
PushNotifications bool `json:"push_notifications" bson:"push_notifications"`
|
||||||
|
PreferredCategories []string `json:"preferred_categories" bson:"preferred_categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationSettings represents notification preferences
|
||||||
|
type NotificationSettings struct {
|
||||||
|
NewTenders bool `json:"new_tenders" bson:"new_tenders"`
|
||||||
|
DeadlineReminder bool `json:"deadline_reminder" bson:"deadline_reminder"`
|
||||||
|
StatusUpdates bool `json:"status_updates" bson:"status_updates"`
|
||||||
|
SystemAlerts bool `json:"system_alerts" bson:"system_alerts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerRole defines roles for mobile app users
|
||||||
|
type CustomerRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CustomerRoleUser CustomerRole = "customer_user" // Regular customer
|
||||||
|
CustomerRoleManager CustomerRole = "customer_manager" // Company manager
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a panel user (admin/staff)
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
Email string `json:"email" bson:"email"`
|
||||||
|
Password string `json:"-" bson:"password"` // Never serialize password
|
||||||
|
FirstName string `json:"first_name" bson:"first_name"`
|
||||||
|
LastName string `json:"last_name" bson:"last_name"`
|
||||||
|
Role UserRole `json:"role" bson:"role"`
|
||||||
|
Department string `json:"department" bson:"department"`
|
||||||
|
IsActive bool `json:"is_active" bson:"is_active"`
|
||||||
|
Permissions []string `json:"permissions" bson:"permissions"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty" bson:"last_login_at,omitempty"`
|
||||||
|
CreatedBy *uuid.UUID `json:"created_by,omitempty" bson:"created_by,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRole defines roles for panel users
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserRoleAdmin UserRole = "admin" // Full system administrator
|
||||||
|
UserRoleModerator UserRole = "moderator" // Moderator with limited admin access
|
||||||
|
UserRoleSupport UserRole = "support" // Support user with read-only access
|
||||||
|
UserRoleAnalyst UserRole = "analyst" // Data analyst with reporting access
|
||||||
|
)
|
||||||
|
|
||||||
|
// Customer methods
|
||||||
|
func (c *Customer) HasRole(role CustomerRole) bool {
|
||||||
|
return c.Role == role
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Customer) IsManager() bool {
|
||||||
|
return c.Role == CustomerRoleManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Customer) CanAccessCompanySettings() bool {
|
||||||
|
return c.IsActive && c.IsVerified && c.IsManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Customer) FullName() string {
|
||||||
|
return c.FirstName + " " + c.LastName
|
||||||
|
}
|
||||||
|
|
||||||
|
// User methods
|
||||||
|
func (u *User) HasRole(role UserRole) bool {
|
||||||
|
return u.Role == role
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) HasAnyRole(roles ...UserRole) bool {
|
||||||
|
for _, role := range roles {
|
||||||
|
if u.Role == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) HasPermission(permission string) bool {
|
||||||
|
for _, perm := range u.Permissions {
|
||||||
|
if perm == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) IsAdmin() bool {
|
||||||
|
return u.Role == UserRoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CanManageUsers() bool {
|
||||||
|
return u.IsActive && (u.Role == UserRoleAdmin || u.HasPermission("manage_users"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CanManageTenders() bool {
|
||||||
|
return u.IsActive && (u.Role == UserRoleAdmin || u.Role == UserRoleModerator || u.HasPermission("manage_tenders"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CanViewReports() bool {
|
||||||
|
return u.IsActive && (u.Role == UserRoleAdmin || u.Role == UserRoleAnalyst || u.HasPermission("view_reports"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) FullName() string {
|
||||||
|
return u.FirstName + " " + u.LastName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company represents a company profile
|
||||||
|
type Company struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
Description string `json:"description" bson:"description"`
|
||||||
|
Industry string `json:"industry" bson:"industry"`
|
||||||
|
Country string `json:"country" bson:"country"`
|
||||||
|
Website string `json:"website" bson:"website"`
|
||||||
|
Keywords []string `json:"keywords" bson:"keywords"`
|
||||||
|
Products []string `json:"products" bson:"products"`
|
||||||
|
Services []string `json:"services" bson:"services"`
|
||||||
|
Capabilities []string `json:"capabilities" bson:"capabilities"`
|
||||||
|
Documents []Document `json:"documents" bson:"documents"`
|
||||||
|
Interests InterestProfile `json:"interests" bson:"interests"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterestProfile represents company's tender interests
|
||||||
|
type InterestProfile struct {
|
||||||
|
Keywords []string `json:"keywords" bson:"keywords"`
|
||||||
|
Industries []string `json:"industries" bson:"industries"`
|
||||||
|
CPVCodes []string `json:"cpv_codes" bson:"cpv_codes"`
|
||||||
|
MinBudget *float64 `json:"min_budget,omitempty" bson:"min_budget,omitempty"`
|
||||||
|
MaxBudget *float64 `json:"max_budget,omitempty" bson:"max_budget,omitempty"`
|
||||||
|
Regions []string `json:"regions" bson:"regions"`
|
||||||
|
Embeddings []float32 `json:"-" bson:"embeddings"` // AI embeddings for matching
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tender represents a tender opportunity
|
||||||
|
type Tender struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
Title string `json:"title" bson:"title"`
|
||||||
|
Description string `json:"description" bson:"description"`
|
||||||
|
Summary string `json:"summary" bson:"summary"`
|
||||||
|
Type TenderType `json:"type" bson:"type"`
|
||||||
|
Status TenderStatus `json:"status" bson:"status"`
|
||||||
|
PublicationDate time.Time `json:"publication_date" bson:"publication_date"`
|
||||||
|
Deadline time.Time `json:"deadline" bson:"deadline"`
|
||||||
|
Budget *float64 `json:"budget,omitempty" bson:"budget,omitempty"`
|
||||||
|
Currency string `json:"currency" bson:"currency"`
|
||||||
|
Region string `json:"region" bson:"region"`
|
||||||
|
Country string `json:"country" bson:"country"`
|
||||||
|
CPVCodes []string `json:"cpv_codes" bson:"cpv_codes"`
|
||||||
|
Categories []string `json:"categories" bson:"categories"`
|
||||||
|
Keywords []string `json:"keywords" bson:"keywords"`
|
||||||
|
Tags []string `json:"tags" bson:"tags"`
|
||||||
|
Language string `json:"language" bson:"language"`
|
||||||
|
SourceURL string `json:"source_url" bson:"source_url"`
|
||||||
|
SourceName string `json:"source_name" bson:"source_name"`
|
||||||
|
Documents []TenderDocument `json:"documents" bson:"documents"`
|
||||||
|
Requirements []Requirement `json:"requirements" bson:"requirements"`
|
||||||
|
Embeddings []float32 `json:"-" bson:"embeddings"` // AI embeddings for matching
|
||||||
|
ProcessedAt *time.Time `json:"processed_at,omitempty" bson:"processed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderType defines types of tenders
|
||||||
|
type TenderType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TenderTypeRFI TenderType = "RFI" // Request for Information
|
||||||
|
TenderTypeRFP TenderType = "RFP" // Request for Proposal
|
||||||
|
TenderTypeRFQ TenderType = "RFQ" // Request for Quote
|
||||||
|
TenderTypeTender TenderType = "TENDER" // Public Tender
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderStatus defines tender statuses
|
||||||
|
type TenderStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TenderStatusActive TenderStatus = "active"
|
||||||
|
TenderStatusExpired TenderStatus = "expired"
|
||||||
|
TenderStatusCancelled TenderStatus = "cancelled"
|
||||||
|
TenderStatusAwarded TenderStatus = "awarded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderDocument represents documents associated with a tender
|
||||||
|
type TenderDocument struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
Type DocumentType `json:"type" bson:"type"`
|
||||||
|
URL string `json:"url" bson:"url"`
|
||||||
|
LocalPath string `json:"local_path" bson:"local_path"`
|
||||||
|
Size int64 `json:"size" bson:"size"`
|
||||||
|
ContentType string `json:"content_type" bson:"content_type"`
|
||||||
|
Checksum string `json:"checksum" bson:"checksum"`
|
||||||
|
Downloaded bool `json:"downloaded" bson:"downloaded"`
|
||||||
|
DownloadedAt *time.Time `json:"downloaded_at,omitempty" bson:"downloaded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requirement represents tender requirements
|
||||||
|
type Requirement struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"id"`
|
||||||
|
Type RequirementType `json:"type" bson:"type"`
|
||||||
|
Description string `json:"description" bson:"description"`
|
||||||
|
Mandatory bool `json:"mandatory" bson:"mandatory"`
|
||||||
|
Category string `json:"category" bson:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirementType defines types of requirements
|
||||||
|
type RequirementType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RequirementTypeLegal RequirementType = "legal"
|
||||||
|
RequirementTypeTechnical RequirementType = "technical"
|
||||||
|
RequirementTypeFinancial RequirementType = "financial"
|
||||||
|
RequirementTypeExperience RequirementType = "experience"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Document represents uploaded company documents
|
||||||
|
type Document struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
Type DocumentType `json:"type" bson:"type"`
|
||||||
|
Category string `json:"category" bson:"category"`
|
||||||
|
Description string `json:"description" bson:"description"`
|
||||||
|
FilePath string `json:"file_path" bson:"file_path"`
|
||||||
|
Size int64 `json:"size" bson:"size"`
|
||||||
|
ContentType string `json:"content_type" bson:"content_type"`
|
||||||
|
Checksum string `json:"checksum" bson:"checksum"`
|
||||||
|
ExpiryDate *time.Time `json:"expiry_date,omitempty" bson:"expiry_date,omitempty"`
|
||||||
|
Keywords []string `json:"keywords" bson:"keywords"`
|
||||||
|
UploadedBy uuid.UUID `json:"uploaded_by" bson:"uploaded_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentType defines types of documents
|
||||||
|
type DocumentType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocumentTypePDF DocumentType = "pdf"
|
||||||
|
DocumentTypeWord DocumentType = "word"
|
||||||
|
DocumentTypeExcel DocumentType = "excel"
|
||||||
|
DocumentTypeImage DocumentType = "image"
|
||||||
|
DocumentTypeCertificate DocumentType = "certificate"
|
||||||
|
DocumentTypeLicense DocumentType = "license"
|
||||||
|
DocumentTypeContract DocumentType = "contract"
|
||||||
|
DocumentTypeCatalog DocumentType = "catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderSource represents tender source configuration
|
||||||
|
type TenderSource struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
Name string `json:"name" bson:"name"`
|
||||||
|
BaseURL string `json:"base_url" bson:"base_url"`
|
||||||
|
Type SourceType `json:"type" bson:"type"`
|
||||||
|
AuthRequired bool `json:"auth_required" bson:"auth_required"`
|
||||||
|
CredentialsID *uuid.UUID `json:"credentials_id,omitempty" bson:"credentials_id,omitempty"`
|
||||||
|
CheckInterval time.Duration `json:"check_interval" bson:"check_interval"`
|
||||||
|
CategoryTags []string `json:"category_tags" bson:"category_tags"`
|
||||||
|
Region string `json:"region" bson:"region"`
|
||||||
|
Language string `json:"language" bson:"language"`
|
||||||
|
Status SourceStatus `json:"status" bson:"status"`
|
||||||
|
LastCheckedAt *time.Time `json:"last_checked_at,omitempty" bson:"last_checked_at,omitempty"`
|
||||||
|
Configuration SourceConfiguration `json:"configuration" bson:"configuration"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceType defines types of tender sources
|
||||||
|
type SourceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceTypeAPI SourceType = "api"
|
||||||
|
SourceTypeHTML SourceType = "html"
|
||||||
|
SourceTypeJS SourceType = "js"
|
||||||
|
SourceTypePDF SourceType = "pdf"
|
||||||
|
SourceTypeRSS SourceType = "rss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceStatus defines tender source statuses
|
||||||
|
type SourceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceStatusActive SourceStatus = "active"
|
||||||
|
SourceStatusInactive SourceStatus = "inactive"
|
||||||
|
SourceStatusError SourceStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceConfiguration holds source-specific configuration
|
||||||
|
type SourceConfiguration struct {
|
||||||
|
Headers map[string]string `json:"headers,omitempty" bson:"headers,omitempty"`
|
||||||
|
QueryParams map[string]string `json:"query_params,omitempty" bson:"query_params,omitempty"`
|
||||||
|
Selectors map[string]string `json:"selectors,omitempty" bson:"selectors,omitempty"`
|
||||||
|
PaginationKey string `json:"pagination_key,omitempty" bson:"pagination_key,omitempty"`
|
||||||
|
MaxPages int `json:"max_pages,omitempty" bson:"max_pages,omitempty"`
|
||||||
|
RateLimitDelay time.Duration `json:"rate_limit_delay,omitempty" bson:"rate_limit_delay,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderMatch represents AI-powered tender recommendations
|
||||||
|
type TenderMatch struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
CompanyID uuid.UUID `json:"company_id" bson:"company_id"`
|
||||||
|
TenderID uuid.UUID `json:"tender_id" bson:"tender_id"`
|
||||||
|
Score float64 `json:"score" bson:"score"`
|
||||||
|
Explanation string `json:"explanation" bson:"explanation"`
|
||||||
|
Tags []string `json:"tags" bson:"tags"`
|
||||||
|
Status MatchStatus `json:"status" bson:"status"`
|
||||||
|
UserAction *UserAction `json:"user_action,omitempty" bson:"user_action,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchStatus defines tender match statuses
|
||||||
|
type MatchStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MatchStatusPending MatchStatus = "pending"
|
||||||
|
MatchStatusViewed MatchStatus = "viewed"
|
||||||
|
MatchStatusLiked MatchStatus = "liked"
|
||||||
|
MatchStatusDisliked MatchStatus = "disliked"
|
||||||
|
MatchStatusApplied MatchStatus = "applied"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserAction represents user interaction with tender matches
|
||||||
|
type UserAction struct {
|
||||||
|
Action MatchStatus `json:"action" bson:"action"`
|
||||||
|
Timestamp time.Time `json:"timestamp" bson:"timestamp"`
|
||||||
|
UserID uuid.UUID `json:"user_id" bson:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification represents system notifications
|
||||||
|
type Notification struct {
|
||||||
|
ID uuid.UUID `json:"id" bson:"_id"`
|
||||||
|
UserID uuid.UUID `json:"user_id" bson:"user_id"`
|
||||||
|
CompanyID uuid.UUID `json:"company_id" bson:"company_id"`
|
||||||
|
Type NotificationType `json:"type" bson:"type"`
|
||||||
|
Title string `json:"title" bson:"title"`
|
||||||
|
Message string `json:"message" bson:"message"`
|
||||||
|
Data map[string]any `json:"data,omitempty" bson:"data,omitempty"`
|
||||||
|
Read bool `json:"read" bson:"read"`
|
||||||
|
Sent bool `json:"sent" bson:"sent"`
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty" bson:"sent_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationType defines types of notifications
|
||||||
|
type NotificationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotificationTypeNewTender NotificationType = "new_tender"
|
||||||
|
NotificationTypeDeadline NotificationType = "deadline_reminder"
|
||||||
|
NotificationTypeStatusUpdate NotificationType = "status_update"
|
||||||
|
NotificationTypeSystemAlert NotificationType = "system_alert"
|
||||||
|
)
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository interfaces define data access contracts
|
||||||
|
|
||||||
|
// CustomerRepository defines methods for customer (mobile user) data access
|
||||||
|
type CustomerRepository interface {
|
||||||
|
Create(ctx context.Context, customer *Customer) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*Customer, error)
|
||||||
|
GetByEmail(ctx context.Context, email string) (*Customer, error)
|
||||||
|
Update(ctx context.Context, customer *Customer) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
List(ctx context.Context, limit, offset int) ([]*Customer, error)
|
||||||
|
GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*Customer, error)
|
||||||
|
UpdatePreferences(ctx context.Context, id uuid.UUID, preferences *CustomerPreferences) error
|
||||||
|
AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error
|
||||||
|
RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRepository defines methods for panel user data access
|
||||||
|
type UserRepository interface {
|
||||||
|
Create(ctx context.Context, user *User) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*User, error)
|
||||||
|
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||||
|
Update(ctx context.Context, user *User) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
List(ctx context.Context, limit, offset int) ([]*User, error)
|
||||||
|
GetByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error)
|
||||||
|
UpdatePermissions(ctx context.Context, id uuid.UUID, permissions []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompanyRepository defines methods for company data access
|
||||||
|
type CompanyRepository interface {
|
||||||
|
Create(ctx context.Context, company *Company) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*Company, error)
|
||||||
|
Update(ctx context.Context, company *Company) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
List(ctx context.Context, limit, offset int) ([]*Company, error)
|
||||||
|
UpdateInterests(ctx context.Context, id uuid.UUID, interests *InterestProfile) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderRepository defines methods for tender data access
|
||||||
|
type TenderRepository interface {
|
||||||
|
Create(ctx context.Context, tender *Tender) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*Tender, error)
|
||||||
|
Update(ctx context.Context, tender *Tender) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
List(ctx context.Context, filters TenderFilters, limit, offset int) ([]*Tender, error)
|
||||||
|
ListByStatus(ctx context.Context, status TenderStatus, limit, offset int) ([]*Tender, error)
|
||||||
|
Search(ctx context.Context, query string, filters TenderFilters, limit, offset int) ([]*Tender, error)
|
||||||
|
GetExpiringTenders(ctx context.Context, days int) ([]*Tender, error)
|
||||||
|
BulkCreate(ctx context.Context, tenders []*Tender) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderSourceRepository defines methods for tender source data access
|
||||||
|
type TenderSourceRepository interface {
|
||||||
|
Create(ctx context.Context, source *TenderSource) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*TenderSource, error)
|
||||||
|
Update(ctx context.Context, source *TenderSource) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
List(ctx context.Context, limit, offset int) ([]*TenderSource, error)
|
||||||
|
GetActiveources(ctx context.Context) ([]*TenderSource, error)
|
||||||
|
UpdateLastChecked(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderMatchRepository defines methods for tender match data access
|
||||||
|
type TenderMatchRepository interface {
|
||||||
|
Create(ctx context.Context, match *TenderMatch) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*TenderMatch, error)
|
||||||
|
Update(ctx context.Context, match *TenderMatch) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
GetByCompany(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*TenderMatch, error)
|
||||||
|
GetByTender(ctx context.Context, tenderID uuid.UUID, limit, offset int) ([]*TenderMatch, error)
|
||||||
|
UpdateUserAction(ctx context.Context, id uuid.UUID, action *UserAction) error
|
||||||
|
BulkCreate(ctx context.Context, matches []*TenderMatch) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentRepository defines methods for document data access
|
||||||
|
type DocumentRepository interface {
|
||||||
|
Create(ctx context.Context, document *Document) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*Document, error)
|
||||||
|
Update(ctx context.Context, document *Document) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
GetByCompany(ctx context.Context, companyID uuid.UUID) ([]*Document, error)
|
||||||
|
GetByCategory(ctx context.Context, companyID uuid.UUID, category string) ([]*Document, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationRepository defines methods for notification data access
|
||||||
|
type NotificationRepository interface {
|
||||||
|
Create(ctx context.Context, notification *Notification) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*Notification, error)
|
||||||
|
Update(ctx context.Context, notification *Notification) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
GetByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]*Notification, error)
|
||||||
|
GetUnreadByUser(ctx context.Context, userID uuid.UUID) ([]*Notification, error)
|
||||||
|
MarkAsRead(ctx context.Context, id uuid.UUID) error
|
||||||
|
MarkAsSent(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service interfaces define business logic contracts
|
||||||
|
|
||||||
|
// CustomerAuthService defines authentication methods for mobile customers
|
||||||
|
type CustomerAuthService interface {
|
||||||
|
Register(ctx context.Context, req CustomerRegisterRequest) (*CustomerAuthResponse, error)
|
||||||
|
Login(ctx context.Context, req LoginRequest) (*CustomerAuthResponse, error)
|
||||||
|
RefreshToken(ctx context.Context, refreshToken string) (*CustomerAuthResponse, error)
|
||||||
|
ValidateToken(ctx context.Context, token string) (*Customer, error)
|
||||||
|
ChangePassword(ctx context.Context, customerID uuid.UUID, oldPassword, newPassword string) error
|
||||||
|
ResetPassword(ctx context.Context, email string) error
|
||||||
|
VerifyEmail(ctx context.Context, customerID uuid.UUID, token string) error
|
||||||
|
ResendVerification(ctx context.Context, email string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAuthService defines authentication methods for panel users
|
||||||
|
type UserAuthService interface {
|
||||||
|
Login(ctx context.Context, req LoginRequest) (*UserAuthResponse, error)
|
||||||
|
RefreshToken(ctx context.Context, refreshToken string) (*UserAuthResponse, error)
|
||||||
|
ValidateToken(ctx context.Context, token string) (*User, error)
|
||||||
|
ChangePassword(ctx context.Context, userID uuid.UUID, oldPassword, newPassword string) error
|
||||||
|
CreatePanelUser(ctx context.Context, req CreateUserRequest, createdBy uuid.UUID) (*User, error)
|
||||||
|
UpdateUserPermissions(ctx context.Context, userID uuid.UUID, permissions []string, updatedBy uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderService defines tender management methods
|
||||||
|
type TenderService interface {
|
||||||
|
GetTenders(ctx context.Context, filters TenderFilters, limit, offset int) ([]*Tender, error)
|
||||||
|
GetTenderByID(ctx context.Context, id uuid.UUID) (*Tender, error)
|
||||||
|
SearchTenders(ctx context.Context, query string, filters TenderFilters, limit, offset int) ([]*Tender, error)
|
||||||
|
GetTenderMatches(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*TenderMatch, error)
|
||||||
|
ProcessTenderAction(ctx context.Context, matchID uuid.UUID, action MatchStatus, customerID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompanyService defines company management methods
|
||||||
|
type CompanyService interface {
|
||||||
|
CreateCompany(ctx context.Context, req CreateCompanyRequest) (*Company, error)
|
||||||
|
GetCompany(ctx context.Context, id uuid.UUID) (*Company, error)
|
||||||
|
UpdateCompany(ctx context.Context, id uuid.UUID, req UpdateCompanyRequest, updatedBy uuid.UUID) (*Company, error)
|
||||||
|
UpdateInterests(ctx context.Context, companyID uuid.UUID, interests *InterestProfile) error
|
||||||
|
UploadDocument(ctx context.Context, companyID uuid.UUID, req UploadDocumentRequest) (*Document, error)
|
||||||
|
GetDocuments(ctx context.Context, companyID uuid.UUID) ([]*Document, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerService defines customer management methods (for panel users)
|
||||||
|
type CustomerService interface {
|
||||||
|
GetCustomers(ctx context.Context, limit, offset int) ([]*Customer, error)
|
||||||
|
GetCustomerByID(ctx context.Context, id uuid.UUID) (*Customer, error)
|
||||||
|
GetCustomersByCompany(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*Customer, error)
|
||||||
|
UpdateCustomerStatus(ctx context.Context, customerID uuid.UUID, isActive bool, updatedBy uuid.UUID) error
|
||||||
|
UpdateCustomerPreferences(ctx context.Context, customerID uuid.UUID, preferences *CustomerPreferences) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapingService defines tender scraping methods
|
||||||
|
type ScrapingService interface {
|
||||||
|
ScrapeTenderSource(ctx context.Context, sourceID uuid.UUID) error
|
||||||
|
ProcessTenderData(ctx context.Context, rawData []byte, sourceID uuid.UUID) ([]*Tender, error)
|
||||||
|
DownloadTenderDocuments(ctx context.Context, tenderID uuid.UUID) error
|
||||||
|
ScheduleScrapingJobs(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIService defines AI and machine learning methods
|
||||||
|
type AIService interface {
|
||||||
|
GenerateEmbeddings(ctx context.Context, text string) ([]float32, error)
|
||||||
|
MatchTenders(ctx context.Context, companyID uuid.UUID) ([]*TenderMatch, error)
|
||||||
|
ExtractTenderRequirements(ctx context.Context, tenderID uuid.UUID) ([]*Requirement, error)
|
||||||
|
TranslateDocument(ctx context.Context, text, sourceLanguage, targetLanguage string) (string, error)
|
||||||
|
ClassifyTender(ctx context.Context, tender *Tender) ([]string, error)
|
||||||
|
ExtractKeywords(ctx context.Context, text string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationService defines notification methods
|
||||||
|
type NotificationService interface {
|
||||||
|
SendNotification(ctx context.Context, notification *Notification) error
|
||||||
|
SendBulkNotifications(ctx context.Context, notifications []*Notification) error
|
||||||
|
GetUserNotifications(ctx context.Context, userID uuid.UUID, limit, offset int) ([]*Notification, error)
|
||||||
|
MarkNotificationAsRead(ctx context.Context, id uuid.UUID) error
|
||||||
|
CreateTenderNotification(ctx context.Context, tender *Tender, companyIDs []uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache interface defines caching methods
|
||||||
|
type CacheService interface {
|
||||||
|
Set(ctx context.Context, key string, value interface{}, ttl int) error
|
||||||
|
Get(ctx context.Context, key string, dest interface{}) error
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
Exists(ctx context.Context, key string) (bool, error)
|
||||||
|
SetHash(ctx context.Context, key, field string, value interface{}) error
|
||||||
|
GetHash(ctx context.Context, key, field string, dest interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue interface defines message queue methods
|
||||||
|
type QueueService interface {
|
||||||
|
Publish(ctx context.Context, queue string, message interface{}) error
|
||||||
|
Subscribe(ctx context.Context, queue string, handler func([]byte) error) error
|
||||||
|
PublishDelayed(ctx context.Context, queue string, message interface{}, delay int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search interface defines search engine methods
|
||||||
|
type SearchService interface {
|
||||||
|
IndexTender(ctx context.Context, tender *Tender) error
|
||||||
|
SearchTenders(ctx context.Context, query string, filters map[string]interface{}, limit, offset int) ([]*Tender, error)
|
||||||
|
DeleteTender(ctx context.Context, tenderID uuid.UUID) error
|
||||||
|
BulkIndexTenders(ctx context.Context, tenders []*Tender) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs and Request/Response types
|
||||||
|
|
||||||
|
// TenderFilters defines filters for tender queries
|
||||||
|
type TenderFilters struct {
|
||||||
|
Type *TenderType `json:"type,omitempty"`
|
||||||
|
Status *TenderStatus `json:"status,omitempty"`
|
||||||
|
CPVCodes []string `json:"cpv_codes,omitempty"`
|
||||||
|
Categories []string `json:"categories,omitempty"`
|
||||||
|
Region *string `json:"region,omitempty"`
|
||||||
|
Country *string `json:"country,omitempty"`
|
||||||
|
MinBudget *float64 `json:"min_budget,omitempty"`
|
||||||
|
MaxBudget *float64 `json:"max_budget,omitempty"`
|
||||||
|
Keywords []string `json:"keywords,omitempty"`
|
||||||
|
DateFrom *string `json:"date_from,omitempty"`
|
||||||
|
DateTo *string `json:"date_to,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer Authentication DTOs
|
||||||
|
type CustomerRegisterRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8"`
|
||||||
|
FirstName string `json:"first_name" validate:"required"`
|
||||||
|
LastName string `json:"last_name" validate:"required"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
CompanyID string `json:"company_id,omitempty"` // Optional, will create new company if not provided
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerAuthResponse struct {
|
||||||
|
Customer *Customer `json:"customer"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel User Authentication DTOs
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8"`
|
||||||
|
FirstName string `json:"first_name" validate:"required"`
|
||||||
|
LastName string `json:"last_name" validate:"required"`
|
||||||
|
Role UserRole `json:"role" validate:"required"`
|
||||||
|
Department string `json:"department"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAuthResponse struct {
|
||||||
|
User *User `json:"user"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared Authentication DTOs
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRequest defines user registration request (kept for backward compatibility)
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8"`
|
||||||
|
FirstName string `json:"first_name" validate:"required"`
|
||||||
|
LastName string `json:"last_name" validate:"required"`
|
||||||
|
CompanyID string `json:"company_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse defines authentication response (kept for backward compatibility)
|
||||||
|
type AuthResponse struct {
|
||||||
|
User *User `json:"user,omitempty"`
|
||||||
|
Customer *Customer `json:"customer,omitempty"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCompanyRequest defines company creation request
|
||||||
|
type CreateCompanyRequest struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Industry string `json:"industry" validate:"required"`
|
||||||
|
Country string `json:"country" validate:"required"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
Services []string `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCompanyRequest defines company update request
|
||||||
|
type UpdateCompanyRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Industry *string `json:"industry,omitempty"`
|
||||||
|
Country *string `json:"country,omitempty"`
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
Keywords []string `json:"keywords,omitempty"`
|
||||||
|
Products []string `json:"products,omitempty"`
|
||||||
|
Services []string `json:"services,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadDocumentRequest defines document upload request
|
||||||
|
type UploadDocumentRequest struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Type DocumentType `json:"type" validate:"required"`
|
||||||
|
Category string `json:"category" validate:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
FilePath string `json:"file_path" validate:"required"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
ExpiryDate *string `json:"expiry_date,omitempty"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler handles authentication related HTTP requests (legacy/generic)
|
||||||
|
// This handler provides backward compatibility and can handle both customer and user authentication
|
||||||
|
type AuthHandler struct {
|
||||||
|
customerAuthService domain.CustomerAuthService
|
||||||
|
userAuthService domain.UserAuthService
|
||||||
|
validator *validator.Validate
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler creates a new authentication handler
|
||||||
|
func NewAuthHandler(
|
||||||
|
customerAuthService domain.CustomerAuthService,
|
||||||
|
userAuthService domain.UserAuthService,
|
||||||
|
validator *validator.Validate,
|
||||||
|
logger logger.Logger,
|
||||||
|
) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
customerAuthService: customerAuthService,
|
||||||
|
userAuthService: userAuthService,
|
||||||
|
validator: validator,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handles user registration (defaults to customer registration)
|
||||||
|
// @Summary Register new user
|
||||||
|
// @Description Create a new user account (defaults to customer)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body domain.RegisterRequest true "Registration request"
|
||||||
|
// @Success 201 {object} response.APIResponse{data=domain.AuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 409 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /auth/register [post]
|
||||||
|
func (h *AuthHandler) Register(c echo.Context) error {
|
||||||
|
var req domain.RegisterRequest
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
h.logger.Warn("Failed to bind register request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logger.Warn("Register request validation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to customer registration request
|
||||||
|
customerReq := domain.CustomerRegisterRequest{
|
||||||
|
Email: req.Email,
|
||||||
|
Password: req.Password,
|
||||||
|
FirstName: req.FirstName,
|
||||||
|
LastName: req.LastName,
|
||||||
|
CompanyID: req.CompanyID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call customer service (default registration is for customers)
|
||||||
|
customerAuthResponse, err := h.customerAuthService.Register(c.Request().Context(), customerReq)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Registration failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "customer already exists" {
|
||||||
|
return response.Conflict(c, "User already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Registration failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to generic auth response for backward compatibility
|
||||||
|
authResponse := &domain.AuthResponse{
|
||||||
|
Customer: customerAuthResponse.Customer,
|
||||||
|
AccessToken: customerAuthResponse.AccessToken,
|
||||||
|
RefreshToken: customerAuthResponse.RefreshToken,
|
||||||
|
ExpiresIn: customerAuthResponse.ExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Created(c, authResponse, "User registered successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles user authentication (tries both customer and panel user)
|
||||||
|
// @Summary User login
|
||||||
|
// @Description Authenticate user and return tokens
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body domain.LoginRequest true "Login request"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.AuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /auth/login [post]
|
||||||
|
func (h *AuthHandler) Login(c echo.Context) error {
|
||||||
|
var req domain.LoginRequest
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
h.logger.Warn("Failed to bind login request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logger.Warn("Login request validation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try customer login first
|
||||||
|
if customerAuthResponse, err := h.customerAuthService.Login(c.Request().Context(), req); err == nil {
|
||||||
|
h.logger.Info("Customer logged in successfully", map[string]interface{}{
|
||||||
|
"customer_id": customerAuthResponse.Customer.ID.String(),
|
||||||
|
"email": customerAuthResponse.Customer.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to generic auth response
|
||||||
|
authResponse := &domain.AuthResponse{
|
||||||
|
Customer: customerAuthResponse.Customer,
|
||||||
|
AccessToken: customerAuthResponse.AccessToken,
|
||||||
|
RefreshToken: customerAuthResponse.RefreshToken,
|
||||||
|
ExpiresIn: customerAuthResponse.ExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Login successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try panel user login
|
||||||
|
if userAuthResponse, err := h.userAuthService.Login(c.Request().Context(), req); err == nil {
|
||||||
|
h.logger.Info("Panel user logged in successfully", map[string]interface{}{
|
||||||
|
"user_id": userAuthResponse.User.ID.String(),
|
||||||
|
"email": userAuthResponse.User.Email,
|
||||||
|
"role": userAuthResponse.User.Role,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to generic auth response
|
||||||
|
authResponse := &domain.AuthResponse{
|
||||||
|
User: userAuthResponse.User,
|
||||||
|
AccessToken: userAuthResponse.AccessToken,
|
||||||
|
RefreshToken: userAuthResponse.RefreshToken,
|
||||||
|
ExpiresIn: userAuthResponse.ExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Login successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Warn("Login failed for both customer and user", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Unauthorized(c, "Invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken handles token refresh (tries both customer and panel user)
|
||||||
|
// @Summary Refresh access token
|
||||||
|
// @Description Generate new access token using refresh token
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body RefreshTokenRequest true "Refresh token request"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.AuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /auth/refresh [post]
|
||||||
|
func (h *AuthHandler) RefreshToken(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try customer token refresh first
|
||||||
|
if customerAuthResponse, err := h.customerAuthService.RefreshToken(c.Request().Context(), req.RefreshToken); err == nil {
|
||||||
|
// Convert to generic auth response
|
||||||
|
authResponse := &domain.AuthResponse{
|
||||||
|
Customer: customerAuthResponse.Customer,
|
||||||
|
AccessToken: customerAuthResponse.AccessToken,
|
||||||
|
RefreshToken: customerAuthResponse.RefreshToken,
|
||||||
|
ExpiresIn: customerAuthResponse.ExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Token refreshed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try panel user token refresh
|
||||||
|
if userAuthResponse, err := h.userAuthService.RefreshToken(c.Request().Context(), req.RefreshToken); err == nil {
|
||||||
|
// Convert to generic auth response
|
||||||
|
authResponse := &domain.AuthResponse{
|
||||||
|
User: userAuthResponse.User,
|
||||||
|
AccessToken: userAuthResponse.AccessToken,
|
||||||
|
RefreshToken: userAuthResponse.RefreshToken,
|
||||||
|
ExpiresIn: userAuthResponse.ExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Token refreshed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Warn("Token refresh failed for both customer and user", map[string]interface{}{})
|
||||||
|
|
||||||
|
return response.Unauthorized(c, "Invalid refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword handles password change (determines user type from context)
|
||||||
|
// @Summary Change user password
|
||||||
|
// @Description Change authenticated user's password
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Param request body ChangePasswordRequest true "Change password request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /auth/change-password [post]
|
||||||
|
func (h *AuthHandler) ChangePassword(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password" validate:"required"`
|
||||||
|
NewPassword string `json:"new_password" validate:"required,min=8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a customer
|
||||||
|
if customer, ok := c.Get("customer").(*domain.Customer); ok {
|
||||||
|
err := h.customerAuthService.ChangePassword(c.Request().Context(), customer.ID, req.OldPassword, req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Customer password change failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid old password" {
|
||||||
|
return response.BadRequest(c, "Invalid old password", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Password change failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Password changed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a panel user
|
||||||
|
if user, ok := c.Get("user").(*domain.User); ok {
|
||||||
|
err := h.userAuthService.ChangePassword(c.Request().Context(), user.ID, req.OldPassword, req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Panel user password change failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid old password" {
|
||||||
|
return response.BadRequest(c, "Invalid old password", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Password change failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Password changed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile returns current user profile (determines user type from context)
|
||||||
|
// @Summary Get user profile
|
||||||
|
// @Description Get authenticated user's profile information
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.User}
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Router /auth/profile [get]
|
||||||
|
func (h *AuthHandler) GetProfile(c echo.Context) error {
|
||||||
|
// Check if it's a customer
|
||||||
|
if customer, ok := c.Get("customer").(*domain.Customer); ok {
|
||||||
|
return response.Success(c, customer, "Profile retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a panel user
|
||||||
|
if user, ok := c.Get("user").(*domain.User); ok {
|
||||||
|
return response.Success(c, user, "Profile retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles user logout (determines user type from context)
|
||||||
|
// @Summary User logout
|
||||||
|
// @Description Logout user (client should remove tokens)
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Router /auth/logout [post]
|
||||||
|
func (h *AuthHandler) Logout(c echo.Context) error {
|
||||||
|
// Check if it's a customer
|
||||||
|
if customer, ok := c.Get("customer").(*domain.Customer); ok {
|
||||||
|
h.logger.Info("Customer logged out", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
})
|
||||||
|
return response.Success(c, nil, "Logged out successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a panel user
|
||||||
|
if user, ok := c.Get("user").(*domain.User); ok {
|
||||||
|
h.logger.Info("Panel user logged out", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
return response.Success(c, nil, "Logged out successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Logged out successfully")
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerAuthHandler handles authentication related HTTP requests for mobile customers
|
||||||
|
type CustomerAuthHandler struct {
|
||||||
|
customerAuthService domain.CustomerAuthService
|
||||||
|
validator *validator.Validate
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerAuthHandler creates a new customer authentication handler
|
||||||
|
func NewCustomerAuthHandler(
|
||||||
|
customerAuthService domain.CustomerAuthService,
|
||||||
|
validator *validator.Validate,
|
||||||
|
logger logger.Logger,
|
||||||
|
) *CustomerAuthHandler {
|
||||||
|
return &CustomerAuthHandler{
|
||||||
|
customerAuthService: customerAuthService,
|
||||||
|
validator: validator,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handles customer registration
|
||||||
|
// @Summary Register new customer
|
||||||
|
// @Description Create a new customer account for mobile app
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body domain.CustomerRegisterRequest true "Customer registration request"
|
||||||
|
// @Success 201 {object} response.APIResponse{data=domain.CustomerAuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 409 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/register [post]
|
||||||
|
func (h *CustomerAuthHandler) Register(c echo.Context) error {
|
||||||
|
var req domain.CustomerRegisterRequest
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
h.logger.Warn("Failed to bind customer register request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logger.Warn("Customer register request validation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
authResponse, err := h.customerAuthService.Register(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Customer registration failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "customer already exists" {
|
||||||
|
return response.Conflict(c, "Customer already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Registration failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Customer registered successfully", map[string]interface{}{
|
||||||
|
"customer_id": authResponse.Customer.ID.String(),
|
||||||
|
"email": authResponse.Customer.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Created(c, authResponse, "Customer registered successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles customer authentication
|
||||||
|
// @Summary Customer login
|
||||||
|
// @Description Authenticate customer and return tokens
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body domain.LoginRequest true "Login request"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.CustomerAuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/login [post]
|
||||||
|
func (h *CustomerAuthHandler) Login(c echo.Context) error {
|
||||||
|
var req domain.LoginRequest
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
h.logger.Warn("Failed to bind customer login request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logger.Warn("Customer login request validation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
authResponse, err := h.customerAuthService.Login(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Customer login failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid credentials" {
|
||||||
|
return response.Unauthorized(c, "Invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Login failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Customer logged in successfully", map[string]interface{}{
|
||||||
|
"customer_id": authResponse.Customer.ID.String(),
|
||||||
|
"email": authResponse.Customer.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Login successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken handles token refresh for customers
|
||||||
|
// @Summary Refresh customer access token
|
||||||
|
// @Description Generate new access token using refresh token
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body RefreshTokenRequest true "Refresh token request"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.CustomerAuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/refresh [post]
|
||||||
|
func (h *CustomerAuthHandler) RefreshToken(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
authResponse, err := h.customerAuthService.RefreshToken(c.Request().Context(), req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Customer token refresh failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid refresh token" {
|
||||||
|
return response.Unauthorized(c, "Invalid refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Token refresh failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Token refreshed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword handles password change for customers
|
||||||
|
// @Summary Change customer password
|
||||||
|
// @Description Change authenticated customer's password
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Param request body ChangePasswordRequest true "Change password request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/change-password [post]
|
||||||
|
func (h *CustomerAuthHandler) ChangePassword(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password" validate:"required"`
|
||||||
|
NewPassword string `json:"new_password" validate:"required,min=8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get customer from context (set by auth middleware)
|
||||||
|
customer, ok := c.Get("customer").(*domain.Customer)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err := h.customerAuthService.ChangePassword(c.Request().Context(), customer.ID, req.OldPassword, req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Customer password change failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid old password" {
|
||||||
|
return response.BadRequest(c, "Invalid old password", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Password change failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Customer password changed successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Password changed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile returns current customer profile
|
||||||
|
// @Summary Get customer profile
|
||||||
|
// @Description Get authenticated customer's profile information
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.Customer}
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/profile [get]
|
||||||
|
func (h *CustomerAuthHandler) GetProfile(c echo.Context) error {
|
||||||
|
// Get customer from context (set by auth middleware)
|
||||||
|
customer, ok := c.Get("customer").(*domain.Customer)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, customer, "Profile retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail handles email verification for customers
|
||||||
|
// @Summary Verify customer email
|
||||||
|
// @Description Verify customer's email address using token
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body VerifyEmailRequest true "Email verification request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/verify-email [post]
|
||||||
|
func (h *CustomerAuthHandler) VerifyEmail(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get customer from context (set by auth middleware)
|
||||||
|
customer, ok := c.Get("customer").(*domain.Customer)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err := h.customerAuthService.VerifyEmail(c.Request().Context(), customer.ID, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Customer email verification failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid verification token" {
|
||||||
|
return response.BadRequest(c, "Invalid verification token", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Email verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Customer email verified successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Email verified successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendVerification handles resending verification email
|
||||||
|
// @Summary Resend verification email
|
||||||
|
// @Description Resend verification email to customer
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body ResendVerificationRequest true "Resend verification request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/resend-verification [post]
|
||||||
|
func (h *CustomerAuthHandler) ResendVerification(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err := h.customerAuthService.ResendVerification(c.Request().Context(), req.Email)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Resend verification failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Failed to resend verification email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Verification email sent successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles customer logout
|
||||||
|
// @Summary Customer logout
|
||||||
|
// @Description Logout customer (client should remove tokens)
|
||||||
|
// @Tags customer-auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Router /api/mobile/auth/logout [post]
|
||||||
|
func (h *CustomerAuthHandler) Logout(c echo.Context) error {
|
||||||
|
// In a stateless JWT implementation, logout is typically handled client-side
|
||||||
|
// by removing the tokens from storage. However, you could implement token
|
||||||
|
// blacklisting here if needed.
|
||||||
|
|
||||||
|
customer, ok := c.Get("customer").(*domain.Customer)
|
||||||
|
if ok {
|
||||||
|
h.logger.Info("Customer logged out", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Logged out successfully")
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserAuthHandler handles authentication related HTTP requests for panel users
|
||||||
|
type UserAuthHandler struct {
|
||||||
|
userAuthService domain.UserAuthService
|
||||||
|
validator *validator.Validate
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserAuthHandler creates a new panel user authentication handler
|
||||||
|
func NewUserAuthHandler(
|
||||||
|
userAuthService domain.UserAuthService,
|
||||||
|
validator *validator.Validate,
|
||||||
|
logger logger.Logger,
|
||||||
|
) *UserAuthHandler {
|
||||||
|
return &UserAuthHandler{
|
||||||
|
userAuthService: userAuthService,
|
||||||
|
validator: validator,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles panel user authentication
|
||||||
|
// @Summary Panel user login
|
||||||
|
// @Description Authenticate panel user and return tokens
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body domain.LoginRequest true "Login request"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.UserAuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/auth/login [post]
|
||||||
|
func (h *UserAuthHandler) Login(c echo.Context) error {
|
||||||
|
var req domain.LoginRequest
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
h.logger.Warn("Failed to bind panel user login request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logger.Warn("Panel user login request validation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
authResponse, err := h.userAuthService.Login(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Panel user login failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid credentials" {
|
||||||
|
return response.Unauthorized(c, "Invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Login failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Panel user logged in successfully", map[string]interface{}{
|
||||||
|
"user_id": authResponse.User.ID.String(),
|
||||||
|
"email": authResponse.User.Email,
|
||||||
|
"role": authResponse.User.Role,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Login successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken handles token refresh for panel users
|
||||||
|
// @Summary Refresh panel user access token
|
||||||
|
// @Description Generate new access token using refresh token
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body RefreshTokenRequest true "Refresh token request"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.UserAuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/auth/refresh [post]
|
||||||
|
func (h *UserAuthHandler) RefreshToken(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
authResponse, err := h.userAuthService.RefreshToken(c.Request().Context(), req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Panel user token refresh failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid refresh token" {
|
||||||
|
return response.Unauthorized(c, "Invalid refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Token refresh failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Token refreshed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword handles password change for panel users
|
||||||
|
// @Summary Change panel user password
|
||||||
|
// @Description Change authenticated panel user's password
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Param request body ChangePasswordRequest true "Change password request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/auth/change-password [post]
|
||||||
|
func (h *UserAuthHandler) ChangePassword(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password" validate:"required"`
|
||||||
|
NewPassword string `json:"new_password" validate:"required,min=8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from context (set by auth middleware)
|
||||||
|
user, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err := h.userAuthService.ChangePassword(c.Request().Context(), user.ID, req.OldPassword, req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Panel user password change failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "invalid old password" {
|
||||||
|
return response.BadRequest(c, "Invalid old password", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Password change failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Panel user password changed successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Password changed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile returns current panel user profile
|
||||||
|
// @Summary Get panel user profile
|
||||||
|
// @Description Get authenticated panel user's profile information
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Success 200 {object} response.APIResponse{data=domain.User}
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/auth/profile [get]
|
||||||
|
func (h *UserAuthHandler) GetProfile(c echo.Context) error {
|
||||||
|
// Get user from context (set by auth middleware)
|
||||||
|
user, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, user, "Profile retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePanelUser handles creating new panel users (admin only)
|
||||||
|
// @Summary Create new panel user
|
||||||
|
// @Description Create a new panel user (admin only)
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Param request body domain.CreateUserRequest true "Create user request"
|
||||||
|
// @Success 201 {object} response.APIResponse{data=domain.User}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/users [post]
|
||||||
|
func (h *UserAuthHandler) CreatePanelUser(c echo.Context) error {
|
||||||
|
var req domain.CreateUserRequest
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
h.logger.Warn("Failed to bind create user request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logger.Warn("Create user request validation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user from context (set by auth middleware)
|
||||||
|
currentUser, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current user can create users
|
||||||
|
if !currentUser.CanManageUsers() {
|
||||||
|
return response.Forbidden(c, "Insufficient permissions to create users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
newUser, err := h.userAuthService.CreatePanelUser(c.Request().Context(), req, currentUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Panel user creation failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
"created_by": currentUser.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "user already exists" {
|
||||||
|
return response.Conflict(c, "User already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "User creation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Panel user created successfully", map[string]interface{}{
|
||||||
|
"user_id": newUser.ID.String(),
|
||||||
|
"email": newUser.Email,
|
||||||
|
"role": newUser.Role,
|
||||||
|
"created_by": currentUser.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Created(c, newUser, "User created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPermissions handles updating user permissions (admin only)
|
||||||
|
// @Summary Update user permissions
|
||||||
|
// @Description Update panel user permissions (admin only)
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Param user_id path string true "User ID"
|
||||||
|
// @Param request body UpdatePermissionsRequest true "Update permissions request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/users/{user_id}/permissions [put]
|
||||||
|
func (h *UserAuthHandler) UpdateUserPermissions(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
Permissions []string `json:"permissions" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind request body
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user ID from path
|
||||||
|
userIDStr := c.Param("user_id")
|
||||||
|
if userIDStr == "" {
|
||||||
|
return response.BadRequest(c, "User ID is required", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid user ID format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user from context (set by auth middleware)
|
||||||
|
currentUser, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current user can manage users
|
||||||
|
if !currentUser.CanManageUsers() {
|
||||||
|
return response.Forbidden(c, "Insufficient permissions to update user permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err = h.userAuthService.UpdateUserPermissions(c.Request().Context(), userID, req.Permissions, currentUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("User permissions update failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
"updated_by": currentUser.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() == "user not found" {
|
||||||
|
return response.NotFound(c, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.InternalServerError(c, "Permissions update failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("User permissions updated successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
"permissions": req.Permissions,
|
||||||
|
"updated_by": currentUser.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Permissions updated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles panel user logout
|
||||||
|
// @Summary Panel user logout
|
||||||
|
// @Description Logout panel user (client should remove tokens)
|
||||||
|
// @Tags user-auth
|
||||||
|
// @Produce json
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Router /api/admin/auth/logout [post]
|
||||||
|
func (h *UserAuthHandler) Logout(c echo.Context) error {
|
||||||
|
// In a stateless JWT implementation, logout is typically handled client-side
|
||||||
|
// by removing the tokens from storage. However, you could implement token
|
||||||
|
// blacklisting here if needed.
|
||||||
|
|
||||||
|
user, ok := c.Get("user").(*domain.User)
|
||||||
|
if ok {
|
||||||
|
h.logger.Info("Panel user logged out", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil, "Logged out successfully")
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package infrastructure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all configuration for the application
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `mapstructure:"server"`
|
||||||
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
|
Queue QueueConfig `mapstructure:"queue"`
|
||||||
|
Search SearchConfig `mapstructure:"search"`
|
||||||
|
Auth AuthConfig `mapstructure:"auth"`
|
||||||
|
AI AIConfig `mapstructure:"ai"`
|
||||||
|
Scraping ScrapingConfig `mapstructure:"scraping"`
|
||||||
|
Logging LoggingConfig `mapstructure:"logging"`
|
||||||
|
RateLimit RateLimitConfig `mapstructure:"rate_limiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
Timeout time.Duration `mapstructure:"timeout"`
|
||||||
|
ReadTimeout time.Duration `mapstructure:"read_timeout"`
|
||||||
|
WriteTimeout time.Duration `mapstructure:"write_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
MongoDB MongoConfig `mapstructure:"mongodb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MongoConfig struct {
|
||||||
|
URI string `mapstructure:"uri"`
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
|
Timeout time.Duration `mapstructure:"timeout"`
|
||||||
|
MaxPoolSize int `mapstructure:"max_pool_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheConfig struct {
|
||||||
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
Password string `mapstructure:"password"`
|
||||||
|
DB int `mapstructure:"db"`
|
||||||
|
PoolSize int `mapstructure:"pool_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueConfig struct {
|
||||||
|
RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RabbitMQConfig struct {
|
||||||
|
URL string `mapstructure:"url"`
|
||||||
|
Exchange string `mapstructure:"exchange"`
|
||||||
|
Queues map[string]string `mapstructure:"queues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchConfig struct {
|
||||||
|
Elasticsearch ElasticsearchConfig `mapstructure:"elasticsearch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElasticsearchConfig struct {
|
||||||
|
URLs []string `mapstructure:"urls"`
|
||||||
|
Username string `mapstructure:"username"`
|
||||||
|
Password string `mapstructure:"password"`
|
||||||
|
IndexPrefix string `mapstructure:"index_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTConfig struct {
|
||||||
|
Secret string `mapstructure:"secret"`
|
||||||
|
AccessTokenDuration time.Duration `mapstructure:"access_token_duration"`
|
||||||
|
RefreshTokenDuration time.Duration `mapstructure:"refresh_token_duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfig struct {
|
||||||
|
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAIConfig struct {
|
||||||
|
APIKey string `mapstructure:"api_key"`
|
||||||
|
Model string `mapstructure:"model"`
|
||||||
|
MaxTokens int `mapstructure:"max_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrapingConfig struct {
|
||||||
|
UserAgent string `mapstructure:"user_agent"`
|
||||||
|
Timeout time.Duration `mapstructure:"timeout"`
|
||||||
|
MaxRetries int `mapstructure:"max_retries"`
|
||||||
|
DelayBetweenRequests time.Duration `mapstructure:"delay_between_requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingConfig struct {
|
||||||
|
Level string `mapstructure:"level"`
|
||||||
|
Format string `mapstructure:"format"`
|
||||||
|
Output string `mapstructure:"output"`
|
||||||
|
File LogFileConfig `mapstructure:"file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogFileConfig struct {
|
||||||
|
Path string `mapstructure:"path"`
|
||||||
|
MaxSize int `mapstructure:"max_size"` // MB
|
||||||
|
MaxBackups int `mapstructure:"max_backups"`
|
||||||
|
MaxAge int `mapstructure:"max_age"` // days
|
||||||
|
Compress bool `mapstructure:"compress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
RequestsPerMinute int `mapstructure:"requests_per_minute"`
|
||||||
|
Burst int `mapstructure:"burst"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from file and environment variables
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
viper.SetConfigName("config")
|
||||||
|
viper.SetConfigType("yaml")
|
||||||
|
viper.AddConfigPath(path)
|
||||||
|
|
||||||
|
// Enable reading from environment variables
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
// Read configuration file
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
if err := viper.Unmarshal(&config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompanyRepositoryImpl implements the CompanyRepository interface
|
||||||
|
type CompanyRepositoryImpl struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCompanyRepository creates a new company repository
|
||||||
|
func NewCompanyRepository(db *mongo.Database, logger logger.Logger) domain.CompanyRepository {
|
||||||
|
collection := db.Collection("companies")
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Name index
|
||||||
|
nameIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"name", 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created at index
|
||||||
|
createdAtIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"created_at", -1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
nameIndex,
|
||||||
|
createdAtIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create company indexes", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CompanyRepositoryImpl{
|
||||||
|
collection: collection,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new company
|
||||||
|
func (r *CompanyRepositoryImpl) Create(ctx context.Context, company *domain.Company) error {
|
||||||
|
// Set created/updated timestamps
|
||||||
|
now := time.Now()
|
||||||
|
company.CreatedAt = now
|
||||||
|
company.UpdatedAt = now
|
||||||
|
|
||||||
|
// Insert company
|
||||||
|
_, err := r.collection.InsertOne(ctx, company)
|
||||||
|
if err != nil {
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return errors.New("company already exists")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to create company", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": company.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Company created successfully", map[string]interface{}{
|
||||||
|
"company_id": company.ID.String(),
|
||||||
|
"name": company.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a company by ID
|
||||||
|
func (r *CompanyRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
||||||
|
var company domain.Company
|
||||||
|
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&company)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("company not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get company by ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": id.String(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &company, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a company
|
||||||
|
func (r *CompanyRepositoryImpl) Update(ctx context.Context, company *domain.Company) error {
|
||||||
|
// Set updated timestamp
|
||||||
|
company.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
filter := bson.M{"_id": company.ID}
|
||||||
|
update := bson.M{"$set": company}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update company", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": company.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("company not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Company updated successfully", map[string]interface{}{
|
||||||
|
"company_id": company.ID.String(),
|
||||||
|
"name": company.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a company
|
||||||
|
func (r *CompanyRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
|
||||||
|
result, err := r.collection.DeleteOne(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to delete company", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.DeletedCount == 0 {
|
||||||
|
return errors.New("company not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Company deleted successfully", map[string]interface{}{
|
||||||
|
"company_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves companies with pagination
|
||||||
|
func (r *CompanyRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*domain.Company, error) {
|
||||||
|
var companies []*domain.Company
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to list companies", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &companies); err != nil {
|
||||||
|
r.logger.Error("Failed to decode companies", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return companies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInterests updates company interests
|
||||||
|
func (r *CompanyRepositoryImpl) UpdateInterests(ctx context.Context, id uuid.UUID, interests *domain.InterestProfile) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"interests": interests,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update company interests", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("company not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Company interests updated successfully", map[string]interface{}{
|
||||||
|
"company_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerRepositoryImpl implements the CustomerRepository interface
|
||||||
|
type CustomerRepositoryImpl struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerRepository creates a new customer repository
|
||||||
|
func NewCustomerRepository(db *mongo.Database, logger logger.Logger) domain.CustomerRepository {
|
||||||
|
collection := db.Collection("customers")
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Email index (unique)
|
||||||
|
emailIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"email", 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company ID index
|
||||||
|
companyIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"company_id", 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active status index
|
||||||
|
activeIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"is_active", 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created at index
|
||||||
|
createdAtIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"created_at", -1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
emailIndex,
|
||||||
|
companyIndex,
|
||||||
|
activeIndex,
|
||||||
|
createdAtIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create customer indexes", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CustomerRepositoryImpl{
|
||||||
|
collection: collection,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new customer
|
||||||
|
func (r *CustomerRepositoryImpl) Create(ctx context.Context, customer *domain.Customer) error {
|
||||||
|
// Set created/updated timestamps
|
||||||
|
now := time.Now()
|
||||||
|
customer.CreatedAt = now
|
||||||
|
customer.UpdatedAt = now
|
||||||
|
|
||||||
|
// Insert customer
|
||||||
|
_, err := r.collection.InsertOne(ctx, customer)
|
||||||
|
if err != nil {
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return errors.New("customer already exists")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to create customer", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": customer.Email,
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Customer created successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a customer by ID
|
||||||
|
func (r *CustomerRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*domain.Customer, error) {
|
||||||
|
var customer domain.Customer
|
||||||
|
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&customer)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("customer not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get customer by ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &customer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail retrieves a customer by email
|
||||||
|
func (r *CustomerRepositoryImpl) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) {
|
||||||
|
var customer domain.Customer
|
||||||
|
|
||||||
|
filter := bson.M{"email": email}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&customer)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("customer not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get customer by email", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &customer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a customer
|
||||||
|
func (r *CustomerRepositoryImpl) Update(ctx context.Context, customer *domain.Customer) error {
|
||||||
|
// Set updated timestamp
|
||||||
|
customer.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
filter := bson.M{"_id": customer.ID}
|
||||||
|
update := bson.M{"$set": customer}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update customer", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Customer updated successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a customer (soft delete by setting is_active to false)
|
||||||
|
func (r *CustomerRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"is_active": false,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to delete customer", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Customer deleted successfully", map[string]interface{}{
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves customers with pagination
|
||||||
|
func (r *CustomerRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*domain.Customer, error) {
|
||||||
|
var customers []*domain.Customer
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Only active customers by default
|
||||||
|
filter := bson.M{"is_active": true}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to list customers", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &customers); err != nil {
|
||||||
|
r.logger.Error("Failed to decode customers", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return customers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCompanyID retrieves customers by company ID with pagination
|
||||||
|
func (r *CustomerRepositoryImpl) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*domain.Customer, error) {
|
||||||
|
var customers []*domain.Customer
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Filter by company ID and active status
|
||||||
|
filter := bson.M{
|
||||||
|
"company_id": companyID,
|
||||||
|
"is_active": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get customers by company ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &customers); err != nil {
|
||||||
|
r.logger.Error("Failed to decode customers by company", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return customers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePreferences updates customer preferences
|
||||||
|
func (r *CustomerRepositoryImpl) UpdatePreferences(ctx context.Context, id uuid.UUID, preferences *domain.CustomerPreferences) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"preferences": preferences,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update customer preferences", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Customer preferences updated successfully", map[string]interface{}{
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDeviceToken adds a device token for push notifications
|
||||||
|
func (r *CustomerRepositoryImpl) AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$addToSet": bson.M{"device_tokens": token}, // Use $addToSet to avoid duplicates
|
||||||
|
"$set": bson.M{"updated_at": time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to add device token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": id.String(),
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Device token added successfully", map[string]interface{}{
|
||||||
|
"customer_id": id.String(),
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDeviceToken removes a device token
|
||||||
|
func (r *CustomerRepositoryImpl) RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$pull": bson.M{"device_tokens": token}, // Use $pull to remove the token
|
||||||
|
"$set": bson.M{"updated_at": time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to remove device token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": id.String(),
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Device token removed successfully", map[string]interface{}{
|
||||||
|
"customer_id": id.String(),
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepositoryImpl implements the UserRepository interface
|
||||||
|
type UserRepositoryImpl struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository creates a new panel user repository
|
||||||
|
func NewUserRepository(db *mongo.Database, logger logger.Logger) domain.UserRepository {
|
||||||
|
collection := db.Collection("users")
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Email index (unique)
|
||||||
|
emailIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"email", 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role index
|
||||||
|
roleIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"role", 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active status index
|
||||||
|
activeIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"is_active", 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Department index
|
||||||
|
departmentIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"department", 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created at index
|
||||||
|
createdAtIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{"created_at", -1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
emailIndex,
|
||||||
|
roleIndex,
|
||||||
|
activeIndex,
|
||||||
|
departmentIndex,
|
||||||
|
createdAtIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create user indexes", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserRepositoryImpl{
|
||||||
|
collection: collection,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new panel user
|
||||||
|
func (r *UserRepositoryImpl) Create(ctx context.Context, user *domain.User) error {
|
||||||
|
// Set created/updated timestamps
|
||||||
|
now := time.Now()
|
||||||
|
user.CreatedAt = now
|
||||||
|
user.UpdatedAt = now
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
_, err := r.collection.InsertOne(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return errors.New("user already exists")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to create user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": user.Email,
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Panel user created successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a panel user by ID
|
||||||
|
func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
||||||
|
var user domain.User
|
||||||
|
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail retrieves a panel user by email
|
||||||
|
func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
|
var user domain.User
|
||||||
|
|
||||||
|
filter := bson.M{"email": email}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by email", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a panel user
|
||||||
|
func (r *UserRepositoryImpl) Update(ctx context.Context, user *domain.User) error {
|
||||||
|
// Set updated timestamp
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
filter := bson.M{"_id": user.ID}
|
||||||
|
update := bson.M{"$set": user}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Panel user updated successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a panel user (soft delete by setting is_active to false)
|
||||||
|
func (r *UserRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"is_active": false,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to delete user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Panel user deleted successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves panel users with pagination
|
||||||
|
func (r *UserRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*domain.User, error) {
|
||||||
|
var users []*domain.User
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Only active users by default
|
||||||
|
filter := bson.M{"is_active": true}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to list users", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &users); err != nil {
|
||||||
|
r.logger.Error("Failed to decode users", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByRole retrieves panel users by role with pagination
|
||||||
|
func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role domain.UserRole, limit, offset int) ([]*domain.User, error) {
|
||||||
|
var users []*domain.User
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Filter by role and active status
|
||||||
|
filter := bson.M{
|
||||||
|
"role": role,
|
||||||
|
"is_active": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get users by role", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"role": role,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &users); err != nil {
|
||||||
|
r.logger.Error("Failed to decode users by role", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePermissions updates panel user permissions
|
||||||
|
func (r *UserRepositoryImpl) UpdatePermissions(ctx context.Context, id uuid.UUID, permissions []string) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"permissions": permissions,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update user permissions", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
"permissions": permissions,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Panel user permissions updated successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
"permissions": permissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerServiceImpl implements the CustomerService interface
|
||||||
|
type CustomerServiceImpl struct {
|
||||||
|
customerRepo domain.CustomerRepository
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerService creates a new customer service
|
||||||
|
func NewCustomerService(
|
||||||
|
customerRepo domain.CustomerRepository,
|
||||||
|
logger logger.Logger,
|
||||||
|
) domain.CustomerService {
|
||||||
|
return &CustomerServiceImpl{
|
||||||
|
customerRepo: customerRepo,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomers retrieves customers with pagination (for panel users)
|
||||||
|
func (s *CustomerServiceImpl) GetCustomers(ctx context.Context, limit, offset int) ([]*domain.Customer, error) {
|
||||||
|
s.logger.Info("Retrieving customers", map[string]interface{}{
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
customers, err := s.customerRepo.List(ctx, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve customers", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to retrieve customers")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customers retrieved successfully", map[string]interface{}{
|
||||||
|
"count": len(customers),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
return customers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomerByID retrieves a customer by ID (for panel users)
|
||||||
|
func (s *CustomerServiceImpl) GetCustomerByID(ctx context.Context, id uuid.UUID) (*domain.Customer, error) {
|
||||||
|
s.logger.Info("Retrieving customer by ID", map[string]interface{}{
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
customer, err := s.customerRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve customer", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": id.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customer retrieved successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return customer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomersByCompany retrieves customers by company ID with pagination (for panel users)
|
||||||
|
func (s *CustomerServiceImpl) GetCustomersByCompany(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*domain.Customer, error) {
|
||||||
|
s.logger.Info("Retrieving customers by company", map[string]interface{}{
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
customers, err := s.customerRepo.GetByCompanyID(ctx, companyID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve customers by company", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to retrieve customers")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customers by company retrieved successfully", map[string]interface{}{
|
||||||
|
"count": len(customers),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
return customers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomerStatus updates customer active status (for panel users)
|
||||||
|
func (s *CustomerServiceImpl) UpdateCustomerStatus(ctx context.Context, customerID uuid.UUID, isActive bool, updatedBy uuid.UUID) error {
|
||||||
|
s.logger.Info("Updating customer status", map[string]interface{}{
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
"is_active": isActive,
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get customer
|
||||||
|
customer, err := s.customerRepo.GetByID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Customer not found for status update", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
customer.IsActive = isActive
|
||||||
|
|
||||||
|
// Save changes
|
||||||
|
if err := s.customerRepo.Update(ctx, customer); err != nil {
|
||||||
|
s.logger.Error("Failed to update customer status", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
"is_active": isActive,
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update customer status")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customer status updated successfully", map[string]interface{}{
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
"is_active": isActive,
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomerPreferences updates customer preferences (for panel users)
|
||||||
|
func (s *CustomerServiceImpl) UpdateCustomerPreferences(ctx context.Context, customerID uuid.UUID, preferences *domain.CustomerPreferences) error {
|
||||||
|
s.logger.Info("Updating customer preferences", map[string]interface{}{
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify customer exists
|
||||||
|
_, err := s.customerRepo.GetByID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Customer not found for preferences update", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preferences
|
||||||
|
if err := s.customerRepo.UpdatePreferences(ctx, customerID, preferences); err != nil {
|
||||||
|
s.logger.Error("Failed to update customer preferences", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update customer preferences")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customer preferences updated successfully", map[string]interface{}{
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/internal/infrastructure"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerAuthServiceImpl implements the CustomerAuthService interface
|
||||||
|
type CustomerAuthServiceImpl struct {
|
||||||
|
customerRepo domain.CustomerRepository
|
||||||
|
companyRepo domain.CompanyRepository
|
||||||
|
config *infrastructure.AuthConfig
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerAuthService creates a new customer authentication service
|
||||||
|
func NewCustomerAuthService(
|
||||||
|
customerRepo domain.CustomerRepository,
|
||||||
|
companyRepo domain.CompanyRepository,
|
||||||
|
config *infrastructure.AuthConfig,
|
||||||
|
logger logger.Logger,
|
||||||
|
) domain.CustomerAuthService {
|
||||||
|
return &CustomerAuthServiceImpl{
|
||||||
|
customerRepo: customerRepo,
|
||||||
|
companyRepo: companyRepo,
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new customer account
|
||||||
|
func (s *CustomerAuthServiceImpl) Register(ctx context.Context, req domain.CustomerRegisterRequest) (*domain.CustomerAuthResponse, error) {
|
||||||
|
s.logger.Info("Registering new customer", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if customer already exists
|
||||||
|
existingCustomer, err := s.customerRepo.GetByEmail(ctx, req.Email)
|
||||||
|
if err == nil && existingCustomer != nil {
|
||||||
|
return nil, errors.New("customer already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := s.hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to hash password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to process password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle company assignment
|
||||||
|
var companyID uuid.UUID
|
||||||
|
if req.CompanyID != "" {
|
||||||
|
// Parse existing company ID
|
||||||
|
companyID, err = uuid.Parse(req.CompanyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid company ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify company exists
|
||||||
|
_, err = s.companyRepo.GetByID(ctx, companyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("company not found")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new company for customer
|
||||||
|
company := &domain.Company{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: req.FirstName + " " + req.LastName + "'s Company",
|
||||||
|
Description: "Personal company profile",
|
||||||
|
Industry: "Not specified",
|
||||||
|
Country: "Not specified",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.companyRepo.Create(ctx, company); err != nil {
|
||||||
|
s.logger.Error("Failed to create company for customer", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to create company")
|
||||||
|
}
|
||||||
|
|
||||||
|
companyID = company.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create customer
|
||||||
|
customer := &domain.Customer{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: req.Email,
|
||||||
|
Password: hashedPassword,
|
||||||
|
FirstName: req.FirstName,
|
||||||
|
LastName: req.LastName,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Role: domain.CustomerRoleUser,
|
||||||
|
CompanyID: companyID,
|
||||||
|
IsActive: true,
|
||||||
|
IsVerified: false, // Require email verification
|
||||||
|
DeviceTokens: make([]string, 0),
|
||||||
|
Preferences: domain.CustomerPreferences{
|
||||||
|
Language: "en",
|
||||||
|
NotificationSettings: domain.NotificationSettings{
|
||||||
|
NewTenders: true,
|
||||||
|
DeadlineReminder: true,
|
||||||
|
StatusUpdates: true,
|
||||||
|
SystemAlerts: true,
|
||||||
|
},
|
||||||
|
TenderAlerts: true,
|
||||||
|
EmailNotifications: true,
|
||||||
|
PushNotifications: true,
|
||||||
|
PreferredCategories: make([]string, 0),
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.customerRepo.Create(ctx, customer); err != nil {
|
||||||
|
s.logger.Error("Failed to create customer", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to create customer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
accessToken, err := s.generateAccessToken(customer)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.generateRefreshToken(customer)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate refresh token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customer registered successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
"company_id": customer.CompanyID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Send verification email
|
||||||
|
s.sendVerificationEmail(ctx, customer)
|
||||||
|
|
||||||
|
return &domain.CustomerAuthResponse{
|
||||||
|
Customer: customer,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a customer and returns tokens
|
||||||
|
func (s *CustomerAuthServiceImpl) Login(ctx context.Context, req domain.LoginRequest) (*domain.CustomerAuthResponse, error) {
|
||||||
|
s.logger.Info("Customer login attempt", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get customer by email
|
||||||
|
customer, err := s.customerRepo.GetByEmail(ctx, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Customer login failed - customer not found", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer is active
|
||||||
|
if !customer.IsActive {
|
||||||
|
s.logger.Warn("Customer login failed - account inactive", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if !s.verifyPassword(req.Password, customer.Password) {
|
||||||
|
s.logger.Warn("Customer login failed - invalid password", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login time
|
||||||
|
customer.LastLoginAt = &time.Time{}
|
||||||
|
*customer.LastLoginAt = time.Now()
|
||||||
|
customer.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.customerRepo.Update(ctx, customer); err != nil {
|
||||||
|
s.logger.Warn("Failed to update customer last login", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
// Don't fail login for this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
accessToken, err := s.generateAccessToken(customer)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.generateRefreshToken(customer)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate refresh token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customer logged in successfully", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
"is_verified": customer.IsVerified,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &domain.CustomerAuthResponse{
|
||||||
|
Customer: customer,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken generates new tokens using refresh token
|
||||||
|
func (s *CustomerAuthServiceImpl) RefreshToken(ctx context.Context, refreshToken string) (*domain.CustomerAuthResponse, error) {
|
||||||
|
// Parse and validate refresh token
|
||||||
|
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("invalid signing method")
|
||||||
|
}
|
||||||
|
return []byte(s.config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("invalid refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token type
|
||||||
|
tokenType, ok := claims["type"].(string)
|
||||||
|
if !ok || tokenType != "refresh" {
|
||||||
|
return nil, errors.New("invalid token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user type
|
||||||
|
userType, ok := claims["user_type"].(string)
|
||||||
|
if !ok || userType != "customer" {
|
||||||
|
return nil, errors.New("invalid user type")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerIDStr, ok := claims["customer_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid customer ID in token")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, err := uuid.Parse(customerIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid customer ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get customer from database
|
||||||
|
customer, err := s.customerRepo.GetByID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !customer.IsActive {
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
accessToken, err := s.generateAccessToken(customer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
newRefreshToken, err := s.generateRefreshToken(customer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to generate refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.CustomerAuthResponse{
|
||||||
|
Customer: customer,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: newRefreshToken,
|
||||||
|
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns the customer
|
||||||
|
func (s *CustomerAuthServiceImpl) ValidateToken(ctx context.Context, tokenString string) (*domain.Customer, error) {
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("invalid signing method")
|
||||||
|
}
|
||||||
|
return []byte(s.config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token type
|
||||||
|
tokenType, ok := claims["type"].(string)
|
||||||
|
if !ok || tokenType != "access" {
|
||||||
|
return nil, errors.New("invalid token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user type
|
||||||
|
userType, ok := claims["user_type"].(string)
|
||||||
|
if !ok || userType != "customer" {
|
||||||
|
return nil, errors.New("invalid user type")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerIDStr, ok := claims["customer_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid customer ID in token")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, err := uuid.Parse(customerIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid customer ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
customer, err := s.customerRepo.GetByID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !customer.IsActive {
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return customer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes customer password
|
||||||
|
func (s *CustomerAuthServiceImpl) ChangePassword(ctx context.Context, customerID uuid.UUID, oldPassword, newPassword string) error {
|
||||||
|
customer, err := s.customerRepo.GetByID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
if !s.verifyPassword(oldPassword, customer.Password) {
|
||||||
|
return errors.New("invalid old password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hashedPassword, err := s.hashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to process new password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
customer.Password = hashedPassword
|
||||||
|
customer.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.customerRepo.Update(ctx, customer); err != nil {
|
||||||
|
s.logger.Error("Failed to update customer password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update password")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Customer password changed successfully", map[string]interface{}{
|
||||||
|
"customer_id": customerID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword initiates password reset process
|
||||||
|
func (s *CustomerAuthServiceImpl) ResetPassword(ctx context.Context, email string) error {
|
||||||
|
// TODO: Implement password reset logic
|
||||||
|
// This would typically involve:
|
||||||
|
// 1. Generate reset token
|
||||||
|
// 2. Store token with expiration
|
||||||
|
// 3. Send reset email
|
||||||
|
return errors.New("password reset not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail verifies customer's email address
|
||||||
|
func (s *CustomerAuthServiceImpl) VerifyEmail(ctx context.Context, customerID uuid.UUID, token string) error {
|
||||||
|
// TODO: Implement email verification logic
|
||||||
|
// This would typically involve:
|
||||||
|
// 1. Validate verification token
|
||||||
|
// 2. Update customer verification status
|
||||||
|
// 3. Send welcome email
|
||||||
|
return errors.New("email verification not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendVerification resends verification email
|
||||||
|
func (s *CustomerAuthServiceImpl) ResendVerification(ctx context.Context, email string) error {
|
||||||
|
customer, err := s.customerRepo.GetByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("customer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if customer.IsVerified {
|
||||||
|
return errors.New("email already verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Send verification email
|
||||||
|
s.sendVerificationEmail(ctx, customer)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
func (s *CustomerAuthServiceImpl) hashPassword(password string) (string, error) {
|
||||||
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerAuthServiceImpl) verifyPassword(password, hashedPassword string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerAuthServiceImpl) generateAccessToken(customer *domain.Customer) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
"role": customer.Role,
|
||||||
|
"company_id": customer.CompanyID.String(),
|
||||||
|
"user_type": "customer",
|
||||||
|
"exp": time.Now().Add(s.config.JWT.AccessTokenDuration).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"type": "access",
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.config.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerAuthServiceImpl) generateRefreshToken(customer *domain.Customer) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"user_type": "customer",
|
||||||
|
"exp": time.Now().Add(s.config.JWT.RefreshTokenDuration).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"type": "refresh",
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.config.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerAuthServiceImpl) sendVerificationEmail(ctx context.Context, customer *domain.Customer) {
|
||||||
|
// TODO: Implement email sending logic
|
||||||
|
s.logger.Info("Verification email would be sent", map[string]interface{}{
|
||||||
|
"customer_id": customer.ID.String(),
|
||||||
|
"email": customer.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/internal/infrastructure"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserAuthServiceImpl implements the UserAuthService interface
|
||||||
|
type UserAuthServiceImpl struct {
|
||||||
|
userRepo domain.UserRepository
|
||||||
|
config *infrastructure.AuthConfig
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserAuthService creates a new panel user authentication service
|
||||||
|
func NewUserAuthService(
|
||||||
|
userRepo domain.UserRepository,
|
||||||
|
config *infrastructure.AuthConfig,
|
||||||
|
logger logger.Logger,
|
||||||
|
) domain.UserAuthService {
|
||||||
|
return &UserAuthServiceImpl{
|
||||||
|
userRepo: userRepo,
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a panel user and returns tokens
|
||||||
|
func (s *UserAuthServiceImpl) Login(ctx context.Context, req domain.LoginRequest) (*domain.UserAuthResponse, error) {
|
||||||
|
s.logger.Info("Panel user login attempt", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
user, err := s.userRepo.GetByEmail(ctx, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Panel user login failed - user not found", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if !user.IsActive {
|
||||||
|
s.logger.Warn("Panel user login failed - account inactive", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if !s.verifyPassword(req.Password, user.Password) {
|
||||||
|
s.logger.Warn("Panel user login failed - invalid password", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login time
|
||||||
|
user.LastLoginAt = &time.Time{}
|
||||||
|
*user.LastLoginAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
|
s.logger.Warn("Failed to update user last login", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
// Don't fail login for this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
accessToken, err := s.generateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.generateRefreshToken(user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate refresh token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Panel user logged in successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
"department": user.Department,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &domain.UserAuthResponse{
|
||||||
|
User: user,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken generates new tokens using refresh token
|
||||||
|
func (s *UserAuthServiceImpl) RefreshToken(ctx context.Context, refreshToken string) (*domain.UserAuthResponse, error) {
|
||||||
|
// Parse and validate refresh token
|
||||||
|
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("invalid signing method")
|
||||||
|
}
|
||||||
|
return []byte(s.config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("invalid refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token type
|
||||||
|
tokenType, ok := claims["type"].(string)
|
||||||
|
if !ok || tokenType != "refresh" {
|
||||||
|
return nil, errors.New("invalid token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user type
|
||||||
|
userType, ok := claims["user_type"].(string)
|
||||||
|
if !ok || userType != "user" {
|
||||||
|
return nil, errors.New("invalid user type")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr, ok := claims["user_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid user ID in token")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid user ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
accessToken, err := s.generateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
newRefreshToken, err := s.generateRefreshToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to generate refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.UserAuthResponse{
|
||||||
|
User: user,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: newRefreshToken,
|
||||||
|
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns the user
|
||||||
|
func (s *UserAuthServiceImpl) ValidateToken(ctx context.Context, tokenString string) (*domain.User, error) {
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("invalid signing method")
|
||||||
|
}
|
||||||
|
return []byte(s.config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token type
|
||||||
|
tokenType, ok := claims["type"].(string)
|
||||||
|
if !ok || tokenType != "access" {
|
||||||
|
return nil, errors.New("invalid token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user type
|
||||||
|
userType, ok := claims["user_type"].(string)
|
||||||
|
if !ok || userType != "user" {
|
||||||
|
return nil, errors.New("invalid user type")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr, ok := claims["user_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid user ID in token")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid user ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes panel user password
|
||||||
|
func (s *UserAuthServiceImpl) ChangePassword(ctx context.Context, userID uuid.UUID, oldPassword, newPassword string) error {
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
if !s.verifyPassword(oldPassword, user.Password) {
|
||||||
|
return errors.New("invalid old password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hashedPassword, err := s.hashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to process new password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
user.Password = hashedPassword
|
||||||
|
|
||||||
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
|
s.logger.Error("Failed to update user password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update password")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Panel user password changed successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePanelUser creates a new panel user (admin only)
|
||||||
|
func (s *UserAuthServiceImpl) CreatePanelUser(ctx context.Context, req domain.CreateUserRequest, createdBy uuid.UUID) (*domain.User, error) {
|
||||||
|
s.logger.Info("Creating new panel user", map[string]interface{}{
|
||||||
|
"email": req.Email,
|
||||||
|
"role": req.Role,
|
||||||
|
"created_by": createdBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
existingUser, err := s.userRepo.GetByEmail(ctx, req.Email)
|
||||||
|
if err == nil && existingUser != nil {
|
||||||
|
return nil, errors.New("user already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
if !s.isValidRole(req.Role) {
|
||||||
|
return nil, errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := s.hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to hash password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to process password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user := &domain.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: req.Email,
|
||||||
|
Password: hashedPassword,
|
||||||
|
FirstName: req.FirstName,
|
||||||
|
LastName: req.LastName,
|
||||||
|
Role: req.Role,
|
||||||
|
Department: req.Department,
|
||||||
|
IsActive: true,
|
||||||
|
Permissions: s.getDefaultPermissions(req.Role),
|
||||||
|
CreatedBy: &createdBy,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom permissions if provided
|
||||||
|
if len(req.Permissions) > 0 {
|
||||||
|
user.Permissions = append(user.Permissions, req.Permissions...)
|
||||||
|
user.Permissions = s.removeDuplicatePermissions(user.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
|
s.logger.Error("Failed to create panel user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": req.Email,
|
||||||
|
"created_by": createdBy.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Panel user created successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
"created_by": createdBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove password from response
|
||||||
|
user.Password = ""
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPermissions updates panel user permissions (admin only)
|
||||||
|
func (s *UserAuthServiceImpl) UpdateUserPermissions(ctx context.Context, userID uuid.UUID, permissions []string, updatedBy uuid.UUID) error {
|
||||||
|
// Check if user exists
|
||||||
|
_, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate permissions
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if !s.isValidPermission(permission) {
|
||||||
|
return errors.New("invalid permission: " + permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update permissions
|
||||||
|
if err := s.userRepo.UpdatePermissions(ctx, userID, permissions); err != nil {
|
||||||
|
s.logger.Error("Failed to update user permissions", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Panel user permissions updated successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
"permissions": permissions,
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) hashPassword(password string) (string, error) {
|
||||||
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) verifyPassword(password, hashedPassword string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) generateAccessToken(user *domain.User) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
"department": user.Department,
|
||||||
|
"permissions": user.Permissions,
|
||||||
|
"user_type": "user",
|
||||||
|
"exp": time.Now().Add(s.config.JWT.AccessTokenDuration).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"type": "access",
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.config.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) generateRefreshToken(user *domain.User) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"user_type": "user",
|
||||||
|
"exp": time.Now().Add(s.config.JWT.RefreshTokenDuration).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"type": "refresh",
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.config.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) isValidRole(role domain.UserRole) bool {
|
||||||
|
validRoles := []domain.UserRole{
|
||||||
|
domain.UserRoleAdmin,
|
||||||
|
domain.UserRoleModerator,
|
||||||
|
domain.UserRoleSupport,
|
||||||
|
domain.UserRoleAnalyst,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, validRole := range validRoles {
|
||||||
|
if role == validRole {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) getDefaultPermissions(role domain.UserRole) []string {
|
||||||
|
switch role {
|
||||||
|
case domain.UserRoleAdmin:
|
||||||
|
return []string{
|
||||||
|
"manage_users",
|
||||||
|
"manage_tenders",
|
||||||
|
"manage_companies",
|
||||||
|
"manage_customers",
|
||||||
|
"view_reports",
|
||||||
|
"manage_system",
|
||||||
|
"manage_sources",
|
||||||
|
}
|
||||||
|
case domain.UserRoleModerator:
|
||||||
|
return []string{
|
||||||
|
"manage_tenders",
|
||||||
|
"manage_companies",
|
||||||
|
"view_customers",
|
||||||
|
"view_reports",
|
||||||
|
"manage_sources",
|
||||||
|
}
|
||||||
|
case domain.UserRoleSupport:
|
||||||
|
return []string{
|
||||||
|
"view_customers",
|
||||||
|
"view_tenders",
|
||||||
|
"view_companies",
|
||||||
|
"create_tickets",
|
||||||
|
}
|
||||||
|
case domain.UserRoleAnalyst:
|
||||||
|
return []string{
|
||||||
|
"view_reports",
|
||||||
|
"view_tenders",
|
||||||
|
"view_customers",
|
||||||
|
"view_companies",
|
||||||
|
"export_data",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) isValidPermission(permission string) bool {
|
||||||
|
validPermissions := []string{
|
||||||
|
"manage_users",
|
||||||
|
"manage_tenders",
|
||||||
|
"manage_companies",
|
||||||
|
"manage_customers",
|
||||||
|
"view_customers",
|
||||||
|
"view_tenders",
|
||||||
|
"view_companies",
|
||||||
|
"view_reports",
|
||||||
|
"manage_system",
|
||||||
|
"manage_sources",
|
||||||
|
"create_tickets",
|
||||||
|
"export_data",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, validPermission := range validPermissions {
|
||||||
|
if permission == validPermission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthServiceImpl) removeDuplicatePermissions(permissions []string) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
result := []string{}
|
||||||
|
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if !seen[permission] {
|
||||||
|
seen[permission] = true
|
||||||
|
result = append(result, permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultMongoURI = "mongodb://localhost:27017"
|
||||||
|
DefaultDBName = "tender_management"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Get database connection details from environment or use defaults
|
||||||
|
mongoURI := DefaultMongoURI
|
||||||
|
if uri := getEnv("MONGO_URI"); uri != "" {
|
||||||
|
mongoURI = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := DefaultDBName
|
||||||
|
if name := getEnv("DB_NAME"); name != "" {
|
||||||
|
dbName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🚀 Starting database migration for dual user system...")
|
||||||
|
fmt.Printf("📊 Database: %s\n", dbName)
|
||||||
|
fmt.Printf("🔗 URI: %s\n", mongoURI)
|
||||||
|
|
||||||
|
// Connect to MongoDB
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("❌ Failed to connect to MongoDB:", err)
|
||||||
|
}
|
||||||
|
defer client.Disconnect(context.Background())
|
||||||
|
|
||||||
|
// Ping to verify connection
|
||||||
|
if err = client.Ping(ctx, nil); err != nil {
|
||||||
|
log.Fatal("❌ Failed to ping MongoDB:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Connected to MongoDB successfully")
|
||||||
|
|
||||||
|
db := client.Database(dbName)
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := createCustomersCollection(db); err != nil {
|
||||||
|
log.Fatal("❌ Failed to create customers collection:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createUsersCollection(db); err != nil {
|
||||||
|
log.Fatal("❌ Failed to create users collection:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createSampleData(db); err != nil {
|
||||||
|
log.Fatal("❌ Failed to create sample data:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🎉 Database migration completed successfully!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCustomersCollection creates the customers collection with indexes
|
||||||
|
func createCustomersCollection(db *mongo.Database) error {
|
||||||
|
fmt.Println("📝 Creating customers collection...")
|
||||||
|
|
||||||
|
collection := db.Collection("customers")
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
indexes := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"email", 1}},
|
||||||
|
Options: options.Index().SetUnique(true).SetName("email_unique"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"company_id", 1}},
|
||||||
|
Options: options.Index().SetName("company_id_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"is_active", 1}},
|
||||||
|
Options: options.Index().SetName("is_active_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"created_at", -1}},
|
||||||
|
Options: options.Index().SetName("created_at_desc_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"role", 1}},
|
||||||
|
Options: options.Index().SetName("role_idx"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := collection.Indexes().CreateMany(ctx, indexes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create customer indexes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Customers collection created with indexes")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUsersCollection creates the panel users collection with indexes
|
||||||
|
func createUsersCollection(db *mongo.Database) error {
|
||||||
|
fmt.Println("📝 Creating users collection...")
|
||||||
|
|
||||||
|
collection := db.Collection("users")
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
indexes := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"email", 1}},
|
||||||
|
Options: options.Index().SetUnique(true).SetName("email_unique"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"role", 1}},
|
||||||
|
Options: options.Index().SetName("role_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"is_active", 1}},
|
||||||
|
Options: options.Index().SetName("is_active_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"department", 1}},
|
||||||
|
Options: options.Index().SetName("department_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"created_at", -1}},
|
||||||
|
Options: options.Index().SetName("created_at_desc_idx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{"created_by", 1}},
|
||||||
|
Options: options.Index().SetName("created_by_idx"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := collection.Indexes().CreateMany(ctx, indexes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create user indexes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Users collection created with indexes")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSampleData creates sample admin user and test customer
|
||||||
|
func createSampleData(db *mongo.Database) error {
|
||||||
|
fmt.Println("📝 Creating sample data...")
|
||||||
|
|
||||||
|
// Create sample admin user
|
||||||
|
if err := createSampleAdmin(db); err != nil {
|
||||||
|
return fmt.Errorf("failed to create sample admin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample customer
|
||||||
|
if err := createSampleCustomer(db); err != nil {
|
||||||
|
return fmt.Errorf("failed to create sample customer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Sample data created successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSampleAdmin creates a sample admin user
|
||||||
|
func createSampleAdmin(db *mongo.Database) error {
|
||||||
|
collection := db.Collection("users")
|
||||||
|
|
||||||
|
// Check if admin already exists
|
||||||
|
ctx := context.Background()
|
||||||
|
count, err := collection.CountDocuments(ctx, bson.M{"email": "admin@tendermanagement.com"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
fmt.Println("ℹ️ Sample admin user already exists, skipping...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password for admin
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123!"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
adminID := uuid.New()
|
||||||
|
admin := bson.M{
|
||||||
|
"_id": adminID,
|
||||||
|
"email": "admin@tendermanagement.com",
|
||||||
|
"password": string(hashedPassword),
|
||||||
|
"first_name": "System",
|
||||||
|
"last_name": "Administrator",
|
||||||
|
"role": domain.UserRoleAdmin,
|
||||||
|
"department": "IT",
|
||||||
|
"is_active": true,
|
||||||
|
"permissions": []string{
|
||||||
|
"manage_users",
|
||||||
|
"manage_tenders",
|
||||||
|
"manage_companies",
|
||||||
|
"manage_customers",
|
||||||
|
"view_reports",
|
||||||
|
"manage_system",
|
||||||
|
"manage_sources",
|
||||||
|
},
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.InsertOne(ctx, admin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("👤 Sample admin user created:")
|
||||||
|
fmt.Println(" 📧 Email: admin@tendermanagement.com")
|
||||||
|
fmt.Println(" 🔑 Password: admin123!")
|
||||||
|
fmt.Println(" 🛡️ Role: admin")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSampleCustomer creates a sample customer
|
||||||
|
func createSampleCustomer(db *mongo.Database) error {
|
||||||
|
collection := db.Collection("customers")
|
||||||
|
|
||||||
|
// Check if customer already exists
|
||||||
|
ctx := context.Background()
|
||||||
|
count, err := collection.CountDocuments(ctx, bson.M{"email": "customer@example.com"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
fmt.Println("ℹ️ Sample customer already exists, skipping...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password for customer
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("customer123!"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
companyID := uuid.New()
|
||||||
|
customerID := uuid.New()
|
||||||
|
|
||||||
|
// Create sample company first
|
||||||
|
companyCollection := db.Collection("companies")
|
||||||
|
company := bson.M{
|
||||||
|
"_id": companyID,
|
||||||
|
"name": "Sample Company Ltd",
|
||||||
|
"description": "A sample company for testing",
|
||||||
|
"industry": "Technology",
|
||||||
|
"country": "United States",
|
||||||
|
"website": "https://example.com",
|
||||||
|
"keywords": []string{"technology", "software", "consulting"},
|
||||||
|
"products": []string{"software development", "consulting"},
|
||||||
|
"services": []string{"web development", "mobile apps"},
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = companyCollection.InsertOne(ctx, company)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create customer
|
||||||
|
customer := bson.M{
|
||||||
|
"_id": customerID,
|
||||||
|
"email": "customer@example.com",
|
||||||
|
"password": string(hashedPassword),
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"phone": "+1234567890",
|
||||||
|
"role": domain.CustomerRoleUser,
|
||||||
|
"company_id": companyID,
|
||||||
|
"is_active": true,
|
||||||
|
"is_verified": true,
|
||||||
|
"device_tokens": []string{},
|
||||||
|
"preferences": bson.M{
|
||||||
|
"language": "en",
|
||||||
|
"notification_settings": bson.M{
|
||||||
|
"new_tenders": true,
|
||||||
|
"deadline_reminder": true,
|
||||||
|
"status_updates": true,
|
||||||
|
"system_alerts": true,
|
||||||
|
},
|
||||||
|
"tender_alerts": true,
|
||||||
|
"email_notifications": true,
|
||||||
|
"push_notifications": true,
|
||||||
|
"preferred_categories": []string{},
|
||||||
|
},
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.InsertOne(ctx, customer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("👤 Sample customer created:")
|
||||||
|
fmt.Println(" 📧 Email: customer@example.com")
|
||||||
|
fmt.Println(" 🔑 Password: customer123!")
|
||||||
|
fmt.Println(" 🏢 Company: Sample Company Ltd")
|
||||||
|
fmt.Println(" 📱 Role: customer_user")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func getEnv(key string) string {
|
||||||
|
// Simple environment variable getter
|
||||||
|
// In a real implementation, you might use os.Getenv()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
|
||||||
|
"tm/internal/infrastructure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger interface defines logging methods
|
||||||
|
type Logger interface {
|
||||||
|
Debug(msg string, fields map[string]interface{})
|
||||||
|
Info(msg string, fields map[string]interface{})
|
||||||
|
Warn(msg string, fields map[string]interface{})
|
||||||
|
Error(msg string, fields map[string]interface{})
|
||||||
|
Fatal(msg string, fields map[string]interface{})
|
||||||
|
WithFields(fields map[string]interface{}) Logger
|
||||||
|
Sync() error // Flush any buffered log entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZapLogger wraps zap logger
|
||||||
|
type ZapLogger struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
sugar *zap.SugaredLogger
|
||||||
|
fields map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a new logger instance with Zap and Lumberjack
|
||||||
|
func NewLogger(config *infrastructure.LoggingConfig) Logger {
|
||||||
|
// Parse log level
|
||||||
|
level := parseLogLevel(config.Level)
|
||||||
|
|
||||||
|
// Create encoder config
|
||||||
|
encoderConfig := createEncoderConfig(config.Format)
|
||||||
|
|
||||||
|
// Create encoder
|
||||||
|
encoder := createEncoder(config.Format, encoderConfig)
|
||||||
|
|
||||||
|
// Create writer
|
||||||
|
writer := createWriter(config)
|
||||||
|
|
||||||
|
// Create core
|
||||||
|
core := zapcore.NewCore(encoder, writer, level)
|
||||||
|
|
||||||
|
// Create logger with caller information
|
||||||
|
logger := zap.New(core,
|
||||||
|
zap.AddCaller(),
|
||||||
|
zap.AddCallerSkip(1),
|
||||||
|
zap.AddStacktrace(zapcore.ErrorLevel),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &ZapLogger{
|
||||||
|
logger: logger,
|
||||||
|
sugar: logger.Sugar(),
|
||||||
|
fields: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWriter creates the appropriate writer based on configuration
|
||||||
|
func createWriter(config *infrastructure.LoggingConfig) zapcore.WriteSyncer {
|
||||||
|
switch config.Output {
|
||||||
|
case "stdout":
|
||||||
|
return zapcore.AddSync(os.Stdout)
|
||||||
|
case "stderr":
|
||||||
|
return zapcore.AddSync(os.Stderr)
|
||||||
|
case "file":
|
||||||
|
return createFileWriter(config.File)
|
||||||
|
default:
|
||||||
|
// Default to stdout
|
||||||
|
return zapcore.AddSync(os.Stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFileWriter creates a file writer with rotation using lumberjack
|
||||||
|
func createFileWriter(fileConfig infrastructure.LogFileConfig) zapcore.WriteSyncer {
|
||||||
|
// Ensure log directory exists
|
||||||
|
logDir := filepath.Dir(fileConfig.Path)
|
||||||
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||||
|
// Fallback to stdout if can't create directory
|
||||||
|
return zapcore.AddSync(os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lumberjack logger for rotation
|
||||||
|
lumberjackLogger := &lumberjack.Logger{
|
||||||
|
Filename: fileConfig.Path,
|
||||||
|
MaxSize: fileConfig.MaxSize, // MB
|
||||||
|
MaxBackups: fileConfig.MaxBackups,
|
||||||
|
MaxAge: fileConfig.MaxAge, // days
|
||||||
|
Compress: fileConfig.Compress,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create multi-writer to write to both file and stdout
|
||||||
|
multiWriter := io.MultiWriter(lumberjackLogger, os.Stdout)
|
||||||
|
|
||||||
|
return zapcore.AddSync(multiWriter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createEncoderConfig creates encoder configuration
|
||||||
|
func createEncoderConfig(format string) zapcore.EncoderConfig {
|
||||||
|
config := zap.NewProductionEncoderConfig()
|
||||||
|
|
||||||
|
// Customize timestamp format
|
||||||
|
config.TimeKey = "timestamp"
|
||||||
|
config.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
|
||||||
|
// Customize level encoding
|
||||||
|
config.EncodeLevel = zapcore.LowercaseLevelEncoder
|
||||||
|
|
||||||
|
// Customize caller encoding
|
||||||
|
config.EncodeCaller = zapcore.ShortCallerEncoder
|
||||||
|
|
||||||
|
// Customize duration encoding
|
||||||
|
config.EncodeDuration = zapcore.StringDurationEncoder
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// createEncoder creates the appropriate encoder
|
||||||
|
func createEncoder(format string, config zapcore.EncoderConfig) zapcore.Encoder {
|
||||||
|
switch format {
|
||||||
|
case "json":
|
||||||
|
return zapcore.NewJSONEncoder(config)
|
||||||
|
case "console", "text":
|
||||||
|
return zapcore.NewConsoleEncoder(config)
|
||||||
|
default:
|
||||||
|
return zapcore.NewJSONEncoder(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLogLevel converts string level to zapcore.Level
|
||||||
|
func parseLogLevel(level string) zapcore.Level {
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
return zapcore.DebugLevel
|
||||||
|
case "info":
|
||||||
|
return zapcore.InfoLevel
|
||||||
|
case "warn", "warning":
|
||||||
|
return zapcore.WarnLevel
|
||||||
|
case "error":
|
||||||
|
return zapcore.ErrorLevel
|
||||||
|
case "fatal":
|
||||||
|
return zapcore.FatalLevel
|
||||||
|
case "panic":
|
||||||
|
return zapcore.PanicLevel
|
||||||
|
default:
|
||||||
|
return zapcore.InfoLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertFields converts map[string]interface{} to zap fields
|
||||||
|
func convertFields(fields map[string]interface{}) []zap.Field {
|
||||||
|
zapFields := make([]zap.Field, 0, len(fields))
|
||||||
|
for key, value := range fields {
|
||||||
|
zapFields = append(zapFields, zap.Any(key, value))
|
||||||
|
}
|
||||||
|
return zapFields
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs a debug message
|
||||||
|
func (l *ZapLogger) Debug(msg string, fields map[string]interface{}) {
|
||||||
|
allFields := l.mergeFields(fields)
|
||||||
|
l.logger.Debug(msg, convertFields(allFields)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info logs an info message
|
||||||
|
func (l *ZapLogger) Info(msg string, fields map[string]interface{}) {
|
||||||
|
allFields := l.mergeFields(fields)
|
||||||
|
l.logger.Info(msg, convertFields(allFields)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn logs a warning message
|
||||||
|
func (l *ZapLogger) Warn(msg string, fields map[string]interface{}) {
|
||||||
|
allFields := l.mergeFields(fields)
|
||||||
|
l.logger.Warn(msg, convertFields(allFields)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs an error message
|
||||||
|
func (l *ZapLogger) Error(msg string, fields map[string]interface{}) {
|
||||||
|
allFields := l.mergeFields(fields)
|
||||||
|
l.logger.Error(msg, convertFields(allFields)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal logs a fatal message and exits
|
||||||
|
func (l *ZapLogger) Fatal(msg string, fields map[string]interface{}) {
|
||||||
|
allFields := l.mergeFields(fields)
|
||||||
|
l.logger.Fatal(msg, convertFields(allFields)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFields returns a logger with predefined fields
|
||||||
|
func (l *ZapLogger) WithFields(fields map[string]interface{}) Logger {
|
||||||
|
newFields := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Copy existing fields
|
||||||
|
for k, v := range l.fields {
|
||||||
|
newFields[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new fields
|
||||||
|
for k, v := range fields {
|
||||||
|
newFields[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ZapLogger{
|
||||||
|
logger: l.logger,
|
||||||
|
sugar: l.sugar,
|
||||||
|
fields: newFields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync flushes any buffered log entries
|
||||||
|
func (l *ZapLogger) Sync() error {
|
||||||
|
return l.logger.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeFields merges instance fields with provided fields
|
||||||
|
func (l *ZapLogger) mergeFields(fields map[string]interface{}) map[string]interface{} {
|
||||||
|
if len(l.fields) == 0 {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Add instance fields first
|
||||||
|
for k, v := range l.fields {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add provided fields (they override instance fields)
|
||||||
|
for k, v := range fields {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global logger instance
|
||||||
|
var globalLogger Logger
|
||||||
|
|
||||||
|
// InitLogger initializes the global logger
|
||||||
|
func InitLogger(config *infrastructure.LoggingConfig) {
|
||||||
|
globalLogger = NewLogger(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogger returns the global logger instance
|
||||||
|
func GetLogger() Logger {
|
||||||
|
if globalLogger == nil {
|
||||||
|
// Fallback configuration
|
||||||
|
config := &infrastructure.LoggingConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
Output: "stdout",
|
||||||
|
File: infrastructure.LogFileConfig{
|
||||||
|
Path: "./logs/app.log",
|
||||||
|
MaxSize: 100,
|
||||||
|
MaxBackups: 5,
|
||||||
|
MaxAge: 30,
|
||||||
|
Compress: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
globalLogger = NewLogger(config)
|
||||||
|
}
|
||||||
|
return globalLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup flushes and closes the logger
|
||||||
|
func Cleanup() {
|
||||||
|
if globalLogger != nil {
|
||||||
|
globalLogger.Sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for convenience
|
||||||
|
func Debug(msg string, fields map[string]interface{}) {
|
||||||
|
GetLogger().Debug(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info(msg string, fields map[string]interface{}) {
|
||||||
|
GetLogger().Info(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warn(msg string, fields map[string]interface{}) {
|
||||||
|
GetLogger().Warn(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(msg string, fields map[string]interface{}) {
|
||||||
|
GetLogger().Error(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fatal(msg string, fields map[string]interface{}) {
|
||||||
|
GetLogger().Fatal(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithFields(fields map[string]interface{}) Logger {
|
||||||
|
return GetLogger().WithFields(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sync() error {
|
||||||
|
return GetLogger().Sync()
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"tm/internal/domain"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware creates JWT authentication middleware for both customers and users
|
||||||
|
func AuthMiddleware(customerAuthService domain.CustomerAuthService, userAuthService domain.UserAuthService, logger logger.Logger) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Skip authentication for public routes
|
||||||
|
if isPublicRoute(c.Path(), c.Request().Method) {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Authorization header
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
logger.Warn("Missing authorization header", map[string]interface{}{
|
||||||
|
"path": c.Path(),
|
||||||
|
"method": c.Request().Method,
|
||||||
|
})
|
||||||
|
return response.Unauthorized(c, "Authorization header required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Bearer format
|
||||||
|
const bearerPrefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||||
|
logger.Warn("Invalid authorization header format", map[string]interface{}{
|
||||||
|
"path": c.Path(),
|
||||||
|
"method": c.Request().Method,
|
||||||
|
})
|
||||||
|
return response.Unauthorized(c, "Invalid authorization header format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token
|
||||||
|
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||||
|
if token == "" {
|
||||||
|
return response.Unauthorized(c, "Token required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to validate as customer first (for mobile routes)
|
||||||
|
if strings.HasPrefix(c.Path(), "/api/mobile/") || strings.HasPrefix(c.Path(), "/mobile/") {
|
||||||
|
customer, err := customerAuthService.ValidateToken(c.Request().Context(), token)
|
||||||
|
if err == nil && customer != nil {
|
||||||
|
// Store customer in context
|
||||||
|
c.Set("customer", customer)
|
||||||
|
c.Set("customer_id", customer.ID.String())
|
||||||
|
c.Set("company_id", customer.CompanyID.String())
|
||||||
|
c.Set("user_type", "customer")
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to validate as panel user (for admin routes)
|
||||||
|
if strings.HasPrefix(c.Path(), "/api/admin/") || strings.HasPrefix(c.Path(), "/admin/") {
|
||||||
|
user, err := userAuthService.ValidateToken(c.Request().Context(), token)
|
||||||
|
if err == nil && user != nil {
|
||||||
|
// Store user in context
|
||||||
|
c.Set("user", user)
|
||||||
|
c.Set("user_id", user.ID.String())
|
||||||
|
c.Set("user_type", "user")
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other routes, try both types
|
||||||
|
if customer, err := customerAuthService.ValidateToken(c.Request().Context(), token); err == nil && customer != nil {
|
||||||
|
c.Set("customer", customer)
|
||||||
|
c.Set("customer_id", customer.ID.String())
|
||||||
|
c.Set("company_id", customer.CompanyID.String())
|
||||||
|
c.Set("user_type", "customer")
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user, err := userAuthService.ValidateToken(c.Request().Context(), token); err == nil && user != nil {
|
||||||
|
c.Set("user", user)
|
||||||
|
c.Set("user_id", user.ID.String())
|
||||||
|
c.Set("user_type", "user")
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warn("Token validation failed", map[string]interface{}{
|
||||||
|
"path": c.Path(),
|
||||||
|
})
|
||||||
|
return response.Unauthorized(c, "Invalid or expired token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerOnlyMiddleware ensures only customers can access the route
|
||||||
|
func CustomerOnlyMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
customer, ok := c.Get("customer").(*domain.Customer)
|
||||||
|
if !ok || customer == nil {
|
||||||
|
return response.Unauthorized(c, "Customer authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !customer.IsActive {
|
||||||
|
return response.Forbidden(c, "Account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserOnlyMiddleware ensures only panel users can access the route
|
||||||
|
func UserOnlyMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
user, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok || user == nil {
|
||||||
|
return response.Unauthorized(c, "Panel user authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return response.Forbidden(c, "Account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerRoleMiddleware creates role-based authorization middleware for customers
|
||||||
|
func CustomerRoleMiddleware(allowedRoles ...domain.CustomerRole) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
customer, ok := c.Get("customer").(*domain.Customer)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Customer authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer has required role
|
||||||
|
for _, role := range allowedRoles {
|
||||||
|
if customer.Role == role {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Forbidden(c, "Insufficient permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRoleMiddleware creates role-based authorization middleware for panel users
|
||||||
|
func UserRoleMiddleware(allowedRoles ...domain.UserRole) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
user, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Panel user authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has required role
|
||||||
|
for _, role := range allowedRoles {
|
||||||
|
if user.Role == role {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Forbidden(c, "Insufficient permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPermissionMiddleware creates permission-based authorization middleware for panel users
|
||||||
|
func UserPermissionMiddleware(requiredPermissions ...string) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
user, ok := c.Get("user").(*domain.User)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "Panel user authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin has all permissions
|
||||||
|
if user.IsAdmin() {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has all required permissions
|
||||||
|
for _, permission := range requiredPermissions {
|
||||||
|
if !user.HasPermission(permission) {
|
||||||
|
return response.Forbidden(c, "Insufficient permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicRoute checks if a route should be accessible without authentication
|
||||||
|
func isPublicRoute(path, method string) bool {
|
||||||
|
// Debug logging
|
||||||
|
fmt.Printf("Checking public route: %s %s\n", method, path)
|
||||||
|
|
||||||
|
// Also log to stderr to ensure we see it
|
||||||
|
fmt.Fprintf(os.Stderr, "DEBUG: Checking public route: %s %s\n", method, path)
|
||||||
|
|
||||||
|
publicRoutes := map[string][]string{
|
||||||
|
// Mobile customer auth routes
|
||||||
|
"/api/v1/mobile/auth/register": {"POST"},
|
||||||
|
"/api/v1/mobile/auth/login": {"POST"},
|
||||||
|
"/api/v1/mobile/auth/refresh": {"POST"},
|
||||||
|
"/api/v1/mobile/auth/resend-verification": {"POST"},
|
||||||
|
"/api/mobile/auth/register": {"POST"},
|
||||||
|
"/api/mobile/auth/login": {"POST"},
|
||||||
|
"/api/mobile/auth/refresh": {"POST"},
|
||||||
|
"/api/mobile/auth/resend-verification": {"POST"},
|
||||||
|
"/mobile/auth/register": {"POST"},
|
||||||
|
"/mobile/auth/login": {"POST"},
|
||||||
|
"/mobile/auth/refresh": {"POST"},
|
||||||
|
"/mobile/auth/resend-verification": {"POST"},
|
||||||
|
|
||||||
|
// Panel user auth routes
|
||||||
|
"/api/v1/admin/auth/login": {"POST"},
|
||||||
|
"/api/v1/admin/auth/refresh": {"POST"},
|
||||||
|
"/api/admin/auth/login": {"POST"},
|
||||||
|
"/api/admin/auth/refresh": {"POST"},
|
||||||
|
"/admin/auth/login": {"POST"},
|
||||||
|
"/admin/auth/refresh": {"POST"},
|
||||||
|
|
||||||
|
// Legacy auth routes (for backward compatibility)
|
||||||
|
"/auth/register": {"POST"},
|
||||||
|
"/auth/login": {"POST"},
|
||||||
|
"/auth/refresh": {"POST"},
|
||||||
|
|
||||||
|
// Health and documentation routes
|
||||||
|
"/health": {"GET"},
|
||||||
|
"/metrics": {"GET"},
|
||||||
|
"/api/docs": {"GET"},
|
||||||
|
"/api/docs/*": {"GET"},
|
||||||
|
"/swagger/*": {"GET"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if methods, exists := publicRoutes[path]; exists {
|
||||||
|
for _, allowedMethod := range methods {
|
||||||
|
if method == allowedMethod {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard routes
|
||||||
|
for route, methods := range publicRoutes {
|
||||||
|
if strings.HasSuffix(route, "/*") {
|
||||||
|
prefix := strings.TrimSuffix(route, "/*")
|
||||||
|
if strings.HasPrefix(path, prefix) {
|
||||||
|
for _, allowedMethod := range methods {
|
||||||
|
if method == allowedMethod {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIResponse represents a standard API response structure
|
||||||
|
type APIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Error *APIError `json:"error,omitempty"`
|
||||||
|
Meta *Meta `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError represents error details in API response
|
||||||
|
type APIError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta represents metadata for paginated responses
|
||||||
|
type Meta struct {
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
|
Page int `json:"page,omitempty"`
|
||||||
|
Pages int `json:"pages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success returns a successful response
|
||||||
|
func Success(c echo.Context, data interface{}, message string) error {
|
||||||
|
return c.JSON(http.StatusOK, APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuccessWithMeta returns a successful response with metadata
|
||||||
|
func SuccessWithMeta(c echo.Context, data interface{}, meta *Meta, message string) error {
|
||||||
|
return c.JSON(http.StatusOK, APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created returns a 201 Created response
|
||||||
|
func Created(c echo.Context, data interface{}, message string) error {
|
||||||
|
return c.JSON(http.StatusCreated, APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadRequest returns a 400 Bad Request response
|
||||||
|
func BadRequest(c echo.Context, message, details string) error {
|
||||||
|
return c.JSON(http.StatusBadRequest, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Bad Request",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "BAD_REQUEST",
|
||||||
|
Message: message,
|
||||||
|
Details: details,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthorized returns a 401 Unauthorized response
|
||||||
|
func Unauthorized(c echo.Context, message string) error {
|
||||||
|
return c.JSON(http.StatusUnauthorized, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Unauthorized",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "UNAUTHORIZED",
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forbidden returns a 403 Forbidden response
|
||||||
|
func Forbidden(c echo.Context, message string) error {
|
||||||
|
return c.JSON(http.StatusForbidden, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Forbidden",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "FORBIDDEN",
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFound returns a 404 Not Found response
|
||||||
|
func NotFound(c echo.Context, message string) error {
|
||||||
|
return c.JSON(http.StatusNotFound, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Not Found",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "NOT_FOUND",
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict returns a 409 Conflict response
|
||||||
|
func Conflict(c echo.Context, message string) error {
|
||||||
|
return c.JSON(http.StatusConflict, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Conflict",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "CONFLICT",
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError returns a 422 Unprocessable Entity response
|
||||||
|
func ValidationError(c echo.Context, message, details string) error {
|
||||||
|
return c.JSON(http.StatusUnprocessableEntity, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Validation Error",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "VALIDATION_ERROR",
|
||||||
|
Message: message,
|
||||||
|
Details: details,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalServerError returns a 500 Internal Server Error response
|
||||||
|
func InternalServerError(c echo.Context, message string) error {
|
||||||
|
return c.JSON(http.StatusInternalServerError, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Internal Server Error",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "INTERNAL_SERVER_ERROR",
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceUnavailable returns a 503 Service Unavailable response
|
||||||
|
func ServiceUnavailable(c echo.Context, message string) error {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Service Unavailable",
|
||||||
|
Error: &APIError{
|
||||||
|
Code: "SERVICE_UNAVAILABLE",
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user