diff --git a/.cursor/rules/backend-coding-rules.mdc b/.cursor/rules/backend-coding-rules.mdc index 2cd10c5..d5d9923 100644 --- a/.cursor/rules/backend-coding-rules.mdc +++ b/.cursor/rules/backend-coding-rules.mdc @@ -35,6 +35,7 @@ internal/{domain}/ - All files in a domain use the same package name for easy access - Repository implementations are in the same package as interfaces - Keep dependencies minimal and focused +- **NO GLOBAL VARIABLES**: Everything must be injected, including validation functions ## ๐Ÿ’ป Go Best Practices @@ -84,6 +85,12 @@ internal/{domain}/ ## ๐Ÿ“ Logging Standards +### Logging Location +- **NO LOGGING IN HANDLERS**: All logging must be done in the service layer +- Handlers should only handle HTTP concerns (validation, response formatting) +- Service layer is responsible for all business logic logging +- Repository layer can log database operations + ### Using Custom Logger Interface - Always use structured logging with fields - Include relevant context (user_id, customer_id, request_id, etc.) @@ -96,19 +103,23 @@ internal/{domain}/ ### Logging Examples ```go -// Good -log.Info("Customer authenticated successfully", map[string]interface{}{ - "customer_id": customer.ID.String(), - "email": customer.Email, - "ip": clientIP, -}) +// Service layer - GOOD +func (s *CustomerService) CreateCustomer(ctx context.Context, req CreateCustomerRequest) (*Customer, error) { + s.logger.Info("Creating new customer", map[string]interface{}{ + "email": req.Email, + "company_id": req.CompanyID, + }) + // ... business logic +} -// Bad -log.Info("Customer login") +// Handler - BAD (no logging here) +func (h *CustomerHandler) CreateCustomer(c *gin.Context) { + // Only handle HTTP concerns, no logging +} ``` ### Error Logging -- Log errors with full context +- Log errors with full context in service layer - Include error details and relevant fields - Don't log the same error multiple times in the call stack @@ -156,6 +167,7 @@ log.Info("Customer login") - **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) +- **NO LOGGING**: Handlers should not contain any logging statements ### Govalidator Usage - Use govalidator for all request validation @@ -164,6 +176,7 @@ log.Info("Customer login") - Validate at handler level before calling services - Return validation errors using `response.ValidationError()` - Use meaningful error messages for validation failures +- **Validation Injection**: Pass validation functions as dependencies, never use global validation ### Response Format - Always use standardized response structure from `pkg/response` @@ -180,6 +193,40 @@ log.Info("Customer login") - Never log sensitive information (passwords, tokens) - Use HTTPS in production +## ๐Ÿ“š Documentation Standards + +### Code Documentation +- **Only document infrastructure and complex business logic** +- Don't over-document simple operations +- Document public interfaces and types that might be confusing +- Use Go doc comments format +- Include examples for complex functions +- Keep comments up to date with code changes + +### API Documentation +- **Use Swagger/OpenAPI comments for all API endpoints** +- Document all endpoints with examples +- Include error response examples +- Document authentication requirements +- Keep Swagger documentation up to date + +### Swagger Comment Examples +```go +// @Summary Create a new customer +// @Description Create a new customer with the provided information +// @Tags customers +// @Accept json +// @Produce json +// @Param customer body CreateCustomerRequest true "Customer information" +// @Success 201 {object} response.Response{data=Customer} +// @Failure 400 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /customers [post] +func (h *CustomerHandler) CreateCustomer(c *gin.Context) { + // Handler implementation +} +``` + ## ๐Ÿงช Testing Guidelines ### Test Structure @@ -232,21 +279,6 @@ log.Info("Customer login") - 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 @@ -297,10 +329,12 @@ log.Info("Customer login") ### Anti-patterns - Don't use panic for regular error handling -- Avoid global variables for application state +- **Avoid global variables for application state** - Don't ignore context cancellation - Avoid deeply nested if statements - Don't use reflection unless absolutely necessary +- **Don't log in handlers** +- **Don't use global validation functions** ### Common Mistakes - Not handling database connection errors @@ -308,6 +342,8 @@ log.Info("Customer login") - Not validating user inputs - Exposing internal errors to clients - Not using proper HTTP status codes +- **Logging in handlers instead of services** +- **Using global variables for validation** ## ๐Ÿ“‹ Checklist for New Features @@ -318,22 +354,28 @@ log.Info("Customer login") - [ ] Design database schema if needed - [ ] Consider caching requirements - [ ] Define custom validators if needed +- [ ] Plan logging strategy (service layer only) +- [ ] Plan dependency injection for validation ### During Implementation - [ ] Follow flat domain structure (all files in same package) - [ ] Use dependency injection -- [ ] Implement proper logging +- [ ] Implement proper logging in service layer only - [ ] Add govalidator validation tags to forms -- [ ] Register custom validators in init() functions +- [ ] Register custom validators as injected dependencies - [ ] Handle errors gracefully +- [ ] Add Swagger comments to handlers +- [ ] Ensure no global variables are used ### After Implementation - [ ] Write unit tests -- [ ] Update API documentation +- [ ] Update API documentation (Swagger) - [ ] Test error scenarios - [ ] Verify security implications - [ ] Check performance impact - [ ] Test validation scenarios +- [ ] Verify no logging in handlers +- [ ] Verify all dependencies are injected ## ๐Ÿš€ Remember - Prioritize simplicity and readability over cleverness @@ -342,7 +384,7 @@ log.Info("Customer login") - Test your code thoroughly - Security and performance are not optional - **Follow the flat domain structure**: All files in a domain use the same package name -- Use the custom logger interface consistently +- Use the custom logger interface consistently in service layer only - Implement proper error handling and logging - Follow the repository pattern with interface and implementation in same file - **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output @@ -351,5 +393,8 @@ log.Info("Customer login") - **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 -- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports \ No newline at end of file +- **Custom Validators**: Register custom validators as injected dependencies +- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports +- **NO GLOBAL VARIABLES**: Everything must be injected +- **NO LOGGING IN HANDLERS**: All logging must be in service layer +- **SWAGGER DOCUMENTATION**: Use Swagger comments for all API endpoints \ No newline at end of file diff --git a/.cursorrules b/.cursorrules index fd2b48c..a45ad2d 100644 --- a/.cursorrules +++ b/.cursorrules @@ -32,6 +32,7 @@ internal/{domain}/ - All files in a domain use the same package name for easy access - Repository implementations are in the same package as interfaces - Keep dependencies minimal and focused +- **NO GLOBAL VARIABLES**: Everything must be injected, including validation functions ## ๐Ÿ’ป Go Best Practices @@ -81,6 +82,12 @@ internal/{domain}/ ## ๐Ÿ“ Logging Standards +### Logging Location +- **NO LOGGING IN HANDLERS**: All logging must be done in the service layer +- Handlers should only handle HTTP concerns (validation, response formatting) +- Service layer is responsible for all business logic logging +- Repository layer can log database operations + ### Using Custom Logger Interface - Always use structured logging with fields - Include relevant context (user_id, customer_id, request_id, etc.) @@ -93,19 +100,23 @@ internal/{domain}/ ### Logging Examples ```go -// Good -log.Info("Customer authenticated successfully", map[string]interface{}{ - "customer_id": customer.ID.String(), - "email": customer.Email, - "ip": clientIP, -}) +// Service layer - GOOD +func (s *CustomerService) CreateCustomer(ctx context.Context, req CreateCustomerRequest) (*Customer, error) { + s.logger.Info("Creating new customer", map[string]interface{}{ + "email": req.Email, + "company_id": req.CompanyID, + }) + // ... business logic +} -// Bad -log.Info("Customer login") +// Handler - BAD (no logging here) +func (h *CustomerHandler) CreateCustomer(c *gin.Context) { + // Only handle HTTP concerns, no logging +} ``` ### Error Logging -- Log errors with full context +- Log errors with full context in service layer - Include error details and relevant fields - Don't log the same error multiple times in the call stack @@ -153,6 +164,7 @@ log.Info("Customer login") - **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) +- **NO LOGGING**: Handlers should not contain any logging statements ### Govalidator Usage - Use govalidator for all request validation @@ -161,6 +173,7 @@ log.Info("Customer login") - Validate at handler level before calling services - Return validation errors using `response.ValidationError()` - Use meaningful error messages for validation failures +- **Validation Injection**: Pass validation functions as dependencies, never use global validation ### Response Format - Always use standardized response structure from `pkg/response` @@ -177,6 +190,40 @@ log.Info("Customer login") - Never log sensitive information (passwords, tokens) - Use HTTPS in production +## ๐Ÿ“š Documentation Standards + +### Code Documentation +- **Only document infrastructure and complex business logic** +- Don't over-document simple operations +- Document public interfaces and types that might be confusing +- Use Go doc comments format +- Include examples for complex functions +- Keep comments up to date with code changes + +### API Documentation +- **Use Swagger/OpenAPI comments for all API endpoints** +- Document all endpoints with examples +- Include error response examples +- Document authentication requirements +- Keep Swagger documentation up to date + +### Swagger Comment Examples +```go +// @Summary Create a new customer +// @Description Create a new customer with the provided information +// @Tags customers +// @Accept json +// @Produce json +// @Param customer body CreateCustomerRequest true "Customer information" +// @Success 201 {object} response.Response{data=Customer} +// @Failure 400 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /customers [post] +func (h *CustomerHandler) CreateCustomer(c *gin.Context) { + // Handler implementation +} +``` + ## ๐Ÿงช Testing Guidelines ### Test Structure @@ -229,21 +276,6 @@ log.Info("Customer login") - 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 @@ -294,10 +326,12 @@ log.Info("Customer login") ### Anti-patterns - Don't use panic for regular error handling -- Avoid global variables for application state +- **Avoid global variables for application state** - Don't ignore context cancellation - Avoid deeply nested if statements - Don't use reflection unless absolutely necessary +- **Don't log in handlers** +- **Don't use global validation functions** ### Common Mistakes - Not handling database connection errors @@ -305,6 +339,8 @@ log.Info("Customer login") - Not validating user inputs - Exposing internal errors to clients - Not using proper HTTP status codes +- **Logging in handlers instead of services** +- **Using global variables for validation** ## ๐Ÿ“‹ Checklist for New Features @@ -315,22 +351,28 @@ log.Info("Customer login") - [ ] Design database schema if needed - [ ] Consider caching requirements - [ ] Define custom validators if needed +- [ ] Plan logging strategy (service layer only) +- [ ] Plan dependency injection for validation ### During Implementation - [ ] Follow flat domain structure (all files in same package) - [ ] Use dependency injection -- [ ] Implement proper logging +- [ ] Implement proper logging in service layer only - [ ] Add govalidator validation tags to forms -- [ ] Register custom validators in init() functions +- [ ] Register custom validators as injected dependencies - [ ] Handle errors gracefully +- [ ] Add Swagger comments to handlers +- [ ] Ensure no global variables are used ### After Implementation - [ ] Write unit tests -- [ ] Update API documentation +- [ ] Update API documentation (Swagger) - [ ] Test error scenarios - [ ] Verify security implications - [ ] Check performance impact - [ ] Test validation scenarios +- [ ] Verify no logging in handlers +- [ ] Verify all dependencies are injected ## ๐Ÿš€ Remember - Prioritize simplicity and readability over cleverness @@ -339,7 +381,7 @@ log.Info("Customer login") - Test your code thoroughly - Security and performance are not optional - **Follow the flat domain structure**: All files in a domain use the same package name -- Use the custom logger interface consistently +- Use the custom logger interface consistently in service layer only - Implement proper error handling and logging - Follow the repository pattern with interface and implementation in same file - **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output @@ -348,5 +390,8 @@ log.Info("Customer login") - **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 -- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports \ No newline at end of file +- **Custom Validators**: Register custom validators as injected dependencies +- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports +- **NO GLOBAL VARIABLES**: Everything must be injected +- **NO LOGGING IN HANDLERS**: All logging must be in service layer +- **SWAGGER DOCUMENTATION**: Use Swagger comments for all API endpoints \ No newline at end of file diff --git a/cmd/web/__debug_bin4016044904 b/cmd/web/__debug_bin4016044904 new file mode 100755 index 0000000..e312160 Binary files /dev/null and b/cmd/web/__debug_bin4016044904 differ diff --git a/cmd/web/bootstrap.go b/cmd/web/bootstrap.go index b60280b..768e303 100644 --- a/cmd/web/bootstrap.go +++ b/cmd/web/bootstrap.go @@ -5,8 +5,10 @@ import ( "net/http" "time" "tm/infra" + "tm/pkg/authorization" "tm/pkg/logger" "tm/pkg/mongo" + "tm/pkg/redis" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -131,3 +133,53 @@ func initHTTPServer(conf infra.Config, log logger.Logger) *echo.Echo { return e } + +// Init Redis Connection Manager +func initRedis(conf infra.RedisConfig, log logger.Logger) redis.Client { + connectionConfig := &redis.Config{ + Host: conf.Host, + Port: conf.Port, + Password: conf.Password, + DB: conf.DB, + PoolSize: conf.PoolSize, + } + + redisClient, err := redis.NewClient(connectionConfig, log) + if err != nil { + log.Error("Failed to initialize Redis connection", map[string]interface{}{ + "error": err.Error(), + "host": conf.Host, + "port": conf.Port, + }) + panic(fmt.Sprintf("Failed to initialize Redis connection: %v", err)) + } + + log.Info("Redis connection manager initialized successfully", map[string]interface{}{ + "host": conf.Host, + "port": conf.Port, + "pool_size": conf.PoolSize, + }) + + return redisClient +} + +// Init Authorization Service +func initAuthorizationService(conf infra.JWTConfig, redisClient redis.Client, log logger.Logger) authorization.AuthorizationService { + authConfig := &authorization.AuthorizationConfig{ + AccessTokenSecret: conf.AccessSecret, + RefreshTokenSecret: conf.RefreshSecret, + AccessTokenExpiry: time.Duration(conf.AccessExpiresIn) * time.Second, + RefreshTokenExpiry: time.Duration(conf.RefreshExpiresIn) * time.Second, + Issuer: "tender-management-system", + Audience: "tender-management-users", + } + + authService := authorization.NewAuthorizationService(authConfig, log, redisClient) + + log.Info("Authorization service initialized successfully", map[string]interface{}{ + "access_expires_in": conf.AccessExpiresIn, + "refresh_expires_in": conf.RefreshExpiresIn, + }) + + return authService +} diff --git a/cmd/web/config.yaml b/cmd/web/config.yaml index 4d6e1a5..aee0c85 100644 --- a/cmd/web/config.yaml +++ b/cmd/web/config.yaml @@ -38,9 +38,10 @@ search: auth: jwt: - secret: "your-super-secret-key" - access_token_duration: "24h" - refresh_token_duration: "168h" # 7 days + access_secret: "your-super-secret-access-token-key-here-make-it-long-and-secure" + refresh_secret: "your-super-secret-refresh-token-key-here-make-it-long-and-secure" + access_expires_in: 3600 # 1 hour in seconds + refresh_expires_in: 2592000 # 30 days in seconds ai: openai: diff --git a/cmd/web/docs/docs.go b/cmd/web/docs/docs.go index c524876..754bb6c 100644 --- a/cmd/web/docs/docs.go +++ b/cmd/web/docs/docs.go @@ -847,6 +847,1117 @@ const docTemplate = `{ } } } + }, + "/users/admin": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List users with search and filters (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "List users", + "parameters": [ + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "User status filter", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "User role filter", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "Company ID filter", + "name": "company_id", + "in": "query" + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset results", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserListResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new user (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Create user", + "parameters": [ + { + "description": "User creation data", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.CreateUserForm" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/company/{company_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get users belonging to a specific company (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get users by company ID", + "parameters": [ + { + "type": "string", + "description": "Company ID", + "name": "company_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset results", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/role/{role}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get users with a specific role (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get users by role", + "parameters": [ + { + "type": "string", + "description": "User role", + "name": "role", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset results", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get user details by ID (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user information (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User update data", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a user (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Delete user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/{id}/role": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user role (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user role", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Role update data", + "name": "role", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserRoleForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/{id}/status": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user account status (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user status", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update data", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserStatusForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/change-password": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Change current user password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Password change data", + "name": "password", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.ChangePasswordForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/login": { + "post": { + "description": "Authenticate user with email/username and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.LoginForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.AuthResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Logout current user and invalidate tokens", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Logout user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get current user profile information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update current user profile information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user profile", + "parameters": [ + { + "description": "Profile update data", + "name": "profile", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/refresh-token": { + "post": { + "description": "Refresh access token using refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh token", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.RefreshTokenForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.AuthResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/reset-password": { + "post": { + "description": "Send password reset email to user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Password reset request", + "name": "reset", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.ResetPasswordForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } } }, "definitions": { @@ -1128,6 +2239,226 @@ const docTemplate = `{ "type": "integer" } } + }, + "user.AuthResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/user.UserResponse" + } + } + }, + "user.ChangePasswordForm": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.CreateUserForm": { + "type": "object", + "properties": { + "company_id": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "position": { + "type": "string" + }, + "profile_image": { + "type": "string" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.LoginForm": { + "type": "object", + "properties": { + "email_or_username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "user.RefreshTokenForm": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "user.ResetPasswordForm": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "user.UpdateUserForm": { + "type": "object", + "properties": { + "company_id": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "position": { + "type": "string" + }, + "profile_image": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.UpdateUserRoleForm": { + "type": "object", + "properties": { + "role": { + "type": "string" + } + } + }, + "user.UpdateUserStatusForm": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "user.UserListResponse": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + }, + "user.UserResponse": { + "type": "object", + "properties": { + "company_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "created_by": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_verified": { + "type": "boolean" + }, + "last_login_at": { + "type": "integer" + }, + "phone": { + "type": "string" + }, + "position": { + "type": "string" + }, + "profile_image": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "integer" + }, + "updated_by": { + "type": "string" + }, + "username": { + "type": "string" + } + } } }, "securityDefinitions": { @@ -1143,6 +2474,10 @@ const docTemplate = `{ "description": "Customer management operations", "name": "customers" }, + { + "description": "User management operations", + "name": "users" + }, { "description": "Health check operations", "name": "health" diff --git a/cmd/web/docs/swagger.json b/cmd/web/docs/swagger.json index 3bfc858..b913ea6 100644 --- a/cmd/web/docs/swagger.json +++ b/cmd/web/docs/swagger.json @@ -841,6 +841,1117 @@ } } } + }, + "/users/admin": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List users with search and filters (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "List users", + "parameters": [ + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "User status filter", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "User role filter", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "Company ID filter", + "name": "company_id", + "in": "query" + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset results", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserListResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new user (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Create user", + "parameters": [ + { + "description": "User creation data", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.CreateUserForm" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/company/{company_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get users belonging to a specific company (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get users by company ID", + "parameters": [ + { + "type": "string", + "description": "Company ID", + "name": "company_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset results", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/role/{role}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get users with a specific role (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get users by role", + "parameters": [ + { + "type": "string", + "description": "User role", + "name": "role", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset results", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get user details by ID (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user information (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User update data", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a user (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Delete user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/{id}/role": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user role (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user role", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Role update data", + "name": "role", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserRoleForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/admin/{id}/status": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user account status (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user status", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update data", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserStatusForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/change-password": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Change current user password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Password change data", + "name": "password", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.ChangePasswordForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/login": { + "post": { + "description": "Authenticate user with email/username and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.LoginForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.AuthResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Logout current user and invalidate tokens", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Logout user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get current user profile information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update current user profile information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user profile", + "parameters": [ + { + "description": "Profile update data", + "name": "profile", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdateUserForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/refresh-token": { + "post": { + "description": "Refresh access token using refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh token", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.RefreshTokenForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.AuthResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/users/reset-password": { + "post": { + "description": "Send password reset email to user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Password reset request", + "name": "reset", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.ResetPasswordForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } } }, "definitions": { @@ -1122,6 +2233,226 @@ "type": "integer" } } + }, + "user.AuthResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/user.UserResponse" + } + } + }, + "user.ChangePasswordForm": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.CreateUserForm": { + "type": "object", + "properties": { + "company_id": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "position": { + "type": "string" + }, + "profile_image": { + "type": "string" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.LoginForm": { + "type": "object", + "properties": { + "email_or_username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "user.RefreshTokenForm": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "user.ResetPasswordForm": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "user.UpdateUserForm": { + "type": "object", + "properties": { + "company_id": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "position": { + "type": "string" + }, + "profile_image": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.UpdateUserRoleForm": { + "type": "object", + "properties": { + "role": { + "type": "string" + } + } + }, + "user.UpdateUserStatusForm": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "user.UserListResponse": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.UserResponse" + } + } + } + }, + "user.UserResponse": { + "type": "object", + "properties": { + "company_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "created_by": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_verified": { + "type": "boolean" + }, + "last_login_at": { + "type": "integer" + }, + "phone": { + "type": "string" + }, + "position": { + "type": "string" + }, + "profile_image": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "integer" + }, + "updated_by": { + "type": "string" + }, + "username": { + "type": "string" + } + } } }, "securityDefinitions": { @@ -1137,6 +2468,10 @@ "description": "Customer management operations", "name": "customers" }, + { + "description": "User management operations", + "name": "users" + }, { "description": "Health check operations", "name": "health" diff --git a/cmd/web/docs/swagger.yaml b/cmd/web/docs/swagger.yaml index aebc953..4a9df92 100644 --- a/cmd/web/docs/swagger.yaml +++ b/cmd/web/docs/swagger.yaml @@ -183,6 +183,149 @@ definitions: total: type: integer type: object + user.AuthResponse: + properties: + access_token: + type: string + expires_at: + type: integer + refresh_token: + type: string + user: + $ref: '#/definitions/user.UserResponse' + type: object + user.ChangePasswordForm: + properties: + new_password: + type: string + old_password: + type: string + type: object + user.CreateUserForm: + properties: + company_id: + type: string + department: + type: string + email: + type: string + full_name: + type: string + password: + type: string + phone: + type: string + position: + type: string + profile_image: + type: string + role: + type: string + username: + type: string + type: object + user.LoginForm: + properties: + email_or_username: + type: string + password: + type: string + type: object + user.RefreshTokenForm: + properties: + refresh_token: + type: string + type: object + user.ResetPasswordForm: + properties: + email: + type: string + type: object + user.UpdateUserForm: + properties: + company_id: + type: string + department: + type: string + email: + type: string + full_name: + type: string + phone: + type: string + position: + type: string + profile_image: + type: string + role: + type: string + status: + type: string + username: + type: string + type: object + user.UpdateUserRoleForm: + properties: + role: + type: string + type: object + user.UpdateUserStatusForm: + properties: + status: + type: string + type: object + user.UserListResponse: + properties: + limit: + type: integer + offset: + type: integer + total: + type: integer + total_pages: + type: integer + users: + items: + $ref: '#/definitions/user.UserResponse' + type: array + type: object + user.UserResponse: + properties: + company_id: + type: string + created_at: + type: integer + created_by: + type: string + department: + type: string + email: + type: string + full_name: + type: string + id: + type: string + is_verified: + type: boolean + last_login_at: + type: integer + phone: + type: string + position: + type: string + profile_image: + type: string + role: + type: string + status: + type: string + updated_at: + type: integer + updated_by: + type: string + username: + type: string + type: object host: localhost:8081 info: contact: @@ -703,6 +846,699 @@ paths: summary: Refresh access token tags: - customers + /users/admin: + get: + consumes: + - application/json + description: List users with search and filters (admin only) + parameters: + - description: Search term + in: query + name: search + type: string + - description: User status filter + in: query + name: status + type: string + - description: User role filter + in: query + name: role + type: string + - description: Company ID filter + in: query + name: company_id + type: string + - description: Limit results + in: query + name: limit + type: integer + - description: Offset results + in: query + name: offset + type: integer + - description: Sort field + in: query + name: sort_by + type: string + - description: Sort order + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.UserListResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: List users + tags: + - users + post: + consumes: + - application/json + description: Create a new user (admin only) + parameters: + - description: User creation data + in: body + name: user + required: true + schema: + $ref: '#/definitions/user.CreateUserForm' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.UserResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Create user + tags: + - users + /users/admin/{id}: + delete: + consumes: + - application/json + description: Delete a user (admin only) + parameters: + - description: User ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Delete user + tags: + - users + get: + consumes: + - application/json + description: Get user details by ID (admin only) + parameters: + - description: User ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.UserResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Get user by ID + tags: + - users + put: + consumes: + - application/json + description: Update user information (admin only) + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: User update data + in: body + name: user + required: true + schema: + $ref: '#/definitions/user.UpdateUserForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.UserResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Update user + tags: + - users + /users/admin/{id}/role: + put: + consumes: + - application/json + description: Update user role (admin only) + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: Role update data + in: body + name: role + required: true + schema: + $ref: '#/definitions/user.UpdateUserRoleForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Update user role + tags: + - users + /users/admin/{id}/status: + put: + consumes: + - application/json + description: Update user account status (admin only) + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: Status update data + in: body + name: status + required: true + schema: + $ref: '#/definitions/user.UpdateUserStatusForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Update user status + tags: + - users + /users/admin/company/{company_id}: + get: + consumes: + - application/json + description: Get users belonging to a specific company (admin only) + parameters: + - description: Company ID + in: path + name: company_id + required: true + type: string + - description: Limit results + in: query + name: limit + type: integer + - description: Offset results + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Get users by company ID + tags: + - users + /users/admin/role/{role}: + get: + consumes: + - application/json + description: Get users with a specific role (admin only) + parameters: + - description: User role + in: path + name: role + required: true + type: string + - description: Limit results + in: query + name: limit + type: integer + - description: Offset results + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Get users by role + tags: + - users + /users/change-password: + put: + consumes: + - application/json + description: Change current user password + parameters: + - description: Password change data + in: body + name: password + required: true + schema: + $ref: '#/definitions/user.ChangePasswordForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Change password + tags: + - users + /users/login: + post: + consumes: + - application/json + description: Authenticate user with email/username and password + parameters: + - description: Login credentials + in: body + name: login + required: true + schema: + $ref: '#/definitions/user.LoginForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.AuthResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Login user + tags: + - users + /users/logout: + post: + consumes: + - application/json + description: Logout current user and invalidate tokens + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Logout user + tags: + - users + /users/profile: + get: + consumes: + - application/json + description: Get current user profile information + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.UserResponse' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Get user profile + tags: + - users + put: + consumes: + - application/json + description: Update current user profile information + parameters: + - description: Profile update data + in: body + name: profile + required: true + schema: + $ref: '#/definitions/user.UpdateUserForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.UserResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - BearerAuth: [] + summary: Update user profile + tags: + - users + /users/refresh-token: + post: + consumes: + - application/json + description: Refresh access token using refresh token + parameters: + - description: Refresh token + in: body + name: refresh + required: true + schema: + $ref: '#/definitions/user.RefreshTokenForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/user.AuthResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Refresh access token + tags: + - users + /users/reset-password: + post: + consumes: + - application/json + description: Send password reset email to user + parameters: + - description: Password reset request + in: body + name: reset + required: true + schema: + $ref: '#/definitions/user.ResetPasswordForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Reset password + tags: + - users securityDefinitions: BearerAuth: description: Type "Bearer" followed by a space and JWT token. @@ -713,6 +1549,8 @@ swagger: "2.0" tags: - description: Customer management operations name: customers +- description: User management operations + name: users - description: Health check operations name: health - description: Authentication operations diff --git a/cmd/web/main.go b/cmd/web/main.go index 967529e..97e43c4 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -42,6 +42,9 @@ package main // @tag.name customers // @tag.description Customer management operations +// @tag.name users +// @tag.description User management operations + // @tag.name health // @tag.description Health check operations @@ -54,6 +57,7 @@ import ( "net/http" "time" "tm/internal/customer" + "tm/internal/user" _ "tm/cmd/web/docs" // This is generated by swag ) @@ -76,33 +80,43 @@ func main() { } }() - logger.Info( - "Starting Tender Management API Server", - map[string]interface{}{ - "version": "1.0.0", - "host": conf.Server.Host, - "port": conf.Server.Port, - }) + // Initialize Redis connection manager + redisClient := initRedis(conf.Cache.Redis, logger) + defer func() { + if err := redisClient.Close(); err != nil { + logger.Error("Failed to close Redis connection", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + // Initialize authorization service + authService := initAuthorizationService(conf.Auth.JWT, redisClient, logger) // Initialize repositories with MongoDB connection manager customerRepository := customer.NewCustomerRepository(mongoManager, logger) + userRepository := user.NewUserRepository(mongoManager, logger) logger.Info("Repositories initialized successfully", map[string]interface{}{ - "repositories": []string{"customer"}, + "repositories": []string{"customer", "user"}, }) + // Initialize validation service + userValidator := user.NewValidationService() + // Initialize services with repositories customerService := customer.NewCustomerService(customerRepository, logger) + userService := user.NewUserService(userRepository, logger, authService, userValidator) logger.Info("Services initialized successfully", map[string]interface{}{ - "services": []string{"customer"}, + "services": []string{"customer", "user"}, }) // Initialize handlers with services customerHandler := customer.NewHandler(customerService) + userHandler := user.NewUserHandler(userService, logger, userValidator, authService) logger.Info("Handlers initialized successfully", map[string]interface{}{ - "handlers": []string{"customer"}, + "handlers": []string{"customer", "user"}, }) // Initialize HTTP server @@ -110,17 +124,24 @@ func main() { // Register routes customerHandler.RegisterRoutes(e) + userHandler.RegisterRoutes(e) // Start server serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port) logger.Info("HTTP server starting", map[string]interface{}{ "address": serverAddr, + "version": "1.0.0", + "host": conf.Server.Host, + "port": conf.Server.Port, }) if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed { logger.Error("Failed to start HTTP server", map[string]interface{}{ "error": err.Error(), "address": serverAddr, + "version": "1.0.0", + "host": conf.Server.Host, + "port": conf.Server.Port, }) panic(fmt.Sprintf("Failed to start HTTP server: %v", err)) } diff --git a/go.mod b/go.mod index 5a9d174..4172dfc 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,14 @@ go 1.23.0 toolchain go1.24.4 require ( + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.4.0 github.com/labstack/echo/v4 v4.13.4 + github.com/redis/go-redis/v9 v9.12.0 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.10.0 + github.com/swaggo/echo-swagger v1.4.1 + github.com/swaggo/swag v1.16.6 go.mongodb.org/mongo-driver v1.17.4 go.uber.org/zap v1.26.0 golang.org/x/crypto v0.40.0 @@ -17,10 +21,9 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.2.1 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -29,20 +32,12 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - github.com/swaggo/echo-swagger v1.4.1 // indirect - github.com/swaggo/files v1.0.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect - github.com/swaggo/swag v1.16.6 // indirect - github.com/urfave/cli/v2 v2.27.7 // indirect - github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.35.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) require ( @@ -57,7 +52,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index 76d6cdc..69e262e 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,19 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= -github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 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/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= -github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -26,6 +28,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -58,22 +62,19 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/redis/go-redis/v9 v9.12.0 h1:XlVPGlflh4nxfhsNXPA8Qp6EmEfTo0rp8oaBzPipXnU= +github.com/redis/go-redis/v9 v9.12.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -87,23 +88,22 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= -github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= -github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= -github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -114,8 +114,6 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -127,12 +125,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= @@ -143,15 +137,10 @@ golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -159,22 +148,15 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -186,8 +168,8 @@ golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -197,5 +179,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/infra/config.go b/infra/config.go index f82095a..86f2f63 100644 --- a/infra/config.go +++ b/infra/config.go @@ -77,9 +77,10 @@ type AuthConfig struct { } type JWTConfig struct { - Secret string `mapstructure:"secret"` - AccessTokenDuration time.Duration `mapstructure:"access_token_duration"` - RefreshTokenDuration time.Duration `mapstructure:"refresh_token_duration"` + AccessSecret string `mapstructure:"access_secret"` + RefreshSecret string `mapstructure:"refresh_secret"` + AccessExpiresIn int `mapstructure:"access_expires_in"` + RefreshExpiresIn int `mapstructure:"refresh_expires_in"` } type AIConfig struct { diff --git a/internal/user/README.md b/internal/user/README.md new file mode 100644 index 0000000..373c506 --- /dev/null +++ b/internal/user/README.md @@ -0,0 +1,214 @@ +# User Management Service + +This service provides comprehensive user management functionality for the Tender Management system, following Clean Architecture principles and Domain-Driven Design patterns. + +## Features + +### User Authentication +- **Login**: Authenticate users with email/username and password +- **Refresh Token**: Refresh access tokens +- **Logout**: Securely log out users +- **Password Management**: Change password and reset password functionality + +### User Management +- **Create User**: Create new users with roles and permissions +- **Update User**: Update user information and profile +- **Delete User**: Soft delete users (set status to inactive) +- **Get User**: Retrieve user information by ID +- **List Users**: Search and filter users with pagination + +### Role-Based Access Control +- **User Roles**: admin, manager, operator, viewer +- **Role Management**: Update user roles +- **Status Management**: Activate, deactivate, or suspend users + +### Company Management +- **Company Users**: Get users by company ID +- **User Count**: Count users per company + +## API Endpoints + +### Public Endpoints + +#### Authentication +``` +POST /api/v1/users/login +POST /api/v1/users/refresh-token +POST /api/v1/users/reset-password +``` + +### Protected Endpoints (Require Authentication) + +#### User Profile +``` +GET /api/v1/users/profile +PUT /api/v1/users/profile +PUT /api/v1/users/change-password +POST /api/v1/users/logout +``` + +### Admin Endpoints (Require Admin Role) + +#### User Management +``` +POST /api/v1/users/admin/ +GET /api/v1/users/admin/ +GET /api/v1/users/admin/:id +PUT /api/v1/users/admin/:id +DELETE /api/v1/users/admin/:id +``` + +#### User Status & Role Management +``` +PUT /api/v1/users/admin/:id/status +PUT /api/v1/users/admin/:id/role +``` + +#### Company & Role Queries +``` +GET /api/v1/users/admin/company/:company_id +GET /api/v1/users/admin/role/:role +``` + +## Request/Response Examples + +### Create User +```json +POST /api/v1/users/admin/ +{ + "full_name": "John Doe", + "username": "johndoe", + "email": "john.doe@example.com", + "password": "securepassword123", + "role": "manager", + "company_id": "550e8400-e29b-41d4-a716-446655440000", + "department": "Engineering", + "position": "Senior Manager", + "phone": "+1234567890", + "profile_image": "https://example.com/avatar.jpg" +} +``` + +### Login +```json +POST /api/v1/users/login +{ + "email_or_username": "john.doe@example.com", + "password": "securepassword123" +} +``` + +### Update User +```json +PUT /api/v1/users/admin/:id +{ + "full_name": "John Smith", + "department": "Product Management", + "status": "active" +} +``` + +### List Users with Filters +``` +GET /api/v1/users/admin/?search=john&role=manager&status=active&limit=20&offset=0&sort_by=created_at&sort_order=desc +``` + +## Data Models + +### User Entity +```go +type User struct { + ID uuid.UUID `bson:"_id"` + FullName string `bson:"full_name"` + Username string `bson:"username"` + Email string `bson:"email"` + Password string `bson:"password"` + Role UserRole `bson:"role"` + Status UserStatus `bson:"status"` + CompanyID *uuid.UUID `bson:"company_id,omitempty"` + Department *string `bson:"department,omitempty"` + Position *string `bson:"position,omitempty"` + Phone *string `bson:"phone,omitempty"` + ProfileImage *string `bson:"profile_image,omitempty"` + IsVerified bool `bson:"is_verified"` + LastLoginAt *int64 `bson:"last_login_at,omitempty"` + CreatedAt int64 `bson:"created_at"` + UpdatedAt int64 `bson:"updated_at"` + CreatedBy *uuid.UUID `bson:"created_by,omitempty"` + UpdatedBy *uuid.UUID `bson:"updated_by,omitempty"` +} +``` + +### User Roles +- `admin`: Full system access +- `manager`: Company and team management +- `operator`: Operational tasks +- `viewer`: Read-only access + +### User Status +- `active`: User can access the system +- `inactive`: User account is disabled +- `suspended`: User account is temporarily suspended + +## Security Features + +### Password Security +- Passwords are hashed using bcrypt +- Minimum password length: 8 characters +- Maximum password length: 128 characters + +### Authentication +- JWT-based authentication (to be implemented) +- Token refresh mechanism +- Secure logout with token invalidation + +### Authorization +- Role-based access control +- Admin-only endpoints for user management +- Company-scoped access for multi-tenant support + +## Database Indexes + +The service creates the following MongoDB indexes for optimal performance: + +- **Email Index**: Unique index on email field +- **Username Index**: Unique index on username field +- **Company ID Index**: For company-based queries +- **Role Index**: For role-based filtering +- **Status Index**: For status-based filtering +- **Created At Index**: For sorting by creation date +- **Text Search Index**: For full-text search across name, email, username, department, and position + +## Error Handling + +The service provides comprehensive error handling with: + +- **Validation Errors**: Detailed validation messages using govalidator +- **Business Logic Errors**: Clear error messages for business rule violations +- **Database Errors**: Proper handling of database constraints and connection issues +- **Security Errors**: Secure error messages that don't leak sensitive information + +## Logging + +All operations are logged with structured logging including: + +- **Operation Context**: User ID, company ID, operation type +- **Error Details**: Full error context for debugging +- **Security Events**: Login attempts, password changes, role updates +- **Performance Metrics**: Operation timing and resource usage + +## Future Enhancements + +### Planned Features +- **JWT Token Management**: Complete JWT implementation with refresh tokens +- **Email Verification**: Email verification for new user accounts +- **Password Reset**: Complete password reset flow with email +- **Audit Trail**: Comprehensive audit logging for all user operations +- **Bulk Operations**: Bulk user import/export functionality +- **Advanced Search**: Elasticsearch integration for advanced search capabilities + +### Integration Points +- **Email Service**: For password reset and verification emails +- **Notification Service**: For user activity notifications +- **Audit Service**: For comprehensive audit logging +- **Permission Service**: For fine-grained permission management \ No newline at end of file diff --git a/internal/user/entity.go b/internal/user/entity.go new file mode 100644 index 0000000..fdb5cc6 --- /dev/null +++ b/internal/user/entity.go @@ -0,0 +1,46 @@ +package user + +import ( + "github.com/google/uuid" +) + +// UserRole represents user roles in the system +type UserRole string + +const ( + UserRoleAdmin UserRole = "admin" + UserRoleManager UserRole = "manager" + UserRoleOperator UserRole = "operator" + UserRoleViewer UserRole = "viewer" +) + +// UserStatus represents user account status +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusInactive UserStatus = "inactive" + UserStatusSuspended UserStatus = "suspended" +) + +// User represents a system user (admin/manager/operator) +type User struct { + ID uuid.UUID `bson:"_id"` + FullName string `bson:"full_name"` + Username string `bson:"username"` + Email string `bson:"email"` + Password string `bson:"password"` // Never serialize password + Role UserRole `bson:"role"` + Status UserStatus `bson:"status"` + CompanyID *uuid.UUID `bson:"company_id,omitempty"` + Department *string `bson:"department,omitempty"` + Position *string `bson:"position,omitempty"` + Phone *string `bson:"phone,omitempty"` + ProfileImage *string `bson:"profile_image,omitempty"` + IsVerified bool `bson:"is_verified"` + LastLoginAt *int64 `bson:"last_login_at,omitempty"` // Unix timestamp + CreatedAt int64 `bson:"created_at"` // Unix timestamp + UpdatedAt int64 `bson:"updated_at"` // Unix timestamp + CreatedBy *uuid.UUID `bson:"created_by,omitempty"` + UpdatedBy *uuid.UUID `bson:"updated_by,omitempty"` +} diff --git a/internal/user/form.go b/internal/user/form.go new file mode 100644 index 0000000..49ef33c --- /dev/null +++ b/internal/user/form.go @@ -0,0 +1,149 @@ +package user + +// User Registration DTOs +type CreateUserForm struct { + FullName string `json:"full_name" valid:"required,length(2|100)"` + Username string `json:"username" valid:"required,alphanum,length(3|30)"` + Email string `json:"email" valid:"required,email"` + Password string `json:"password" valid:"required,length(8|128)"` + Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"` + CompanyID *string `json:"company_id,omitempty" valid:"optional,uuid"` + Department *string `json:"department,omitempty" valid:"optional,length(2|100)"` + Position *string `json:"position,omitempty" valid:"optional,length(2|100)"` + Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)"` + ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"` +} + +type UpdateUserForm struct { + FullName *string `json:"full_name,omitempty" valid:"optional,length(2|100)"` + Username *string `json:"username,omitempty" valid:"optional,alphanum,length(3|30)"` + Email *string `json:"email,omitempty" valid:"optional,email"` + Role *string `json:"role,omitempty" valid:"optional,in(admin|manager|operator|viewer)"` + CompanyID *string `json:"company_id,omitempty" valid:"optional,uuid"` + Department *string `json:"department,omitempty" valid:"optional,length(2|100)"` + Position *string `json:"position,omitempty" valid:"optional,length(2|100)"` + Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)"` + ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"` + Status *string `json:"status,omitempty" valid:"optional,in(active|inactive|suspended)"` +} + +// User Authentication DTOs +type LoginForm struct { + Username string `json:"username" valid:"required"` + Password string `json:"password" valid:"required"` +} + +type RefreshTokenForm struct { + RefreshToken string `json:"refresh_token" valid:"required"` +} + +type ChangePasswordForm struct { + OldPassword string `json:"old_password" valid:"required"` + NewPassword string `json:"new_password" valid:"required,length(8|128)"` +} + +type UpdateProfileForm struct { + FullName *string `json:"full_name,omitempty" valid:"optional,length(2|100)"` + Department *string `json:"department,omitempty" valid:"optional,length(2|100)"` + Position *string `json:"position,omitempty" valid:"optional,length(2|100)"` + Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)"` + ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"` +} + +type ResetPasswordForm struct { + Email string `json:"email" valid:"required,email"` +} + +// Admin DTOs +type ListUsersForm struct { + Search *string `query:"search" valid:"optional"` + Status *string `query:"status" valid:"optional,in(active|inactive|suspended)"` + Role *string `query:"role" valid:"optional,in(admin|manager|operator|viewer)"` + CompanyID *string `query:"company_id" valid:"optional,uuid"` + Limit *int `query:"limit" valid:"optional,range(1|100)"` + Offset *int `query:"offset" valid:"optional,min(0)"` + SortBy *string `query:"sort_by" valid:"optional,in(full_name|email|username|role|created_at|last_login_at)"` + SortOrder *string `query:"sort_order" valid:"optional,in(asc|desc)"` +} + +type UpdateUserStatusForm struct { + Status string `json:"status" valid:"required,in(active|inactive|suspended)"` +} + +type UpdateUserRoleForm struct { + Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"` +} + +// Response DTOs +type UserResponse struct { + ID string `json:"id"` + FullName string `json:"full_name"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Status string `json:"status"` + CompanyID *string `json:"company_id,omitempty"` + Department *string `json:"department,omitempty"` + Position *string `json:"position,omitempty"` + Phone *string `json:"phone,omitempty"` + ProfileImage *string `json:"profile_image,omitempty"` + IsVerified bool `json:"is_verified"` + LastLoginAt *int64 `json:"last_login_at,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CreatedBy *string `json:"created_by,omitempty"` + UpdatedBy *string `json:"updated_by,omitempty"` +} + +type AuthResponse struct { + User *UserResponse `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` +} + +type UserListResponse struct { + Users []*UserResponse `json:"users"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalPages int `json:"total_pages"` +} + +// Helper function to convert User to UserResponse +func (u *User) ToResponse() *UserResponse { + companyID := "" + if u.CompanyID != nil { + companyID = u.CompanyID.String() + } + + createdBy := "" + if u.CreatedBy != nil { + createdBy = u.CreatedBy.String() + } + + updatedBy := "" + if u.UpdatedBy != nil { + updatedBy = u.UpdatedBy.String() + } + + return &UserResponse{ + ID: u.ID.String(), + FullName: u.FullName, + Username: u.Username, + Email: u.Email, + Role: string(u.Role), + Status: string(u.Status), + CompanyID: &companyID, + Department: u.Department, + Position: u.Position, + Phone: u.Phone, + ProfileImage: u.ProfileImage, + IsVerified: u.IsVerified, + LastLoginAt: u.LastLoginAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + CreatedBy: &createdBy, + UpdatedBy: &updatedBy, + } +} diff --git a/internal/user/handler.go b/internal/user/handler.go new file mode 100644 index 0000000..cff4b8e --- /dev/null +++ b/internal/user/handler.go @@ -0,0 +1,687 @@ +package user + +import ( + "strconv" + "tm/pkg/authorization" + "tm/pkg/logger" + "tm/pkg/response" + + "github.com/asaskevich/govalidator" + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// Handler handles HTTP requests for user operations +type Handler struct { + service Service + logger logger.Logger + validator ValidationService + authService authorization.AuthorizationService +} + +// NewUserHandler creates a new user handler +func NewUserHandler(service Service, logger logger.Logger, validator ValidationService, authService authorization.AuthorizationService) *Handler { + return &Handler{ + service: service, + logger: logger, + validator: validator, + authService: authService, + } +} + +// RegisterRoutes registers user routes +func (h *Handler) RegisterRoutes(e *echo.Echo) { + v1 := e.Group("/api/v1") + + userGroup := v1.Group("/users") + { + // Public routes + userGroup.POST("/login", h.Login) + userGroup.POST("/refresh-token", h.RefreshToken) + userGroup.POST("/reset-password", h.ResetPassword) + + // Protected routes (require authentication) + authGroup := userGroup.Group("") + // authGroup.Use(h.AuthMiddleware()) + { + authGroup.GET("/profile", h.GetProfile) + authGroup.PUT("/profile", h.UpdateProfile) + authGroup.PUT("/change-password", h.ChangePassword) + authGroup.POST("/logout", h.Logout) + + // Admin routes + adminGroup := authGroup.Group("/admin") + // adminGroup.Use(h.AdminMiddleware()) + { + adminGroup.POST("", h.CreateUser) + adminGroup.GET("", h.ListUsers) + adminGroup.GET("/:id", h.GetUserByID) + adminGroup.PUT("/:id", h.UpdateUser) + adminGroup.DELETE("/:id", h.DeleteUser) + adminGroup.PUT("/:id/status", h.UpdateUserStatus) + adminGroup.PUT("/:id/role", h.UpdateUserRole) + adminGroup.GET("/company/:company_id", h.GetUsersByCompanyID) + adminGroup.GET("/role/:role", h.GetUsersByRole) + } + } + } +} + +// Login handles user login +// @Summary Login user +// @Description Authenticate user with email/username and password +// @Tags users +// @Accept json +// @Produce json +// @Param login body LoginForm true "Login credentials" +// @Success 200 {object} response.APIResponse{data=AuthResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/login [post] +func (h *Handler) Login(c echo.Context) error { + form, err := response.Parse[LoginForm](c) + if err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + // Call service + authResponse, err := h.service.Login(c.Request().Context(), form) + if err != nil { + return response.Unauthorized(c, err.Error()) + } + + return response.Success(c, authResponse, "Login successful") +} + +// RefreshToken handles token refresh +// @Summary Refresh access token +// @Description Refresh access token using refresh token +// @Tags users +// @Accept json +// @Produce json +// @Param refresh body RefreshTokenForm true "Refresh token" +// @Success 200 {object} response.APIResponse{data=AuthResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/refresh-token [post] +func (h *Handler) RefreshToken(c echo.Context) error { + form, err := response.Parse[RefreshTokenForm](c) + if err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + authResponse, err := h.service.RefreshToken(c.Request().Context(), form.RefreshToken) + if err != nil { + return response.Unauthorized(c, err.Error()) + } + + return response.Success(c, authResponse, "Token refreshed successfully") +} + +// ResetPassword handles password reset request +// @Summary Reset password +// @Description Send password reset email to user +// @Tags users +// @Accept json +// @Produce json +// @Param reset body ResetPasswordForm true "Password reset request" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/reset-password [post] +func (h *Handler) ResetPassword(c echo.Context) error { + form, err := response.Parse[ResetPasswordForm](c) + if err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + err = h.service.ResetPassword(c.Request().Context(), form) + if err != nil { + return response.InternalServerError(c, "Failed to process password reset") + } + + return response.Success(c, map[string]interface{}{ + "message": "Password reset email sent successfully", + }, "Password reset initiated") +} + +// GetProfile gets current user profile +// @Summary Get user profile +// @Description Get current user profile information +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.APIResponse{data=UserResponse} +// @Failure 401 {object} response.APIResponse +// @Failure 404 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/profile [get] +func (h *Handler) GetProfile(c echo.Context) error { + // Extract user ID from JWT token context + userID, err := GetUserIDFromContext(c) + if err != nil { + return response.Unauthorized(c, "User not authenticated") + } + + user, err := h.service.GetProfile(c.Request().Context(), userID) + if err != nil { + return response.NotFound(c, "User not found") + } + + return response.Success(c, user.ToResponse(), "Profile retrieved successfully") +} + +// UpdateProfile updates current user profile +// @Summary Update user profile +// @Description Update current user profile information +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param profile body UpdateUserForm true "Profile update data" +// @Success 200 {object} response.APIResponse{data=UserResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/profile [put] +func (h *Handler) UpdateProfile(c echo.Context) error { + // TODO: Extract user ID from JWT token + userID := uuid.New() // Placeholder - should be extracted from JWT + + form, err := response.Parse[UpdateUserForm](c) + if err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + user, err := h.service.UpdateUser(c.Request().Context(), userID, form, &userID) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Success(c, user.ToResponse(), "Profile updated successfully") +} + +// ChangePassword changes current user password +// @Summary Change password +// @Description Change current user password +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param password body ChangePasswordForm true "Password change data" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/change-password [put] +func (h *Handler) ChangePassword(c echo.Context) error { + // TODO: Extract user ID from JWT token + userID := uuid.New() // Placeholder - should be extracted from JWT + + form, err := response.Parse[ChangePasswordForm](c) + if err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + err = h.service.ChangePassword(c.Request().Context(), userID, form) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Success(c, map[string]interface{}{ + "message": "Password changed successfully", + }, "Password changed successfully") +} + +// Logout handles user logout +// @Summary Logout user +// @Description Logout current user and invalidate tokens +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/logout [post] +func (h *Handler) Logout(c echo.Context) error { + // Extract user ID and access token from context + userID, err := GetUserIDFromContext(c) + if err != nil { + return response.Unauthorized(c, "User not authenticated") + } + + accessToken, err := GetAccessTokenFromContext(c) + if err != nil { + return response.Unauthorized(c, "Access token not found") + } + + err = h.service.Logout(c.Request().Context(), userID, accessToken) + if err != nil { + return response.InternalServerError(c, "Failed to logout") + } + + return response.Success(c, map[string]interface{}{ + "message": "Logged out successfully", + }, "Logged out successfully") +} + +// CreateUser creates a new user (admin only) +// @Summary Create user +// @Description Create a new user (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body CreateUserForm true "User creation data" +// @Success 201 {object} response.APIResponse{data=UserResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 403 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/admin [post] +func (h *Handler) CreateUser(c echo.Context) error { + // Get current user ID from JWT token + currentUserID, err := GetUserIDFromContext(c) + if err != nil { + return response.Unauthorized(c, "User not authenticated") + } + + var form CreateUserForm + if err := c.Bind(&form); err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + // Validate form + if _, err := govalidator.ValidateStruct(form); err != nil { + return response.ValidationError(c, err.Error(), "") + } + + // Call service + user, err := h.service.CreateUser(c.Request().Context(), &form, ¤tUserID) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Created(c, user.ToResponse(), "User created successfully") +} + +// ListUsers lists users with search and filters (admin only) +// @Summary List users +// @Description List users with search and filters (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param search query string false "Search term" +// @Param status query string false "User status filter" +// @Param role query string false "User role filter" +// @Param company_id query string false "Company ID filter" +// @Param limit query int false "Limit results" +// @Param offset query int false "Offset results" +// @Param sort_by query string false "Sort field" +// @Param sort_order query string false "Sort order" +// @Success 200 {object} response.APIResponse{data=UserListResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 403 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/admin [get] +func (h *Handler) ListUsers(c echo.Context) error { + var form ListUsersForm + if err := c.Bind(&form); err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + // Validate form + if _, err := govalidator.ValidateStruct(form); err != nil { + return response.ValidationError(c, err.Error(), "") + } + + // Call service + users, err := h.service.ListUsers(c.Request().Context(), &form) + if err != nil { + return response.InternalServerError(c, "Failed to list users") + } + + return response.Success(c, users, "Users retrieved successfully") +} + +// GetUserByID gets a user by ID (admin only) +// @Summary Get user by ID +// @Description Get user details by ID (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} response.APIResponse{data=UserResponse} +// @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 /users/admin/{id} [get] +func (h *Handler) GetUserByID(c echo.Context) error { + idStr := c.Param("id") + userID, err := uuid.Parse(idStr) + if err != nil { + return response.BadRequest(c, "Invalid user ID", "") + } + + user, err := h.service.GetUserByID(c.Request().Context(), userID) + if err != nil { + return response.NotFound(c, "User not found") + } + + return response.Success(c, user.ToResponse(), "User retrieved successfully") +} + +// UpdateUser updates a user (admin only) +// @Summary Update user +// @Description Update user information (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param user body UpdateUserForm true "User update data" +// @Success 200 {object} response.APIResponse{data=UserResponse} +// @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 /users/admin/{id} [put] +func (h *Handler) UpdateUser(c echo.Context) error { + // TODO: Get current user ID from JWT token + currentUserID := uuid.New() // Placeholder + + idStr := c.Param("id") + userID, err := uuid.Parse(idStr) + if err != nil { + return response.BadRequest(c, "Invalid user ID", "") + } + + var form UpdateUserForm + if err := c.Bind(&form); err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + // Validate form + if _, err := govalidator.ValidateStruct(form); err != nil { + return response.ValidationError(c, err.Error(), "") + } + + // Call service + user, err := h.service.UpdateUser(c.Request().Context(), userID, &form, ¤tUserID) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Success(c, user.ToResponse(), "User updated successfully") +} + +// DeleteUser deletes a user (admin only) +// @Summary Delete user +// @Description Delete a user (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @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 /users/admin/{id} [delete] +func (h *Handler) DeleteUser(c echo.Context) error { + // TODO: Get current user ID from JWT token + currentUserID := uuid.New() // Placeholder + + idStr := c.Param("id") + userID, err := uuid.Parse(idStr) + if err != nil { + return response.BadRequest(c, "Invalid user ID", "") + } + + err = h.service.DeleteUser(c.Request().Context(), userID, ¤tUserID) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Success(c, map[string]interface{}{ + "message": "User deleted successfully", + }, "User deleted successfully") +} + +// UpdateUserStatus updates user status (admin only) +// @Summary Update user status +// @Description Update user account status (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param status body UpdateUserStatusForm true "Status update data" +// @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 /users/admin/{id}/status [put] +func (h *Handler) UpdateUserStatus(c echo.Context) error { + // TODO: Get current user ID from JWT token + currentUserID := uuid.New() // Placeholder + + idStr := c.Param("id") + userID, err := uuid.Parse(idStr) + if err != nil { + return response.BadRequest(c, "Invalid user ID", "") + } + + var form UpdateUserStatusForm + if err := c.Bind(&form); err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + // Validate form + if _, err := govalidator.ValidateStruct(form); err != nil { + return response.ValidationError(c, err.Error(), "") + } + + // Call service + err = h.service.UpdateUserStatus(c.Request().Context(), userID, &form, ¤tUserID) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Success(c, map[string]interface{}{ + "message": "User status updated successfully", + }, "User status updated successfully") +} + +// UpdateUserRole updates user role (admin only) +// @Summary Update user role +// @Description Update user role (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param role body UpdateUserRoleForm true "Role update data" +// @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 /users/admin/{id}/role [put] +func (h *Handler) UpdateUserRole(c echo.Context) error { + // TODO: Get current user ID from JWT token + currentUserID := uuid.New() // Placeholder + + idStr := c.Param("id") + userID, err := uuid.Parse(idStr) + if err != nil { + return response.BadRequest(c, "Invalid user ID", "") + } + + var form UpdateUserRoleForm + if err := c.Bind(&form); err != nil { + return response.BadRequest(c, "Invalid request format", "") + } + + // Validate form + if _, err := govalidator.ValidateStruct(form); err != nil { + return response.ValidationError(c, err.Error(), "") + } + + // Call service + err = h.service.UpdateUserRole(c.Request().Context(), userID, &form, ¤tUserID) + if err != nil { + return response.BadRequest(c, err.Error(), "") + } + + return response.Success(c, map[string]interface{}{ + "message": "User role updated successfully", + }, "User role updated successfully") +} + +// GetUsersByCompanyID gets users by company ID (admin only) +// @Summary Get users by company ID +// @Description Get users belonging to a specific company (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param company_id path string true "Company ID" +// @Param limit query int false "Limit results" +// @Param offset query int false "Offset results" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 403 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/admin/company/{company_id} [get] +func (h *Handler) GetUsersByCompanyID(c echo.Context) error { + companyIDStr := c.Param("company_id") + companyID, err := uuid.Parse(companyIDStr) + if err != nil { + return response.BadRequest(c, "Invalid company ID", "") + } + + // Get query parameters + limitStr := c.QueryParam("limit") + if limitStr == "" { + limitStr = "20" + } + offsetStr := c.QueryParam("offset") + if offsetStr == "" { + offsetStr = "0" + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + return response.BadRequest(c, "Invalid limit parameter", "") + } + + offset, err := strconv.Atoi(offsetStr) + if err != nil { + return response.BadRequest(c, "Invalid offset parameter", "") + } + + // Call service + users, total, err := h.service.GetUsersByCompanyID(c.Request().Context(), companyID, limit, offset) + if err != nil { + return response.InternalServerError(c, "Failed to get users by company") + } + + // Convert to responses + var userResponses []*UserResponse + for _, user := range users { + userResponses = append(userResponses, user.ToResponse()) + } + + return response.SuccessWithMeta(c, map[string]interface{}{ + "users": userResponses, + }, &response.Meta{ + Total: int(total), + Limit: limit, + Offset: offset, + }, "Users retrieved successfully") +} + +// GetUsersByRole gets users by role (admin only) +// @Summary Get users by role +// @Description Get users with a specific role (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param role path string true "User role" +// @Param limit query int false "Limit results" +// @Param offset query int false "Offset results" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 403 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /users/admin/role/{role} [get] +func (h *Handler) GetUsersByRole(c echo.Context) error { + roleStr := c.Param("role") + role := UserRole(roleStr) + + // Validate role + if !h.validator.IsValidRole(role) { + return response.BadRequest(c, "Invalid role", "") + } + + // Get query parameters + limitStr := c.QueryParam("limit") + if limitStr == "" { + limitStr = "20" + } + offsetStr := c.QueryParam("offset") + if offsetStr == "" { + offsetStr = "0" + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + return response.BadRequest(c, "Invalid limit parameter", "") + } + + offset, err := strconv.Atoi(offsetStr) + if err != nil { + return response.BadRequest(c, "Invalid offset parameter", "") + } + + // Call service + users, err := h.service.GetUsersByRole(c.Request().Context(), role, limit, offset) + if err != nil { + return response.InternalServerError(c, "Failed to get users by role") + } + + // Convert to responses + var userResponses []*UserResponse + for _, user := range users { + userResponses = append(userResponses, user.ToResponse()) + } + + return response.SuccessWithMeta(c, map[string]interface{}{ + "users": userResponses, + "role": role, + }, &response.Meta{ + Limit: limit, + Offset: offset, + }, "Users retrieved successfully") +} + +// AuthMiddleware and AdminMiddleware are now implemented in middleware.go diff --git a/internal/user/middleware.go b/internal/user/middleware.go new file mode 100644 index 0000000..4950a99 --- /dev/null +++ b/internal/user/middleware.go @@ -0,0 +1,192 @@ +package user + +import ( + "net/http" + "strings" + "tm/pkg/response" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// AuthMiddleware validates JWT access tokens and extracts user information +func (h *Handler) AuthMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Extract token from Authorization header + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + return response.Unauthorized(c, "Authorization header required") + } + + // Check if it's a Bearer token + if !strings.HasPrefix(authHeader, "Bearer ") { + return response.Unauthorized(c, "Invalid authorization format. Use 'Bearer '") + } + + // Extract the token + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + // Validate the access token + validationResult, err := h.authService.ValidateAccessToken(tokenString) + if err != nil { + h.logger.Error("Token validation error", map[string]interface{}{ + "error": err.Error(), + }) + return response.Unauthorized(c, "Invalid token") + } + + if !validationResult.Valid { + if validationResult.Expired { + return response.Unauthorized(c, "Token expired") + } + return response.Unauthorized(c, validationResult.Error) + } + + // Extract user information from token + userID, err := uuid.Parse(validationResult.Claims.UserID) + if err != nil { + h.logger.Error("Failed to parse user ID from token", map[string]interface{}{ + "error": err.Error(), + }) + return response.Unauthorized(c, "Invalid token format") + } + + // Store user information in context for handlers to use + c.Set("user_id", userID) + c.Set("user_email", validationResult.Claims.Email) + c.Set("user_role", validationResult.Claims.Role) + c.Set("company_id", validationResult.Claims.CompanyID) + c.Set("access_token", tokenString) + + // Continue to next handler + return next(c) + } + } +} + +// AdminMiddleware checks if the authenticated user has admin role +func (h *Handler) AdminMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Get user role from context (set by AuthMiddleware) + userRole, ok := c.Get("user_role").(string) + if !ok { + return response.Unauthorized(c, "User role not found in context") + } + + // Check if user has admin role + if userRole != string(UserRoleAdmin) { + return response.Forbidden(c, "Admin access required") + } + + // Continue to next handler + return next(c) + } + } +} + +// RoleMiddleware checks if the authenticated user has the required role +func (h *Handler) RoleMiddleware(requiredRole UserRole) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Get user role from context (set by AuthMiddleware) + userRole, ok := c.Get("user_role").(string) + if !ok { + return response.Unauthorized(c, "User role not found in context") + } + + // Check if user has the required role + if userRole != string(requiredRole) { + return response.Forbidden(c, "Insufficient permissions") + } + + // Continue to next handler + return next(c) + } + } +} + +// CompanyAccessMiddleware checks if the authenticated user has access to the specified company +func (h *Handler) CompanyAccessMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Get user information from context + userRole, ok := c.Get("user_role").(string) + if !ok { + return response.Unauthorized(c, "User role not found in context") + } + + // Admin users have access to all companies + if userRole == string(UserRoleAdmin) { + return next(c) + } + + // Get company ID from context + userCompanyID, ok := c.Get("company_id").(string) + if !ok || userCompanyID == "" { + return response.Forbidden(c, "Company access not available") + } + + // Get company ID from URL parameter + requestedCompanyID := c.Param("company_id") + if requestedCompanyID == "" { + // If no company ID in URL, allow access to user's own company + return next(c) + } + + // Check if user has access to the requested company + if userCompanyID != requestedCompanyID { + return response.Forbidden(c, "Access to this company not allowed") + } + + // Continue to next handler + return next(c) + } + } +} + +// GetUserIDFromContext extracts user ID from Echo context +func GetUserIDFromContext(c echo.Context) (uuid.UUID, error) { + userID, ok := c.Get("user_id").(uuid.UUID) + if !ok { + return uuid.Nil, echo.NewHTTPError(http.StatusUnauthorized, "User ID not found in context") + } + return userID, nil +} + +// GetUserEmailFromContext extracts user email from Echo context +func GetUserEmailFromContext(c echo.Context) (string, error) { + userEmail, ok := c.Get("user_email").(string) + if !ok { + return "", echo.NewHTTPError(http.StatusUnauthorized, "User email not found in context") + } + return userEmail, nil +} + +// GetUserRoleFromContext extracts user role from Echo context +func GetUserRoleFromContext(c echo.Context) (string, error) { + userRole, ok := c.Get("user_role").(string) + if !ok { + return "", echo.NewHTTPError(http.StatusUnauthorized, "User role not found in context") + } + return userRole, nil +} + +// GetCompanyIDFromContext extracts company ID from Echo context +func GetCompanyIDFromContext(c echo.Context) (string, error) { + companyID, ok := c.Get("company_id").(string) + if !ok { + return "", echo.NewHTTPError(http.StatusUnauthorized, "Company ID not found in context") + } + return companyID, nil +} + +// GetAccessTokenFromContext extracts access token from Echo context +func GetAccessTokenFromContext(c echo.Context) (string, error) { + accessToken, ok := c.Get("access_token").(string) + if !ok { + return "", echo.NewHTTPError(http.StatusUnauthorized, "Access token not found in context") + } + return accessToken, nil +} diff --git a/internal/user/repository.go b/internal/user/repository.go new file mode 100644 index 0000000..07f5fe2 --- /dev/null +++ b/internal/user/repository.go @@ -0,0 +1,481 @@ +package user + +import ( + "context" + "errors" + "time" + "tm/pkg/logger" + mongopkg "tm/pkg/mongo" + + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// Repository defines methods for user data access +type Repository 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) + GetByUsername(ctx context.Context, username 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) + Search(ctx context.Context, search string, status *string, role *string, companyID *uuid.UUID, limit, offset int, sortBy, sortOrder string) ([]*User, error) + GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, error) + GetByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error) + UpdateLastLogin(ctx context.Context, id uuid.UUID) error + CountByCompanyID(ctx context.Context, companyID uuid.UUID) (int64, error) +} + +// userRepository implements the Repository interface +type userRepository struct { + collection *mongo.Collection + logger logger.Logger +} + +// NewUserRepository creates a new user repository +func NewUserRepository(mongoManager *mongopkg.ConnectionManager, logger logger.Logger) Repository { + collection := mongoManager.GetCollection("users") + + // Create indexes + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Email index (unique) + emailIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + } + + // Username index (unique) + usernameIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "username", Value: 1}}, + Options: options.Index().SetUnique(true), + } + + // Company ID index + companyIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "company_id", Value: 1}}, + } + + // Role index + roleIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "role", Value: 1}}, + } + + // Status index + statusIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "status", Value: 1}}, + } + + // Created at index + createdAtIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "created_at", Value: -1}}, + } + + // Full text search index + textIndex := mongo.IndexModel{ + Keys: bson.D{ + {Key: "full_name", Value: "text"}, + {Key: "email", Value: "text"}, + {Key: "username", Value: "text"}, + {Key: "department", Value: "text"}, + {Key: "position", Value: "text"}, + }, + } + + _, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + emailIndex, + usernameIndex, + companyIndex, + roleIndex, + statusIndex, + createdAtIndex, + textIndex, + }) + + if err != nil { + logger.Warn("Failed to create user indexes", map[string]interface{}{ + "error": err.Error(), + }) + } + + return &userRepository{ + collection: collection, + logger: logger, + } +} + +// Create creates a new user +func (r *userRepository) Create(ctx context.Context, user *User) error { + // Set created/updated timestamps using Unix timestamps + now := time.Now().Unix() + 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("User created successfully", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + "role": user.Role, + }) + + return nil +} + +// GetByID retrieves a user by ID +func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { + var user 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 user by email +func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, error) { + var user 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 +} + +// GetByUsername retrieves a user by username +func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) { + var user User + + filter := bson.M{"username": username} + 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 username", map[string]interface{}{ + "error": err.Error(), + "username": username, + }) + return nil, err + } + + return &user, nil +} + +// Update updates a user +func (r *userRepository) Update(ctx context.Context, user *User) error { + // Set updated timestamp using Unix timestamp + user.UpdatedAt = time.Now().Unix() + + 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("User updated successfully", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + }) + + return nil +} + +// Delete deletes a user (soft delete by setting status to inactive) +func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error { + filter := bson.M{"_id": id} + update := bson.M{ + "$set": bson.M{ + "status": UserStatusInactive, + "updated_at": time.Now().Unix(), + }, + } + + 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("User deleted successfully", map[string]interface{}{ + "user_id": id.String(), + }) + + return nil +} + +// List retrieves users with pagination +func (r *userRepository) List(ctx context.Context, limit, offset int) ([]*User, error) { + var users []*User + + // Build options + opts := options.Find() + opts.SetLimit(int64(limit)) + opts.SetSkip(int64(offset)) + opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc + + // Only active users by default + filter := bson.M{"status": bson.M{"$ne": UserStatusInactive}} + + 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 +} + +// Search retrieves users with search and filters +func (r *userRepository) Search(ctx context.Context, search string, status *string, role *string, companyID *uuid.UUID, limit, offset int, sortBy, sortOrder string) ([]*User, error) { + var users []*User + + // Build filter + filter := bson.M{} + + if search != "" { + filter["$text"] = bson.M{"$search": search} + } + + if status != nil { + filter["status"] = *status + } + + if role != nil { + filter["role"] = *role + } + + if companyID != nil { + filter["company_id"] = *companyID + } + + // Build options + opts := options.Find() + opts.SetLimit(int64(limit)) + opts.SetSkip(int64(offset)) + + // Set sort + if sortBy != "" { + sortValue := 1 + if sortOrder == "desc" { + sortValue = -1 + } + opts.SetSort(bson.D{{Key: sortBy, Value: sortValue}}) + } else { + opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Default sort + } + + cursor, err := r.collection.Find(ctx, filter, opts) + if err != nil { + r.logger.Error("Failed to search users", map[string]interface{}{ + "error": err.Error(), + "search": search, + }) + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &users); err != nil { + r.logger.Error("Failed to decode users from search", map[string]interface{}{ + "error": err.Error(), + }) + return nil, err + } + + return users, nil +} + +// GetByCompanyID retrieves users by company ID with pagination +func (r *userRepository) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, error) { + var users []*User + + // Build options + opts := options.Find() + opts.SetLimit(int64(limit)) + opts.SetSkip(int64(offset)) + opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc + + // Filter by company ID and active status + filter := bson.M{ + "company_id": companyID, + "status": bson.M{"$ne": UserStatusInactive}, + } + + cursor, err := r.collection.Find(ctx, filter, opts) + if err != nil { + r.logger.Error("Failed to get users by company ID", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID.String(), + "limit": limit, + "offset": offset, + }) + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &users); err != nil { + r.logger.Error("Failed to decode users by company", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID.String(), + }) + return nil, err + } + + return users, nil +} + +// GetByRole retrieves users by role with pagination +func (r *userRepository) GetByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error) { + var users []*User + + // Build options + opts := options.Find() + opts.SetLimit(int64(limit)) + opts.SetSkip(int64(offset)) + opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc + + // Filter by role and active status + filter := bson.M{ + "role": role, + "status": bson.M{"$ne": UserStatusInactive}, + } + + 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 +} + +// UpdateLastLogin updates the last login timestamp +func (r *userRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error { + filter := bson.M{"_id": id} + update := bson.M{ + "$set": bson.M{ + "last_login_at": time.Now().Unix(), + "updated_at": time.Now().Unix(), + }, + } + + 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(), + "user_id": id.String(), + }) + return err + } + + if result.MatchedCount == 0 { + return errors.New("user not found") + } + + r.logger.Info("Last login updated successfully", map[string]interface{}{ + "user_id": id.String(), + }) + + return nil +} + +// CountByCompanyID counts users by company ID +func (r *userRepository) CountByCompanyID(ctx context.Context, companyID uuid.UUID) (int64, error) { + filter := bson.M{ + "company_id": companyID, + "status": bson.M{"$ne": UserStatusInactive}, + } + + count, err := r.collection.CountDocuments(ctx, filter) + if err != nil { + r.logger.Error("Failed to count users by company ID", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID.String(), + }) + return 0, err + } + + return count, nil +} diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..2130ee7 --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,721 @@ +package user + +import ( + "context" + "errors" + "strings" + "time" + "tm/pkg/authorization" + "tm/pkg/logger" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// Service defines business logic for user operations +type Service interface { + CreateUser(ctx context.Context, form *CreateUserForm, createdBy *uuid.UUID) (*User, error) + Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) + RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) + GetProfile(ctx context.Context, userID uuid.UUID) (*User, error) + UpdateProfile(ctx context.Context, userID uuid.UUID, form *UpdateProfileForm) error + ChangePassword(ctx context.Context, userID uuid.UUID, form *ChangePasswordForm) error + ResetPassword(ctx context.Context, form *ResetPasswordForm) error + GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) + UpdateUser(ctx context.Context, id uuid.UUID, form *UpdateUserForm, updatedBy *uuid.UUID) (*User, error) + DeleteUser(ctx context.Context, id uuid.UUID, deletedBy *uuid.UUID) error + ListUsers(ctx context.Context, form *ListUsersForm) (*UserListResponse, error) + UpdateUserStatus(ctx context.Context, id uuid.UUID, form *UpdateUserStatusForm, updatedBy *uuid.UUID) error + UpdateUserRole(ctx context.Context, id uuid.UUID, form *UpdateUserRoleForm, updatedBy *uuid.UUID) error + GetUsersByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, int64, error) + GetUsersByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error) + Logout(ctx context.Context, userID uuid.UUID, accessToken string) error +} + +// userService implements the Service interface +type userService struct { + repository Repository + logger logger.Logger + authService authorization.AuthorizationService + validator ValidationService +} + +// NewUserService creates a new user service +func NewUserService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, validator ValidationService) Service { + return &userService{ + repository: repository, + logger: logger, + authService: authService, + validator: validator, + } +} + +// CreateUser creates a new user +func (s *userService) CreateUser(ctx context.Context, form *CreateUserForm, createdBy *uuid.UUID) (*User, error) { + // Check if email already exists + existingUser, _ := s.repository.GetByEmail(ctx, form.Email) + if existingUser != nil { + return nil, errors.New("email already exists") + } + + // Check if username already exists + existingUser, _ = s.repository.GetByUsername(ctx, form.Username) + if existingUser != nil { + return nil, errors.New("username already exists") + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost) + if err != nil { + s.logger.Error("Failed to hash password", map[string]interface{}{ + "error": err.Error(), + }) + return nil, errors.New("failed to process password") + } + + // Parse optional fields + var companyID *uuid.UUID + if form.CompanyID != nil { + parsedID, err := uuid.Parse(*form.CompanyID) + if err != nil { + return nil, errors.New("invalid company ID") + } + companyID = &parsedID + } + + // Validate role + role := UserRole(form.Role) + if !s.validator.IsValidRole(role) { + return nil, errors.New("invalid role") + } + + // Create user + user := &User{ + ID: uuid.New(), + FullName: form.FullName, + Username: form.Username, + Email: form.Email, + Password: string(hashedPassword), + Role: role, + Status: UserStatusActive, + CompanyID: companyID, + Department: form.Department, + Position: form.Position, + Phone: form.Phone, + ProfileImage: form.ProfileImage, + IsVerified: false, + CreatedBy: createdBy, + } + + // Save to database + err = s.repository.Create(ctx, user) + if err != nil { + s.logger.Error("Failed to create user", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + "username": form.Username, + }) + return nil, err + } + + s.logger.Info("User created successfully", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + "username": user.Username, + "role": user.Role, + "created_by": createdBy.String(), + }) + + return user, nil +} + +// Login authenticates a user +func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) { + // Find user by email or username + var user *User + var err error + + if strings.Contains(form.Username, "@") { + user, err = s.repository.GetByEmail(ctx, form.Username) + } else { + user, err = s.repository.GetByUsername(ctx, form.Username) + } + + if err != nil { + s.logger.Warn("Login attempt with invalid credentials", map[string]interface{}{ + "email_or_username": form.Username, + "error": err.Error(), + }) + return nil, errors.New("invalid credentials") + } + + // Check if user is active + if user.Status != UserStatusActive { + return nil, errors.New("account is inactive") + } + + // Verify password + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.Password)) + if err != nil { + s.logger.Warn("Login attempt with wrong password", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + }) + return nil, errors.New("invalid credentials") + } + + // Update last login + err = s.repository.UpdateLastLogin(ctx, user.ID) + if err != nil { + s.logger.Error("Failed to update last login", map[string]interface{}{ + "error": err.Error(), + "user_id": user.ID.String(), + }) + } + + // Generate JWT tokens using authorization service + companyID := "" + if user.CompanyID != nil { + companyID = user.CompanyID.String() + } + + tokenPair, err := s.authService.GenerateTokenPair( + user.ID.String(), + user.Email, + string(user.Role), + companyID, + ) + if err != nil { + s.logger.Error("Failed to generate JWT tokens", map[string]interface{}{ + "error": err.Error(), + "user_id": user.ID.String(), + }) + return nil, errors.New("failed to generate authentication tokens") + } + + s.logger.Info("User logged in successfully", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + "role": user.Role, + }) + + return &AuthResponse{ + User: user.ToResponse(), + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now + }, nil +} + +// RefreshToken refreshes the access token +func (s *userService) RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) { + // Validate refresh token and generate new access token + newAccessToken, err := s.authService.RefreshAccessToken(refreshToken) + if err != nil { + s.logger.Warn("Failed to refresh access token", map[string]interface{}{ + "error": err.Error(), + }) + return nil, errors.New("invalid or expired refresh token") + } + + // Parse the new access token to get user information + validationResult, err := s.authService.ValidateAccessToken(newAccessToken) + if err != nil { + s.logger.Error("Failed to validate new access token", map[string]interface{}{ + "error": err.Error(), + }) + return nil, errors.New("failed to generate valid access token") + } + + if !validationResult.Valid { + s.logger.Error("New access token validation failed", map[string]interface{}{ + "error": validationResult.Error, + }) + return nil, errors.New("failed to generate valid access token") + } + + // Get user information + userID, err := uuid.Parse(validationResult.Claims.UserID) + if err != nil { + s.logger.Error("Failed to parse user ID from token", map[string]interface{}{ + "error": err.Error(), + }) + return nil, errors.New("invalid token format") + } + + user, err := s.repository.GetByID(ctx, userID) + if err != nil { + s.logger.Error("Failed to get user for token refresh", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + return nil, errors.New("user not found") + } + + // Check if user is still active + if user.Status != UserStatusActive { + return nil, errors.New("user account is inactive") + } + + s.logger.Info("Access token refreshed successfully", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + }) + + return &AuthResponse{ + User: user.ToResponse(), + AccessToken: newAccessToken, + RefreshToken: refreshToken, // Return the same refresh token + ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now + }, nil +} + +// ChangePassword changes user password +func (s *userService) ChangePassword(ctx context.Context, userID uuid.UUID, form *ChangePasswordForm) error { + // Get user + user, err := s.repository.GetByID(ctx, userID) + if err != nil { + return errors.New("user not found") + } + + // Verify old password + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.OldPassword)) + if err != nil { + return errors.New("invalid old password") + } + + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost) + if err != nil { + s.logger.Error("Failed to hash new password", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + return errors.New("failed to process new password") + } + + // Update password + user.Password = string(hashedPassword) + err = s.repository.Update(ctx, user) + if err != nil { + s.logger.Error("Failed to update password", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + return errors.New("failed to update password") + } + + s.logger.Info("Password changed successfully", map[string]interface{}{ + "user_id": userID.String(), + }) + + return nil +} + +// ResetPassword initiates password reset process +func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm) error { + // Get user by email + user, err := s.repository.GetByEmail(ctx, form.Email) + if err != nil { + // Don't reveal if user exists or not for security + s.logger.Info("Password reset requested for non-existent email", map[string]interface{}{ + "email": form.Email, + }) + return nil // Return success even if user doesn't exist + } + + // TODO: Implement password reset logic (send email with reset link) + s.logger.Info("Password reset requested", map[string]interface{}{ + "user_id": user.ID.String(), + "email": user.Email, + }) + + return nil +} + +// GetUserByID retrieves a user by ID +func (s *userService) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) { + user, err := s.repository.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return user, nil +} + +// GetProfile retrieves the current user's profile +func (s *userService) GetProfile(ctx context.Context, userID uuid.UUID) (*User, error) { + user, err := s.repository.GetByID(ctx, userID) + if err != nil { + s.logger.Error("Failed to get user profile", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + return nil, errors.New("user not found") + } + + s.logger.Info("User profile retrieved successfully", map[string]interface{}{ + "user_id": userID.String(), + }) + + return user, nil +} + +// UpdateProfile updates the current user's profile +func (s *userService) UpdateProfile(ctx context.Context, userID uuid.UUID, form *UpdateProfileForm) error { + user, err := s.repository.GetByID(ctx, userID) + if err != nil { + s.logger.Error("Failed to get user for profile update", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + return errors.New("user not found") + } + + // Update fields if provided + if form.FullName != nil { + user.FullName = *form.FullName + } + if form.Department != nil { + user.Department = form.Department + } + if form.Position != nil { + user.Position = form.Position + } + if form.Phone != nil { + user.Phone = form.Phone + } + if form.ProfileImage != nil { + user.ProfileImage = form.ProfileImage + } + + // Update timestamp + user.UpdatedAt = time.Now().Unix() + + // Save to database + err = s.repository.Update(ctx, user) + if err != nil { + s.logger.Error("Failed to update user profile", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + return errors.New("failed to update profile") + } + + s.logger.Info("User profile updated successfully", map[string]interface{}{ + "user_id": userID.String(), + }) + + return nil +} + +// UpdateUser updates user information +func (s *userService) UpdateUser(ctx context.Context, id uuid.UUID, form *UpdateUserForm, updatedBy *uuid.UUID) (*User, error) { + // Get user + user, err := s.repository.GetByID(ctx, id) + if err != nil { + return nil, errors.New("user not found") + } + + // Update fields if provided + if form.FullName != nil { + user.FullName = *form.FullName + } + + if form.Username != nil { + // Check if username already exists + existingUser, _ := s.repository.GetByUsername(ctx, *form.Username) + if existingUser != nil && existingUser.ID != id { + return nil, errors.New("username already exists") + } + user.Username = *form.Username + } + + if form.Email != nil { + // Check if email already exists + existingUser, _ := s.repository.GetByEmail(ctx, *form.Email) + if existingUser != nil && existingUser.ID != id { + return nil, errors.New("email already exists") + } + user.Email = *form.Email + } + + if form.Role != nil { + role := UserRole(*form.Role) + if !s.validator.IsValidRole(role) { + return nil, errors.New("invalid role") + } + user.Role = role + } + + if form.CompanyID != nil { + parsedID, err := uuid.Parse(*form.CompanyID) + if err != nil { + return nil, errors.New("invalid company ID") + } + user.CompanyID = &parsedID + } + + if form.Department != nil { + user.Department = form.Department + } + + if form.Position != nil { + user.Position = form.Position + } + + if form.Phone != nil { + user.Phone = form.Phone + } + + if form.ProfileImage != nil { + user.ProfileImage = form.ProfileImage + } + + if form.Status != nil { + user.Status = UserStatus(*form.Status) + } + + // Set updated by + user.UpdatedBy = updatedBy + + // Update in database + err = s.repository.Update(ctx, user) + if err != nil { + s.logger.Error("Failed to update user", map[string]interface{}{ + "error": err.Error(), + "user_id": id.String(), + }) + return nil, errors.New("failed to update user") + } + + s.logger.Info("User updated successfully", map[string]interface{}{ + "user_id": id.String(), + "updated_by": updatedBy.String(), + }) + + return user, nil +} + +// DeleteUser deletes a user (soft delete) +func (s *userService) DeleteUser(ctx context.Context, id uuid.UUID, deletedBy *uuid.UUID) error { + // Get user to check if exists + user, err := s.repository.GetByID(ctx, id) + if err != nil { + return errors.New("user not found") + } + + // Set updated by before deletion + user.UpdatedBy = deletedBy + + // Update user status to inactive (soft delete) + user.Status = UserStatusInactive + err = s.repository.Update(ctx, user) + if err != nil { + s.logger.Error("Failed to delete user", map[string]interface{}{ + "error": err.Error(), + "user_id": id.String(), + }) + return errors.New("failed to delete user") + } + + s.logger.Info("User deleted successfully", map[string]interface{}{ + "user_id": id.String(), + "deleted_by": deletedBy.String(), + }) + + return nil +} + +// ListUsers lists users with search and filters +func (s *userService) ListUsers(ctx context.Context, form *ListUsersForm) (*UserListResponse, error) { + // Set defaults + limit := 20 + if form.Limit != nil { + limit = *form.Limit + } + + offset := 0 + if form.Offset != nil { + offset = *form.Offset + } + + search := "" + if form.Search != nil { + search = *form.Search + } + + sortBy := "created_at" + if form.SortBy != nil { + sortBy = *form.SortBy + } + + sortOrder := "desc" + if form.SortOrder != nil { + sortOrder = *form.SortOrder + } + + // Parse company ID if provided + var companyID *uuid.UUID + if form.CompanyID != nil { + parsedID, err := uuid.Parse(*form.CompanyID) + if err != nil { + return nil, errors.New("invalid company ID") + } + companyID = &parsedID + } + + // Get users + users, err := s.repository.Search(ctx, search, form.Status, form.Role, companyID, limit, offset, sortBy, sortOrder) + if err != nil { + s.logger.Error("Failed to list users", map[string]interface{}{ + "error": err.Error(), + }) + return nil, errors.New("failed to list users") + } + + // Convert to responses + var userResponses []*UserResponse + for _, user := range users { + userResponses = append(userResponses, user.ToResponse()) + } + + // TODO: Implement proper count for pagination metadata + total := int64(len(users)) // This should be a separate count query + totalPages := (total + int64(limit) - 1) / int64(limit) + + return &UserListResponse{ + Users: userResponses, + Total: total, + Limit: limit, + Offset: offset, + TotalPages: int(totalPages), + }, nil +} + +// UpdateUserStatus updates user status +func (s *userService) UpdateUserStatus(ctx context.Context, id uuid.UUID, form *UpdateUserStatusForm, updatedBy *uuid.UUID) error { + // Get user + user, err := s.repository.GetByID(ctx, id) + if err != nil { + return errors.New("user not found") + } + + // Update status + user.Status = UserStatus(form.Status) + user.UpdatedBy = updatedBy + + // Update in database + err = s.repository.Update(ctx, user) + if err != nil { + s.logger.Error("Failed to update user status", map[string]interface{}{ + "error": err.Error(), + "user_id": id.String(), + "status": form.Status, + }) + return errors.New("failed to update user status") + } + + s.logger.Info("User status updated successfully", map[string]interface{}{ + "user_id": id.String(), + "status": form.Status, + "updated_by": updatedBy.String(), + }) + + return nil +} + +// UpdateUserRole updates user role +func (s *userService) UpdateUserRole(ctx context.Context, id uuid.UUID, form *UpdateUserRoleForm, updatedBy *uuid.UUID) error { + // Get user + user, err := s.repository.GetByID(ctx, id) + if err != nil { + return errors.New("user not found") + } + + // Validate role + role := UserRole(form.Role) + if !s.validator.IsValidRole(role) { + return errors.New("invalid role") + } + + // Update role + user.Role = role + user.UpdatedBy = updatedBy + + // Update in database + err = s.repository.Update(ctx, user) + if err != nil { + s.logger.Error("Failed to update user role", map[string]interface{}{ + "error": err.Error(), + "user_id": id.String(), + "role": form.Role, + }) + return errors.New("failed to update user role") + } + + s.logger.Info("User role updated successfully", map[string]interface{}{ + "user_id": id.String(), + "role": form.Role, + "updated_by": updatedBy.String(), + }) + + return nil +} + +// GetUsersByCompanyID retrieves users by company ID with pagination +func (s *userService) GetUsersByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, int64, error) { + users, err := s.repository.GetByCompanyID(ctx, companyID, limit, offset) + if err != nil { + s.logger.Error("Failed to get users by company ID", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID.String(), + }) + return nil, 0, errors.New("failed to get users by company") + } + + // Get total count + total, err := s.repository.CountByCompanyID(ctx, companyID) + if err != nil { + s.logger.Error("Failed to count users by company ID", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID.String(), + }) + return users, 0, nil // Return users even if count fails + } + + return users, total, nil +} + +// GetUsersByRole retrieves users by role with pagination +func (s *userService) GetUsersByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error) { + users, err := s.repository.GetByRole(ctx, role, limit, offset) + if err != nil { + s.logger.Error("Failed to get users by role", map[string]interface{}{ + "error": err.Error(), + "role": role, + }) + return nil, errors.New("failed to get users by role") + } + + return users, nil +} + +// Logout logs out a user and invalidates tokens +func (s *userService) Logout(ctx context.Context, userID uuid.UUID, accessToken string) error { + // Invalidate the access token + err := s.authService.InvalidateToken(accessToken) + if err != nil { + s.logger.Error("Failed to invalidate access token", map[string]interface{}{ + "error": err.Error(), + "user_id": userID.String(), + }) + // Don't return error here as the user should still be logged out + } + + s.logger.Info("User logged out successfully", map[string]interface{}{ + "user_id": userID.String(), + }) + + return nil +} diff --git a/internal/user/validation.go b/internal/user/validation.go new file mode 100644 index 0000000..dc12555 --- /dev/null +++ b/internal/user/validation.go @@ -0,0 +1,35 @@ +package user + +// ValidationService defines validation methods for user operations +type ValidationService interface { + IsValidRole(role UserRole) bool + IsValidStatus(status UserStatus) bool +} + +// validationService implements the ValidationService interface +type validationService struct{} + +// NewValidationService creates a new validation service +func NewValidationService() ValidationService { + return &validationService{} +} + +// IsValidRole checks if a user role is valid +func (v *validationService) IsValidRole(role UserRole) bool { + switch role { + case UserRoleAdmin, UserRoleManager, UserRoleOperator, UserRoleViewer: + return true + default: + return false + } +} + +// IsValidStatus checks if a user status is valid +func (v *validationService) IsValidStatus(status UserStatus) bool { + switch status { + case UserStatusActive, UserStatusInactive, UserStatusSuspended: + return true + default: + return false + } +} diff --git a/pkg/authorization/README.md b/pkg/authorization/README.md new file mode 100644 index 0000000..44a1237 --- /dev/null +++ b/pkg/authorization/README.md @@ -0,0 +1,353 @@ +# Authorization Package + +This package provides JWT-based authorization services for the Tender Management system, following Clean Architecture principles and best practices. + +## Features + +- **JWT Token Management**: Generate and validate access and refresh tokens +- **Middleware Support**: Ready-to-use Echo middleware for authentication and authorization +- **Role-Based Access Control**: Built-in role validation middleware +- **Company Scoping**: Company-level access control middleware +- **Token Blacklisting**: Support for token invalidation (Redis-ready) +- **Injectable Configuration**: Dependency injection for flexible configuration management +- **Configuration Providers**: Multiple ways to create configurations (environment, custom, hybrid) +- **Security Best Practices**: Secure token generation and validation + +## Quick Start + +### 1. Basic Usage + +```go +package main + +import ( + "tm/pkg/authorization" + "tm/pkg/logger" +) + +func main() { + // Create logger + logger := logger.NewLogger() + + // Create configuration provider (environment-based) + configProvider := authorization.NewEnvConfigProvider() + config := configProvider.CreateConfig() + + // Create authorization service + authService := authorization.NewAuthorizationService(config, logger) + + // Generate tokens + tokenPair, err := authService.GenerateTokenPair( + "user123", + "user@example.com", + "admin", + "company456", + ) + if err != nil { + log.Fatal(err) + } + + // Validate tokens + result, err := authService.ValidateAccessToken(tokenPair.AccessToken) + if err != nil { + log.Fatal(err) + } + + if result.Valid { + fmt.Printf("User ID: %s, Role: %s\n", result.Claims.UserID, result.Claims.Role) + } +} +``` + +### 2. Using with Echo Framework + +```go +package main + +import ( + "github.com/labstack/echo/v4" + "tm/pkg/authorization" + "tm/pkg/logger" +) + +func main() { + e := echo.New() + + // Setup authorization + logger := logger.NewLogger() + configProvider := authorization.NewEnvConfigProvider() + config := configProvider.CreateConfig() + authService := authorization.NewAuthorizationService(config, logger) + + // Public routes + e.POST("/login", loginHandler) + + // Protected routes + protected := e.Group("/api") + protected.Use(authorization.AuthMiddleware(authService)) + { + protected.GET("/profile", getProfileHandler) + protected.PUT("/profile", updateProfileHandler) + } + + // Admin routes + admin := protected.Group("/admin") + admin.Use(authorization.AdminMiddleware(authService)) + { + admin.POST("/users", createUserHandler) + admin.GET("/users", listUsersHandler) + } + + // Company-scoped routes + company := protected.Group("/company/:company_id") + company.Use(authorization.CompanyMiddleware(authService)) + { + company.GET("/tenders", getCompanyTendersHandler) + } + + e.Start(":8080") +} +``` + +### 3. Configuration Management + +The authorization package uses dependency injection for configuration. You can create configurations using different providers: + +#### Configuration Providers + +The package provides several configuration providers that implement the `ConfigProvider` interface: + +```go +type ConfigProvider interface { + CreateConfig() *AuthorizationConfig +} +``` + +#### Environment-based Configuration + +```go +// Create configuration from environment variables +configProvider := authorization.NewEnvConfigProvider() +config := configProvider.CreateConfig() +``` + +#### Custom Configuration + +```go +// Create custom configuration +customConfig := &authorization.AuthorizationConfig{ + AccessTokenSecret: "my-secret-key", + RefreshTokenSecret: "my-refresh-secret", + AccessTokenExpiry: 30 * time.Minute, + RefreshTokenExpiry: 14 * 24 * time.Hour, + Issuer: "my-app", + Audience: "my-api", +} + +configProvider := authorization.NewCustomConfigProvider(customConfig) +config := configProvider.CreateConfig() +``` + +#### Hybrid Configuration + +```go +// Combine multiple configuration sources with priority +envProvider := authorization.NewEnvConfigProvider() +customProvider := authorization.NewCustomConfigProvider(customConfig) +hybridProvider := authorization.NewHybridConfigProvider(envProvider, customProvider) +config := hybridProvider.CreateConfig() + +// The hybrid provider applies configurations in order: +// 1. Environment variables (base) +// 2. Custom overrides (priority) +``` + +#### File-based Configuration (Future) + +```go +// File-based configuration (placeholder for future implementation) +fileProvider := authorization.NewFileConfigProvider("config.yaml") +config := fileProvider.CreateConfig() +``` + +#### Environment Variables + +```bash +# Required +export TM_ACCESS_TOKEN_SECRET="your-super-secret-access-key-here" +export TM_REFRESH_TOKEN_SECRET="your-super-secret-refresh-key-here" + +# Optional (with defaults) +export TM_ACCESS_TOKEN_EXPIRY="15m" +export TM_REFRESH_TOKEN_EXPIRY="168h" # 7 days +export TM_JWT_ISSUER="tender-management" +export TM_JWT_AUDIENCE="tender-management-api" +``` + +## API Reference + +### AuthorizationService Interface + +```go +type AuthorizationService interface { + // Generate tokens + GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error) + GenerateAccessToken(userID, email, role, companyID string) (string, error) + GenerateRefreshToken(userID, email, role, companyID string) (string, error) + + // Validate tokens + ValidateToken(tokenString string) (*TokenValidationResult, error) + ValidateAccessToken(tokenString string) (*TokenValidationResult, error) + ValidateRefreshToken(tokenString string) (*TokenValidationResult, error) + + // Token management + RefreshAccessToken(refreshToken string) (string, error) + InvalidateToken(tokenString string) error + IsTokenBlacklisted(tokenString string) (bool, error) +} +``` + +### Middleware Functions + +```go +// Authentication middleware +AuthMiddleware(authService AuthorizationService) echo.MiddlewareFunc + +// Role-based access control +RoleMiddleware(authService AuthorizationService, requiredRoles ...string) echo.MiddlewareFunc + +// Admin-only access +AdminMiddleware(authService AuthorizationService) echo.MiddlewareFunc + +// Company-scoped access +CompanyMiddleware(authService AuthorizationService) echo.MiddlewareFunc + +// Optional authentication +OptionalAuthMiddleware(authService AuthorizationService) echo.MiddlewareFunc +``` + +### Context Helpers + +```go +// Extract user information from context +userID, email, role, companyID, authenticated := GetUserFromContext(c) + +// Extract token claims +claims := GetTokenClaimsFromContext(c) + +// Check authentication status +if IsAuthenticated(c) { + // User is authenticated +} +``` + +## Token Structure + +### Access Token Claims + +```json +{ + "user_id": "uuid-string", + "email": "user@example.com", + "role": "admin", + "company_id": "company-uuid", + "token_type": "access", + "iss": "tender-management", + "sub": "uuid-string", + "aud": ["tender-management-api"], + "exp": 1640995200, + "nbf": 1640994300, + "iat": 1640994300 +} +``` + +### Refresh Token Claims + +```json +{ + "user_id": "uuid-string", + "email": "user@example.com", + "role": "admin", + "company_id": "company-uuid", + "token_type": "refresh", + "iss": "tender-management", + "sub": "uuid-string", + "aud": ["tender-management-api"], + "exp": 1641600000, + "nbf": 1640994300, + "iat": 1640994300 +} +``` + +## Security Features + +### Token Security + +- **Separate Secrets**: Access and refresh tokens use different signing secrets +- **Short Expiry**: Access tokens expire quickly (default: 15 minutes) +- **Long Expiry**: Refresh tokens last longer (default: 7 days) +- **Type Validation**: Tokens are validated against their intended type +- **Blacklist Support**: Tokens can be invalidated and blacklisted + +### Best Practices + +- **Environment Variables**: Never hardcode secrets in code +- **Strong Secrets**: Use cryptographically strong random secrets +- **HTTPS Only**: Always use HTTPS in production +- **Token Rotation**: Implement refresh token rotation for security +- **Audit Logging**: Log all authentication and authorization events + +## Configuration Validation + +The package automatically validates configuration on startup: + +```go +config := authorization.LoadConfigFromEnv() +if err := config.ValidateConfig(); err != nil { + log.Fatal("Invalid authorization configuration:", err) +} +``` + +Validation checks: +- Non-empty secrets +- Valid expiry durations +- Access token expiry < refresh token expiry +- Non-empty issuer and audience + +## Error Handling + +All functions return descriptive errors: + +```go +result, err := authService.ValidateAccessToken(token) +if err != nil { + // Handle validation error + return err +} + +if !result.Valid { + if result.Expired { + return response.Unauthorized(c, "Token has expired") + } + return response.Unauthorized(c, result.Error) +} +``` + +## Future Enhancements + +- **Redis Integration**: Full token blacklist implementation +- **Rate Limiting**: Token generation rate limiting +- **Audit Trail**: Comprehensive authentication audit logging +- **Multi-Tenant**: Enhanced company-level security +- **OAuth2 Support**: Integration with external OAuth providers + +## Dependencies + +- `github.com/golang-jwt/jwt/v5` - JWT implementation +- `github.com/labstack/echo/v4` - HTTP framework +- `tm/pkg/logger` - Internal logging package +- `tm/pkg/response` - Internal response package + +## License + +This package is part of the Tender Management system and follows the same license terms. \ No newline at end of file diff --git a/pkg/authorization/authorization.go b/pkg/authorization/authorization.go new file mode 100644 index 0000000..251850c --- /dev/null +++ b/pkg/authorization/authorization.go @@ -0,0 +1,620 @@ +package authorization + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "time" + + "tm/pkg/logger" + "tm/pkg/redis" + + "github.com/golang-jwt/jwt/v5" +) + +// TokenType represents the type of token +type TokenType string + +const ( + AccessToken TokenType = "access" + RefreshToken TokenType = "refresh" +) + +// TokenClaims represents the JWT claims structure +type TokenClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + CompanyID string `json:"company_id,omitempty"` + TokenType TokenType `json:"token_type"` + jwt.RegisteredClaims +} + +// TokenPair represents a pair of access and refresh tokens +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` +} + +// TokenValidationResult represents the result of token validation +type TokenValidationResult struct { + Valid bool + Claims *TokenClaims + Error string + Expired bool +} + +// AuthorizationService defines the interface for authorization operations +type AuthorizationService interface { + // GenerateTokenPair generates both access and refresh tokens + GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error) + + // GenerateAccessToken generates only an access token + GenerateAccessToken(userID, email, role, companyID string) (string, error) + + // GenerateRefreshToken generates only a refresh token + GenerateRefreshToken(userID, email, role, companyID string) (string, error) + + // ValidateToken validates a token and returns the claims + ValidateToken(tokenString string) (*TokenValidationResult, error) + + // ValidateAccessToken validates specifically an access token + ValidateAccessToken(tokenString string) (*TokenValidationResult, error) + + // ValidateRefreshToken validates specifically a refresh token + ValidateRefreshToken(tokenString string) (*TokenValidationResult, error) + + // RefreshAccessToken generates a new access token using a valid refresh token + RefreshAccessToken(refreshToken string) (string, error) + + // InvalidateToken adds a token to the blacklist (if using Redis) + InvalidateToken(tokenString string) error + + // IsTokenBlacklisted checks if a token is blacklisted + IsTokenBlacklisted(tokenString string) (bool, error) + + // ClearExpiredTokens removes expired tokens from the blacklist + ClearExpiredTokens() error + + // GetBlacklistStats returns statistics about the blacklist + GetBlacklistStats(ctx context.Context) (map[string]interface{}, error) +} + +// AuthorizationConfig holds the configuration for the authorization service +type AuthorizationConfig struct { + AccessTokenSecret string `json:"access_token_secret"` + RefreshTokenSecret string `json:"refresh_token_secret"` + AccessTokenExpiry time.Duration `json:"access_token_expiry"` + RefreshTokenExpiry time.Duration `json:"refresh_token_expiry"` + Issuer string `json:"issuer"` + Audience string `json:"audience"` +} + +// DefaultConfig returns a default configuration +func DefaultConfig() *AuthorizationConfig { + return &AuthorizationConfig{ + AccessTokenSecret: "your-access-token-secret-key-here", + RefreshTokenSecret: "your-refresh-token-secret-key-here", + AccessTokenExpiry: 15 * time.Minute, // 15 minutes + RefreshTokenExpiry: 7 * 24 * time.Hour, // 7 days + Issuer: "tender-management", + Audience: "tender-management-api", + } +} + +// Service implements the AuthorizationService interface +type Service struct { + config *AuthorizationConfig + logger logger.Logger + redis redis.Client +} + +// NewAuthorizationService creates a new authorization service +func NewAuthorizationService(config *AuthorizationConfig, logger logger.Logger, redis redis.Client) AuthorizationService { + if config == nil { + config = DefaultConfig() + } + + return &Service{ + config: config, + logger: logger, + redis: redis, + } +} + +// GenerateTokenPair generates both access and refresh tokens +func (s *Service) GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error) { + s.logger.Info("Generating token pair", map[string]interface{}{ + "user_id": userID, + "email": email, + "role": role, + }) + + accessToken, err := s.GenerateAccessToken(userID, email, role, companyID) + if err != nil { + s.logger.Error("Failed to generate access token", map[string]interface{}{ + "user_id": userID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + + refreshToken, err := s.GenerateRefreshToken(userID, email, role, companyID) + if err != nil { + s.logger.Error("Failed to generate refresh token", map[string]interface{}{ + "user_id": userID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + + tokenPair := &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: int64(s.config.AccessTokenExpiry.Seconds()), + } + + s.logger.Info("Token pair generated successfully", map[string]interface{}{ + "user_id": userID, + "expires_in": tokenPair.ExpiresIn, + }) + + return tokenPair, nil +} + +// GenerateAccessToken generates only an access token +func (s *Service) GenerateAccessToken(userID, email, role, companyID string) (string, error) { + now := time.Now() + expiresAt := now.Add(s.config.AccessTokenExpiry) + + claims := &TokenClaims{ + UserID: userID, + Email: email, + Role: role, + CompanyID: companyID, + TokenType: AccessToken, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: s.config.Issuer, + Subject: userID, + Audience: []string{s.config.Audience}, + ExpiresAt: jwt.NewNumericDate(expiresAt), + NotBefore: jwt.NewNumericDate(now), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.config.AccessTokenSecret)) + if err != nil { + return "", fmt.Errorf("failed to sign access token: %w", err) + } + + return tokenString, nil +} + +// GenerateRefreshToken generates only a refresh token +func (s *Service) GenerateRefreshToken(userID, email, role, companyID string) (string, error) { + now := time.Now() + expiresAt := now.Add(s.config.RefreshTokenExpiry) + + claims := &TokenClaims{ + UserID: userID, + Email: email, + Role: role, + CompanyID: companyID, + TokenType: RefreshToken, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: s.config.Issuer, + Subject: userID, + Audience: []string{s.config.Audience}, + ExpiresAt: jwt.NewNumericDate(expiresAt), + NotBefore: jwt.NewNumericDate(now), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.config.RefreshTokenSecret)) + if err != nil { + return "", fmt.Errorf("failed to sign refresh token: %w", err) + } + + return tokenString, nil +} + +// ValidateToken validates a token and returns the claims +func (s *Service) ValidateToken(tokenString string) (*TokenValidationResult, error) { + // First check if token is blacklisted + isBlacklisted, err := s.IsTokenBlacklisted(tokenString) + if err != nil { + s.logger.Error("Failed to check token blacklist", map[string]interface{}{ + "error": err.Error(), + }) + // Continue with validation even if blacklist check fails + } + + if isBlacklisted { + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: "token is blacklisted", + Expired: false, + }, nil + } + + // Parse the token + token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Determine which secret to use based on token type + claims, ok := token.Claims.(*TokenClaims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + switch claims.TokenType { + case AccessToken: + return []byte(s.config.AccessTokenSecret), nil + case RefreshToken: + return []byte(s.config.RefreshTokenSecret), nil + default: + return nil, fmt.Errorf("invalid token type") + } + }) + + if err != nil { + if jwt.ErrTokenExpired.Error() == err.Error() { + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: "token expired", + Expired: true, + }, nil + } + + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: fmt.Sprintf("token validation failed: %s", err.Error()), + Expired: false, + }, nil + } + + if !token.Valid { + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: "invalid token", + Expired: false, + }, nil + } + + claims, ok := token.Claims.(*TokenClaims) + if !ok { + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: "invalid token claims", + Expired: false, + }, nil + } + + return &TokenValidationResult{ + Valid: true, + Claims: claims, + Error: "", + Expired: false, + }, nil +} + +// ValidateAccessToken validates specifically an access token +func (s *Service) ValidateAccessToken(tokenString string) (*TokenValidationResult, error) { + result, err := s.ValidateToken(tokenString) + if err != nil { + return nil, err + } + + if !result.Valid { + return result, nil + } + + // Check if it's actually an access token + if result.Claims.TokenType != AccessToken { + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: "token is not an access token", + Expired: false, + }, nil + } + + return result, nil +} + +// ValidateRefreshToken validates specifically a refresh token +func (s *Service) ValidateRefreshToken(tokenString string) (*TokenValidationResult, error) { + result, err := s.ValidateToken(tokenString) + if err != nil { + return nil, err + } + + if !result.Valid { + return result, nil + } + + // Check if it's actually a refresh token + if result.Claims.TokenType != RefreshToken { + return &TokenValidationResult{ + Valid: false, + Claims: nil, + Error: "token is not a refresh token", + Expired: false, + }, nil + } + + return result, nil +} + +// RefreshAccessToken generates a new access token using a valid refresh token +func (s *Service) RefreshAccessToken(refreshToken string) (string, error) { + s.logger.Info("Refreshing access token", map[string]interface{}{ + "refresh_token_length": len(refreshToken), + }) + + // Validate the refresh token + result, err := s.ValidateRefreshToken(refreshToken) + if err != nil { + s.logger.Error("Failed to validate refresh token", map[string]interface{}{ + "error": err.Error(), + }) + return "", fmt.Errorf("failed to validate refresh token: %w", err) + } + + if !result.Valid { + s.logger.Warn("Invalid refresh token provided", map[string]interface{}{ + "error": result.Error, + }) + return "", fmt.Errorf("invalid refresh token: %s", result.Error) + } + + // Generate new access token + newAccessToken, err := s.GenerateAccessToken( + result.Claims.UserID, + result.Claims.Email, + result.Claims.Role, + result.Claims.CompanyID, + ) + if err != nil { + s.logger.Error("Failed to generate new access token", map[string]interface{}{ + "user_id": result.Claims.UserID, + "error": err.Error(), + }) + return "", fmt.Errorf("failed to generate new access token: %w", err) + } + + s.logger.Info("Access token refreshed successfully", map[string]interface{}{ + "user_id": result.Claims.UserID, + }) + + return newAccessToken, nil +} + +// generateTokenHash generates a SHA-256 hash of the token for storage +func (s *Service) generateTokenHash(tokenString string) string { + hash := sha256.Sum256([]byte(tokenString)) + return hex.EncodeToString(hash[:]) +} + +// InvalidateToken adds a token to the blacklist +func (s *Service) InvalidateToken(tokenString string) error { + if s.redis == nil { + s.logger.Warn("Redis client not available, token blacklisting disabled", map[string]interface{}{ + "token_length": len(tokenString), + }) + return nil + } + + // Generate hash of the token for storage + tokenHash := s.generateTokenHash(tokenString) + + // Parse token to get expiration time for blacklist TTL + claims := &TokenClaims{} + _, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Determine which secret to use based on token type + claims, ok := token.Claims.(*TokenClaims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + switch claims.TokenType { + case AccessToken: + return []byte(s.config.AccessTokenSecret), nil + case RefreshToken: + return []byte(s.config.RefreshTokenSecret), nil + default: + return nil, fmt.Errorf("invalid token type") + } + }) + + if err != nil { + s.logger.Error("Failed to parse token for blacklisting", map[string]interface{}{ + "error": err.Error(), + }) + // Still add to blacklist with default TTL even if parsing fails + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Use default TTL (24 hours) if we can't determine actual expiration + defaultTTL := 24 * time.Hour + err = s.redis.Set(ctx, "blacklist:"+tokenHash, "1", defaultTTL) + if err != nil { + s.logger.Error("Failed to add token to blacklist", map[string]interface{}{ + "error": err.Error(), + }) + return fmt.Errorf("failed to add token to blacklist: %w", err) + } + + s.logger.Info("Token added to blacklist with default TTL", map[string]interface{}{ + "token_hash": tokenHash, + "ttl": defaultTTL.String(), + }) + return nil + } + + // Calculate TTL based on token expiration + var ttl time.Duration + if claims.ExpiresAt != nil { + ttl = time.Until(claims.ExpiresAt.Time) + // Add some buffer time to ensure token is blacklisted until it would have expired + ttl += 5 * time.Minute + } else { + // Fallback to default TTL if no expiration + ttl = 24 * time.Hour + } + + // Add token to blacklist with TTL + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = s.redis.Set(ctx, "blacklist:"+tokenHash, "1", ttl) + if err != nil { + s.logger.Error("Failed to add token to blacklist", map[string]interface{}{ + "token_hash": tokenHash, + "error": err.Error(), + }) + return fmt.Errorf("failed to add token to blacklist: %w", err) + } + + s.logger.Info("Token successfully added to blacklist", map[string]interface{}{ + "token_hash": tokenHash, + "ttl": ttl.String(), + }) + + return nil +} + +// IsTokenBlacklisted checks if a token is blacklisted +func (s *Service) IsTokenBlacklisted(tokenString string) (bool, error) { + if s.redis == nil { + // If Redis is not available, assume token is not blacklisted + // This allows the system to continue functioning without Redis + return false, nil + } + + // Generate hash of the token for lookup + tokenHash := s.generateTokenHash(tokenString) + + // Check if token exists in blacklist + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + exists, err := s.redis.Exists(ctx, "blacklist:"+tokenHash) + if err != nil { + s.logger.Error("Failed to check token blacklist", map[string]interface{}{ + "token_hash": tokenHash, + "error": err.Error(), + }) + // If we can't check the blacklist, assume token is not blacklisted + // This prevents blocking legitimate requests due to Redis issues + return false, nil + } + + isBlacklisted := exists > 0 + + if isBlacklisted { + s.logger.Debug("Token found in blacklist", map[string]interface{}{ + "token_hash": tokenHash, + }) + } else { + s.logger.Debug("Token not found in blacklist", map[string]interface{}{ + "token_hash": tokenHash, + }) + } + + return isBlacklisted, nil +} + +// ClearExpiredTokens removes expired tokens from the blacklist +// This is useful for cleanup operations +func (s *Service) ClearExpiredTokens() error { + if s.redis == nil { + return nil + } + + s.logger.Info("Clearing expired tokens from blacklist", map[string]interface{}{}) + + // Note: Redis automatically expires keys based on TTL + // This method is provided for manual cleanup if needed + // In most cases, Redis TTL handling is sufficient + + return nil +} + +// GetBlacklistStats returns statistics about the blacklist +func (s *Service) GetBlacklistStats(ctx context.Context) (map[string]interface{}, error) { + if s.redis == nil { + return map[string]interface{}{ + "redis_available": false, + "message": "Redis client not available", + }, nil + } + + // This is a placeholder for future implementation + // Could return metrics like total blacklisted tokens, memory usage, etc. + return map[string]interface{}{ + "redis_available": true, + "message": "Blacklist statistics not yet implemented", + }, nil +} + +// GenerateRandomSecret generates a random secret key for JWT signing +func GenerateRandomSecret(length int) (string, error) { + if length < 32 { + length = 32 // Minimum recommended length + } + + bytes := make([]byte, length) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + return fmt.Sprintf("%x", bytes), nil +} + +// GenerateRSAKeyPair generates an RSA key pair for JWT signing +func GenerateRSAKeyPair(bits int) (privateKeyPEM, publicKeyPEM string, err error) { + if bits < 2048 { + bits = 2048 // Minimum recommended bits + } + + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return "", "", fmt.Errorf("failed to generate RSA key: %w", err) + } + + // Encode private key + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + })) + + // Encode public key + publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) + publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: publicKeyBytes, + })) + + return privateKeyPEM, publicKeyPEM, nil +} diff --git a/pkg/authorization/authorization_test.go b/pkg/authorization/authorization_test.go new file mode 100644 index 0000000..4666447 --- /dev/null +++ b/pkg/authorization/authorization_test.go @@ -0,0 +1,268 @@ +package authorization + +import ( + "context" + "testing" + "time" + "tm/pkg/logger" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockRedisClient is a mock implementation of the Redis client interface +type MockRedisClient struct { + mock.Mock +} + +func (m *MockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + args := m.Called(ctx, key, value, expiration) + return args.Error(0) +} + +func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) { + args := m.Called(ctx, key) + return args.String(0), args.Error(1) +} + +func (m *MockRedisClient) Del(ctx context.Context, keys ...string) error { + args := m.Called(ctx, keys) + return args.Error(0) +} + +func (m *MockRedisClient) Exists(ctx context.Context, keys ...string) (int64, error) { + args := m.Called(ctx, keys) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockRedisClient) Close() error { + args := m.Called() + return args.Error(0) +} + +// MockLogger is a mock implementation of the logger interface +type MockLogger struct { + mock.Mock +} + +func (m *MockLogger) Debug(msg string, fields map[string]interface{}) { + m.Called(msg, fields) +} + +func (m *MockLogger) Info(msg string, fields map[string]interface{}) { + m.Called(msg, fields) +} + +func (m *MockLogger) Warn(msg string, fields map[string]interface{}) { + m.Called(msg, fields) +} + +func (m *MockLogger) Error(msg string, fields map[string]interface{}) { + m.Called(msg, fields) +} + +func (m *MockLogger) Fatal(msg string, fields map[string]interface{}) { + m.Called(msg, fields) +} + +func (m *MockLogger) Sync() error { + args := m.Called() + return args.Error(0) +} + +func (m *MockLogger) WithFields(fields map[string]interface{}) logger.Logger { + args := m.Called(fields) + return args.Get(0).(logger.Logger) +} + +func TestGenerateTokenHash(t *testing.T) { + service := &Service{} + + token1 := "test-token-1" + token2 := "test-token-2" + + hash1 := service.generateTokenHash(token1) + hash2 := service.generateTokenHash(token2) + + // Hash should be consistent for the same input + assert.Equal(t, service.generateTokenHash(token1), hash1) + + // Different tokens should have different hashes + assert.NotEqual(t, hash1, hash2) + + // Hash should be a valid hex string + assert.Len(t, hash1, 64) // SHA-256 produces 32 bytes = 64 hex chars +} + +func TestInvalidateToken_WithRedis(t *testing.T) { + mockRedis := new(MockRedisClient) + mockLogger := new(MockLogger) + + config := &AuthorizationConfig{ + AccessTokenSecret: "test-secret", + RefreshTokenSecret: "test-refresh-secret", + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + Issuer: "test", + Audience: "test-api", + } + + service := &Service{ + config: config, + logger: mockLogger, + redis: mockRedis, + } + + // Generate a valid token first + token, err := service.GenerateAccessToken("user123", "test@example.com", "user", "company123") + assert.NoError(t, err) + + // Mock Redis Set call + mockRedis.On("Set", mock.Anything, mock.Anything, "1", mock.Anything).Return(nil) + + // Mock logger calls + mockLogger.On("Info", mock.Anything, mock.Anything).Return() + + // Test invalidation + err = service.InvalidateToken(token) + assert.NoError(t, err) + + // Verify Redis was called + mockRedis.AssertExpectations(t) +} + +func TestInvalidateToken_WithoutRedis(t *testing.T) { + mockLogger := new(MockLogger) + + config := &AuthorizationConfig{ + AccessTokenSecret: "test-secret", + RefreshTokenSecret: "test-refresh-secret", + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + Issuer: "test", + Audience: "test-api", + } + + service := &Service{ + config: config, + logger: mockLogger, + redis: nil, // No Redis client + } + + // Mock logger calls + mockLogger.On("Warn", mock.Anything, mock.Anything).Return() + + // Test invalidation without Redis + err := service.InvalidateToken("test-token") + assert.NoError(t, err) + + // Verify warning was logged + mockLogger.AssertExpectations(t) +} + +func TestIsTokenBlacklisted_WithRedis(t *testing.T) { + mockRedis := new(MockRedisClient) + mockLogger := new(MockLogger) + + config := &AuthorizationConfig{ + AccessTokenSecret: "test-secret", + RefreshTokenSecret: "test-refresh-secret", + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + Issuer: "test", + Audience: "test-api", + } + + service := &Service{ + config: config, + logger: mockLogger, + redis: mockRedis, + } + + token := "test-token" + tokenHash := service.generateTokenHash(token) + + // Test case 1: Token is blacklisted + mockRedis.On("Exists", mock.Anything, []string{"blacklist:" + tokenHash}).Return(int64(1), nil).Once() + mockLogger.On("Debug", mock.Anything, mock.Anything).Return() + + isBlacklisted, err := service.IsTokenBlacklisted(token) + assert.NoError(t, err) + assert.True(t, isBlacklisted) + + // Test case 2: Token is not blacklisted + mockRedis.On("Exists", mock.Anything, []string{"blacklist:" + tokenHash}).Return(int64(0), nil).Once() + mockLogger.On("Debug", mock.Anything, mock.Anything).Return() + + isBlacklisted, err = service.IsTokenBlacklisted(token) + assert.NoError(t, err) + assert.False(t, isBlacklisted) + + mockRedis.AssertExpectations(t) +} + +func TestIsTokenBlacklisted_WithoutRedis(t *testing.T) { + mockLogger := new(MockLogger) + + config := &AuthorizationConfig{ + AccessTokenSecret: "test-secret", + RefreshTokenSecret: "test-refresh-secret", + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + Issuer: "test", + Audience: "test-api", + } + + service := &Service{ + config: config, + logger: mockLogger, + redis: nil, // No Redis client + } + + // Test blacklist check without Redis + isBlacklisted, err := service.IsTokenBlacklisted("test-token") + assert.NoError(t, err) + assert.False(t, isBlacklisted) +} + +func TestClearExpiredTokens(t *testing.T) { + mockRedis := new(MockRedisClient) + mockLogger := new(MockLogger) + + service := &Service{ + logger: mockLogger, + redis: mockRedis, + } + + // Mock logger call + mockLogger.On("Info", mock.Anything, mock.Anything).Return() + + // Test clearing expired tokens + err := service.ClearExpiredTokens() + assert.NoError(t, err) + + mockLogger.AssertExpectations(t) +} + +func TestGetBlacklistStats(t *testing.T) { + mockRedis := new(MockRedisClient) + mockLogger := new(MockLogger) + + service := &Service{ + logger: mockLogger, + redis: mockRedis, + } + + ctx := context.Background() + + // Test with Redis available + stats, err := service.GetBlacklistStats(ctx) + assert.NoError(t, err) + assert.Equal(t, true, stats["redis_available"]) + + // Test without Redis + service.redis = nil + stats, err = service.GetBlacklistStats(ctx) + assert.NoError(t, err) + assert.Equal(t, false, stats["redis_available"]) +} diff --git a/pkg/authorization/errors.go b/pkg/authorization/errors.go new file mode 100644 index 0000000..4600519 --- /dev/null +++ b/pkg/authorization/errors.go @@ -0,0 +1,23 @@ +package authorization + +import "errors" + +// Authorization errors +var ( + ErrEmptyAccessTokenSecret = errors.New("access token secret cannot be empty") + ErrEmptyRefreshTokenSecret = errors.New("refresh token secret cannot be empty") + ErrInvalidAccessTokenExpiry = errors.New("access token expiry must be greater than 0") + ErrInvalidRefreshTokenExpiry = errors.New("refresh token expiry must be greater than 0") + ErrAccessTokenExpiryTooLong = errors.New("access token expiry cannot be longer than refresh token expiry") + ErrEmptyIssuer = errors.New("JWT issuer cannot be empty") + ErrEmptyAudience = errors.New("JWT audience cannot be empty") + ErrInvalidToken = errors.New("invalid token") + ErrTokenExpired = errors.New("token has expired") + ErrTokenBlacklisted = errors.New("token is blacklisted") + ErrInvalidTokenType = errors.New("invalid token type") + ErrInvalidSigningMethod = errors.New("invalid signing method") + ErrInvalidClaims = errors.New("invalid token claims") + ErrTokenValidationFailed = errors.New("token validation failed") + ErrInsufficientPermissions = errors.New("insufficient permissions") + ErrCompanyAccessDenied = errors.New("access denied to this company") +) diff --git a/pkg/redis/README.md b/pkg/redis/README.md new file mode 100644 index 0000000..d85a1b9 --- /dev/null +++ b/pkg/redis/README.md @@ -0,0 +1,103 @@ +# Redis Package + +This package provides Redis client functionality for the Tender Management system, specifically designed for token blacklisting and caching operations. + +## Features + +- **Connection Management**: Handles Redis client connections with proper error handling +- **Interface-based Design**: Uses interfaces for easy testing and mocking +- **Configuration Support**: Supports Redis configuration from environment variables +- **Connection Testing**: Validates connections on startup +- **Resource Cleanup**: Proper connection closing and cleanup + +## Usage + +### Basic Setup + +```go +import "tm/pkg/redis" + +// Create Redis configuration +config := &redis.Config{ + Host: "localhost", + Port: 6379, + Password: "password", + DB: 0, + PoolSize: 10, +} + +// Create Redis client +client, err := redis.NewClient(config, logger) +if err != nil { + log.Fatal(err) +} +defer client.Close() + +// Use the client +err = client.Set(ctx, "key", "value", time.Hour) +``` + +### Token Blacklisting + +The Redis client is primarily used for JWT token blacklisting: + +```go +// Add token to blacklist +err = client.Set(ctx, "blacklist:token_hash", "1", tokenExpiration) + +// Check if token is blacklisted +exists, err := client.Exists(ctx, "blacklist:token_hash") +if err != nil { + // Handle error +} +if exists > 0 { + // Token is blacklisted +} +``` + +## Configuration + +The Redis configuration supports the following fields: + +- `Host`: Redis server hostname (default: localhost) +- `Port`: Redis server port (default: 6379) +- `Password`: Redis authentication password +- `DB`: Redis database number (default: 0) +- `PoolSize`: Connection pool size (default: 10) + +## Environment Variables + +The configuration can be loaded from environment variables: + +- `TM_CACHE_REDIS_HOST` +- `TM_CACHE_REDIS_PORT` +- `TM_CACHE_REDIS_PASSWORD` +- `TM_CACHE_REDIS_DB` +- `TM_CACHE_REDIS_POOL_SIZE` + +## Error Handling + +The package provides comprehensive error handling: + +- Connection failures are logged with context +- All Redis operations return proper errors +- Connection timeouts are handled gracefully +- Resource cleanup is guaranteed with defer statements + +## Testing + +The interface-based design makes it easy to mock Redis operations in tests: + +```go +type MockRedisClient struct { + // Implement the Client interface +} + +// Use in tests +client := &MockRedisClient{} +``` + +## Dependencies + +- `github.com/redis/go-redis/v9`: Redis client library +- `tm/pkg/logger`: Logging interface \ No newline at end of file diff --git a/pkg/redis/connection.go b/pkg/redis/connection.go new file mode 100644 index 0000000..ebf9ce5 --- /dev/null +++ b/pkg/redis/connection.go @@ -0,0 +1,96 @@ +package redis + +import ( + "context" + "fmt" + "time" + + "tm/pkg/logger" + + "github.com/redis/go-redis/v9" +) + +// Config holds Redis configuration +type Config struct { + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + DB int `json:"db"` + PoolSize int `json:"pool_size"` +} + +// Client represents a Redis client interface +type Client interface { + Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error + Get(ctx context.Context, key string) (string, error) + Del(ctx context.Context, keys ...string) error + Exists(ctx context.Context, keys ...string) (int64, error) + Close() error +} + +// client implements the Client interface +type client struct { + rdb *redis.Client +} + +// NewClient creates a new Redis client +func NewClient(config *Config, logger logger.Logger) (Client, error) { + if config == nil { + return nil, fmt.Errorf("redis config is required") + } + + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + + rdb := redis.NewClient(&redis.Options{ + Addr: addr, + Password: config.Password, + DB: config.DB, + PoolSize: config.PoolSize, + }) + + // Test the connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := rdb.Ping(ctx).Err(); err != nil { + logger.Error("Failed to connect to Redis", map[string]interface{}{ + "host": config.Host, + "port": config.Port, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to connect to Redis: %w", err) + } + + logger.Info("Successfully connected to Redis", map[string]interface{}{ + "host": config.Host, + "port": config.Port, + "db": config.DB, + }) + + return &client{rdb: rdb}, nil +} + +// Set sets a key-value pair with expiration +func (c *client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + return c.rdb.Set(ctx, key, value, expiration).Err() +} + +// Get retrieves a value by key +func (c *client) Get(ctx context.Context, key string) (string, error) { + return c.rdb.Get(ctx, key).Result() +} + +// Del deletes one or more keys +func (c *client) Del(ctx context.Context, keys ...string) error { + return c.rdb.Del(ctx, keys...).Err() +} + +// Exists checks if one or more keys exist +func (c *client) Exists(ctx context.Context, keys ...string) (int64, error) { + return c.rdb.Exists(ctx, keys...).Result() +} + +// Close closes the Redis connection +func (c *client) Close() error { + return c.rdb.Close() +} diff --git a/pkg/response/response.go b/pkg/response/response.go index 416eda9..12bdc14 100644 --- a/pkg/response/response.go +++ b/pkg/response/response.go @@ -3,6 +3,7 @@ package response import ( "net/http" + "github.com/asaskevich/govalidator" "github.com/labstack/echo/v4" ) @@ -156,3 +157,17 @@ func ServiceUnavailable(c echo.Context, message string) error { }, }) } + +// Parse parses the form and validates it +func Parse[T any](c echo.Context) (*T, error) { + var form T + if err := c.Bind(&form); err != nil { + return nil, err + } + + if _, err := govalidator.ValidateStruct(form); err != nil { + return nil, err + } + + return &form, nil +} diff --git a/web b/web new file mode 100755 index 0000000..0fa4f04 Binary files /dev/null and b/web differ