From 5a1ceb0bd66485585e706320d29bb035360126ba Mon Sep 17 00:00:00 2001 From: "n.nakhostin" Date: Sat, 2 Aug 2025 10:43:47 +0330 Subject: [PATCH] Add cursor rules and initial configuration for Tender Management Go Backend --- .cursor/rules/backend-coding-rules.mdc | 992 ++++++++++-------- .cursorrules | 838 +++++++++++++++ cmd/api/main.go | 260 ----- cmd/web/bootstrap.go | 33 + {configs => cmd/web}/config.yaml | 0 cmd/web/main.go | 16 + configs/config.example.yaml | 70 -- go.mod | 13 +- go.sum | 16 +- {internal/infrastructure => infra}/config.go | 14 +- .../customer/domain/aggregate/aggregate.go | 37 + .../domain/customer.go} | 91 +- internal/customer/domain/entity/entity.go | 24 + internal/customer/domain/repository.go | 21 + internal/customer/handler/v1/handler.go | 222 ++++ internal/{ => customer}/service/customer.go | 64 +- internal/customer/service/form.go | 24 + .../service/profile.go} | 132 +-- internal/domain/entities.go | 389 ------- internal/domain/interfaces.go | 321 ------ internal/handler/auth.go | 358 ------- internal/handler/customer.go | 378 ------- internal/handler/user.go | 380 ------- internal/repository/company_repository.go | 219 ---- internal/repository/user_repository.go | 311 ------ internal/service/user_auth.go | 517 --------- migrations/001_dual_user_system.go | 336 ------ pkg/logger/logger.go | 88 +- pkg/middleware/auth.go | 270 ----- pkg/mongo/README.md | 479 +++++++++ pkg/mongo/connection.go | 264 +++++ pkg/mongo/interfaces.go | 64 ++ pkg/mongo/repository.go | 593 +++++++++++ pkg/mongo/repository_test.go | 538 ++++++++++ pkg/mongo/utils.go | 301 ++++++ 35 files changed, 4123 insertions(+), 4550 deletions(-) create mode 100644 .cursorrules delete mode 100644 cmd/api/main.go create mode 100644 cmd/web/bootstrap.go rename {configs => cmd/web}/config.yaml (100%) create mode 100644 cmd/web/main.go delete mode 100644 configs/config.example.yaml rename {internal/infrastructure => infra}/config.go (92%) create mode 100644 internal/customer/domain/aggregate/aggregate.go rename internal/{repository/customer_repository.go => customer/domain/customer.go} (74%) create mode 100644 internal/customer/domain/entity/entity.go create mode 100644 internal/customer/domain/repository.go create mode 100644 internal/customer/handler/v1/handler.go rename internal/{ => customer}/service/customer.go (64%) create mode 100644 internal/customer/service/form.go rename internal/{service/customer_auth.go => customer/service/profile.go} (72%) delete mode 100644 internal/domain/entities.go delete mode 100644 internal/domain/interfaces.go delete mode 100644 internal/handler/auth.go delete mode 100644 internal/handler/customer.go delete mode 100644 internal/handler/user.go delete mode 100644 internal/repository/company_repository.go delete mode 100644 internal/repository/user_repository.go delete mode 100644 internal/service/user_auth.go delete mode 100644 migrations/001_dual_user_system.go delete mode 100644 pkg/middleware/auth.go create mode 100644 pkg/mongo/README.md create mode 100644 pkg/mongo/connection.go create mode 100644 pkg/mongo/interfaces.go create mode 100644 pkg/mongo/repository.go create mode 100644 pkg/mongo/repository_test.go create mode 100644 pkg/mongo/utils.go diff --git a/.cursor/rules/backend-coding-rules.mdc b/.cursor/rules/backend-coding-rules.mdc index 01364c4..c59ed8b 100644 --- a/.cursor/rules/backend-coding-rules.mdc +++ b/.cursor/rules/backend-coding-rules.mdc @@ -1,40 +1,64 @@ --- -alwaysApply: true +description: +globs: +alwaysApply: false --- - # Cursor Rules for Tender Management Go Backend ## Project Context -This is a Go backend API for a tender management system following Clean Architecture principles with minimal complexity, designed to be AI-friendly and easy to maintain. +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/`): Business entities, interfaces, and core business rules -- **Service Layer** (`internal/service/`): Business logic and use cases -- **Handler Layer** (`internal/handler/`): HTTP controllers and request/response handling -- **Repository Layer** (`internal/repository/`): Data access implementations -- **Infrastructure Layer** (`internal/infrastructure/`): External dependencies (DB, cache, queues) +- **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 packages +- 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., `UserRepository`, `AuthService`) +- 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 @@ -53,23 +77,23 @@ This is a Go backend API for a tender management system following Clean Architec ## ๐Ÿ”Œ Interface Design ### Repository Interfaces -- Always create interfaces in the domain layer +- 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`, `GetActiveUsers`) +- Use specific query methods (e.g., `GetByEmail`, `GetByCompanyID`) ### Service Interfaces -- Define business operations clearly -- Use request/response DTOs for complex operations +- 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 Zap Logger +### Using Custom Logger Interface - Always use structured logging with fields -- Include relevant context (user_id, request_id, etc.) +- Include relevant context (user_id, customer_id, request_id, etc.) - Use appropriate log levels: - `Debug`: Detailed debugging information - `Info`: General operational messages @@ -80,14 +104,14 @@ This is a Go backend API for a tender management system following Clean Architec ### Logging Examples ```go // Good -log.Info("User authenticated successfully", map[string]interface{}{ - "user_id": user.ID.String(), - "email": user.Email, - "ip": clientIP, +log.Info("Customer authenticated successfully", map[string]interface{}{ + "customer_id": customer.ID.String(), + "email": customer.Email, + "ip": clientIP, }) // Bad -log.Info("User login") +log.Info("Customer login") ``` ### Error Logging @@ -98,32 +122,63 @@ log.Info("User login") ## ๐Ÿ—„๏ธ Database & Repository Patterns ### Repository Implementation -- Implement repository interfaces in `internal/repository/` +- 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 +- 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 -- Separate database models from domain entities -- Convert between database models and domain entities in repositories -- Use proper database field tags (bson, json) +- 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 struct tags +- 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 @@ -149,13 +204,21 @@ log.Info("User login") ## ๐Ÿ“‹ Configuration Management -### Configuration Files +### 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 @@ -257,16 +320,18 @@ log.Info("User login") ### Before Implementation - [ ] Define interfaces in domain layer -- [ ] Create proper request/response DTOs +- [ ] 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 input validation +- [ ] Add govalidator validation tags to forms +- [ ] Register custom validators in init() functions - [ ] Handle errors gracefully ### After Implementation @@ -275,81 +340,487 @@ log.Info("User login") - [ ] 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 UserServiceImpl struct { - userRepo domain.UserRepository - logger logger.Logger +type CustomerService struct { + customerRepo domain.Repository + config *infra.AuthConfig + logger logger.Logger } -func NewUserService(userRepo domain.UserRepository, logger logger.Logger) domain.UserService { - return &UserServiceImpl{ - userRepo: userRepo, - logger: logger, +func NewCustomerService( + customerRepo domain.Repository, + config *infra.AuthConfig, + logger logger.Logger, +) CustomerService { + return CustomerService{ + customerRepo: customerRepo, + config: config, + logger: logger, } } -func (s *UserServiceImpl) CreateUser(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) { - // Validate input - if err := validateCreateUserRequest(req); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) +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, } - - // Business logic - user := &domain.User{ - ID: uuid.New(), - Email: req.Email, - // ... other fields - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - // Repository call - if err := s.userRepo.Create(ctx, user); err != nil { - s.logger.Error("Failed to create user", map[string]interface{}{ + + // 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, fmt.Errorf("failed to create user: %w", err) + return nil, err } - - s.logger.Info("User created successfully", map[string]interface{}{ - "user_id": user.ID.String(), - "email": user.Email, + + // 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, }) - - return user, nil + + 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 -func (h *UserHandler) CreateUser(c echo.Context) error { - var req domain.CreateUserRequest - - // Bind and validate request +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()) } - - if err := h.validator.Struct(&req); err != nil { + + // Validate request using govalidator + if _, err := govalidator.ValidateStruct(&req); err != nil { return response.ValidationError(c, "Validation failed", err.Error()) } - + // Call service - user, err := h.userService.CreateUser(c.Request().Context(), req) + authResponse, err := h.service.Register(c.Request().Context(), req) if err != nil { - h.logger.Error("Create user failed", map[string]interface{}{ - "error": err.Error(), - "email": req.Email, - }) - return response.InternalServerError(c, "Failed to create user") + return response.InternalServerError(c, "Registration failed") } - - return response.Created(c, user, "User created successfully") + + 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, + }) } ``` @@ -358,360 +829,15 @@ func (h *UserHandler) CreateUser(c echo.Context) error { - Make code self-documenting through clear naming - Always consider the person who will maintain this code - Test your code thoroughly -- Security and performance are not optional # Cursor Rules for Tender Management Go Backend - -## Project Context -This is a Go backend API for a tender management system following Clean Architecture principles with minimal complexity, designed to be AI-friendly and easy to maintain. - -## ๐Ÿ—๏ธ Architecture Guidelines - -### Clean Architecture Layers -- **Domain Layer** (`internal/domain/`): Business entities, interfaces, and core business rules -- **Service Layer** (`internal/service/`): Business logic and use cases -- **Handler Layer** (`internal/handler/`): HTTP controllers and request/response handling -- **Repository Layer** (`internal/repository/`): Data access implementations -- **Infrastructure Layer** (`internal/infrastructure/`): External dependencies (DB, cache, queues) - -### Dependency Rules -- Always depend on interfaces, not concrete implementations -- Use dependency injection through constructors -- Inner layers should never depend on outer layers -- All dependencies flow inward toward the domain - -## ๐Ÿ’ป Go Best Practices - -### Code Structure -- Use standard Go project layout with `internal/` for private code -- Group related functionality in packages -- Keep packages focused on single responsibility -- Use meaningful package names (not generic like `utils` or `helpers`) - -### Naming Conventions -- Use Go naming conventions: camelCase for private, PascalCase for public -- Interface names should describe what they do (e.g., `UserRepository`, `AuthService`) -- Use descriptive variable names, avoid abbreviations -- Constants should be in ALL_CAPS with underscores - -### Error Handling -- Always handle errors explicitly, never ignore them -- Use structured error messages with context -- Wrap errors with additional context using `fmt.Errorf("context: %w", err)` -- Log errors at the point they occur with relevant fields -- Return domain-specific error types when appropriate - -### Function Design -- Keep functions small and focused (max 20-30 lines) -- Use early returns to reduce nesting -- Validate inputs at function entry points -- Use descriptive parameter names -- Limit function parameters (max 3-4, use structs for more) - -## ๐Ÿ”Œ Interface Design - -### Repository Interfaces -- Always create interfaces in the domain layer -- Use context.Context as first parameter for all methods -- Return domain entities, not database models -- Include common operations: Create, GetByID, Update, Delete, List -- Use specific query methods (e.g., `GetByEmail`, `GetActiveUsers`) - -### Service Interfaces -- Define business operations clearly -- Use request/response DTOs for complex operations -- Include validation in service methods -- Handle business logic, not infrastructure concerns - -## ๐Ÿ“ Logging Standards - -### Using Zap Logger -- Always use structured logging with fields -- Include relevant context (user_id, request_id, etc.) -- Use appropriate log levels: - - `Debug`: Detailed debugging information - - `Info`: General operational messages - - `Warn`: Warning conditions that should be addressed - - `Error`: Error conditions that need attention - - `Fatal`: Critical errors that cause program termination - -### Logging Examples -```go -// Good -log.Info("User authenticated successfully", map[string]interface{}{ - "user_id": user.ID.String(), - "email": user.Email, - "ip": clientIP, -}) - -// Bad -log.Info("User login") -``` - -### Error Logging -- Log errors with full context -- Include error details and relevant fields -- Don't log the same error multiple times in the call stack - -## ๐Ÿ—„๏ธ Database & Repository Patterns - -### Repository Implementation -- Implement repository interfaces in `internal/repository/` -- Use proper database transactions for related operations -- Handle database errors gracefully -- Use prepared statements or ORM query builders -- Implement proper pagination - -### Data Models -- Separate database models from domain entities -- Convert between database models and domain entities in repositories -- Use proper database field tags (bson, json) -- Include audit fields (created_at, updated_at) - -## ๐ŸŒ HTTP Handler Guidelines - -### Request Handling -- Validate all incoming requests using struct tags -- Use proper HTTP status codes -- Return consistent API response format using `pkg/response` -- Handle CORS appropriately -- Include request timeout handling - -### Response Format -- Always use standardized response structure from `pkg/response` -- Include appropriate metadata for paginated responses -- Provide meaningful error messages -- Never expose internal errors to clients - -### Security -- Validate and sanitize all inputs -- Use proper authentication middleware -- Implement rate limiting -- Never log sensitive information (passwords, tokens) -- Use HTTPS in production - -## ๐Ÿงช Testing Guidelines - -### Test Structure -- Write unit tests for all business logic -- Use table-driven tests for multiple scenarios -- Mock external dependencies using interfaces -- Test error cases, not just happy paths -- Use meaningful test names that describe the scenario - -### Test Organization -- Place tests in same package as code being tested -- Use `_test.go` suffix for test files -- Create test utilities in `testutil` package if needed -- Use setup/teardown functions for common test data - -## ๐Ÿ“‹ Configuration Management - -### Configuration Files -- Use YAML for configuration files -- Support environment variable overrides -- Validate configuration on startup -- Use default values for optional settings -- Document all configuration options - -### Environment Variables -- Use consistent naming convention (e.g., `TM_DATABASE_URI`) -- Never commit secrets in configuration files -- Use different configs for different environments - -## ๐Ÿš€ Performance Guidelines - -### Optimization -- Use database indexes appropriately -- Implement caching for frequently accessed data -- Use connection pooling for databases -- Avoid N+1 queries -- Profile performance bottlenecks - -### Memory Management -- Close resources properly (database connections, files) -- Use context for cancellation and timeouts -- Avoid memory leaks in goroutines -- Use sync.Pool for frequently allocated objects - -## ๐Ÿ“š Documentation Standards - -### Code Documentation -- Write clear comments for complex business logic -- Document all public interfaces and types -- Use Go doc comments format -- Include examples for complex functions -- Keep comments up to date with code changes - -### API Documentation -- Use OpenAPI/Swagger annotations -- Document all endpoints with examples -- Include error response examples -- Document authentication requirements - -## ๐Ÿ” Security Best Practices - -### Authentication & Authorization -- Use JWT tokens with proper expiration -- Implement refresh token rotation -- Use bcrypt for password hashing -- Validate token claims properly -- Implement proper RBAC - -### Input Validation -- Validate all inputs at API boundaries -- Use whitelist validation, not blacklist -- Sanitize inputs to prevent injection attacks -- Validate file uploads properly - -## ๐Ÿ“ฆ Dependency Management - -### Go Modules -- Keep go.mod clean and organized -- Pin major versions for stability -- Regular dependency updates with testing -- Remove unused dependencies -- Use vendor directory for critical dependencies - -### External Libraries -- Prefer standard library when possible -- Choose well-maintained libraries -- Avoid dependencies with security issues -- Keep dependencies minimal - -## ๐Ÿ”„ Code Review Guidelines - -### Before Committing -- Run `go fmt`, `go vet`, and linting tools -- Ensure all tests pass -- Check for proper error handling -- Verify logging is appropriate -- Confirm no sensitive data is exposed - -### Code Quality -- Follow single responsibility principle -- Avoid code duplication -- Use meaningful variable names -- Keep functions and files reasonably sized -- Maintain consistent code style - -## ๐Ÿšจ Error Patterns to Avoid - -### Anti-patterns -- Don't use panic for regular error handling -- Avoid global variables for application state -- Don't ignore context cancellation -- Avoid deeply nested if statements -- Don't use reflection unless absolutely necessary - -### Common Mistakes -- Not handling database connection errors -- Forgetting to close resources -- Not validating user inputs -- Exposing internal errors to clients -- Not using proper HTTP status codes - -## ๐Ÿ“‹ Checklist for New Features - -### Before Implementation -- [ ] Define interfaces in domain layer -- [ ] Create proper request/response DTOs -- [ ] Plan error handling strategy -- [ ] Design database schema if needed -- [ ] Consider caching requirements - -### During Implementation -- [ ] Follow clean architecture layers -- [ ] Use dependency injection -- [ ] Implement proper logging -- [ ] Add input validation -- [ ] Handle errors gracefully - -### After Implementation -- [ ] Write unit tests -- [ ] Update API documentation -- [ ] Test error scenarios -- [ ] Verify security implications -- [ ] Check performance impact - -## ๐ŸŽฏ Code Examples - -### Service Implementation Template -```go -type UserServiceImpl struct { - userRepo domain.UserRepository - logger logger.Logger -} - -func NewUserService(userRepo domain.UserRepository, logger logger.Logger) domain.UserService { - return &UserServiceImpl{ - userRepo: userRepo, - logger: logger, - } -} - -func (s *UserServiceImpl) CreateUser(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) { - // Validate input - if err := validateCreateUserRequest(req); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } - - // Business logic - user := &domain.User{ - ID: uuid.New(), - Email: req.Email, - // ... other fields - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - // Repository call - if err := s.userRepo.Create(ctx, user); err != nil { - s.logger.Error("Failed to create user", map[string]interface{}{ - "error": err.Error(), - "email": req.Email, - }) - return nil, fmt.Errorf("failed to create user: %w", err) - } - - s.logger.Info("User created successfully", map[string]interface{}{ - "user_id": user.ID.String(), - "email": user.Email, - }) - - return user, nil -} -``` - -### Handler Implementation Template -```go -func (h *UserHandler) CreateUser(c echo.Context) error { - var req domain.CreateUserRequest - - // Bind and validate request - if err := c.Bind(&req); err != nil { - return response.BadRequest(c, "Invalid request format", err.Error()) - } - - if err := h.validator.Struct(&req); err != nil { - return response.ValidationError(c, "Validation failed", err.Error()) - } - - // Call service - user, err := h.userService.CreateUser(c.Request().Context(), req) - if err != nil { - h.logger.Error("Create user failed", map[string]interface{}{ - "error": err.Error(), - "email": req.Email, - }) - return response.InternalServerError(c, "Failed to create user") - } - - return response.Created(c, user, "User created successfully") -} -``` - -## ๐Ÿš€ Remember -- Prioritize simplicity and readability over cleverness -- Make code self-documenting through clear naming -- Always consider the person who will maintain this code -- Test your code thoroughly -- Security and performance are not optional \ No newline at end of file +- 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 \ No newline at end of file diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..108b378 --- /dev/null +++ b/.cursorrules @@ -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 \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go deleted file mode 100644 index d158896..0000000 --- a/cmd/api/main.go +++ /dev/null @@ -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 -} diff --git a/cmd/web/bootstrap.go b/cmd/web/bootstrap.go new file mode 100644 index 0000000..f8d540f --- /dev/null +++ b/cmd/web/bootstrap.go @@ -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, + }, + }) +} diff --git a/configs/config.yaml b/cmd/web/config.yaml similarity index 100% rename from configs/config.yaml rename to cmd/web/config.yaml diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..57a6a54 --- /dev/null +++ b/cmd/web/main.go @@ -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, + }) +} diff --git a/configs/config.example.yaml b/configs/config.example.yaml deleted file mode 100644 index 9f04408..0000000 --- a/configs/config.example.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod index c96697d..8353e63 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.23.0 toolchain go1.24.4 require ( - github.com/go-playground/validator/v10 v10.27.0 github.com/golang-jwt/jwt/v5 v5.2.3 github.com/google/uuid v1.4.0 github.com/labstack/echo/v4 v4.13.4 github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.10.0 go.mongodb.org/mongo-driver v1.17.4 go.uber.org/zap v1.26.0 golang.org/x/crypto v0.38.0 @@ -17,15 +17,17 @@ 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/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/klauspost/compress v1.17.0 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -51,7 +53,6 @@ require ( golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect - golang.org/x/time v0.11.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0c41eda..79018e1 100644 --- a/go.sum +++ b/go.sum @@ -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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= @@ -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/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/infrastructure/config.go b/infra/config.go similarity index 92% rename from internal/infrastructure/config.go rename to infra/config.go index 601f329..f82095a 100644 --- a/internal/infrastructure/config.go +++ b/infra/config.go @@ -1,4 +1,4 @@ -package infrastructure +package infra import ( "time" @@ -100,17 +100,17 @@ type ScrapingConfig struct { } type LoggingConfig struct { - Level string `mapstructure:"level"` - Format string `mapstructure:"format"` - Output string `mapstructure:"output"` - File LogFileConfig `mapstructure:"file"` + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` + Output string `mapstructure:"output"` + File LogFileConfig `mapstructure:"file"` } type LogFileConfig struct { Path string `mapstructure:"path"` - MaxSize int `mapstructure:"max_size"` // MB + MaxSize int `mapstructure:"max_size"` // MB MaxBackups int `mapstructure:"max_backups"` - MaxAge int `mapstructure:"max_age"` // days + MaxAge int `mapstructure:"max_age"` // days Compress bool `mapstructure:"compress"` } diff --git a/internal/customer/domain/aggregate/aggregate.go b/internal/customer/domain/aggregate/aggregate.go new file mode 100644 index 0000000..81ed162 --- /dev/null +++ b/internal/customer/domain/aggregate/aggregate.go @@ -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"` +} diff --git a/internal/repository/customer_repository.go b/internal/customer/domain/customer.go similarity index 74% rename from internal/repository/customer_repository.go rename to internal/customer/domain/customer.go index 896f2d2..5f7a3a7 100644 --- a/internal/repository/customer_repository.go +++ b/internal/customer/domain/customer.go @@ -1,52 +1,51 @@ -package repository +package customer import ( "context" "errors" "time" + "tm/internal/customer/domain/entity" + "tm/pkg/logger" "github.com/google/uuid" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" - - "tm/internal/domain" - "tm/pkg/logger" ) -// CustomerRepositoryImpl implements the CustomerRepository interface -type CustomerRepositoryImpl struct { +// customerRepository implements the Repository interface +type customerRepository struct { collection *mongo.Collection logger logger.Logger } // 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") // Create indexes ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // Email index (unique) + // Email index (unique) emailIndex := mongo.IndexModel{ - Keys: bson.D{{"email", 1}}, + Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetUnique(true), } - + // Company ID index companyIndex := mongo.IndexModel{ - Keys: bson.D{{"company_id", 1}}, + Keys: bson.D{{Key: "company_id", Value: 1}}, } - + // Active status index activeIndex := mongo.IndexModel{ - Keys: bson.D{{"is_active", 1}}, + Keys: bson.D{{Key: "is_active", Value: 1}}, } - + // Created at index createdAtIndex := mongo.IndexModel{ - Keys: bson.D{{"created_at", -1}}, + Keys: bson.D{{Key: "created_at", Value: -1}}, } _, 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, logger: logger, } } // 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 now := time.Now() customer.CreatedAt = now @@ -98,8 +97,8 @@ func (r *CustomerRepositoryImpl) Create(ctx context.Context, customer *domain.Cu } // GetByID retrieves a customer by ID -func (r *CustomerRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*domain.Customer, error) { - var customer domain.Customer +func (r *customerRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Customer, error) { + var customer entity.Customer filter := bson.M{"_id": id} 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 -func (r *CustomerRepositoryImpl) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { - var customer domain.Customer +func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*entity.Customer, error) { + var customer entity.Customer filter := bson.M{"email": email} 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 -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 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) -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} update := 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 -func (r *CustomerRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*domain.Customer, error) { - var customers []*domain.Customer +func (r *customerRepository) List(ctx context.Context, limit, offset int) ([]*entity.Customer, error) { + var customers []*entity.Customer // Build options opts := options.Find() opts.SetLimit(int64(limit)) opts.SetSkip(int64(offset)) - opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc + opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc // Only active customers by default 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 -func (r *CustomerRepositoryImpl) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*domain.Customer, error) { - var customers []*domain.Customer +func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*entity.Customer, error) { + var customers []*entity.Customer // Build options opts := options.Find() opts.SetLimit(int64(limit)) opts.SetSkip(int64(offset)) - opts.SetSort(bson.D{{"created_at", -1}}) // Sort by created_at desc + opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc // Filter by company ID and active status filter := bson.M{ @@ -271,38 +270,8 @@ func (r *CustomerRepositoryImpl) GetByCompanyID(ctx context.Context, companyID u return customers, nil } -// UpdatePreferences updates customer preferences -func (r *CustomerRepositoryImpl) UpdatePreferences(ctx context.Context, id uuid.UUID, preferences *domain.CustomerPreferences) error { - filter := bson.M{"_id": id} - update := bson.M{ - "$set": bson.M{ - "preferences": preferences, - "updated_at": time.Now(), - }, - } - - result, err := r.collection.UpdateOne(ctx, filter, update) - if err != nil { - r.logger.Error("Failed to update customer preferences", map[string]interface{}{ - "error": err.Error(), - "customer_id": id.String(), - }) - return err - } - - if result.MatchedCount == 0 { - return errors.New("customer not found") - } - - r.logger.Info("Customer preferences updated successfully", map[string]interface{}{ - "customer_id": id.String(), - }) - - return nil -} - // AddDeviceToken adds a device token for push notifications -func (r *CustomerRepositoryImpl) AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error { +func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error { filter := bson.M{"_id": id} update := bson.M{ "$addToSet": bson.M{"device_tokens": token}, // Use $addToSet to avoid duplicates @@ -332,7 +301,7 @@ func (r *CustomerRepositoryImpl) AddDeviceToken(ctx context.Context, id uuid.UUI } // 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} update := bson.M{ "$pull": bson.M{"device_tokens": token}, // Use $pull to remove the token diff --git a/internal/customer/domain/entity/entity.go b/internal/customer/domain/entity/entity.go new file mode 100644 index 0000000..7e895c4 --- /dev/null +++ b/internal/customer/domain/entity/entity.go @@ -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"` +} diff --git a/internal/customer/domain/repository.go b/internal/customer/domain/repository.go new file mode 100644 index 0000000..c86bb7e --- /dev/null +++ b/internal/customer/domain/repository.go @@ -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 +} diff --git a/internal/customer/handler/v1/handler.go b/internal/customer/handler/v1/handler.go new file mode 100644 index 0000000..ba76d28 --- /dev/null +++ b/internal/customer/handler/v1/handler.go @@ -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") +} diff --git a/internal/service/customer.go b/internal/customer/service/customer.go similarity index 64% rename from internal/service/customer.go rename to internal/customer/service/customer.go index c11f45a..1c23781 100644 --- a/internal/service/customer.go +++ b/internal/customer/service/customer.go @@ -1,34 +1,38 @@ -package service +package customer import ( "context" "errors" + infrastructure "tm/infra" + domain "tm/internal/customer/domain" + "tm/internal/customer/domain/entity" + "tm/pkg/logger" "github.com/google/uuid" - - "tm/internal/domain" - "tm/pkg/logger" ) -// CustomerServiceImpl implements the CustomerService interface -type CustomerServiceImpl struct { - customerRepo domain.CustomerRepository +// CustomerService implements the CustomerService interface +type CustomerService struct { + customerRepo domain.Repository + config *infrastructure.AuthConfig logger logger.Logger } // NewCustomerService creates a new customer service func NewCustomerService( - customerRepo domain.CustomerRepository, + customerRepo domain.Repository, + config *infrastructure.AuthConfig, logger logger.Logger, -) domain.CustomerService { - return &CustomerServiceImpl{ +) CustomerService { + return CustomerService{ customerRepo: customerRepo, + config: config, logger: logger, } } // 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{}{ "limit": limit, "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) -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{}{ "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) -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{}{ "company_id": companyID.String(), "limit": limit, @@ -106,7 +110,7 @@ func (s *CustomerServiceImpl) GetCustomersByCompany(ctx context.Context, company } // 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{}{ "customer_id": customerID.String(), "is_active": isActive, @@ -145,35 +149,3 @@ func (s *CustomerServiceImpl) UpdateCustomerStatus(ctx context.Context, customer 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 -} diff --git a/internal/customer/service/form.go b/internal/customer/service/form.go new file mode 100644 index 0000000..5d719ec --- /dev/null +++ b/internal/customer/service/form.go @@ -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"` +} diff --git a/internal/service/customer_auth.go b/internal/customer/service/profile.go similarity index 72% rename from internal/service/customer_auth.go rename to internal/customer/service/profile.go index 54695e4..87381ed 100644 --- a/internal/service/customer_auth.go +++ b/internal/customer/service/profile.go @@ -1,4 +1,4 @@ -package service +package customer import ( "context" @@ -9,36 +9,12 @@ import ( "github.com/google/uuid" "golang.org/x/crypto/bcrypt" - "tm/internal/domain" - "tm/internal/infrastructure" - "tm/pkg/logger" + "tm/internal/customer/domain/aggregate" + "tm/internal/customer/domain/entity" ) -// CustomerAuthServiceImpl implements the CustomerAuthService interface -type CustomerAuthServiceImpl struct { - customerRepo domain.CustomerRepository - companyRepo domain.CompanyRepository - config *infrastructure.AuthConfig - logger logger.Logger -} - -// NewCustomerAuthService creates a new customer authentication service -func NewCustomerAuthService( - customerRepo domain.CustomerRepository, - companyRepo domain.CompanyRepository, - config *infrastructure.AuthConfig, - logger logger.Logger, -) domain.CustomerAuthService { - return &CustomerAuthServiceImpl{ - customerRepo: customerRepo, - companyRepo: companyRepo, - config: config, - logger: logger, - } -} - // Register creates a new customer account -func (s *CustomerAuthServiceImpl) Register(ctx context.Context, req domain.CustomerRegisterRequest) (*domain.CustomerAuthResponse, error) { +func (s *CustomerService) Register(ctx context.Context, req RegisterForm) (*aggregate.AuthorizationResponse, error) { s.logger.Info("Registering new customer", map[string]interface{}{ "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") } - // Handle company assignment - var companyID uuid.UUID - if req.CompanyID != "" { - // Parse existing company ID - companyID, err = uuid.Parse(req.CompanyID) - if err != nil { - return nil, errors.New("invalid company ID") - } - - // Verify company exists - _, err = s.companyRepo.GetByID(ctx, companyID) - if err != nil { - return nil, errors.New("company not found") - } - } else { - // Create new company for customer - company := &domain.Company{ - ID: uuid.New(), - Name: req.FirstName + " " + req.LastName + "'s Company", - Description: "Personal company profile", - Industry: "Not specified", - Country: "Not specified", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := s.companyRepo.Create(ctx, company); err != nil { - s.logger.Error("Failed to create company for customer", map[string]interface{}{ - "error": err.Error(), - "email": req.Email, - }) - return nil, errors.New("failed to create company") - } - - companyID = company.ID - } - // Create customer - customer := &domain.Customer{ + customer := &entity.Customer{ ID: uuid.New(), Email: req.Email, Password: hashedPassword, FirstName: req.FirstName, LastName: req.LastName, - Phone: req.Phone, - Role: domain.CustomerRoleUser, - CompanyID: companyID, + Mobile: req.Mobile, IsActive: true, IsVerified: false, // Require email verification DeviceTokens: make([]string, 0), - Preferences: domain.CustomerPreferences{ - Language: "en", - NotificationSettings: domain.NotificationSettings{ - NewTenders: true, - DeadlineReminder: true, - StatusUpdates: true, - SystemAlerts: true, - }, - TenderAlerts: true, - EmailNotifications: true, - PushNotifications: true, - PreferredCategories: make([]string, 0), - }, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + UpdatedAt: time.Now(), + CreatedAt: time.Now(), } 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 s.sendVerificationEmail(ctx, customer) - return &domain.CustomerAuthResponse{ - Customer: customer, + return &aggregate.AuthorizationResponse{ + Customer: aggregate.NewCustomer(*customer), AccessToken: accessToken, RefreshToken: refreshToken, 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 -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{}{ "email": req.Email, }) @@ -240,8 +164,8 @@ func (s *CustomerAuthServiceImpl) Login(ctx context.Context, req domain.LoginReq "is_verified": customer.IsVerified, }) - return &domain.CustomerAuthResponse{ - Customer: customer, + return &aggregate.AuthorizationResponse{ + Customer: aggregate.NewCustomer(*customer), AccessToken: accessToken, RefreshToken: refreshToken, 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 -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 token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) { 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 &domain.CustomerAuthResponse{ - Customer: customer, + return &aggregate.AuthorizationResponse{ + Customer: aggregate.NewCustomer(*customer), AccessToken: accessToken, RefreshToken: newRefreshToken, 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 -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) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("invalid signing method") @@ -371,7 +295,7 @@ func (s *CustomerAuthServiceImpl) ValidateToken(ctx context.Context, tokenString } // 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) if err != nil { return errors.New("customer not found") @@ -408,7 +332,7 @@ func (s *CustomerAuthServiceImpl) ChangePassword(ctx context.Context, customerID } // 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 // This would typically involve: // 1. Generate reset token @@ -418,7 +342,7 @@ func (s *CustomerAuthServiceImpl) ResetPassword(ctx context.Context, email strin } // 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 // This would typically involve: // 1. Validate verification token @@ -427,8 +351,8 @@ func (s *CustomerAuthServiceImpl) VerifyEmail(ctx context.Context, customerID uu return errors.New("email verification not implemented") } -// ResendVerification resends verification email -func (s *CustomerAuthServiceImpl) ResendVerification(ctx context.Context, email string) error { +// ResendVerification resend verification email +func (s *CustomerService) ResendVerification(ctx context.Context, email string) error { customer, err := s.customerRepo.GetByEmail(ctx, email) if err != nil { return errors.New("customer not found") @@ -446,7 +370,7 @@ func (s *CustomerAuthServiceImpl) ResendVerification(ctx context.Context, email // 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) if err != nil { return "", err @@ -454,16 +378,15 @@ func (s *CustomerAuthServiceImpl) hashPassword(password string) (string, error) 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)) return err == nil } -func (s *CustomerAuthServiceImpl) generateAccessToken(customer *domain.Customer) (string, error) { +func (s *CustomerService) generateAccessToken(customer *entity.Customer) (string, error) { claims := jwt.MapClaims{ "customer_id": customer.ID.String(), "email": customer.Email, - "role": customer.Role, "company_id": customer.CompanyID.String(), "user_type": "customer", "exp": time.Now().Add(s.config.JWT.AccessTokenDuration).Unix(), @@ -475,7 +398,7 @@ func (s *CustomerAuthServiceImpl) generateAccessToken(customer *domain.Customer) 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{ "customer_id": customer.ID.String(), "user_type": "customer", @@ -488,8 +411,9 @@ func (s *CustomerAuthServiceImpl) generateRefreshToken(customer *domain.Customer 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 + _ = ctx s.logger.Info("Verification email would be sent", map[string]interface{}{ "customer_id": customer.ID.String(), "email": customer.Email, diff --git a/internal/domain/entities.go b/internal/domain/entities.go deleted file mode 100644 index 2fb166e..0000000 --- a/internal/domain/entities.go +++ /dev/null @@ -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" -) diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go deleted file mode 100644 index 33435a7..0000000 --- a/internal/domain/interfaces.go +++ /dev/null @@ -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"` -} diff --git a/internal/handler/auth.go b/internal/handler/auth.go deleted file mode 100644 index 1b3aad8..0000000 --- a/internal/handler/auth.go +++ /dev/null @@ -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") -} diff --git a/internal/handler/customer.go b/internal/handler/customer.go deleted file mode 100644 index c3bc854..0000000 --- a/internal/handler/customer.go +++ /dev/null @@ -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") -} diff --git a/internal/handler/user.go b/internal/handler/user.go deleted file mode 100644 index 08b5a4d..0000000 --- a/internal/handler/user.go +++ /dev/null @@ -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") -} diff --git a/internal/repository/company_repository.go b/internal/repository/company_repository.go deleted file mode 100644 index fae4e5f..0000000 --- a/internal/repository/company_repository.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go deleted file mode 100644 index d744f9c..0000000 --- a/internal/repository/user_repository.go +++ /dev/null @@ -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 -} diff --git a/internal/service/user_auth.go b/internal/service/user_auth.go deleted file mode 100644 index 2814319..0000000 --- a/internal/service/user_auth.go +++ /dev/null @@ -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 -} diff --git a/migrations/001_dual_user_system.go b/migrations/001_dual_user_system.go deleted file mode 100644 index 5398862..0000000 --- a/migrations/001_dual_user_system.go +++ /dev/null @@ -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 "" -} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index f0c0393..cd723d0 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -8,8 +8,6 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" - - "tm/internal/infrastructure" ) // Logger interface defines logging methods @@ -30,8 +28,23 @@ type ZapLogger struct { 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 -func NewLogger(config *infrastructure.LoggingConfig) Logger { +func NewLogger(config *Config) Logger { // Parse log level level := parseLogLevel(config.Level) @@ -62,7 +75,7 @@ func NewLogger(config *infrastructure.LoggingConfig) Logger { } // createWriter creates the appropriate writer based on configuration -func createWriter(config *infrastructure.LoggingConfig) zapcore.WriteSyncer { +func createWriter(config *Config) zapcore.WriteSyncer { switch config.Output { case "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 -func createFileWriter(fileConfig infrastructure.LogFileConfig) zapcore.WriteSyncer { +func createFileWriter(fileConfig FileConfig) zapcore.WriteSyncer { // Ensure log directory exists logDir := filepath.Dir(fileConfig.Path) 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 } - -// 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() -} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go deleted file mode 100644 index 17a702f..0000000 --- a/pkg/middleware/auth.go +++ /dev/null @@ -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 -} diff --git a/pkg/mongo/README.md b/pkg/mongo/README.md new file mode 100644 index 0000000..d787cc6 --- /dev/null +++ b/pkg/mongo/README.md @@ -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. \ No newline at end of file diff --git a/pkg/mongo/connection.go b/pkg/mongo/connection.go new file mode 100644 index 0000000..cf6668a --- /dev/null +++ b/pkg/mongo/connection.go @@ -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 +} diff --git a/pkg/mongo/interfaces.go b/pkg/mongo/interfaces.go new file mode 100644 index 0000000..c7c38d0 --- /dev/null +++ b/pkg/mongo/interfaces.go @@ -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 +} diff --git a/pkg/mongo/repository.go b/pkg/mongo/repository.go new file mode 100644 index 0000000..1de2b00 --- /dev/null +++ b/pkg/mongo/repository.go @@ -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") +} diff --git a/pkg/mongo/repository_test.go b/pkg/mongo/repository_test.go new file mode 100644 index 0000000..3bd7f67 --- /dev/null +++ b/pkg/mongo/repository_test.go @@ -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) +} diff --git a/pkg/mongo/utils.go b/pkg/mongo/utils.go new file mode 100644 index 0000000..6026ffe --- /dev/null +++ b/pkg/mongo/utils.go @@ -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 +}