diff --git a/go.mod b/go.mod index da078ec..4e9a604 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/gosimple/slug v1.15.0 github.com/minio/minio-go/v7 v7.0.100 github.com/pressly/goose/v3 v3.27.0 - github.com/prometheus/client_golang v1.23.2 github.com/samber/lo v1.52.0 github.com/swaggo/swag v1.16.6 github.com/uptrace/bun v1.2.18 @@ -79,6 +78,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect diff --git a/internal/db/migrations/20260424000000_add_user_name.sql b/internal/db/migrations/20260424000000_add_user_name.sql new file mode 100644 index 0000000..0ef6552 --- /dev/null +++ b/internal/db/migrations/20260424000000_add_user_name.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE `users` +ADD COLUMN `name` VARCHAR(255) NULL; +-- +goose StatementEnd +-- +goose StatementBegin +UPDATE `users` +SET `name` = SUBSTRING_INDEX(`email`, '@', 1) +WHERE `name` IS NULL + OR `name` = ''; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE `users` +MODIFY COLUMN `name` VARCHAR(255) NOT NULL; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE INDEX idx_users_status_lower_name_prefix ON users (`status`, `name`); +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP INDEX idx_users_status_lower_name_prefix ON users; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE `users` DROP COLUMN `name`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/example/config.go b/internal/example/config.go deleted file mode 100644 index eda1ba7..0000000 --- a/internal/example/config.go +++ /dev/null @@ -1,22 +0,0 @@ -package example - -// Config holds the configuration for the example module. -// -// This struct contains all the configuration parameters needed to initialize -// and configure the example module. Typically, these values would be loaded -// from environment variables, configuration files, or other configuration sources. -// -// Example: -// -// cfg := example.Config{ -// Example: "demo-value", -// } -// -// service := example.New(cfg, repo, metrics, logger) -type Config struct { - // Example is a configuration parameter that demonstrates how to include - // custom configuration values in the module. This could be used for - // feature flags, API endpoints, timeouts, or any other configurable - // aspect of the module. - Example string -} diff --git a/internal/example/doc.go b/internal/example/doc.go deleted file mode 100644 index f1ef226..0000000 --- a/internal/example/doc.go +++ /dev/null @@ -1,104 +0,0 @@ -// Package example provides a demonstration of a well-structured Go module using -// the Uber FX framework for dependency injection. This module showcases common -// patterns and best practices for organizing Go code in a modular, maintainable way. -// -// The example module is organized into several files, each with a specific responsibility: -// -// - config.go: Contains configuration structures for the module -// - domain.go: Defines domain entities and core business logic types -// - errors.go: Custom error definitions for the module -// - metrics.go: Prometheus metrics collection and reporting -// - models.go: Data models used by the module -// - module.go: FX module definition for dependency injection -// - repository.go: Data access layer implementation -// - service.go: Business logic and service layer implementation -// -// This structure follows a clean architecture approach with clear separation of concerns. -package example - -// Overview -// -// The example module demonstrates a typical structure for a Go service module: -// -// 1. Configuration (config.go) -// - Defines the Config struct that holds module-specific configuration -// - Typically loaded from environment variables or configuration files -// -// 2. Domain Layer (domain.go) -// - Contains core business entities and types -// - Represents the business domain concepts -// -// 3. Error Handling (errors.go) -// - Defines module-specific error types -// - Provides consistent error handling throughout the module -// -// 4. Metrics (metrics.go) -// - Implements Prometheus metrics for monitoring -// - Provides observability into module operations -// -// 5. Data Models (models.go) -// - Defines data structures used internally -// - May represent database entities or DTOs -// -// 6. Module Definition (module.go) -// - Uses Uber FX for dependency injection -// - Wires up all components and their dependencies -// -// 7. Repository Layer (repository.go) -// - Handles data access and persistence -// - Abstracts the data source from the service layer -// -// 8. Service Layer (service.go) -// - Implements business logic -// - Orchestrates operations between domain, repository, and other components -// -// Usage -// -// To use this module in your application, import it and include the FX module: -// -// app := fx.New( -// example.Module(), -// // other modules... -// ) -// -// The module will automatically wire up all dependencies and make the Service available -// for use by other modules. -// -// Dependencies -// -// This module depends on: -// - go.uber.org/fx: For dependency injection -// - go.uber.org/zap: For structured logging -// - github.com/prometheus/client_golang: For metrics collection -// - github.com/go-core-fx/logger: For enhanced logging capabilities -// -// Example -// -// Here's a basic example of how to use the example module: -// -// package main -// -// import ( -// "context" -// "go.uber.org/fx" -// -// "yourproject/internal/example" -// ) -// -// func main() { -// app := fx.New( -// example.Module(), -// fx.Invoke(run), -// ) -// -// app.Run() -// } -// -// func run(lc fx.Lifecycle, service *example.Service) { -// lc.Append(fx.Hook{ -// OnStart: func(ctx context.Context) error { -// // Use the service here -// return nil -// }, -// }) -// } diff --git a/internal/example/domain.go b/internal/example/domain.go deleted file mode 100644 index 1aee669..0000000 --- a/internal/example/domain.go +++ /dev/null @@ -1,29 +0,0 @@ -package example - -// Example represents a domain entity in the example module. -// -// This struct demonstrates the domain-driven design approach where core business -// concepts are modeled as domain entities. In a real application, this would -// contain business logic, validation, and behavior related to the concept it -// represents. -// -// Domain entities are typically: -// - Rich in behavior, not just data -// - Responsible for maintaining their own integrity -// - Focused on business rules and logic -// -// Example: -// -// // In a real application, this might have methods like: -// func (e *Example) Validate() error { -// // Validation logic -// } -// -// func (e *Example) Process() error { -// // Business logic -// } -type Example struct { - // In a real application, this would contain fields that represent - // the state and properties of the domain entity. - Value string -} diff --git a/internal/example/errors.go b/internal/example/errors.go deleted file mode 100644 index b41923a..0000000 --- a/internal/example/errors.go +++ /dev/null @@ -1,36 +0,0 @@ -package example - -import "errors" - -// This file defines module-specific error types and values. -// -// Having dedicated error types for a module provides several benefits: -// - Enables error handling specific to this module's domain -// - Allows for programmatic error type checking -// - Improves error message consistency -// - Makes debugging and troubleshooting easier -// -// Example: -// -// if err := processExample(); err != nil { -// if errors.Is(err, example.ErrExample) { -// // Handle specific example error -// } -// // Handle other errors -// } -var ( - // ErrExample is a predefined error for the example module. - // - // This demonstrates how to create module-specific errors that can be - // used throughout the codebase. In a real application, you might have - // multiple error types for different error conditions. - // - // Usage: - // return fmt.Errorf("failed to process: %w", example.ErrExample) - // - // Checking: - // if errors.Is(err, example.ErrExample) { - // // Handle example error - // } - ErrExample = errors.New("example error") -) diff --git a/internal/example/metrics.go b/internal/example/metrics.go deleted file mode 100644 index fc35f80..0000000 --- a/internal/example/metrics.go +++ /dev/null @@ -1,72 +0,0 @@ -package example - -import ( - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -// Metrics handles Prometheus metrics collection for the example module. -// -// This struct encapsulates all Prometheus metrics related to the example module. -// Having a dedicated metrics struct provides a clean way to organize and manage -// metrics, making it easier to add new metrics and maintain existing ones. -// -// Metrics are important for: -// - Monitoring application health and performance -// - Tracking business metrics and KPIs -// - Setting up alerts and dashboards -// - Debugging and troubleshooting issues -// -// Example: -// -// metrics := example.NewMetrics() -// metrics.IncTotal() // Increment the counter -type Metrics struct { - // totalCounter is a Prometheus counter that tracks the total number - // of examples processed or created by this module. - // - // Counters are used for values that only increase, such as request counts, - // error counts, or completed operations. - totalCounter prometheus.Counter -} - -// NewMetrics creates and initializes a new Metrics instance. -// -// This function serves as a constructor for the Metrics struct, initializing -// all the Prometheus metrics with their appropriate configuration. -// -// The metrics defined here are: -// - example_total: A counter that tracks the total number of examples -// -// Returns: -// - *Metrics: A pointer to the newly created Metrics instance -// -// Example: -// -// metrics := example.NewMetrics() -// // Use metrics in your service -// service := example.New(config, repo, metrics, logger) -func NewMetrics() *Metrics { - return &Metrics{ - totalCounter: promauto.NewCounter(prometheus.CounterOpts{ - Name: "example_total", - Help: "Total number of examples", - }), - } -} - -// IncTotal increments the total example counter. -// -// This method should be called whenever an example is processed, created, -// or any other event occurs that should be tracked by the total counter. -// -// Example: -// -// func (s *Service) ProcessExample() error { -// // Process the example -// s.metrics.IncTotal() // Record the metric -// return nil -// } -func (m *Metrics) IncTotal() { - m.totalCounter.Inc() -} diff --git a/internal/example/models.go b/internal/example/models.go deleted file mode 100644 index 302454c..0000000 --- a/internal/example/models.go +++ /dev/null @@ -1,32 +0,0 @@ -package example - -// exampleModel represents a data model used by the example module. -// -// This struct demonstrates how to define data models that might be used for -// database entities, DTOs (Data Transfer Objects), or other data structures -// that need to be serialized/deserialized or mapped to external systems. -// -// In a typical application: -// - Models represent the shape of your data -// - They often include tags for serialization (JSON, database, etc.) -// - They might include validation rules -// - They separate the internal representation from external APIs -// -// Example with tags: -// -// type exampleModel struct { -// ID int `json:"id" db:"id"` -// Name string `json:"name" db:"name"` -// CreatedAt time.Time `json:"created_at" db:"created_at"` -// } -// -// Note: This model is unexported (lowercase 'e') to indicate it's for -// internal use within the module. If it needed to be accessed from outside -// the module, it would be exported (uppercase 'E'). -type exampleModel struct { - // In a real application, this would contain fields that represent - // the data structure, possibly with tags for serialization, - // database mapping, validation, etc. - ID int - Value string -} diff --git a/internal/example/module.go b/internal/example/module.go deleted file mode 100644 index bc0c1c5..0000000 --- a/internal/example/module.go +++ /dev/null @@ -1,52 +0,0 @@ -package example - -import ( - "github.com/go-core-fx/logger" - "go.uber.org/fx" -) - -// Module creates and returns an FX module for the example package. -// -// This function defines how the example module should be wired into an application -// using the Uber FX dependency injection framework. It specifies all the components -// that make up the module and how they depend on each other. -// -// The module includes: -// - A named logger for structured logging -// - A repository for data access (provided privately) -// - A service for business logic (provided publicly) -// -// FX will automatically resolve dependencies and inject them where needed. -// For example, the Service depends on Config, Repository, Metrics, and Logger, -// so FX will ensure these are available when creating the Service. -// -// Usage: -// -// app := fx.New( -// example.Module(), -// // other modules... -// ) -// -// // The Service can then be injected into other components: -// fx.Invoke(func(service *example.Service) { -// // Use the service -// }) -func Module() fx.Option { - return fx.Module( - "example", - // Add a named logger for this module - logger.WithNamedLogger("example"), - - // Provide the metrics collector as a private dependency - // This means it can only be used within this module - fx.Provide(NewMetrics, fx.Private), - - // Provide the repository as a private dependency - // This means it can only be used within this module - fx.Provide(NewRepository, fx.Private), - - // Provide the service as a public dependency - // This means it can be injected into other modules - fx.Provide(New), - ) -} diff --git a/internal/example/repository.go b/internal/example/repository.go deleted file mode 100644 index 6754f76..0000000 --- a/internal/example/repository.go +++ /dev/null @@ -1,54 +0,0 @@ -package example - -import "sync" - -// Repository handles data access operations for the example module. -// -// This struct represents the repository layer in a clean architecture pattern. -// The repository is responsible for all data access operations, abstracting -// the details of data storage and retrieval from the rest of the application. -// -// Benefits of using a repository pattern: -// - Separates data access logic from business logic -// - Makes it easier to switch data sources (e.g., from SQL to NoSQL) -// - Centralizes data access operations -// - Improves testability by allowing mock repositories -// -// In a real application, this struct would contain methods for: -// - Creating, reading, updating, and deleting (CRUD) data -// - Querying data with various filters -// - Handling transactions -// - Mapping between domain entities and data models -type Repository struct { - // In a real application, this would contain fields like: - // - db *sql.DB (for SQL databases) - // - client *mongo.Client (for MongoDB) - // - cache *redis.Client (for caching) - // - logger *zap.Logger (for logging) - items []exampleModel - mu sync.Mutex -} - -// NewRepository creates and initializes a new Repository instance. -// -// This function serves as a constructor for the Repository struct. In a real -// application, it would typically accept dependencies like database connections, -// clients, or configuration needed to initialize the repository. -// -// Returns: -// - *Repository: A pointer to the newly created Repository instance -// -// Example: -// -// repo := example.NewRepository() -// // Use the repository in your service -// service := example.New(config, repo, metrics, logger) -func NewRepository() *Repository { - return &Repository{} -} - -func (r *Repository) Add(item Example) { - r.mu.Lock() - defer r.mu.Unlock() - r.items = append(r.items, exampleModel{ID: len(r.items) + 1, Value: item.Value}) -} diff --git a/internal/example/service.go b/internal/example/service.go deleted file mode 100644 index 236eeaf..0000000 --- a/internal/example/service.go +++ /dev/null @@ -1,67 +0,0 @@ -package example - -import "go.uber.org/zap" - -// Service implements the business logic for the example module. -// -// This struct represents the service layer in a clean architecture pattern. -// The service is responsible for implementing business rules, orchestrating -// operations between different components, and exposing functionality to -// the rest of the application. -// -// The Service depends on: -// - Config: For configuration parameters -// - Repository: For data access operations -// - Metrics: For collecting and reporting metrics -// - Logger: For structured logging -// -// In a real application, this struct would contain methods for: -// - Business operations and workflows -// - Coordinating between repositories and other services -// - Enforcing business rules and validation -// - Handling errors and logging -type Service struct { - // config holds the configuration for the service - config Config - - // examples provides access to data operations - examples *Repository - - // metrics is used for collecting and reporting metrics - metrics *Metrics - - // logger is used for structured logging - logger *zap.Logger -} - -// New creates and initializes a new Service instance. -// -// This function serves as a constructor for the Service struct, accepting -// all its dependencies as parameters. This approach, known as dependency -// injection, makes the code more testable and maintainable. -// -// Parameters: -// - config: Configuration for the service -// - examples: Repository for data access -// - metrics: Metrics collector for monitoring -// - logger: Logger for structured logging -// -// Returns: -// - *Service: A pointer to the newly created Service instance -// -// Example: -// -// config := example.Config{Example: "demo"} -// repo := example.NewRepository() -// metrics := example.NewMetrics() -// logger, _ := zap.NewProduction() -// -// service := example.New(config, repo, metrics, logger) -func New(config Config, examples *Repository, metrics *Metrics, logger *zap.Logger) *Service { - return &Service{ - config: config, - examples: examples, - metrics: metrics, - logger: logger, - } -} diff --git a/internal/server/auth/dto.go b/internal/server/auth/dto.go index c9c9b19..2b1d21a 100644 --- a/internal/server/auth/dto.go +++ b/internal/server/auth/dto.go @@ -36,6 +36,7 @@ type LoginResponse struct { type UserResponseDTO struct { ID int64 `json:"id"` Email string `json:"email"` + Name string `json:"name"` Role users.Role `json:"role"` Status users.Status `json:"status"` CreatedAt time.Time `json:"created_at"` @@ -55,6 +56,7 @@ func toUserResponseDTO(u *users.User) UserResponseDTO { return UserResponseDTO{ ID: u.ID, Email: u.Email, + Name: u.Name, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt, diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index e97b2f1..657ef4b 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -1047,7 +1047,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Admin can list all users with optional status filter and pagination.", + "description": "List all users with optional status filter.", "consumes": [ "application/json" ], @@ -1055,7 +1055,8 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Admin" + "Admin", + "Users" ], "summary": "List all users", "parameters": [ @@ -1117,6 +1118,63 @@ const docTemplate = `{ } } }, + "/users/search": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Search active users by name. Accessible for all authorized users.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Search active users by name", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "query", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Active users list", + "schema": { + "$ref": "#/definitions/dto.UserBriefList" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, "/users/{id}": { "patch": { "security": [ @@ -1132,7 +1190,8 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Admin" + "Admin", + "Users" ], "summary": "Update user", "parameters": [ @@ -1267,6 +1326,9 @@ const docTemplate = `{ "id": { "type": "integer" }, + "name": { + "type": "string" + }, "role": { "$ref": "#/definitions/users.Role" }, @@ -1285,17 +1347,31 @@ const docTemplate = `{ "created_at": { "type": "string" }, - "email": { - "type": "string" - }, "id": { "type": "integer" }, + "name": { + "type": "string" + }, "role": { "type": "string" } } }, + "dto.UserBriefList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserBrief" + } + }, + "total": { + "type": "integer" + } + } + }, "fiberfx.ErrorResponse": { "type": "object", "properties": { @@ -1658,6 +1734,10 @@ const docTemplate = `{ "type": "integer", "example": 42 }, + "name": { + "type": "string", + "example": "user" + }, "role": { "enum": [ "admin", diff --git a/internal/server/dto/response.go b/internal/server/dto/response.go index 75aa903..8fb5a3d 100644 --- a/internal/server/dto/response.go +++ b/internal/server/dto/response.go @@ -1,11 +1,39 @@ package dto +import ( + "time" + + "github.com/bit-issues/backend/internal/users" + "github.com/samber/lo" +) + // UserBrief represents a minimal user profile for task author/assignee. // // @Description Minimal user information for task relationships. type UserBrief struct { ID int64 `json:"id"` - Email string `json:"email"` + Name string `json:"name"` Role string `json:"role"` CreatedAt string `json:"created_at"` } + +func ToUserBrief(u *users.User) UserBrief { + return UserBrief{ + ID: u.ID, + Name: u.Name, + Role: string(u.Role), + CreatedAt: u.CreatedAt.Format(time.RFC3339), + } +} + +type UserBriefList struct { + Items []UserBrief `json:"items"` + Total int `json:"total"` +} + +func ToUserBriefList(items []users.User, total int) UserBriefList { + return UserBriefList{ + Items: lo.Map(items, func(u users.User, _ int) UserBrief { return ToUserBrief(&u) }), + Total: total, + } +} diff --git a/internal/server/tasks/dto.go b/internal/server/tasks/dto.go index 876a7bc..a23d719 100644 --- a/internal/server/tasks/dto.go +++ b/internal/server/tasks/dto.go @@ -110,7 +110,7 @@ func newTaskResponse(task *tasks.Task) TaskResponse { if task.AssigneeID != nil { assignee = &dto.UserBrief{ ID: *task.AssigneeID, - Email: "", + Name: "", Role: "", CreatedAt: "", } @@ -126,7 +126,7 @@ func newTaskResponse(task *tasks.Task) TaskResponse { Status: string(task.Status), Author: dto.UserBrief{ ID: task.AuthorID, - Email: "", + Name: "", Role: "", CreatedAt: "", }, diff --git a/internal/server/tasks/dto_attachments.go b/internal/server/tasks/dto_attachments.go index 522ff2c..9f5dc98 100644 --- a/internal/server/tasks/dto_attachments.go +++ b/internal/server/tasks/dto_attachments.go @@ -59,7 +59,7 @@ func toConfirmResponse(attachment *attachments.Attachment, downloadURL string) A UploadedAt: attachment.UploadedAt.UTC().Format(time.RFC3339), UploadedBy: dto.UserBrief{ ID: attachment.UploadedBy, - Email: "", + Name: "", Role: "", CreatedAt: "", }, @@ -75,7 +75,7 @@ func toAttachmentResponse(item attachments.AttachmentWithURL) AttachmentResponse DownloadURL: item.DownloadURL, UploadedBy: dto.UserBrief{ ID: item.UploadedBy, - Email: "", + Name: "", Role: "", CreatedAt: "", }, diff --git a/internal/server/tasks/dto_comments.go b/internal/server/tasks/dto_comments.go index 8b06d1c..4a5889c 100644 --- a/internal/server/tasks/dto_comments.go +++ b/internal/server/tasks/dto_comments.go @@ -38,7 +38,7 @@ func toCommentResponse(comment *comments.Comment) CommentResponse { ID: comment.ID, Author: dto.UserBrief{ ID: comment.AuthorID, - Email: "", + Name: "", Role: "", CreatedAt: "", }, diff --git a/internal/server/users/dto.go b/internal/server/users/dto.go index 5e266bf..334d5fb 100644 --- a/internal/server/users/dto.go +++ b/internal/server/users/dto.go @@ -3,7 +3,9 @@ package users import ( "time" + "github.com/bit-issues/backend/internal/server/dto" "github.com/bit-issues/backend/internal/users" + "github.com/samber/lo" ) const ( @@ -34,6 +36,7 @@ type UpdateRequest struct { type GetResponse struct { ID int64 `json:"id" example:"42"` Email string `json:"email" example:"user@example.com"` + Name string `json:"name" example:"user"` Role users.Role `json:"role" example:"user" enums:"admin,user"` Status users.Status `json:"status" example:"active" enums:"pending,active,blocked"` CreatedAt time.Time `json:"created_at" example:"2026-04-06T10:00:00Z"` @@ -48,6 +51,24 @@ type ListResponse struct { Total int `json:"total" example:"42"` } +// SearchQuery represents query parameters for searching users by name. +type SearchQuery struct { + dto.PaginationQuery + + Query string `query:"query" validate:"required,min=1,max=255"` +} + +func defaultSearchQuery() SearchQuery { + return SearchQuery{ + PaginationQuery: dto.PaginationQuery{ + Limit: defaultLimit, + Offset: 0, + }, + + Query: "", + } +} + func defaultListFilter() ListFilter { return ListFilter{ Status: nil, @@ -62,9 +83,17 @@ func toGetResponse(u *users.User) GetResponse { return GetResponse{ ID: u.ID, Email: u.Email, + Name: u.Name, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } } + +func toListResponse(items []users.User, total int) ListResponse { + return ListResponse{ + Items: lo.Map(items, func(u users.User, _ int) GetResponse { return toGetResponse(&u) }), + Total: total, + } +} diff --git a/internal/server/users/handler.go b/internal/server/users/handler.go index 57bd481..b9a161a 100644 --- a/internal/server/users/handler.go +++ b/internal/server/users/handler.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/server/dto" "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" "github.com/bit-issues/backend/internal/users" "github.com/go-core-fx/fiberfx/handler" @@ -33,24 +34,26 @@ func NewHandler(usersSvc *users.Service, jwtSvc *jwt.Service, validate *validato } func (h *Handler) Register(r fiber.Router) { - admin := r.Group( + group := r.Group( "/users", h.errorsHandler, - jwtauth.WithRole(users.RoleAdmin), ) // GET /users - list all users with optional filters - admin.Get("/", h.handleList) + group.Get("/", jwtauth.WithRole(users.RoleAdmin), h.handleList) + + // GET /users/search - search active users by name + group.Get("/search", h.handleSearch) // PATCH /users/{id} - update user status/role - admin.Patch("/:id", validation.DecorateWithBodyEx(h.Validator, h.handleUpdate)) + group.Patch("/:id", jwtauth.WithRole(users.RoleAdmin), validation.DecorateWithBodyEx(h.Validator, h.handleUpdate)) } // handleList returns a paginated list of users with optional status filter. // // @Summary List all users -// @Description Admin can list all users with optional status filter and pagination. -// @Tags Admin +// @Description List all users with optional status filter. +// @Tags Admin, Users // @Accept json // @Produce json // @Security BearerAuth @@ -80,23 +83,46 @@ func (h *Handler) handleList(c *fiber.Ctx) error { return fmt.Errorf("failed to list users: %w", err) } - // Convert to response DTOs - items := make([]GetResponse, 0, len(usersList)) - for _, u := range usersList { - items = append(items, toGetResponse(&u)) + return c.JSON(toListResponse(usersList, total)) +} + +// handleSearch returns a paginated list of active users filtered by user name prefix. +// +// @Summary Search active users by name +// @Description Search active users by name. Accessible for all authorized users. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param query query string true "Search query" +// @Param limit query int false "Page limit" default(20) +// @Param offset query int false "Page offset" default(0) +// @Success 200 {object} dto.UserBriefList "Active users list" +// @Failure 401 {object} fiberfx.ErrorResponse "Unauthorized" +// @Router /users/search [get] +func (h *Handler) handleSearch(c *fiber.Ctx) error { + query := defaultSearchQuery() + if err := h.QueryParserValidator(c, &query); err != nil { + return fmt.Errorf("failed to parse query: %w", err) + } + + usersList, total, err := h.usersSvc.Search( + c.Context(), + query.Query, + users.NewPagination(query.Limit, query.Offset), + ) + if err != nil { + return fmt.Errorf("failed to search users: %w", err) } - return c.JSON(ListResponse{ - Items: items, - Total: total, - }) + return c.JSON(dto.ToUserBriefList(usersList, total)) } // handleUpdate updates user status and/or role by admin. // // @Summary Update user // @Description Admin can update user status (active/blocked/pending) and role (admin/user). -// @Tags Admin +// @Tags Admin, Users // @Accept json // @Produce json // @Security BearerAuth @@ -158,6 +184,8 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) case errors.Is(err, users.ErrNotActive): return fiber.NewError(fiber.StatusForbidden, err.Error()) + case errors.Is(err, users.ErrEmptyQuery): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) default: return err //nolint:wrapcheck // err is already wrapped diff --git a/internal/users/domain.go b/internal/users/domain.go index e131365..728705b 100644 --- a/internal/users/domain.go +++ b/internal/users/domain.go @@ -41,6 +41,7 @@ type UserUpdate struct { type User struct { ID int64 Email string + Name string Role Role Status Status CreatedAt time.Time diff --git a/internal/users/errors.go b/internal/users/errors.go index 8e0068b..a3b38f8 100644 --- a/internal/users/errors.go +++ b/internal/users/errors.go @@ -7,4 +7,5 @@ var ( ErrEmailAlreadyUsed = errors.New("email already used") ErrInvalidCredential = errors.New("invalid credentials") ErrNotActive = errors.New("user is not active") + ErrEmptyQuery = errors.New("empty query") ) diff --git a/internal/users/models.go b/internal/users/models.go index 31f7499..bfb957b 100644 --- a/internal/users/models.go +++ b/internal/users/models.go @@ -1,6 +1,7 @@ package users import ( + "strings" "time" "github.com/go-core-fx/bunfx" @@ -15,6 +16,7 @@ type userModel struct { ID int64 `bun:"id,pk,autoincrement"` Email string `bun:"email,notnull,unique"` + Name string `bun:"name,notnull"` PasswordHash string `bun:"password_hash,notnull"` Role Role `bun:"role,notnull,default:'user'"` Status Status `bun:"status,notnull,default:'pending'"` @@ -32,6 +34,7 @@ func newUserModel(u UserInput, passwordHash string) *userModel { ID: 0, Email: u.Email, + Name: emailUsername(u.Email), PasswordHash: passwordHash, Role: u.Role, Status: StatusPending, @@ -47,6 +50,7 @@ func (m *userModel) toDomain() *UserWithPasswordHash { User: User{ ID: m.ID, Email: m.Email, + Name: m.Name, Role: m.Role, Status: m.Status, CreatedAt: m.CreatedAt, @@ -55,3 +59,11 @@ func (m *userModel) toDomain() *UserWithPasswordHash { PasswordHash: m.PasswordHash, } } + +func emailUsername(email string) string { + // Best-effort parsing; email is expected to contain '@' due to validation elsewhere. + if local, _, ok := strings.Cut(email, "@"); ok && local != "" { + return local + } + return email +} diff --git a/internal/users/repository.go b/internal/users/repository.go index e5edb23..ce300ee 100644 --- a/internal/users/repository.go +++ b/internal/users/repository.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "github.com/bit-issues/backend/internal/db" "github.com/uptrace/bun" @@ -101,6 +102,36 @@ func (r *Repository) List( return users, total, nil } +func (r *Repository) Search( + ctx context.Context, + q string, + pagination *Pagination, +) ([]User, int, error) { + // Escape LIKE metacharacters in user input so they are matched literally. + escaper := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + pattern := escaper.Replace(strings.ToLower(q)) + "%" + + models := make([]userModel, 0) + query := r.db.NewSelect().Model(&models). + Where("status = ?", StatusActive). + Where("name LIKE ?", pattern). + OrderBy("name", bun.OrderAsc) + + query = pagination.Apply(query) + + total, err := query.ScanAndCount(ctx) + if err != nil { + return nil, 0, fmt.Errorf("failed to search users by name: %w", err) + } + + users := make([]User, 0, len(models)) + for _, model := range models { + users = append(users, model.toDomain().User) + } + + return users, total, nil +} + func (r *Repository) Update(ctx context.Context, id int64, update UserUpdate) error { if update.IsEmpty() { return nil diff --git a/internal/users/service.go b/internal/users/service.go index a34da82..e9b5303 100644 --- a/internal/users/service.go +++ b/internal/users/service.go @@ -58,6 +58,14 @@ func (s *Service) List(ctx context.Context, status *Status, role *Role, paginati return s.repo.List(ctx, status, role, pagination) } +func (s *Service) Search(ctx context.Context, q string, pagination *Pagination) ([]User, int, error) { + if q == "" { + return nil, 0, ErrEmptyQuery + } + + return s.repo.Search(ctx, q, pagination) +} + func (s *Service) Update(ctx context.Context, id int64, update UserUpdate) error { return s.repo.Update(ctx, id, update) } diff --git a/requests.http b/requests.http index cb78f3c..07c8765 100644 --- a/requests.http +++ b/requests.http @@ -65,6 +65,13 @@ Authorization: Bearer {{accessToken}} GET {{baseURL}}/users?status=pending Authorization: Bearer {{adminAccessToken}} +### +# Search active users by name (authenticated users) +# Requires authentication JWT token in Authorization header +# Query params: name=, limit, offset +GET {{baseURL}}/users/search?name=user +Authorization: Bearer {{accessToken}} + ### # Update user status/role (admin only) # Requires admin JWT token in Authorization header