Add cursor rules and initial configuration for Tender Management Go Backend

This commit is contained in:
n.nakhostin
2025-08-02 10:43:47 +03:30
parent ad9db7bcce
commit 5a1ceb0bd6
35 changed files with 4123 additions and 4550 deletions
File diff suppressed because it is too large Load Diff
+838
View File
@@ -0,0 +1,838 @@
# 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 Domain-Driven Design (DDD) patterns, designed to be AI-friendly and easy to maintain.
## 🏗️ Architecture Guidelines
### Clean Architecture Layers
- **Domain Layer** (`internal/{domain}/domain/`): Business entities, aggregates, interfaces, and core business rules
- **Service Layer** (`internal/{domain}/service/`): Business logic and use cases
- **Handler Layer** (`internal/{domain}/handler/`): HTTP controllers and request/response handling
- **Repository Layer** (`internal/{domain}/domain/`): Data access implementations (embedded in domain)
- **Infrastructure Layer** (`infra/`): External dependencies (DB, cache, queues, config)
### Domain Structure Pattern
Each domain follows this structure:
```
internal/{domain}/
├── domain/
│ ├── entity/
│ │ └── entity.go # Core domain entities
│ ├── aggregate/
│ │ └── aggregate.go # Aggregate roots and DTOs
│ ├── {domain}.go # Repository implementation
│ └── repository.go # Repository interface
├── service/
│ ├── {domain}.go # Main service implementation
│ ├── profile.go # Profile-related operations
│ └── form.go # Request/response forms
└── handler/
├── v1/
│ └── handler.go # HTTP handlers
└── middleware/ # Domain-specific middleware
```
### 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
- Repository implementations are embedded in domain package
## 💻 Go Best Practices
### Code Structure
- Use standard Go project layout with `internal/` for private code
- Group related functionality in domain 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., `CustomerRepository`, `AuthService`)
- Use descriptive variable names, avoid abbreviations
- Constants should be in ALL_CAPS with underscores
- Domain packages use singular form (e.g., `customer`, not `customers`)
### 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 (`repository.go`)
- 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`, `GetByCompanyID`)
### Service Interfaces
- Define business operations clearly in service package
- Use request/response forms for complex operations
- Include validation in service methods
- Handle business logic, not infrastructure concerns
## 📝 Logging Standards
### Using Custom Logger Interface
- Always use structured logging with fields
- Include relevant context (user_id, customer_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("Customer authenticated successfully", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
"ip": clientIP,
})
// Bad
log.Info("Customer 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 domain package (`{domain}.go`)
- Use proper database transactions for related operations
- Handle database errors gracefully
- Use prepared statements or ORM query builders
- Implement proper pagination with limit/offset
- **Time Handling**: Always use Unix timestamps (int64) for database operations
- **Timestamp Updates**: Use `time.Now().Unix()` for created_at and updated_at fields
### Data Models
- Use domain entities directly with BSON tags for MongoDB
- Include audit fields (created_at, updated_at)
- Use proper database field tags (bson, json)
- Never serialize sensitive fields (passwords)
### Time Handling Patterns
- **Input**: Accept Unix timestamps (int64) for all time fields
- **Database Storage**: Store as Unix timestamps (int64) in MongoDB
- **Output**: Return Unix timestamps (int64) for all time fields in API responses
- **Internal Processing**: Use Unix timestamps (int64) for all business logic
- **Audit Fields**: Always use Unix timestamps for created_at, updated_at
- **Consistency**: All time operations use Unix timestamps throughout the application
### MongoDB Patterns
- Create indexes in repository constructor
- Use proper BSON field names
- Handle duplicate key errors appropriately
- Use MongoDB-specific features (aggregations, etc.)
- **Time Fields**: Store all time fields as Unix timestamps (int64) for consistency
- **Queries**: Use Unix timestamps for time-based queries and filtering
## 🌐 HTTP Handler Guidelines
### Request Handling
- Validate all incoming requests using govalidator
- Use proper HTTP status codes
- Return consistent API response format using `pkg/response`
- Handle CORS appropriately
- Include request timeout handling
- **Time Input**: Accept Unix timestamps (int64) for all time fields in requests
- **Time Validation**: Validate Unix timestamps are within reasonable ranges
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
### Govalidator Usage
- Use govalidator for all request validation
- Define validation tags in request structs
- Use custom validation functions for complex rules
- Validate at handler level before calling services
- Return validation errors using `response.ValidationError()`
- Use meaningful error messages for validation failures
### 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
- **Time Output**: Always return Unix timestamps (int64) for all time fields in API responses
- **Time Consistency**: Ensure all time fields in responses use Unix timestamps consistently
### 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 Structure
- Use YAML for configuration files
- Support environment variable overrides
- Validate configuration on startup
- Use default values for optional settings
- Document all configuration options
### Configuration Types
- Server configuration (host, port, timeouts)
- Database configuration (MongoDB URI, pool settings)
- Cache configuration (Redis settings)
- Queue configuration (RabbitMQ settings)
- Auth configuration (JWT settings)
- Logging configuration (level, format, output)
### 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 forms with govalidator tags
- [ ] Plan error handling strategy
- [ ] Design database schema if needed
- [ ] Consider caching requirements
- [ ] Define custom validators if needed
### During Implementation
- [ ] Follow clean architecture layers
- [ ] Use dependency injection
- [ ] Implement proper logging
- [ ] Add govalidator validation tags to forms
- [ ] Register custom validators in init() functions
- [ ] Handle errors gracefully
### After Implementation
- [ ] Write unit tests
- [ ] Update API documentation
- [ ] Test error scenarios
- [ ] Verify security implications
- [ ] Check performance impact
- [ ] Test validation scenarios
## 🎯 Code Examples
### Time Handling Utilities
```go
// pkg/timeutil/timeutil.go
package timeutil
import "time"
// NowUnix returns current Unix timestamp
func NowUnix() int64 {
return time.Now().Unix()
}
// ValidateUnixTimestamp validates if Unix timestamp is reasonable
func ValidateUnixTimestamp(unix int64) bool {
// Check if timestamp is not too old (before 2020) or too future (after 2030)
minTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
maxTime := time.Date(2030, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
return unix >= minTime && unix <= maxTime
}
// ParseUnixTimestamp parses string to Unix timestamp
func ParseUnixTimestamp(timestamp string) (int64, error) {
// Handle various timestamp formats and convert to Unix
// This can be extended based on your input requirements
return 0, nil // Implementation depends on input format
}
// FormatUnixTimestamp formats Unix timestamp for display (if needed)
func FormatUnixTimestamp(unix int64, format string) string {
return time.Unix(unix, 0).Format(format)
}
```
### Govalidator Request Forms
```go
// internal/customer/service/form.go
package service
import (
"github.com/asaskevich/govalidator"
"github.com/google/uuid"
)
// RegisterForm represents customer registration request
type RegisterForm struct {
FirstName string `json:"first_name" valid:"required,alpha,length(2|50)"`
LastName string `json:"last_name" valid:"required,alpha,length(2|50)"`
Email string `json:"email" valid:"required,email"`
Password string `json:"password" valid:"required,length(8|128)"`
Mobile string `json:"mobile" valid:"required,length(10|15)"`
CompanyID string `json:"company_id" valid:"required,uuid"`
}
// LoginForm represents customer login request
type LoginForm struct {
Email string `json:"email" valid:"required,email"`
Password string `json:"password" valid:"required"`
}
// UpdateProfileForm represents profile update request
type UpdateProfileForm struct {
FirstName string `json:"first_name" valid:"required,alpha,length(2|50)"`
LastName string `json:"last_name" valid:"required,alpha,length(2|50)"`
Mobile string `json:"mobile" valid:"required,length(10|15)"`
LastLoginAt *int64 `json:"last_login_at,omitempty" valid:"optional,unix_timestamp"`
}
// CreateCustomerForm represents customer creation request
type CreateCustomerForm struct {
FirstName string `json:"first_name" valid:"required,alpha,length(2|50)"`
LastName string `json:"last_name" valid:"required,alpha,length(2|50)"`
Email string `json:"email" valid:"required,email"`
Password string `json:"password" valid:"required,length(8|128)"`
Mobile string `json:"mobile" valid:"required,length(10|15)"`
CompanyID string `json:"company_id" valid:"required,uuid"`
CreatedAt int64 `json:"created_at,omitempty" valid:"optional,unix_timestamp"`
UpdatedAt int64 `json:"updated_at,omitempty" valid:"optional,unix_timestamp"`
LastLoginAt *int64 `json:"last_login_at,omitempty" valid:"optional,unix_timestamp"`
}
// Custom validation functions
func init() {
// Register custom validators
govalidator.CustomTypeTagMap.Set("unix_timestamp", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
if unix, ok := i.(int64); ok {
return timeutil.ValidateUnixTimestamp(unix)
}
return false
}))
}
```
### Domain Entity Pattern
```go
// internal/customer/domain/entity/entity.go
type Customer struct {
ID uuid.UUID `bson:"_id"`
Email string `bson:"email"`
Password string `bson:"password"` // Never serialize
FirstName string `bson:"first_name"`
LastName string `bson:"last_name"`
Mobile string `bson:"mobile"`
CompanyID uuid.UUID `bson:"company_id"`
IsActive bool `bson:"is_active"`
IsVerified bool `bson:"is_verified"`
DeviceTokens []string `bson:"device_tokens"`
LastLoginAt *int64 `bson:"last_login_at,omitempty"` // Unix timestamp
CreatedAt int64 `bson:"created_at"` // Unix timestamp
UpdatedAt int64 `bson:"updated_at"` // Unix timestamp
}
```
### Aggregate Pattern
```go
// internal/customer/domain/aggregate/aggregate.go
type Customer struct {
ID uuid.UUID `json:"_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Mobile string `json:"mobile"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"`
LastLoginAt *int64 `json:"last_login_at,omitempty"` // Unix timestamp
CreatedAt int64 `json:"created_at"` // Unix timestamp
UpdatedAt int64 `json:"updated_at"` // Unix timestamp
}
func NewCustomer(c entity.Customer) Customer {
customer := Customer{
ID: c.ID,
FirstName: c.FirstName,
LastName: c.LastName,
Mobile: c.Mobile,
Email: c.Email,
IsActive: c.IsActive,
IsVerified: c.IsVerified,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.LastLoginAt != nil {
customer.LastLoginAt = c.LastLoginAt
}
return customer
}
```
### Repository Implementation with Time Handling
```go
// internal/customer/domain/customer.go
func (r *customerRepository) Create(ctx context.Context, customer *entity.Customer) error {
// Set created/updated timestamps using Unix timestamps
now := time.Now().Unix()
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
}
func (r *customerRepository) Update(ctx context.Context, customer *entity.Customer) error {
// Set updated timestamp using Unix timestamp
customer.UpdatedAt = time.Now().Unix()
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")
}
return nil
}
func (r *customerRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
now := time.Now().Unix()
filter := bson.M{"_id": id}
update := bson.M{
"$set": bson.M{
"last_login_at": now,
"updated_at": now,
},
}
result, err := r.collection.UpdateOne(ctx, filter, update)
if err != nil {
r.logger.Error("Failed to update last login", map[string]interface{}{
"error": err.Error(),
"customer_id": id.String(),
})
return err
}
if result.MatchedCount == 0 {
return errors.New("customer not found")
}
return nil
}
```
### Repository Interface
```go
// internal/customer/domain/repository.go
type Repository interface {
Create(ctx context.Context, customer *entity.Customer) error
GetByID(ctx context.Context, id uuid.UUID) (*entity.Customer, error)
GetByEmail(ctx context.Context, email string) (*entity.Customer, error)
Update(ctx context.Context, customer *entity.Customer) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, limit, offset int) ([]*entity.Customer, error)
GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*entity.Customer, error)
AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error
RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error
}
```
### Service Implementation Template
```go
type CustomerService struct {
customerRepo domain.Repository
config *infra.AuthConfig
logger logger.Logger
}
func NewCustomerService(
customerRepo domain.Repository,
config *infra.AuthConfig,
logger logger.Logger,
) CustomerService {
return CustomerService{
customerRepo: customerRepo,
config: config,
logger: logger,
}
}
func (s *CustomerService) Register(ctx context.Context, req RegisterForm) (*aggregate.AuthorizationResponse, error) {
s.logger.Info("Processing customer registration", map[string]interface{}{
"email": req.Email,
})
// Convert form to entity
customer := &entity.Customer{
ID: uuid.New(),
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
Password: req.Password, // Will be hashed in service
Mobile: req.Mobile,
CompanyID: uuid.MustParse(req.CompanyID),
IsActive: true,
IsVerified: false,
}
// Set timestamps
s.setTimestamps(customer)
// Create customer
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, err
}
// Generate tokens and return response
// ... token generation logic
return &aggregate.AuthorizationResponse{
Customer: aggregate.NewCustomer(*customer),
// ... token fields
}, nil
}
func (s *CustomerService) GetCustomers(ctx context.Context, limit, offset int) ([]*entity.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
}
// Time handling helper functions - all using Unix timestamps
func (s *CustomerService) setTimestamps(customer *entity.Customer) {
now := timeutil.NowUnix()
if customer.CreatedAt == 0 {
customer.CreatedAt = now
}
customer.UpdatedAt = now
}
func (s *CustomerService) setLastLogin(customer *entity.Customer) {
now := timeutil.NowUnix()
customer.LastLoginAt = &now
customer.UpdatedAt = now
}
func (s *CustomerService) validateTimestamps(customer *entity.Customer) error {
if customer.CreatedAt != 0 && !timeutil.ValidateUnixTimestamp(customer.CreatedAt) {
return errors.New("invalid created_at timestamp")
}
if customer.UpdatedAt != 0 && !timeutil.ValidateUnixTimestamp(customer.UpdatedAt) {
return errors.New("invalid updated_at timestamp")
}
if customer.LastLoginAt != nil && !timeutil.ValidateUnixTimestamp(*customer.LastLoginAt) {
return errors.New("invalid last_login_at timestamp")
}
return nil
}
```
### Handler Implementation Template
```go
type CustomerHandler struct {
service service.CustomerService
logger logger.Logger
}
func NewHandler(
customerService service.CustomerService,
logger logger.Logger,
) *CustomerHandler {
return &CustomerHandler{
service: customerService,
logger: logger,
}
}
func (h *CustomerHandler) Register(c echo.Context) error {
var req service.RegisterForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request using govalidator
if _, err := govalidator.ValidateStruct(&req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.Register(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Registration failed")
}
return response.Created(c, authResponse, "Customer registered successfully")
}
func (h *CustomerHandler) UpdateProfile(c echo.Context) error {
var req service.UpdateProfileForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request using govalidator
if _, err := govalidator.ValidateStruct(&req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
customer, err := h.service.UpdateProfile(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Profile update failed")
}
return response.Success(c, customer, "Profile updated successfully")
}
func (h *CustomerHandler) CreateWithTimestamp(c echo.Context) error {
var req service.CreateCustomerForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request using govalidator
if _, err := govalidator.ValidateStruct(&req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
customer, err := h.service.CreateCustomer(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Customer creation failed")
}
return response.Created(c, customer, "Customer created successfully")
}
```
### Configuration Structure
```go
// infra/config.go
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"`
}
```
### Application Bootstrap
```go
// cmd/web/main.go
func main() {
conf := intiConfig()
logger := initLogger(conf.Logging)
defer logger.Sync()
logger.Info(
"Starting Tender Management API Server",
map[string]interface{}{
"version": "1.0.0",
"host": conf.Server.Host,
"port": conf.Server.Port,
})
}
```
## 🚀 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
- Follow the established domain structure pattern
- Use the custom logger interface consistently
- Implement proper error handling and logging
- Follow the repository pattern with embedded implementations
- **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output
- **Time Validation**: Validate all Unix timestamp inputs are within reasonable ranges
- **Time Utilities**: Use centralized time utility functions for consistency across the application
- **No Format Conversion**: Never convert between Unix timestamps and other time formats in the application
- **Validation**: Always use govalidator for request validation with proper tags
- **Form Design**: Define validation rules in request forms, not in handlers
- **Custom Validators**: Register custom validators for complex validation rules
-260
View File
@@ -1,260 +0,0 @@
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
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"fmt"
"tm/infra"
"tm/pkg/logger"
)
// Init Application Configuration
func intiConfig() infra.Config {
config, err := infra.LoadConfig("./")
if err != nil {
panic(fmt.Sprintf("Failed to load config: %v", err))
}
return *config
}
// Init Logger Service
func initLogger(conf infra.LoggingConfig) logger.Logger {
return logger.NewLogger(&logger.Config{
Level: conf.Level,
Format: conf.Format,
Output: conf.Output,
File: logger.FileConfig{
Path: conf.File.Path,
MaxSize: conf.File.MaxSize,
MaxBackups: conf.File.MaxBackups,
MaxAge: conf.File.MaxAge,
Compress: conf.File.Compress,
},
})
}
+16
View File
@@ -0,0 +1,16 @@
package main
func main() {
conf := intiConfig()
logger := initLogger(conf.Logging)
defer logger.Sync()
logger.Info(
"Starting Tender Management API Server",
map[string]interface{}{
"version": "1.0.0",
"host": conf.Server.Host,
"port": conf.Server.Port,
})
}
-70
View File
@@ -1,70 +0,0 @@
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
+7 -6
View File
@@ -5,11 +5,11 @@ go 1.23.0
toolchain go1.24.4 toolchain go1.24.4
require ( require (
github.com/go-playground/validator/v10 v10.27.0
github.com/golang-jwt/jwt/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.2.3
github.com/google/uuid v1.4.0 github.com/google/uuid v1.4.0
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.10.0
go.mongodb.org/mongo-driver v1.17.4 go.mongodb.org/mongo-driver v1.17.4
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
@@ -17,15 +17,17 @@ require (
) )
require ( require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/fsnotify/fsnotify v1.7.0 // indirect 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/golang/snappy v0.0.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect github.com/klauspost/compress v1.17.0 // indirect
github.com/labstack/gommon v0.4.2 // 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/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -51,7 +53,6 @@ require (
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.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/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+2 -14
View File
@@ -1,3 +1,5 @@
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -6,16 +8,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
@@ -36,8 +28,6 @@ github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcX
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 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 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 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 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -132,8 +122,6 @@ 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 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/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-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.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/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -1,4 +1,4 @@
package infrastructure package infra
import ( import (
"time" "time"
@@ -100,17 +100,17 @@ type ScrapingConfig struct {
} }
type LoggingConfig struct { type LoggingConfig struct {
Level string `mapstructure:"level"` Level string `mapstructure:"level"`
Format string `mapstructure:"format"` Format string `mapstructure:"format"`
Output string `mapstructure:"output"` Output string `mapstructure:"output"`
File LogFileConfig `mapstructure:"file"` File LogFileConfig `mapstructure:"file"`
} }
type LogFileConfig struct { type LogFileConfig struct {
Path string `mapstructure:"path"` Path string `mapstructure:"path"`
MaxSize int `mapstructure:"max_size"` // MB MaxSize int `mapstructure:"max_size"` // MB
MaxBackups int `mapstructure:"max_backups"` MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"` // days MaxAge int `mapstructure:"max_age"` // days
Compress bool `mapstructure:"compress"` Compress bool `mapstructure:"compress"`
} }
@@ -0,0 +1,37 @@
package aggregate
import (
"tm/internal/customer/domain/entity"
"github.com/google/uuid"
)
type Customer struct {
ID uuid.UUID `json:"_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Mobile string `json:"mobile"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"`
}
func NewCustomer(c entity.Customer) Customer {
return Customer{
ID: c.ID,
FirstName: c.FirstName,
LastName: c.LastName,
Mobile: c.Mobile,
Email: c.Email,
IsActive: c.IsActive,
IsVerified: c.IsVerified,
}
}
// AuthResponse defines authentication response (kept for backward compatibility)
type AuthorizationResponse struct {
Customer Customer `json:"customer"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
@@ -1,52 +1,51 @@
package repository package customer
import ( import (
"context" "context"
"errors" "errors"
"time" "time"
"tm/internal/customer/domain/entity"
"tm/pkg/logger"
"github.com/google/uuid" "github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
"tm/internal/domain"
"tm/pkg/logger"
) )
// CustomerRepositoryImpl implements the CustomerRepository interface // customerRepository implements the Repository interface
type CustomerRepositoryImpl struct { type customerRepository struct {
collection *mongo.Collection collection *mongo.Collection
logger logger.Logger logger logger.Logger
} }
// NewCustomerRepository creates a new customer repository // NewCustomerRepository creates a new customer repository
func NewCustomerRepository(db *mongo.Database, logger logger.Logger) domain.CustomerRepository { func NewCustomerRepository(db *mongo.Database, logger logger.Logger) Repository {
collection := db.Collection("customers") collection := db.Collection("customers")
// Create indexes // Create indexes
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
// Email index (unique) // Email index (unique)
emailIndex := mongo.IndexModel{ emailIndex := mongo.IndexModel{
Keys: bson.D{{"email", 1}}, Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
} }
// Company ID index // Company ID index
companyIndex := mongo.IndexModel{ companyIndex := mongo.IndexModel{
Keys: bson.D{{"company_id", 1}}, Keys: bson.D{{Key: "company_id", Value: 1}},
} }
// Active status index // Active status index
activeIndex := mongo.IndexModel{ activeIndex := mongo.IndexModel{
Keys: bson.D{{"is_active", 1}}, Keys: bson.D{{Key: "is_active", Value: 1}},
} }
// Created at index // Created at index
createdAtIndex := mongo.IndexModel{ createdAtIndex := mongo.IndexModel{
Keys: bson.D{{"created_at", -1}}, Keys: bson.D{{Key: "created_at", Value: -1}},
} }
_, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
@@ -62,14 +61,14 @@ func NewCustomerRepository(db *mongo.Database, logger logger.Logger) domain.Cust
}) })
} }
return &CustomerRepositoryImpl{ return &customerRepository{
collection: collection, collection: collection,
logger: logger, logger: logger,
} }
} }
// Create creates a new customer // Create creates a new customer
func (r *CustomerRepositoryImpl) Create(ctx context.Context, customer *domain.Customer) error { func (r *customerRepository) Create(ctx context.Context, customer *entity.Customer) error {
// Set created/updated timestamps // Set created/updated timestamps
now := time.Now() now := time.Now()
customer.CreatedAt = now customer.CreatedAt = now
@@ -98,8 +97,8 @@ func (r *CustomerRepositoryImpl) Create(ctx context.Context, customer *domain.Cu
} }
// GetByID retrieves a customer by ID // GetByID retrieves a customer by ID
func (r *CustomerRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*domain.Customer, error) { func (r *customerRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Customer, error) {
var customer domain.Customer var customer entity.Customer
filter := bson.M{"_id": id} filter := bson.M{"_id": id}
err := r.collection.FindOne(ctx, filter).Decode(&customer) err := r.collection.FindOne(ctx, filter).Decode(&customer)
@@ -119,8 +118,8 @@ func (r *CustomerRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*do
} }
// GetByEmail retrieves a customer by email // GetByEmail retrieves a customer by email
func (r *CustomerRepositoryImpl) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*entity.Customer, error) {
var customer domain.Customer var customer entity.Customer
filter := bson.M{"email": email} filter := bson.M{"email": email}
err := r.collection.FindOne(ctx, filter).Decode(&customer) err := r.collection.FindOne(ctx, filter).Decode(&customer)
@@ -140,7 +139,7 @@ func (r *CustomerRepositoryImpl) GetByEmail(ctx context.Context, email string) (
} }
// Update updates a customer // Update updates a customer
func (r *CustomerRepositoryImpl) Update(ctx context.Context, customer *domain.Customer) error { func (r *customerRepository) Update(ctx context.Context, customer *entity.Customer) error {
// Set updated timestamp // Set updated timestamp
customer.UpdatedAt = time.Now() customer.UpdatedAt = time.Now()
@@ -169,7 +168,7 @@ func (r *CustomerRepositoryImpl) Update(ctx context.Context, customer *domain.Cu
} }
// Delete deletes a customer (soft delete by setting is_active to false) // Delete deletes a customer (soft delete by setting is_active to false)
func (r *CustomerRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { func (r *customerRepository) Delete(ctx context.Context, id uuid.UUID) error {
filter := bson.M{"_id": id} filter := bson.M{"_id": id}
update := bson.M{ update := bson.M{
"$set": bson.M{ "$set": bson.M{
@@ -199,14 +198,14 @@ func (r *CustomerRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error
} }
// List retrieves customers with pagination // List retrieves customers with pagination
func (r *CustomerRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*domain.Customer, error) { func (r *customerRepository) List(ctx context.Context, limit, offset int) ([]*entity.Customer, error) {
var customers []*domain.Customer var customers []*entity.Customer
// Build options // Build options
opts := options.Find() opts := options.Find()
opts.SetLimit(int64(limit)) opts.SetLimit(int64(limit))
opts.SetSkip(int64(offset)) opts.SetSkip(int64(offset))
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc
// Only active customers by default // Only active customers by default
filter := bson.M{"is_active": true} filter := bson.M{"is_active": true}
@@ -233,14 +232,14 @@ func (r *CustomerRepositoryImpl) List(ctx context.Context, limit, offset int) ([
} }
// GetByCompanyID retrieves customers by company ID with pagination // GetByCompanyID retrieves customers by company ID with pagination
func (r *CustomerRepositoryImpl) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*domain.Customer, error) { func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*entity.Customer, error) {
var customers []*domain.Customer var customers []*entity.Customer
// Build options // Build options
opts := options.Find() opts := options.Find()
opts.SetLimit(int64(limit)) opts.SetLimit(int64(limit))
opts.SetSkip(int64(offset)) opts.SetSkip(int64(offset))
opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc
// Filter by company ID and active status // Filter by company ID and active status
filter := bson.M{ filter := bson.M{
@@ -271,38 +270,8 @@ func (r *CustomerRepositoryImpl) GetByCompanyID(ctx context.Context, companyID u
return customers, nil 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 // AddDeviceToken adds a device token for push notifications
func (r *CustomerRepositoryImpl) AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error { func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error {
filter := bson.M{"_id": id} filter := bson.M{"_id": id}
update := bson.M{ update := bson.M{
"$addToSet": bson.M{"device_tokens": token}, // Use $addToSet to avoid duplicates "$addToSet": bson.M{"device_tokens": token}, // Use $addToSet to avoid duplicates
@@ -332,7 +301,7 @@ func (r *CustomerRepositoryImpl) AddDeviceToken(ctx context.Context, id uuid.UUI
} }
// RemoveDeviceToken removes a device token // RemoveDeviceToken removes a device token
func (r *CustomerRepositoryImpl) RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error { func (r *customerRepository) RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error {
filter := bson.M{"_id": id} filter := bson.M{"_id": id}
update := bson.M{ update := bson.M{
"$pull": bson.M{"device_tokens": token}, // Use $pull to remove the token "$pull": bson.M{"device_tokens": token}, // Use $pull to remove the token
+24
View File
@@ -0,0 +1,24 @@
package entity
import (
"time"
"github.com/google/uuid"
)
// Customer represents a mobile app user (customer)
type Customer struct {
ID uuid.UUID `bson:"_id"`
Email string `bson:"email"`
Password string `bson:"password"` // Never serialize password
FirstName string `bson:"first_name"`
LastName string `bson:"last_name"`
Mobile string `bson:"mobile"`
CompanyID uuid.UUID `bson:"company_id"`
IsActive bool `bson:"is_active"`
IsVerified bool `bson:"is_verified"`
DeviceTokens []string `bson:"device_tokens"` // For push notifications
LastLoginAt *time.Time `bson:"last_login_at,omitempty"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
+21
View File
@@ -0,0 +1,21 @@
package customer
import (
"context"
"tm/internal/customer/domain/entity"
"github.com/google/uuid"
)
// CustomerRepository defines methods for customer (mobile user) data access
type Repository interface {
Create(ctx context.Context, customer *entity.Customer) error
GetByID(ctx context.Context, id uuid.UUID) (*entity.Customer, error)
GetByEmail(ctx context.Context, email string) (*entity.Customer, error)
Update(ctx context.Context, customer *entity.Customer) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, limit, offset int) ([]*entity.Customer, error)
GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*entity.Customer, error)
AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error
RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error
}
+222
View File
@@ -0,0 +1,222 @@
package v1
import (
service "tm/internal/customer/service"
"tm/pkg/logger"
"tm/pkg/response"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo/v4"
)
// CustomerHandler handles authentication related HTTP requests for mobile customers
type CustomerHandler struct {
service service.CustomerService
logger logger.Logger
}
// NewCustomerHandler creates a new customer authentication handler
func NewHandler(
customerAuthService service.CustomerService,
logger logger.Logger,
) *CustomerHandler {
return &CustomerHandler{
service: customerAuthService,
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 *CustomerHandler) Register(c echo.Context) error {
var req service.RegisterForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.Register(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Registration failed")
}
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 *CustomerHandler) Login(c echo.Context) error {
var req service.LoginForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.Login(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Login failed")
}
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 *CustomerHandler) RefreshToken(c echo.Context) error {
var req service.RefreshTokenForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.RefreshToken(c.Request().Context(), req.RefreshToken)
if err != nil {
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 *CustomerHandler) ChangePassword(c echo.Context) error {
var req service.ChangePasswordForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&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")
// }
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 *CustomerHandler) 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, nil, "Profile retrieved 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 *CustomerHandler) 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")
}
@@ -1,34 +1,38 @@
package service package customer
import ( import (
"context" "context"
"errors" "errors"
infrastructure "tm/infra"
domain "tm/internal/customer/domain"
"tm/internal/customer/domain/entity"
"tm/pkg/logger"
"github.com/google/uuid" "github.com/google/uuid"
"tm/internal/domain"
"tm/pkg/logger"
) )
// CustomerServiceImpl implements the CustomerService interface // CustomerService implements the CustomerService interface
type CustomerServiceImpl struct { type CustomerService struct {
customerRepo domain.CustomerRepository customerRepo domain.Repository
config *infrastructure.AuthConfig
logger logger.Logger logger logger.Logger
} }
// NewCustomerService creates a new customer service // NewCustomerService creates a new customer service
func NewCustomerService( func NewCustomerService(
customerRepo domain.CustomerRepository, customerRepo domain.Repository,
config *infrastructure.AuthConfig,
logger logger.Logger, logger logger.Logger,
) domain.CustomerService { ) CustomerService {
return &CustomerServiceImpl{ return CustomerService{
customerRepo: customerRepo, customerRepo: customerRepo,
config: config,
logger: logger, logger: logger,
} }
} }
// GetCustomers retrieves customers with pagination (for panel users) // GetCustomers retrieves customers with pagination (for panel users)
func (s *CustomerServiceImpl) GetCustomers(ctx context.Context, limit, offset int) ([]*domain.Customer, error) { func (s *CustomerService) GetCustomers(ctx context.Context, limit, offset int) ([]*entity.Customer, error) {
s.logger.Info("Retrieving customers", map[string]interface{}{ s.logger.Info("Retrieving customers", map[string]interface{}{
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
@@ -54,7 +58,7 @@ func (s *CustomerServiceImpl) GetCustomers(ctx context.Context, limit, offset in
} }
// GetCustomerByID retrieves a customer by ID (for panel users) // GetCustomerByID retrieves a customer by ID (for panel users)
func (s *CustomerServiceImpl) GetCustomerByID(ctx context.Context, id uuid.UUID) (*domain.Customer, error) { func (s *CustomerService) GetCustomerByID(ctx context.Context, id uuid.UUID) (*entity.Customer, error) {
s.logger.Info("Retrieving customer by ID", map[string]interface{}{ s.logger.Info("Retrieving customer by ID", map[string]interface{}{
"customer_id": id.String(), "customer_id": id.String(),
}) })
@@ -77,7 +81,7 @@ func (s *CustomerServiceImpl) GetCustomerByID(ctx context.Context, id uuid.UUID)
} }
// GetCustomersByCompany retrieves customers by company ID with pagination (for panel users) // 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) { func (s *CustomerService) GetCustomersByCompany(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*entity.Customer, error) {
s.logger.Info("Retrieving customers by company", map[string]interface{}{ s.logger.Info("Retrieving customers by company", map[string]interface{}{
"company_id": companyID.String(), "company_id": companyID.String(),
"limit": limit, "limit": limit,
@@ -106,7 +110,7 @@ func (s *CustomerServiceImpl) GetCustomersByCompany(ctx context.Context, company
} }
// UpdateCustomerStatus updates customer active status (for panel users) // UpdateCustomerStatus updates customer active status (for panel users)
func (s *CustomerServiceImpl) UpdateCustomerStatus(ctx context.Context, customerID uuid.UUID, isActive bool, updatedBy uuid.UUID) error { func (s *CustomerService) UpdateCustomerStatus(ctx context.Context, customerID uuid.UUID, isActive bool, updatedBy uuid.UUID) error {
s.logger.Info("Updating customer status", map[string]interface{}{ s.logger.Info("Updating customer status", map[string]interface{}{
"customer_id": customerID.String(), "customer_id": customerID.String(),
"is_active": isActive, "is_active": isActive,
@@ -145,35 +149,3 @@ func (s *CustomerServiceImpl) UpdateCustomerStatus(ctx context.Context, customer
return nil 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
}
+24
View File
@@ -0,0 +1,24 @@
package customer
// Customer Authentication DTOs
type RegisterForm struct {
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
Email string `json:"email" validate:"required,email"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password" validate:"required,min=8"`
}
type LoginForm struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type RefreshTokenForm struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
type ChangePasswordForm struct {
OldPassword string `json:"old_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=8"`
}
@@ -1,4 +1,4 @@
package service package customer
import ( import (
"context" "context"
@@ -9,36 +9,12 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"tm/internal/domain" "tm/internal/customer/domain/aggregate"
"tm/internal/infrastructure" "tm/internal/customer/domain/entity"
"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 // Register creates a new customer account
func (s *CustomerAuthServiceImpl) Register(ctx context.Context, req domain.CustomerRegisterRequest) (*domain.CustomerAuthResponse, error) { func (s *CustomerService) Register(ctx context.Context, req RegisterForm) (*aggregate.AuthorizationResponse, error) {
s.logger.Info("Registering new customer", map[string]interface{}{ s.logger.Info("Registering new customer", map[string]interface{}{
"email": req.Email, "email": req.Email,
}) })
@@ -58,71 +34,19 @@ func (s *CustomerAuthServiceImpl) Register(ctx context.Context, req domain.Custo
return nil, errors.New("failed to process password") 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 // Create customer
customer := &domain.Customer{ customer := &entity.Customer{
ID: uuid.New(), ID: uuid.New(),
Email: req.Email, Email: req.Email,
Password: hashedPassword, Password: hashedPassword,
FirstName: req.FirstName, FirstName: req.FirstName,
LastName: req.LastName, LastName: req.LastName,
Phone: req.Phone, Mobile: req.Mobile,
Role: domain.CustomerRoleUser,
CompanyID: companyID,
IsActive: true, IsActive: true,
IsVerified: false, // Require email verification IsVerified: false, // Require email verification
DeviceTokens: make([]string, 0), DeviceTokens: make([]string, 0),
Preferences: domain.CustomerPreferences{ UpdatedAt: time.Now(),
Language: "en", CreatedAt: time.Now(),
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 { if err := s.customerRepo.Create(ctx, customer); err != nil {
@@ -161,8 +85,8 @@ func (s *CustomerAuthServiceImpl) Register(ctx context.Context, req domain.Custo
// TODO: Send verification email // TODO: Send verification email
s.sendVerificationEmail(ctx, customer) s.sendVerificationEmail(ctx, customer)
return &domain.CustomerAuthResponse{ return &aggregate.AuthorizationResponse{
Customer: customer, Customer: aggregate.NewCustomer(*customer),
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()), ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
@@ -170,7 +94,7 @@ func (s *CustomerAuthServiceImpl) Register(ctx context.Context, req domain.Custo
} }
// Login authenticates a customer and returns tokens // Login authenticates a customer and returns tokens
func (s *CustomerAuthServiceImpl) Login(ctx context.Context, req domain.LoginRequest) (*domain.CustomerAuthResponse, error) { func (s *CustomerService) Login(ctx context.Context, req LoginForm) (*aggregate.AuthorizationResponse, error) {
s.logger.Info("Customer login attempt", map[string]interface{}{ s.logger.Info("Customer login attempt", map[string]interface{}{
"email": req.Email, "email": req.Email,
}) })
@@ -240,8 +164,8 @@ func (s *CustomerAuthServiceImpl) Login(ctx context.Context, req domain.LoginReq
"is_verified": customer.IsVerified, "is_verified": customer.IsVerified,
}) })
return &domain.CustomerAuthResponse{ return &aggregate.AuthorizationResponse{
Customer: customer, Customer: aggregate.NewCustomer(*customer),
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()), ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
@@ -249,7 +173,7 @@ func (s *CustomerAuthServiceImpl) Login(ctx context.Context, req domain.LoginReq
} }
// RefreshToken generates new tokens using refresh token // RefreshToken generates new tokens using refresh token
func (s *CustomerAuthServiceImpl) RefreshToken(ctx context.Context, refreshToken string) (*domain.CustomerAuthResponse, error) { func (s *CustomerService) RefreshToken(ctx context.Context, refreshToken string) (*aggregate.AuthorizationResponse, error) {
// Parse and validate refresh token // Parse and validate refresh token
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
@@ -310,8 +234,8 @@ func (s *CustomerAuthServiceImpl) RefreshToken(ctx context.Context, refreshToken
return nil, errors.New("failed to generate refresh token") return nil, errors.New("failed to generate refresh token")
} }
return &domain.CustomerAuthResponse{ return &aggregate.AuthorizationResponse{
Customer: customer, Customer: aggregate.NewCustomer(*customer),
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: newRefreshToken, RefreshToken: newRefreshToken,
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()), ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
@@ -319,7 +243,7 @@ func (s *CustomerAuthServiceImpl) RefreshToken(ctx context.Context, refreshToken
} }
// ValidateToken validates a JWT token and returns the customer // ValidateToken validates a JWT token and returns the customer
func (s *CustomerAuthServiceImpl) ValidateToken(ctx context.Context, tokenString string) (*domain.Customer, error) { func (s *CustomerService) ValidateToken(ctx context.Context, tokenString string) (*entity.Customer, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method") return nil, errors.New("invalid signing method")
@@ -371,7 +295,7 @@ func (s *CustomerAuthServiceImpl) ValidateToken(ctx context.Context, tokenString
} }
// ChangePassword changes customer password // ChangePassword changes customer password
func (s *CustomerAuthServiceImpl) ChangePassword(ctx context.Context, customerID uuid.UUID, oldPassword, newPassword string) error { func (s *CustomerService) ChangePassword(ctx context.Context, customerID uuid.UUID, oldPassword, newPassword string) error {
customer, err := s.customerRepo.GetByID(ctx, customerID) customer, err := s.customerRepo.GetByID(ctx, customerID)
if err != nil { if err != nil {
return errors.New("customer not found") return errors.New("customer not found")
@@ -408,7 +332,7 @@ func (s *CustomerAuthServiceImpl) ChangePassword(ctx context.Context, customerID
} }
// ResetPassword initiates password reset process // ResetPassword initiates password reset process
func (s *CustomerAuthServiceImpl) ResetPassword(ctx context.Context, email string) error { func (s *CustomerService) ResetPassword(ctx context.Context, email string) error {
// TODO: Implement password reset logic // TODO: Implement password reset logic
// This would typically involve: // This would typically involve:
// 1. Generate reset token // 1. Generate reset token
@@ -418,7 +342,7 @@ func (s *CustomerAuthServiceImpl) ResetPassword(ctx context.Context, email strin
} }
// VerifyEmail verifies customer's email address // VerifyEmail verifies customer's email address
func (s *CustomerAuthServiceImpl) VerifyEmail(ctx context.Context, customerID uuid.UUID, token string) error { func (s *CustomerService) VerifyEmail(ctx context.Context, customerID uuid.UUID, token string) error {
// TODO: Implement email verification logic // TODO: Implement email verification logic
// This would typically involve: // This would typically involve:
// 1. Validate verification token // 1. Validate verification token
@@ -427,8 +351,8 @@ func (s *CustomerAuthServiceImpl) VerifyEmail(ctx context.Context, customerID uu
return errors.New("email verification not implemented") return errors.New("email verification not implemented")
} }
// ResendVerification resends verification email // ResendVerification resend verification email
func (s *CustomerAuthServiceImpl) ResendVerification(ctx context.Context, email string) error { func (s *CustomerService) ResendVerification(ctx context.Context, email string) error {
customer, err := s.customerRepo.GetByEmail(ctx, email) customer, err := s.customerRepo.GetByEmail(ctx, email)
if err != nil { if err != nil {
return errors.New("customer not found") return errors.New("customer not found")
@@ -446,7 +370,7 @@ func (s *CustomerAuthServiceImpl) ResendVerification(ctx context.Context, email
// Private helper methods // Private helper methods
func (s *CustomerAuthServiceImpl) hashPassword(password string) (string, error) { func (s *CustomerService) hashPassword(password string) (string, error) {
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return "", err return "", err
@@ -454,16 +378,15 @@ func (s *CustomerAuthServiceImpl) hashPassword(password string) (string, error)
return string(hashedBytes), nil return string(hashedBytes), nil
} }
func (s *CustomerAuthServiceImpl) verifyPassword(password, hashedPassword string) bool { func (s *CustomerService) verifyPassword(password, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil return err == nil
} }
func (s *CustomerAuthServiceImpl) generateAccessToken(customer *domain.Customer) (string, error) { func (s *CustomerService) generateAccessToken(customer *entity.Customer) (string, error) {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"customer_id": customer.ID.String(), "customer_id": customer.ID.String(),
"email": customer.Email, "email": customer.Email,
"role": customer.Role,
"company_id": customer.CompanyID.String(), "company_id": customer.CompanyID.String(),
"user_type": "customer", "user_type": "customer",
"exp": time.Now().Add(s.config.JWT.AccessTokenDuration).Unix(), "exp": time.Now().Add(s.config.JWT.AccessTokenDuration).Unix(),
@@ -475,7 +398,7 @@ func (s *CustomerAuthServiceImpl) generateAccessToken(customer *domain.Customer)
return token.SignedString([]byte(s.config.JWT.Secret)) return token.SignedString([]byte(s.config.JWT.Secret))
} }
func (s *CustomerAuthServiceImpl) generateRefreshToken(customer *domain.Customer) (string, error) { func (s *CustomerService) generateRefreshToken(customer *entity.Customer) (string, error) {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"customer_id": customer.ID.String(), "customer_id": customer.ID.String(),
"user_type": "customer", "user_type": "customer",
@@ -488,8 +411,9 @@ func (s *CustomerAuthServiceImpl) generateRefreshToken(customer *domain.Customer
return token.SignedString([]byte(s.config.JWT.Secret)) return token.SignedString([]byte(s.config.JWT.Secret))
} }
func (s *CustomerAuthServiceImpl) sendVerificationEmail(ctx context.Context, customer *domain.Customer) { func (s *CustomerService) sendVerificationEmail(ctx context.Context, customer *entity.Customer) {
// TODO: Implement email sending logic // TODO: Implement email sending logic
_ = ctx
s.logger.Info("Verification email would be sent", map[string]interface{}{ s.logger.Info("Verification email would be sent", map[string]interface{}{
"customer_id": customer.ID.String(), "customer_id": customer.ID.String(),
"email": customer.Email, "email": customer.Email,
-389
View File
@@ -1,389 +0,0 @@
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"
)
-321
View File
@@ -1,321 +0,0 @@
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"`
}
-358
View File
@@ -1,358 +0,0 @@
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")
}
-378
View File
@@ -1,378 +0,0 @@
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")
}
-380
View File
@@ -1,380 +0,0 @@
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")
}
-219
View File
@@ -1,219 +0,0 @@
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
}
-311
View File
@@ -1,311 +0,0 @@
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
}
-517
View File
@@ -1,517 +0,0 @@
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
}
-336
View File
@@ -1,336 +0,0 @@
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 ""
}
+18 -70
View File
@@ -8,8 +8,6 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
"tm/internal/infrastructure"
) )
// Logger interface defines logging methods // Logger interface defines logging methods
@@ -30,8 +28,23 @@ type ZapLogger struct {
fields map[string]interface{} fields map[string]interface{}
} }
type Config struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
Output string `mapstructure:"output"`
File FileConfig `mapstructure:"file"`
}
type FileConfig 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"`
}
// NewLogger creates a new logger instance with Zap and Lumberjack // NewLogger creates a new logger instance with Zap and Lumberjack
func NewLogger(config *infrastructure.LoggingConfig) Logger { func NewLogger(config *Config) Logger {
// Parse log level // Parse log level
level := parseLogLevel(config.Level) level := parseLogLevel(config.Level)
@@ -62,7 +75,7 @@ func NewLogger(config *infrastructure.LoggingConfig) Logger {
} }
// createWriter creates the appropriate writer based on configuration // createWriter creates the appropriate writer based on configuration
func createWriter(config *infrastructure.LoggingConfig) zapcore.WriteSyncer { func createWriter(config *Config) zapcore.WriteSyncer {
switch config.Output { switch config.Output {
case "stdout": case "stdout":
return zapcore.AddSync(os.Stdout) return zapcore.AddSync(os.Stdout)
@@ -77,7 +90,7 @@ func createWriter(config *infrastructure.LoggingConfig) zapcore.WriteSyncer {
} }
// createFileWriter creates a file writer with rotation using lumberjack // createFileWriter creates a file writer with rotation using lumberjack
func createFileWriter(fileConfig infrastructure.LogFileConfig) zapcore.WriteSyncer { func createFileWriter(fileConfig FileConfig) zapcore.WriteSyncer {
// Ensure log directory exists // Ensure log directory exists
logDir := filepath.Dir(fileConfig.Path) logDir := filepath.Dir(fileConfig.Path)
if err := os.MkdirAll(logDir, 0755); err != nil { if err := os.MkdirAll(logDir, 0755); err != nil {
@@ -237,68 +250,3 @@ func (l *ZapLogger) mergeFields(fields map[string]interface{}) map[string]interf
return merged 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()
}
-270
View File
@@ -1,270 +0,0 @@
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
}
+479
View File
@@ -0,0 +1,479 @@
# MongoDB ORM for Go
A production-grade, high-performance generic ORM for MongoDB in Go, using the official MongoDB driver. This ORM provides a clean, type-safe interface for MongoDB operations with support for pagination, transactions, and advanced querying.
## Features
**Generic Repository Pattern** - Type-safe operations for any struct
**Dual Pagination Support** - Both offset-based and cursor-based pagination
**Comprehensive CRUD Operations** - Create, Read, Update, Delete with bulk operations
**Advanced Querying** - Fluent query builder with complex conditions
**Aggregation Support** - Full MongoDB aggregation pipeline support
**Connection Management** - Connection pooling and lifecycle management
**Index Management** - Easy index creation and management
**Transaction Support** - ACID transactions for multi-document operations
**Structured Logging** - Comprehensive logging with structured fields
**Performance Optimized** - Efficient cursor-based pagination and connection pooling
**Type Safety** - Full Go generics support for compile-time type safety
## Quick Start
### 1. Define Your Model
```go
type User struct {
Model
Email string `bson:"email" json:"email"`
FirstName string `bson:"first_name" json:"first_name"`
LastName string `bson:"last_name" json:"last_name"`
Age int `bson:"age" json:"age"`
IsActive bool `bson:"is_active" json:"is_active"`
}
```
### 2. Set Up Connection
```go
// Create logger
logger := &YourLogger{}
// Configure connection
config := mongo.ConnectionConfig{
URI: "mongodb://localhost:27017",
Database: "your_database",
}
// Create connection manager
connManager, err := mongo.NewConnectionManager(config, logger)
if err != nil {
log.Fatal(err)
}
defer connManager.Close(context.Background())
// Create repository
userRepo := mongo.NewRepository[User](connManager.GetCollection("users"), logger)
```
### 3. Basic Operations
```go
// Create
user := &User{
Email: "john@example.com",
FirstName: "John",
LastName: "Doe",
Age: 30,
IsActive: true,
}
err := userRepo.Create(context.Background(), user)
// Find by ID
foundUser, err := userRepo.FindByID(context.Background(), user.ID)
// Update
user.FirstName = "Jane"
err = userRepo.Update(context.Background(), user)
// Delete
err = userRepo.Delete(context.Background(), user.ID)
```
## Pagination
### Offset-based Pagination
```go
pagination := mongo.NewPaginationBuilder().
Limit(10).
Skip(20).
SortDesc("created_at").
Build()
results, err := userRepo.FindAll(context.Background(), bson.M{}, pagination)
```
### Cursor-based Pagination (Recommended for Performance)
```go
// First page
pagination := mongo.NewPaginationBuilder().
Limit(10).
SortDesc("created_at").
Build()
firstPage, err := userRepo.FindAll(context.Background(), bson.M{}, pagination)
// Next page using cursor
if firstPage.NextCursor != "" {
nextPagination := mongo.NewPaginationBuilder().
Limit(10).
SortDesc("created_at").
Cursor(firstPage.NextCursor).
Build()
nextPage, err := userRepo.FindAll(context.Background(), bson.M{}, nextPagination)
}
```
## Advanced Querying
### Using QueryBuilder
```go
query := mongo.NewQueryBuilder().
Where("is_active", true).
WhereGreaterThan("age", 18).
WhereIn("category", []interface{}{"admin", "user"}).
WhereRegex("email", ".*@example\\.com", "i").
Build()
results, err := userRepo.FindAll(context.Background(), query, pagination)
```
### Complex Queries
```go
// AND/OR conditions
query := mongo.NewQueryBuilder().
WhereAnd(
bson.M{"is_active": true},
bson.M{"age": bson.M{"$gte": 18}},
).
WhereOr(
bson.M{"role": "admin"},
bson.M{"role": "moderator"},
).
Build()
```
## Aggregation
```go
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{"is_active": true}}},
{{Key: "$group", Value: bson.M{
"_id": "$role",
"count": bson.M{"$sum": 1},
"avgAge": bson.M{"$avg": "$age"},
}}},
{{Key: "$sort", Value: bson.M{"count": -1}}},
}
results, err := userRepo.Aggregate(context.Background(), pipeline)
```
## Bulk Operations
```go
// Bulk create
users := []User{
{Email: "user1@example.com", FirstName: "User1"},
{Email: "user2@example.com", FirstName: "User2"},
}
err := userRepo.BulkCreate(context.Background(), users)
// Bulk update
filter := bson.M{"is_active": false}
update := bson.M{"$set": bson.M{"status": "inactive"}}
err = userRepo.BulkUpdate(context.Background(), filter, update)
// Bulk delete
filter := bson.M{"created_at": bson.M{"$lt": time.Now().AddDate(0, -1, 0).Unix()}}
err = userRepo.BulkDelete(context.Background(), filter)
```
## Index Management
### Create Indexes
```go
// Create common indexes
indexes := mongo.UserIndexes()
err := connManager.CreateIndexes("users", indexes)
// Create custom indexes
customIndexes := []mongo.Index{
*mongo.CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
*mongo.CreateTTLIndex("expires_at_idx", "expires_at", 3600),
*mongo.CreateTextIndex("search_idx", "title", "description"),
}
err = connManager.CreateIndexes("documents", customIndexes)
```
### Index Types
```go
// Unique index
uniqueIdx := mongo.CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}})
// TTL index
ttlIdx := mongo.CreateTTLIndex("expires_at_idx", "expires_at", 3600)
// Text index
textIdx := mongo.CreateTextIndex("search_idx", "title", "description")
// Compound index
compoundIdx := mongo.CreateCompoundIndex("user_status_idx", map[string]int{
"user_id": 1,
"status": 1,
})
// Geospatial index
geoIdx := mongo.CreateGeospatialIndex("location_idx", "location")
// Hashed index
hashIdx := mongo.CreateHashedIndex("user_id_hash_idx", "user_id")
```
## Transactions
```go
// Start session
session, err := connManager.client.StartSession()
if err != nil {
log.Fatal(err)
}
defer session.EndSession(context.Background())
// Execute transaction
_, err = session.WithTransaction(context.Background(), func(sessCtx mongo.SessionContext) (interface{}, error) {
// Create user
user := &User{Email: "transaction@example.com"}
if err := userRepo.Create(sessCtx, user); err != nil {
return nil, err
}
// Create related document
profile := &Profile{UserID: user.ID}
if err := profileRepo.Create(sessCtx, profile); err != nil {
return nil, err
}
return nil, nil
})
```
## Connection Configuration
```go
config := mongo.ConnectionConfig{
URI: "mongodb://localhost:27017",
Database: "your_database",
MaxPoolSize: 100,
MinPoolSize: 5,
MaxConnIdleTime: 30 * time.Minute,
ConnectTimeout: 10 * time.Second,
ServerSelectionTimeout: 5 * time.Second,
}
```
## Logger Interface
Implement the Logger interface for structured logging:
```go
type YourLogger struct{}
func (l *YourLogger) Info(message string, fields map[string]interface{}) {
log.Printf("[INFO] %s: %+v", message, fields)
}
func (l *YourLogger) Error(message string, fields map[string]interface{}) {
log.Printf("[ERROR] %s: %+v", message, fields)
}
func (l *YourLogger) Debug(message string, fields map[string]interface{}) {
log.Printf("[DEBUG] %s: %+v", message, fields)
}
func (l *YourLogger) Warn(message string, fields map[string]interface{}) {
log.Printf("[WARN] %s: %+v", message, fields)
}
```
## Model Interfaces
### Timestampable Interface
Implement for automatic timestamp management:
```go
type YourModel struct {
Model
// Your fields...
}
// Model already implements Timestampable
// Timestamps are automatically set on Create/Update operations
```
### Custom ID Management
```go
type CustomModel struct {
Model
// Your fields...
}
// Model already implements IDGetter and IDSetter
// ID is automatically set after Create operations
```
## Performance Best Practices
### 1. Use Cursor-based Pagination
Cursor-based pagination is more efficient than offset-based pagination for large datasets:
```go
// Good - Cursor-based
pagination := mongo.NewPaginationBuilder().
Limit(10).
SortDesc("created_at").
Build()
// Avoid - Offset-based for large datasets
pagination := mongo.NewPaginationBuilder().
Limit(10).
Skip(10000). // Expensive for large datasets
Build()
```
### 2. Create Appropriate Indexes
```go
// Create indexes for frequently queried fields
indexes := []mongo.Index{
*mongo.CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
*mongo.NewIndex("status_created_idx", bson.D{
{Key: "status", Value: 1},
{Key: "created_at", Value: -1},
}),
}
```
### 3. Use Projection for Large Documents
```go
// Use FindMany with limit for large result sets
users, err := userRepo.FindMany(context.Background(), filter, 1000)
```
### 4. Connection Pooling
Configure appropriate connection pool settings:
```go
config := mongo.ConnectionConfig{
MaxPoolSize: 100,
MinPoolSize: 5,
MaxConnIdleTime: 30 * time.Minute,
}
```
## Error Handling
The ORM provides specific error types:
```go
var (
ErrDocumentNotFound = errors.New("document not found")
ErrInvalidCursor = errors.New("invalid cursor")
ErrInvalidPagination = errors.New("invalid pagination parameters")
)
// Handle specific errors
user, err := userRepo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
// Handle not found
return
}
// Handle other errors
}
```
## Testing
### Unit Testing with Mocks
```go
// Create mock repository for testing
type MockUserRepository struct {
users map[string]*User
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
if user, exists := m.users[id]; exists {
return user, nil
}
return nil, mongo.ErrDocumentNotFound
}
// Use in tests
mockRepo := &MockUserRepository{users: make(map[string]*User)}
```
### Integration Testing
```go
func TestUserRepository(t *testing.T) {
// Set up test database
config := mongo.ConnectionConfig{
URI: "mongodb://localhost:27017",
Database: "test_database",
}
connManager, err := mongo.NewConnectionManager(config, logger)
require.NoError(t, err)
defer connManager.Close(context.Background())
userRepo := mongo.NewRepository[User](connManager.GetCollection("users"), logger)
// Run tests...
}
```
## Migration from Existing Code
### From Manual MongoDB Operations
```go
// Before
collection := client.Database("db").Collection("users")
filter := bson.M{"email": email}
var user User
err := collection.FindOne(ctx, filter).Decode(&user)
// After
userRepo := mongo.NewRepository[User](collection, logger)
user, err := userRepo.FindOne(ctx, bson.M{"email": email})
```
### From Other ORMs
The generic repository pattern makes migration straightforward:
```go
// Replace existing repository interface
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
// ... other methods
}
// Implementation remains the same, just use mongo.NewRepository
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Ensure all tests pass
5. Submit a pull request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
+264
View File
@@ -0,0 +1,264 @@
package mongo
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
)
// ConnectionConfig holds MongoDB connection configuration
type ConnectionConfig struct {
URI string `json:"uri"`
Database string `json:"database"`
MaxPoolSize uint64 `json:"maxPoolSize"`
MinPoolSize uint64 `json:"minPoolSize"`
MaxConnIdleTime time.Duration `json:"maxConnIdleTime"`
ConnectTimeout time.Duration `json:"connectTimeout"`
ServerSelectionTimeout time.Duration `json:"serverSelectionTimeout"`
}
// ConnectionManager manages MongoDB connections and collections
type ConnectionManager struct {
client *mongo.Client
database *mongo.Database
logger Logger
config ConnectionConfig
}
// NewConnectionManager creates a new MongoDB connection manager
func NewConnectionManager(config ConnectionConfig, logger Logger) (*ConnectionManager, error) {
// Set default values
if config.MaxPoolSize == 0 {
config.MaxPoolSize = 100
}
if config.MinPoolSize == 0 {
config.MinPoolSize = 5
}
if config.MaxConnIdleTime == 0 {
config.MaxConnIdleTime = 30 * time.Minute
}
if config.ConnectTimeout == 0 {
config.ConnectTimeout = 10 * time.Second
}
if config.ServerSelectionTimeout == 0 {
config.ServerSelectionTimeout = 5 * time.Second
}
// Create client options
clientOptions := options.Client().
ApplyURI(config.URI).
SetMaxPoolSize(config.MaxPoolSize).
SetMinPoolSize(config.MinPoolSize).
SetMaxConnIdleTime(config.MaxConnIdleTime).
SetConnectTimeout(config.ConnectTimeout).
SetServerSelectionTimeout(config.ServerSelectionTimeout)
// Connect to MongoDB
ctx, cancel := context.WithTimeout(context.Background(), config.ConnectTimeout)
defer cancel()
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
logger.Error("Failed to connect to MongoDB", map[string]interface{}{
"uri": config.URI,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to connect to MongoDB: %w", err)
}
// Ping the database
if err = client.Ping(ctx, readpref.Primary()); err != nil {
logger.Error("Failed to ping MongoDB", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to ping MongoDB: %w", err)
}
database := client.Database(config.Database)
logger.Info("Successfully connected to MongoDB", map[string]interface{}{
"database": config.Database,
"uri": config.URI,
})
return &ConnectionManager{
client: client,
database: database,
logger: logger,
config: config,
}, nil
}
// GetCollection returns a MongoDB collection
func (cm *ConnectionManager) GetCollection(name string) *mongo.Collection {
return cm.database.Collection(name)
}
// CreateRepository creates a new repository for a given collection
func (cm *ConnectionManager) CreateRepository(collectionName string) *mongo.Collection {
return cm.GetCollection(collectionName)
}
// CreateIndexes creates indexes for a collection
func (cm *ConnectionManager) CreateIndexes(collectionName string, indexes []Index) error {
collection := cm.GetCollection(collectionName)
for _, index := range indexes {
indexModel := mongo.IndexModel{
Keys: index.Keys,
Options: index.Options,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := collection.Indexes().CreateOne(ctx, indexModel)
if err != nil {
cm.logger.Error("Failed to create index", map[string]interface{}{
"collection": collectionName,
"index": index.Name,
"error": err.Error(),
})
return fmt.Errorf("failed to create index %s: %w", index.Name, err)
}
cm.logger.Info("Index created successfully", map[string]interface{}{
"collection": collectionName,
"index": index.Name,
})
}
return nil
}
// DropIndexes drops indexes from a collection
func (cm *ConnectionManager) DropIndexes(collectionName string, indexNames []string) error {
collection := cm.GetCollection(collectionName)
for _, indexName := range indexNames {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := collection.Indexes().DropOne(ctx, indexName)
if err != nil {
cm.logger.Error("Failed to drop index", map[string]interface{}{
"collection": collectionName,
"index": indexName,
"error": err.Error(),
})
return fmt.Errorf("failed to drop index %s: %w", indexName, err)
}
cm.logger.Info("Index dropped successfully", map[string]interface{}{
"collection": collectionName,
"index": indexName,
})
}
return nil
}
// ListIndexes lists all indexes for a collection
func (cm *ConnectionManager) ListIndexes(collectionName string) ([]bson.M, error) {
collection := cm.GetCollection(collectionName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := collection.Indexes().List(ctx)
if err != nil {
cm.logger.Error("Failed to list indexes", map[string]interface{}{
"collection": collectionName,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to list indexes: %w", err)
}
defer cursor.Close(ctx)
var indexes []bson.M
if err = cursor.All(ctx, &indexes); err != nil {
cm.logger.Error("Failed to decode indexes", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to decode indexes: %w", err)
}
return indexes, nil
}
// Close closes the MongoDB connection
func (cm *ConnectionManager) Close(ctx context.Context) error {
if err := cm.client.Disconnect(ctx); err != nil {
cm.logger.Error("Failed to disconnect from MongoDB", map[string]interface{}{
"error": err.Error(),
})
return fmt.Errorf("failed to disconnect from MongoDB: %w", err)
}
cm.logger.Info("Successfully disconnected from MongoDB", nil)
return nil
}
// Ping pings the MongoDB server
func (cm *ConnectionManager) Ping(ctx context.Context) error {
if err := cm.client.Ping(ctx, readpref.Primary()); err != nil {
cm.logger.Error("Failed to ping MongoDB", map[string]interface{}{
"error": err.Error(),
})
return fmt.Errorf("failed to ping MongoDB: %w", err)
}
return nil
}
// GetStats returns database statistics
func (cm *ConnectionManager) GetStats(ctx context.Context) (bson.M, error) {
stats := cm.database.RunCommand(ctx, bson.M{"dbStats": 1})
if stats.Err() != nil {
cm.logger.Error("Failed to get database stats", map[string]interface{}{
"error": stats.Err().Error(),
})
return nil, fmt.Errorf("failed to get database stats: %w", stats.Err())
}
var result bson.M
if err := stats.Decode(&result); err != nil {
cm.logger.Error("Failed to decode database stats", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to decode database stats: %w", err)
}
return result, nil
}
// GetCollectionStats returns collection statistics
func (cm *ConnectionManager) GetCollectionStats(ctx context.Context, collectionName string) (bson.M, error) {
collection := cm.GetCollection(collectionName)
stats := collection.Database().RunCommand(ctx, bson.M{
"collStats": collectionName,
})
if stats.Err() != nil {
cm.logger.Error("Failed to get collection stats", map[string]interface{}{
"collection": collectionName,
"error": stats.Err().Error(),
})
return nil, fmt.Errorf("failed to get collection stats: %w", stats.Err())
}
var result bson.M
if err := stats.Decode(&result); err != nil {
cm.logger.Error("Failed to decode collection stats", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to decode collection stats: %w", err)
}
return result, nil
}
+64
View File
@@ -0,0 +1,64 @@
package mongo
// Logger defines the interface for logging operations
type Logger interface {
Info(message string, fields map[string]interface{})
Error(message string, fields map[string]interface{})
Debug(message string, fields map[string]interface{})
Warn(message string, fields map[string]interface{})
}
// Timestampable interface for models that support timestamps
type Timestampable interface {
SetCreatedAt(timestamp int64)
SetUpdatedAt(timestamp int64)
GetCreatedAt() int64
GetUpdatedAt() int64
}
// IDGetter interface for models that can provide their ID
type IDGetter interface {
GetID() string
}
// IDSetter interface for models that can set their ID
type IDSetter interface {
SetID(id string)
}
// Model provides a common base for MongoDB documents
type Model struct {
ID string `bson:"_id,omitempty" json:"id,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
// GetID returns the document ID
func (b *Model) GetID() string {
return b.ID
}
// SetID sets the document ID
func (b *Model) SetID(id string) {
b.ID = id
}
// SetCreatedAt sets the creation timestamp
func (b *Model) SetCreatedAt(timestamp int64) {
b.CreatedAt = timestamp
}
// SetUpdatedAt sets the update timestamp
func (b *Model) SetUpdatedAt(timestamp int64) {
b.UpdatedAt = timestamp
}
// GetCreatedAt returns the creation timestamp
func (b *Model) GetCreatedAt() int64 {
return b.CreatedAt
}
// GetUpdatedAt returns the update timestamp
func (b *Model) GetUpdatedAt() int64 {
return b.UpdatedAt
}
+593
View File
@@ -0,0 +1,593 @@
package mongo
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Common errors
var (
ErrDocumentNotFound = errors.New("document not found")
ErrInvalidCursor = errors.New("invalid cursor")
ErrInvalidPagination = errors.New("invalid pagination parameters")
)
// Pagination represents pagination parameters for queries
type Pagination struct {
Limit int `json:"limit"` // Number of documents to return
Skip int `json:"skip"` // Number of documents to skip (for offset-based)
Cursor string `json:"cursor"` // Base64 encoded cursor for cursor-based pagination
SortField string `json:"sortField"` // Field to sort by (default: "_id")
SortOrder int `json:"sortOrder"` // Sort order: 1 for ascending, -1 for descending
}
// PaginatedResult represents a paginated result set
type PaginatedResult[T any] struct {
Items []T `json:"items"` // The actual documents
TotalCount int64 `json:"totalCount"` // Total number of documents matching the filter
NextCursor string `json:"nextCursor"` // Base64 encoded cursor for next page
HasMore bool `json:"hasMore"` // Whether there are more documents
}
// Repository defines the interface for MongoDB operations
type Repository[T any] interface {
// FindByID retrieves a document by its ID
FindByID(ctx context.Context, id string) (*T, error)
// FindAll retrieves documents with optional filtering and pagination
FindAll(ctx context.Context, filter bson.M, pagination Pagination) (*PaginatedResult[T], error)
// Create inserts a new document
Create(ctx context.Context, model *T) error
// Update updates an existing document
Update(ctx context.Context, model *T) error
// Delete removes a document by ID
Delete(ctx context.Context, id string) error
// Count returns the number of documents matching the filter
Count(ctx context.Context, filter bson.M) (int64, error)
// Aggregate executes an aggregation pipeline
Aggregate(ctx context.Context, pipeline mongo.Pipeline) ([]bson.M, error)
// FindOne retrieves a single document matching the filter
FindOne(ctx context.Context, filter bson.M) (*T, error)
// FindMany retrieves multiple documents without pagination
FindMany(ctx context.Context, filter bson.M, limit int) ([]T, error)
// BulkCreate inserts multiple documents
BulkCreate(ctx context.Context, models []T) error
// BulkUpdate updates multiple documents
BulkUpdate(ctx context.Context, filter bson.M, update bson.M) error
// BulkDelete removes multiple documents
BulkDelete(ctx context.Context, filter bson.M) error
}
// repository implements the Repository interface
type repository[T any] struct {
collection *mongo.Collection
logger Logger
}
// NewRepository creates a new repository instance
func NewRepository[T any](collection *mongo.Collection, logger Logger) Repository[T] {
return &repository[T]{
collection: collection,
logger: logger,
}
}
// FindByID retrieves a document by its ID
func (r *repository[T]) FindByID(ctx context.Context, id string) (*T, error) {
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
r.logger.Error("Invalid ObjectID format", map[string]interface{}{
"id": id,
"error": err.Error(),
})
return nil, fmt.Errorf("invalid id format: %w", err)
}
filter := bson.M{"_id": objectID}
var result T
err = r.collection.FindOne(ctx, filter).Decode(&result)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrDocumentNotFound
}
r.logger.Error("Failed to find document by ID", map[string]interface{}{
"id": id,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to find document: %w", err)
}
return &result, nil
}
// FindAll retrieves documents with optional filtering and pagination
func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination Pagination) (*PaginatedResult[T], error) {
// Validate pagination parameters
if err := r.validatePagination(pagination); err != nil {
return nil, err
}
// Set default values
if pagination.Limit <= 0 {
pagination.Limit = 10
}
if pagination.SortField == "" {
pagination.SortField = "_id"
}
if pagination.SortOrder == 0 {
pagination.SortOrder = -1 // Default to descending
}
// Build sort options
sort := bson.D{{Key: pagination.SortField, Value: pagination.SortOrder}}
// Build find options
findOptions := options.Find().
SetSort(sort).
SetLimit(int64(pagination.Limit + 1)) // +1 to check if there are more documents
// Handle cursor-based pagination
if pagination.Cursor != "" {
cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder)
if err != nil {
return nil, err
}
// Merge cursor filter with existing filter
for key, value := range cursorFilter {
filter[key] = value
}
} else if pagination.Skip > 0 {
// Use offset-based pagination
findOptions.SetSkip(int64(pagination.Skip))
}
// Execute query
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
r.logger.Error("Failed to execute find query", map[string]interface{}{
"filter": filter,
"pagination": pagination,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to execute query: %w", err)
}
defer cursor.Close(ctx)
// Decode results
var items []T
if err = cursor.All(ctx, &items); err != nil {
r.logger.Error("Failed to decode query results", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to decode results: %w", err)
}
// Check if there are more documents
hasMore := false
if len(items) > pagination.Limit {
hasMore = true
items = items[:pagination.Limit] // Remove the extra item
}
// Get total count
totalCount, err := r.Count(ctx, filter)
if err != nil {
r.logger.Error("Failed to get total count", map[string]interface{}{
"error": err.Error(),
})
// Don't fail the entire query, just set count to 0
totalCount = 0
}
// Generate next cursor
var nextCursor string
if hasMore && len(items) > 0 {
nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField)
if err != nil {
r.logger.Error("Failed to generate next cursor", map[string]interface{}{
"error": err.Error(),
})
// Don't fail the entire query, just leave cursor empty
}
}
return &PaginatedResult[T]{
Items: items,
TotalCount: totalCount,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
}
// Create inserts a new document
func (r *repository[T]) Create(ctx context.Context, model *T) error {
// Set timestamps if the model implements Timestampable
if timestampable, ok := any(model).(Timestampable); ok {
now := time.Now().Unix()
timestampable.SetCreatedAt(now)
timestampable.SetUpdatedAt(now)
}
result, err := r.collection.InsertOne(ctx, model)
if err != nil {
r.logger.Error("Failed to create document", map[string]interface{}{
"error": err.Error(),
})
return fmt.Errorf("failed to create document: %w", err)
}
// Set the ID if the model implements IDSetter
if idSetter, ok := any(model).(IDSetter); ok {
if oid, ok := result.InsertedID.(primitive.ObjectID); ok {
idSetter.SetID(oid.Hex())
}
}
r.logger.Info("Document created successfully", map[string]interface{}{
"id": result.InsertedID,
})
return nil
}
// Update updates an existing document
func (r *repository[T]) Update(ctx context.Context, model *T) error {
// Set updated timestamp if the model implements Timestampable
if timestampable, ok := any(model).(Timestampable); ok {
timestampable.SetUpdatedAt(time.Now().Unix())
}
// Get the ID from the model
id, err := r.getModelID(model)
if err != nil {
return err
}
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return fmt.Errorf("invalid id format: %w", err)
}
filter := bson.M{"_id": objectID}
update := bson.M{"$set": model}
result, err := r.collection.UpdateOne(ctx, filter, update)
if err != nil {
r.logger.Error("Failed to update document", map[string]interface{}{
"id": id,
"error": err.Error(),
})
return fmt.Errorf("failed to update document: %w", err)
}
if result.MatchedCount == 0 {
return ErrDocumentNotFound
}
r.logger.Info("Document updated successfully", map[string]interface{}{
"id": id,
})
return nil
}
// Delete removes a document by ID
func (r *repository[T]) Delete(ctx context.Context, id string) error {
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return fmt.Errorf("invalid id format: %w", err)
}
filter := bson.M{"_id": objectID}
result, err := r.collection.DeleteOne(ctx, filter)
if err != nil {
r.logger.Error("Failed to delete document", map[string]interface{}{
"id": id,
"error": err.Error(),
})
return fmt.Errorf("failed to delete document: %w", err)
}
if result.DeletedCount == 0 {
return ErrDocumentNotFound
}
r.logger.Info("Document deleted successfully", map[string]interface{}{
"id": id,
})
return nil
}
// Count returns the number of documents matching the filter
func (r *repository[T]) Count(ctx context.Context, filter bson.M) (int64, error) {
count, err := r.collection.CountDocuments(ctx, filter)
if err != nil {
r.logger.Error("Failed to count documents", map[string]interface{}{
"filter": filter,
"error": err.Error(),
})
return 0, fmt.Errorf("failed to count documents: %w", err)
}
return count, nil
}
// Aggregate executes an aggregation pipeline
func (r *repository[T]) Aggregate(ctx context.Context, pipeline mongo.Pipeline) ([]bson.M, error) {
cursor, err := r.collection.Aggregate(ctx, pipeline)
if err != nil {
r.logger.Error("Failed to execute aggregation", map[string]interface{}{
"pipeline": pipeline,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to execute aggregation: %w", err)
}
defer cursor.Close(ctx)
var results []bson.M
if err = cursor.All(ctx, &results); err != nil {
r.logger.Error("Failed to decode aggregation results", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to decode aggregation results: %w", err)
}
return results, nil
}
// FindOne retrieves a single document matching the filter
func (r *repository[T]) FindOne(ctx context.Context, filter bson.M) (*T, error) {
var result T
err := r.collection.FindOne(ctx, filter).Decode(&result)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrDocumentNotFound
}
r.logger.Error("Failed to find document", map[string]interface{}{
"filter": filter,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to find document: %w", err)
}
return &result, nil
}
// FindMany retrieves multiple documents without pagination
func (r *repository[T]) FindMany(ctx context.Context, filter bson.M, limit int) ([]T, error) {
findOptions := options.Find()
if limit > 0 {
findOptions.SetLimit(int64(limit))
}
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
r.logger.Error("Failed to execute find query", map[string]interface{}{
"filter": filter,
"limit": limit,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to execute query: %w", err)
}
defer cursor.Close(ctx)
var results []T
if err = cursor.All(ctx, &results); err != nil {
r.logger.Error("Failed to decode query results", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to decode results: %w", err)
}
return results, nil
}
// BulkCreate inserts multiple documents
func (r *repository[T]) BulkCreate(ctx context.Context, models []T) error {
if len(models) == 0 {
return nil
}
// Set timestamps for all models
now := time.Now().Unix()
for i := range models {
if timestampable, ok := any(&models[i]).(Timestampable); ok {
timestampable.SetCreatedAt(now)
timestampable.SetUpdatedAt(now)
}
}
// Convert to interface slice for bulk insert
documents := make([]interface{}, len(models))
for i, model := range models {
documents[i] = model
}
result, err := r.collection.InsertMany(ctx, documents)
if err != nil {
r.logger.Error("Failed to bulk create documents", map[string]interface{}{
"count": len(models),
"error": err.Error(),
})
return fmt.Errorf("failed to bulk create documents: %w", err)
}
r.logger.Info("Documents bulk created successfully", map[string]interface{}{
"count": len(result.InsertedIDs),
})
return nil
}
// BulkUpdate updates multiple documents
func (r *repository[T]) BulkUpdate(ctx context.Context, filter bson.M, update bson.M) error {
result, err := r.collection.UpdateMany(ctx, filter, update)
if err != nil {
r.logger.Error("Failed to bulk update documents", map[string]interface{}{
"filter": filter,
"update": update,
"error": err.Error(),
})
return fmt.Errorf("failed to bulk update documents: %w", err)
}
r.logger.Info("Documents bulk updated successfully", map[string]interface{}{
"matched": result.MatchedCount,
"modified": result.ModifiedCount,
})
return nil
}
// BulkDelete removes multiple documents
func (r *repository[T]) BulkDelete(ctx context.Context, filter bson.M) error {
result, err := r.collection.DeleteMany(ctx, filter)
if err != nil {
r.logger.Error("Failed to bulk delete documents", map[string]interface{}{
"filter": filter,
"error": err.Error(),
})
return fmt.Errorf("failed to bulk delete documents: %w", err)
}
r.logger.Info("Documents bulk deleted successfully", map[string]interface{}{
"deleted": result.DeletedCount,
})
return nil
}
// Helper methods
// validatePagination validates pagination parameters
func (r *repository[T]) validatePagination(pagination Pagination) error {
if pagination.Limit < 0 {
return fmt.Errorf("%w: limit cannot be negative", ErrInvalidPagination)
}
if pagination.Skip < 0 {
return fmt.Errorf("%w: skip cannot be negative", ErrInvalidPagination)
}
if pagination.SortOrder != 0 && pagination.SortOrder != 1 && pagination.SortOrder != -1 {
return fmt.Errorf("%w: sort order must be 1, -1, or 0", ErrInvalidPagination)
}
return nil
}
// buildCursorFilter builds a filter for cursor-based pagination
func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int) (bson.M, error) {
// Decode cursor
cursorBytes, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return nil, fmt.Errorf("%w: invalid cursor encoding", ErrInvalidCursor)
}
var cursorValue interface{}
// Try to parse as ObjectID first
if sortField == "_id" {
if objectID, err := primitive.ObjectIDFromHex(string(cursorBytes)); err == nil {
cursorValue = objectID
} else {
return nil, fmt.Errorf("%w: invalid ObjectID in cursor", ErrInvalidCursor)
}
} else {
// For other fields, try to parse as appropriate type
// This is a simplified implementation - you might want to make this more sophisticated
cursorValue = string(cursorBytes)
}
// Build comparison operator based on sort order
var operator string
if sortOrder == 1 {
operator = "$gt"
} else {
operator = "$lt"
}
return bson.M{sortField: bson.M{operator: cursorValue}}, nil
}
// generateCursor generates a cursor from a document
func (r *repository[T]) generateCursor(doc T, sortField string) (string, error) {
// Convert document to BSON
docBytes, err := bson.Marshal(doc)
if err != nil {
return "", fmt.Errorf("failed to marshal document: %w", err)
}
var docMap bson.M
if err = bson.Unmarshal(docBytes, &docMap); err != nil {
return "", fmt.Errorf("failed to unmarshal document: %w", err)
}
// Get the sort field value
sortValue, exists := docMap[sortField]
if !exists {
return "", fmt.Errorf("sort field %s not found in document", sortField)
}
// Convert to string for encoding
var cursorStr string
switch v := sortValue.(type) {
case primitive.ObjectID:
cursorStr = v.Hex()
case string:
cursorStr = v
case int64:
cursorStr = fmt.Sprintf("%d", v)
case float64:
cursorStr = fmt.Sprintf("%f", v)
default:
cursorStr = fmt.Sprintf("%v", v)
}
// Encode as base64
return base64.StdEncoding.EncodeToString([]byte(cursorStr)), nil
}
// getModelID extracts the ID from a model
func (r *repository[T]) getModelID(model *T) (string, error) {
if idGetter, ok := any(model).(IDGetter); ok {
return idGetter.GetID(), nil
}
// Fallback: try to get ID from BSON
docBytes, err := bson.Marshal(model)
if err != nil {
return "", fmt.Errorf("failed to marshal model: %w", err)
}
var docMap bson.M
if err = bson.Unmarshal(docBytes, &docMap); err != nil {
return "", fmt.Errorf("failed to unmarshal model: %w", err)
}
if id, exists := docMap["_id"]; exists {
if oid, ok := id.(primitive.ObjectID); ok {
return oid.Hex(), nil
}
}
return "", errors.New("model does not have an ID field")
}
+538
View File
@@ -0,0 +1,538 @@
package mongo
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
// TestUser represents a test user model
type TestUser struct {
Model
Email string `bson:"email" json:"email"`
FirstName string `bson:"first_name" json:"first_name"`
LastName string `bson:"last_name" json:"last_name"`
Age int `bson:"age" json:"age"`
IsActive bool `bson:"is_active" json:"is_active"`
}
// TestLogger implements Logger for testing
type TestLogger struct{}
func (l *TestLogger) Info(message string, fields map[string]interface{}) {}
func (l *TestLogger) Error(message string, fields map[string]interface{}) {}
func (l *TestLogger) Debug(message string, fields map[string]interface{}) {}
func (l *TestLogger) Warn(message string, fields map[string]interface{}) {}
// setupTestConnection creates a test connection manager
func setupTestConnection(t *testing.T) *ConnectionManager {
config := ConnectionConfig{
URI: "mongodb://localhost:27017",
Database: "test_database",
}
logger := &TestLogger{}
connManager, err := NewConnectionManager(config, logger)
require.NoError(t, err)
return connManager
}
// cleanupTestData cleans up test data
func cleanupTestData(t *testing.T, connManager *ConnectionManager, collectionName string) {
collection := connManager.GetCollection(collectionName)
_, err := collection.DeleteMany(context.Background(), bson.M{})
require.NoError(t, err)
}
func TestRepository_Create(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
user := &TestUser{
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Age: 25,
IsActive: true,
}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.NotZero(t, user.CreatedAt)
assert.NotZero(t, user.UpdatedAt)
}
func TestRepository_FindByID(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create a user
user := &TestUser{
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Age: 25,
IsActive: true,
}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
// Find by ID
foundUser, err := repo.FindByID(context.Background(), user.ID)
require.NoError(t, err)
assert.Equal(t, user.Email, foundUser.Email)
assert.Equal(t, user.FirstName, foundUser.FirstName)
// Test not found
_, err = repo.FindByID(context.Background(), "507f1f77bcf86cd799439011")
assert.ErrorIs(t, err, ErrDocumentNotFound)
}
func TestRepository_FindOne(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create a user
user := &TestUser{
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Age: 25,
IsActive: true,
}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
// Find by email
foundUser, err := repo.FindOne(context.Background(), bson.M{"email": "test@example.com"})
require.NoError(t, err)
assert.Equal(t, user.Email, foundUser.Email)
// Test not found
_, err = repo.FindOne(context.Background(), bson.M{"email": "nonexistent@example.com"})
assert.ErrorIs(t, err, ErrDocumentNotFound)
}
func TestRepository_Update(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create a user
user := &TestUser{
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Age: 25,
IsActive: true,
}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
originalUpdatedAt := user.UpdatedAt
// Update user
user.FirstName = "Updated"
user.Age = 30
time.Sleep(1 * time.Second) // Ensure timestamp difference
err = repo.Update(context.Background(), user)
require.NoError(t, err)
// Verify update
foundUser, err := repo.FindByID(context.Background(), user.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", foundUser.FirstName)
assert.Equal(t, 30, foundUser.Age)
assert.Greater(t, foundUser.UpdatedAt, originalUpdatedAt)
}
func TestRepository_Delete(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create a user
user := &TestUser{
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Age: 25,
IsActive: true,
}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
// Delete user
err = repo.Delete(context.Background(), user.ID)
require.NoError(t, err)
// Verify deletion
_, err = repo.FindByID(context.Background(), user.ID)
assert.ErrorIs(t, err, ErrDocumentNotFound)
}
func TestRepository_FindAll_OffsetPagination(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create multiple users
users := []TestUser{
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
{Email: "user4@example.com", FirstName: "User4", Age: 40, IsActive: true},
{Email: "user5@example.com", FirstName: "User5", Age: 45, IsActive: true},
}
err := repo.BulkCreate(context.Background(), users)
require.NoError(t, err)
// Test offset-based pagination
pagination := NewPaginationBuilder().
Limit(2).
Skip(1).
SortAsc("created_at").
Build()
results, err := repo.FindAll(context.Background(), bson.M{}, pagination)
require.NoError(t, err)
assert.Len(t, results.Items, 2)
assert.Equal(t, int64(5), results.TotalCount)
assert.False(t, results.HasMore) // Should be false since we're using offset
}
func TestRepository_FindAll_CursorPagination(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create multiple users
users := []TestUser{
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
{Email: "user4@example.com", FirstName: "User4", Age: 40, IsActive: true},
{Email: "user5@example.com", FirstName: "User5", Age: 45, IsActive: true},
}
err := repo.BulkCreate(context.Background(), users)
require.NoError(t, err)
// Test cursor-based pagination
pagination := NewPaginationBuilder().
Limit(2).
SortDesc("created_at").
Build()
firstPage, err := repo.FindAll(context.Background(), bson.M{}, pagination)
require.NoError(t, err)
assert.Len(t, firstPage.Items, 2)
assert.Equal(t, int64(5), firstPage.TotalCount)
assert.True(t, firstPage.HasMore)
assert.NotEmpty(t, firstPage.NextCursor)
// Get second page
secondPagePagination := NewPaginationBuilder().
Limit(2).
SortDesc("created_at").
Cursor(firstPage.NextCursor).
Build()
secondPage, err := repo.FindAll(context.Background(), bson.M{}, secondPagePagination)
require.NoError(t, err)
assert.Len(t, secondPage.Items, 2)
assert.Equal(t, int64(5), secondPage.TotalCount)
assert.True(t, secondPage.HasMore)
assert.NotEmpty(t, secondPage.NextCursor)
}
func TestRepository_Count(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create users
users := []TestUser{
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
}
err := repo.BulkCreate(context.Background(), users)
require.NoError(t, err)
// Count all users
totalCount, err := repo.Count(context.Background(), bson.M{})
require.NoError(t, err)
assert.Equal(t, int64(3), totalCount)
// Count active users
activeCount, err := repo.Count(context.Background(), bson.M{"is_active": true})
require.NoError(t, err)
assert.Equal(t, int64(2), activeCount)
}
func TestRepository_BulkOperations(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Test bulk create
users := []TestUser{
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
}
err := repo.BulkCreate(context.Background(), users)
require.NoError(t, err)
// Verify bulk create
count, err := repo.Count(context.Background(), bson.M{})
require.NoError(t, err)
assert.Equal(t, int64(3), count)
// Test bulk update
updateFilter := bson.M{"is_active": false}
update := bson.M{"$set": bson.M{"age": 50}}
err = repo.BulkUpdate(context.Background(), updateFilter, update)
require.NoError(t, err)
// Verify bulk update
updatedUser, err := repo.FindOne(context.Background(), bson.M{"email": "user3@example.com"})
require.NoError(t, err)
assert.Equal(t, 50, updatedUser.Age)
// Test bulk delete
deleteFilter := bson.M{"is_active": false}
err = repo.BulkDelete(context.Background(), deleteFilter)
require.NoError(t, err)
// Verify bulk delete
count, err = repo.Count(context.Background(), bson.M{})
require.NoError(t, err)
assert.Equal(t, int64(2), count)
}
func TestRepository_Aggregate(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create users
users := []TestUser{
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
}
err := repo.BulkCreate(context.Background(), users)
require.NoError(t, err)
// Test aggregation
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{"is_active": true}}},
{{Key: "$group", Value: bson.M{
"_id": nil,
"count": bson.M{"$sum": 1},
"avgAge": bson.M{"$avg": "$age"},
}}},
}
results, err := repo.Aggregate(context.Background(), pipeline)
require.NoError(t, err)
assert.Len(t, results, 1)
result := results[0]
assert.Equal(t, int64(2), result["count"])
assert.Equal(t, 27.5, result["avgAge"])
}
func TestRepository_QueryBuilder(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Create users
users := []TestUser{
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
}
err := repo.BulkCreate(context.Background(), users)
require.NoError(t, err)
// Test QueryBuilder
query := NewQueryBuilder().
Where("is_active", true).
WhereGreaterThan("age", 20).
WhereLessThan("age", 35).
Build()
results, err := repo.FindMany(context.Background(), query, 10)
require.NoError(t, err)
assert.Len(t, results, 2)
// Test complex query with AND/OR
complexQuery := NewQueryBuilder().
WhereAnd(
bson.M{"is_active": true},
bson.M{"age": bson.M{"$gte": 25}},
).
WhereOr(
bson.M{"email": "user1@example.com"},
bson.M{"email": "user2@example.com"},
).
Build()
complexResults, err := repo.FindMany(context.Background(), complexQuery, 10)
require.NoError(t, err)
assert.Len(t, complexResults, 2)
}
func TestRepository_Validation(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Test invalid pagination
invalidPagination := Pagination{
Limit: -1, // Invalid
SortOrder: 2, // Invalid
}
_, err := repo.FindAll(context.Background(), bson.M{}, invalidPagination)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidPagination)
// Test invalid cursor
invalidCursorPagination := NewPaginationBuilder().
Limit(10).
Cursor("invalid-base64").
Build()
_, err = repo.FindAll(context.Background(), bson.M{}, invalidCursorPagination)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidCursor)
}
func TestRepository_IndexManagement(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
// Test creating indexes
indexes := []Index{
*CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
*NewIndex("age_idx", bson.D{{Key: "age", Value: 1}}),
}
err := connManager.CreateIndexes("test_users", indexes)
require.NoError(t, err)
// Test listing indexes
listIndexes, err := connManager.ListIndexes("test_users")
require.NoError(t, err)
assert.GreaterOrEqual(t, len(listIndexes), 3) // _id + created_at + our indexes
// Test dropping indexes
err = connManager.DropIndexes("test_users", []string{"age_idx"})
require.NoError(t, err)
}
func TestRepository_ConnectionManagement(t *testing.T) {
config := ConnectionConfig{
URI: "mongodb://localhost:27017",
Database: "test_database",
}
logger := &TestLogger{}
connManager, err := NewConnectionManager(config, logger)
require.NoError(t, err)
// Test ping
err = connManager.Ping(context.Background())
require.NoError(t, err)
// Test get stats
stats, err := connManager.GetStats(context.Background())
require.NoError(t, err)
assert.NotNil(t, stats)
// Test collection stats
collectionStats, err := connManager.GetCollectionStats(context.Background(), "test_users")
require.NoError(t, err)
assert.NotNil(t, collectionStats)
// Test close
err = connManager.Close(context.Background())
require.NoError(t, err)
}
func TestRepository_ErrorHandling(t *testing.T) {
connManager := setupTestConnection(t)
defer connManager.Close(context.Background())
defer cleanupTestData(t, connManager, "test_users")
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
// Test invalid ObjectID
_, err := repo.FindByID(context.Background(), "invalid-id")
assert.Error(t, err)
// Test update non-existent document
nonExistentUser := &TestUser{
Model: Model{ID: "507f1f77bcf86cd799439011"},
Email: "nonexistent@example.com",
}
err = repo.Update(context.Background(), nonExistentUser)
assert.ErrorIs(t, err, ErrDocumentNotFound)
// Test delete non-existent document
err = repo.Delete(context.Background(), "507f1f77bcf86cd799439011")
assert.ErrorIs(t, err, ErrDocumentNotFound)
}
+301
View File
@@ -0,0 +1,301 @@
package mongo
import (
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Index represents a MongoDB index
type Index struct {
Name string `json:"name"`
Keys bson.D `json:"keys"`
Options *options.IndexOptions `json:"options"`
}
// NewIndex creates a new index with default options
func NewIndex(name string, keys bson.D) *Index {
return &Index{
Name: name,
Keys: keys,
Options: options.Index(),
}
}
// WithUnique sets the unique option for the index
func (i *Index) WithUnique(unique bool) *Index {
i.Options.SetUnique(unique)
return i
}
// WithSparse sets the sparse option for the index
func (i *Index) WithSparse(sparse bool) *Index {
i.Options.SetSparse(sparse)
return i
}
// WithBackground sets the background option for the index
func (i *Index) WithBackground(background bool) *Index {
i.Options.SetBackground(background)
return i
}
// WithExpireAfterSeconds sets the TTL for the index
func (i *Index) WithExpireAfterSeconds(seconds int32) *Index {
i.Options.SetExpireAfterSeconds(seconds)
return i
}
// WithPartialFilterExpression sets the partial filter expression for the index
func (i *Index) WithPartialFilterExpression(filter bson.M) *Index {
i.Options.SetPartialFilterExpression(filter)
return i
}
// WithCollation sets the collation for the index
func (i *Index) WithCollation(collation *options.Collation) *Index {
i.Options.SetCollation(collation)
return i
}
// Common index builders
// CreateUniqueIndex creates a unique index
func CreateUniqueIndex(name string, keys bson.D) *Index {
return NewIndex(name, keys).WithUnique(true)
}
// CreateTTLIndex creates a TTL index
func CreateTTLIndex(name string, field string, expireAfterSeconds int32) *Index {
return NewIndex(name, bson.D{{Key: field, Value: 1}}).WithExpireAfterSeconds(expireAfterSeconds)
}
// CreateTextIndex creates a text index
func CreateTextIndex(name string, fields ...string) *Index {
keys := bson.D{}
for _, field := range fields {
keys = append(keys, bson.E{Key: field, Value: "text"})
}
return NewIndex(name, keys)
}
// CreateCompoundIndex creates a compound index
func CreateCompoundIndex(name string, fields map[string]int) *Index {
keys := bson.D{}
for field, direction := range fields {
keys = append(keys, bson.E{Key: field, Value: direction})
}
return NewIndex(name, keys)
}
// CreateGeospatialIndex creates a 2dsphere geospatial index
func CreateGeospatialIndex(name string, field string) *Index {
return NewIndex(name, bson.D{{Key: field, Value: "2dsphere"}})
}
// CreateHashedIndex creates a hashed index
func CreateHashedIndex(name string, field string) *Index {
return NewIndex(name, bson.D{{Key: field, Value: "hashed"}})
}
// Common index patterns
// DefaultIndexes returns common default indexes for most collections
func DefaultIndexes() []Index {
return []Index{
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
}
}
// UserIndexes returns common indexes for user collections
func UserIndexes() []Index {
return []Index{
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
*CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
*NewIndex("is_active_idx", bson.D{{Key: "is_active", Value: 1}}),
}
}
// TenderIndexes returns common indexes for tender collections
func TenderIndexes() []Index {
return []Index{
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
*NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*NewIndex("deadline_idx", bson.D{{Key: "deadline", Value: 1}}),
*NewIndex("category_idx", bson.D{{Key: "category", Value: 1}}),
*NewIndex("location_idx", bson.D{{Key: "location", Value: 1}}),
}
}
// CompanyIndexes returns common indexes for company collections
func CompanyIndexes() []Index {
return []Index{
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
*CreateUniqueIndex("name_idx", bson.D{{Key: "name", Value: 1}}),
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
*NewIndex("is_active_idx", bson.D{{Key: "is_active", Value: 1}}),
}
}
// QueryBuilder provides a fluent interface for building MongoDB queries
type QueryBuilder struct {
filter bson.M
}
// NewQueryBuilder creates a new query builder
func NewQueryBuilder() *QueryBuilder {
return &QueryBuilder{
filter: bson.M{},
}
}
// Where adds a simple equality condition
func (qb *QueryBuilder) Where(field string, value interface{}) *QueryBuilder {
qb.filter[field] = value
return qb
}
// WhereIn adds an $in condition
func (qb *QueryBuilder) WhereIn(field string, values []interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$in": values}
return qb
}
// WhereNotIn adds a $nin condition
func (qb *QueryBuilder) WhereNotIn(field string, values []interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$nin": values}
return qb
}
// WhereGreaterThan adds a $gt condition
func (qb *QueryBuilder) WhereGreaterThan(field string, value interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$gt": value}
return qb
}
// WhereGreaterThanOrEqual adds a $gte condition
func (qb *QueryBuilder) WhereGreaterThanOrEqual(field string, value interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$gte": value}
return qb
}
// WhereLessThan adds a $lt condition
func (qb *QueryBuilder) WhereLessThan(field string, value interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$lt": value}
return qb
}
// WhereLessThanOrEqual adds a $lte condition
func (qb *QueryBuilder) WhereLessThanOrEqual(field string, value interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$lte": value}
return qb
}
// WhereNotEqual adds a $ne condition
func (qb *QueryBuilder) WhereNotEqual(field string, value interface{}) *QueryBuilder {
qb.filter[field] = bson.M{"$ne": value}
return qb
}
// WhereExists adds an $exists condition
func (qb *QueryBuilder) WhereExists(field string, exists bool) *QueryBuilder {
qb.filter[field] = bson.M{"$exists": exists}
return qb
}
// WhereRegex adds a $regex condition
func (qb *QueryBuilder) WhereRegex(field string, pattern string, options string) *QueryBuilder {
qb.filter[field] = bson.M{"$regex": pattern, "$options": options}
return qb
}
// WhereText adds a $text condition
func (qb *QueryBuilder) WhereText(search string) *QueryBuilder {
qb.filter["$text"] = bson.M{"$search": search}
return qb
}
// WhereAnd adds an $and condition
func (qb *QueryBuilder) WhereAnd(conditions ...bson.M) *QueryBuilder {
qb.filter["$and"] = conditions
return qb
}
// WhereOr adds an $or condition
func (qb *QueryBuilder) WhereOr(conditions ...bson.M) *QueryBuilder {
qb.filter["$or"] = conditions
return qb
}
// WhereNor adds a $nor condition
func (qb *QueryBuilder) WhereNor(conditions ...bson.M) *QueryBuilder {
qb.filter["$nor"] = conditions
return qb
}
// Build returns the final filter
func (qb *QueryBuilder) Build() bson.M {
return qb.filter
}
// PaginationBuilder provides a fluent interface for building pagination options
type PaginationBuilder struct {
pagination Pagination
}
// NewPaginationBuilder creates a new pagination builder
func NewPaginationBuilder() *PaginationBuilder {
return &PaginationBuilder{
pagination: Pagination{
Limit: 10,
SortField: "_id",
SortOrder: -1,
},
}
}
// Limit sets the limit
func (pb *PaginationBuilder) Limit(limit int) *PaginationBuilder {
pb.pagination.Limit = limit
return pb
}
// Skip sets the skip value for offset-based pagination
func (pb *PaginationBuilder) Skip(skip int) *PaginationBuilder {
pb.pagination.Skip = skip
return pb
}
// Cursor sets the cursor for cursor-based pagination
func (pb *PaginationBuilder) Cursor(cursor string) *PaginationBuilder {
pb.pagination.Cursor = cursor
return pb
}
// SortBy sets the sort field and order
func (pb *PaginationBuilder) SortBy(field string, order int) *PaginationBuilder {
pb.pagination.SortField = field
pb.pagination.SortOrder = order
return pb
}
// SortAsc sorts in ascending order
func (pb *PaginationBuilder) SortAsc(field string) *PaginationBuilder {
return pb.SortBy(field, 1)
}
// SortDesc sorts in descending order
func (pb *PaginationBuilder) SortDesc(field string) *PaginationBuilder {
return pb.SortBy(field, -1)
}
// Build returns the final pagination options
func (pb *PaginationBuilder) Build() Pagination {
return pb.pagination
}