This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
package users
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type userResponse struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
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}
}
func (h *Handler) List(c *gin.Context) {
list, err := h.service.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
return
}
resp := make([]userResponse, 0, len(list))
for _, u := range list {
resp = append(resp, toResponse(u))
}
c.JSON(http.StatusOK, gin.H{"users": resp})
}
type createUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=12"`
Role string `json:"role" binding:"required,oneof=admin customer"`
}
func (h *Handler) Create(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
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)
if err != nil {
if errors.Is(err, ErrEmailTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
c.JSON(http.StatusCreated, toResponse(user))
}
func (h *Handler) Get(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.service.FindByID(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"})
return
}
c.JSON(http.StatusOK, toResponse(user))
}
type updateUserRequest struct {
Email string `json:"email" binding:"required,email"`
IsActive bool `json:"is_active"`
}
func (h *Handler) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req updateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Email, 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"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
return
}
c.JSON(http.StatusOK, toResponse(user))
}
func (h *Handler) Delete(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
c.Status(http.StatusNoContent)
}
+27
View File
@@ -0,0 +1,27 @@
// Package users owns the user account entity and its CRUD operations.
// The auth module depends on this package to look up credentials, but
// never owns or duplicates the User model itself.
package users
import (
"time"
"github.com/google/uuid"
)
const (
RoleAdmin = "admin"
RoleCustomer = "customer"
)
type User struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
Email string `gorm:"uniqueIndex;not null"`
PasswordHash string `gorm:"not null"`
Role string `gorm:"not null;default:admin"`
IsActive bool `gorm:"not null;default:true"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (User) TableName() string { return "users" }
@@ -0,0 +1,98 @@
package users
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"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)
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
}
type gormRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &gormRepository{db: db}
}
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 err
}
return nil
}
func (r *gormRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &user, nil
}
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*User, error) {
var user User
err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &user, 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 {
return nil, err
}
return list, nil
}
func (r *gormRepository) Update(ctx context.Context, user *User) error {
err := r.db.WithContext(ctx).Save(user).Error
if isUniqueViolation(err) {
return ErrEmailTaken
}
return err
}
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
res := r.db.WithContext(ctx).Delete(&User{}, "id = ?", id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// isUniqueViolation reports whether err is a Postgres unique-constraint
// violation (SQLSTATE 23505), e.g. a duplicate email.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+14
View File
@@ -0,0 +1,14 @@
package users
import "github.com/gin-gonic/gin"
// RegisterAdminRoutes mounts the user-management endpoints under the given
// group, guarded by the requireAdmin middleware supplied by the caller.
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
group := rg.Group("/admin/users", requireAdmin)
group.GET("", h.List)
group.POST("", h.Create)
group.GET("/:id", h.Get)
group.PUT("/:id", h.Update)
group.DELETE("/:id", h.Delete)
}
+83
View File
@@ -0,0 +1,83 @@
package users
import (
"context"
"fmt"
"github.com/google/uuid"
"backend/internal/platform/security"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(ctx context.Context) ([]*User, error) {
return s.repo.List(ctx)
}
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) Create(ctx context.Context, email, password, role string) (*User, error) {
if role != RoleAdmin && role != RoleCustomer {
return nil, fmt.Errorf("invalid role %q", role)
}
hash, err := security.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
user := &User{
ID: uuid.New(),
Email: email,
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) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
user.Email = email
user.IsActive = isActive
if err := s.repo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return err
}
hash, err := security.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
user.PasswordHash = hash
return s.repo.Update(ctx, user)
}
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
return s.repo.Delete(ctx, id)
}