chore: build
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package users
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrAdminLimitReached = errors.New("admin limit reached")
|
||||
ErrNotFound = errors.New("user not found")
|
||||
ErrUsernameTaken = errors.New("username already in use")
|
||||
)
|
||||
@@ -18,13 +18,13 @@ func NewHandler(service *Service) *Handler {
|
||||
|
||||
type userResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func toResponse(u *User) userResponse {
|
||||
return userResponse{ID: u.ID, Email: u.Email, Role: u.Role, IsActive: u.IsActive}
|
||||
return userResponse{ID: u.ID, Username: u.Username, Role: u.Role, IsActive: u.IsActive}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
@@ -41,7 +41,7 @@ func (h *Handler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=12"`
|
||||
Role string `json:"role" binding:"required,oneof=admin customer"`
|
||||
}
|
||||
@@ -52,11 +52,17 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.Create(c.Request.Context(), req.Email, req.Password, req.Role)
|
||||
user, err := h.service.Create(c.Request.Context(), req.Username, req.Password, req.Role)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
|
||||
if errors.Is(err, ErrAdminLimitReached) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "admin limit reached"})
|
||||
}
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already in use"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrCustomerAccountsDisabled) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "customer accounts are disabled: enable customer login or registration first"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||
@@ -83,8 +89,27 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, toResponse(user))
|
||||
}
|
||||
|
||||
func (h *Handler) GetRole(c *gin.Context) {
|
||||
role := c.Param("role")
|
||||
|
||||
if role == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.service.CountByRole(c.Request.Context(), role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to count users",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, count)
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
@@ -100,14 +125,14 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Email, req.IsActive)
|
||||
user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Username, req.IsActive)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already in use"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
||||
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Username string `gorm:"uniqueIndex;not null"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
Role string `gorm:"not null;default:admin"`
|
||||
IsActive bool `gorm:"not null;default:true"`
|
||||
|
||||
@@ -9,16 +9,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("user not found")
|
||||
var ErrEmailTaken = errors.New("email already in use")
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, user *User) error
|
||||
FindByEmail(ctx context.Context, email string) (*User, error)
|
||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*User, error)
|
||||
List(ctx context.Context) ([]*User, error)
|
||||
Update(ctx context.Context, user *User) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
CountByRole(ctx context.Context, role string) (int64, error)
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
@@ -32,16 +30,16 @@ func NewRepository(db *gorm.DB) Repository {
|
||||
func (r *gormRepository) Create(ctx context.Context, user *User) error {
|
||||
if err := r.db.WithContext(ctx).Create(user).Error; err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrEmailTaken
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||||
func (r *gormRepository) FindByUsername(ctx context.Context, username string) (*User, error) {
|
||||
var user User
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
||||
err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -63,6 +61,20 @@ func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*User, err
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) CountByRole(ctx context.Context, role string) (int64, error) {
|
||||
var count int64
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&User{}).
|
||||
Where("role = ?", role).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
func (r *gormRepository) List(ctx context.Context) ([]*User, error) {
|
||||
var list []*User
|
||||
if err := r.db.WithContext(ctx).Order("created_at asc").Find(&list).Error; err != nil {
|
||||
@@ -74,7 +86,7 @@ func (r *gormRepository) List(ctx context.Context) ([]*User, error) {
|
||||
func (r *gormRepository) Update(ctx context.Context, user *User) error {
|
||||
err := r.db.WithContext(ctx).Save(user).Error
|
||||
if isUniqueViolation(err) {
|
||||
return ErrEmailTaken
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +103,7 @@ func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique-constraint
|
||||
// violation (SQLSTATE 23505), e.g. a duplicate email.
|
||||
// violation (SQLSTATE 23505), e.g. a duplicate username.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
|
||||
@@ -11,4 +11,5 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.Handl
|
||||
group.GET("/:id", h.Get)
|
||||
group.PUT("/:id", h.Update)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
group.GET("/verify/:role", h.GetRole)
|
||||
}
|
||||
|
||||
@@ -2,19 +2,34 @@ package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/platform/config"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
// ErrCustomerAccountsDisabled is returned by Create when trying to create a
|
||||
// customer-role user while neither customer login nor registration is
|
||||
// enabled -- such an account would have no way to sign in.
|
||||
var ErrCustomerAccountsDisabled = errors.New("customer accounts are disabled")
|
||||
|
||||
// AccountsGate lets the users module check the site-wide customer-accounts
|
||||
// toggles without importing the site module directly.
|
||||
type AccountsGate interface {
|
||||
CustomerAccountsAvailable(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
type Service struct {
|
||||
repo Repository
|
||||
gate AccountsGate
|
||||
cfg config.SeedConfig
|
||||
}
|
||||
|
||||
func NewService(repo Repository, gate AccountsGate, cfg config.SeedConfig) *Service {
|
||||
return &Service{repo: repo, gate: gate, cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]*User, error) {
|
||||
@@ -25,14 +40,41 @@ func (s *Service) FindByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||||
return s.repo.FindByEmail(ctx, email)
|
||||
func (s *Service) CountByRole(ctx context.Context, role string) (int64, error) {
|
||||
return s.repo.CountByRole(ctx, role)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, email, password, role string) (*User, error) {
|
||||
func (s *Service) FindByUsername(ctx context.Context, username string) (*User, error) {
|
||||
return s.repo.FindByUsername(ctx, username)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, username, password, role string) (*User, error) {
|
||||
if role != RoleAdmin && role != RoleCustomer {
|
||||
return nil, fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
|
||||
if role == RoleAdmin {
|
||||
count, err := s.repo.CountByRole(ctx, RoleAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if count >= s.cfg.AdminNumber {
|
||||
return nil, ErrAdminLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
if role == RoleCustomer {
|
||||
available, err := s.gate.CustomerAccountsAvailable(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !available {
|
||||
return nil, ErrCustomerAccountsDisabled
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
@@ -40,24 +82,26 @@ func (s *Service) Create(ctx context.Context, email, password, role string) (*Us
|
||||
|
||||
user := &User{
|
||||
ID: uuid.New(),
|
||||
Email: email,
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the mutable, non-security fields of a user.
|
||||
func (s *Service) UpdateProfile(ctx context.Context, id uuid.UUID, email string, isActive bool) (*User, error) {
|
||||
func (s *Service) UpdateProfile(ctx context.Context, id uuid.UUID, username string, isActive bool) (*User, error) {
|
||||
user, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email
|
||||
user.Username = username
|
||||
user.IsActive = isActive
|
||||
if err := s.repo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user