chore: build
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s

This commit is contained in:
Xor290
2026-09-20 12:18:33 +02:00
parent 8f4c7fa47a
commit 919c807004
174 changed files with 9669 additions and 1308 deletions
+9
View File
@@ -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")
)
+36 -11
View File
@@ -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"})
+1 -1
View File
@@ -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"`
+21 -9
View File
@@ -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"
+1
View File
@@ -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)
}
+54 -10
View File
@@ -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