first commit
This commit is contained in:
72
internal/modules/user/dto.go
Normal file
72
internal/modules/user/dto.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"cardverse/internal/pkg/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=150"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=user admin superadmin"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=2,max=100"`
|
||||
Email string `json:"email" binding:"omitempty,email,max=150"`
|
||||
AvatarURL string `json:"avatar_url" binding:"omitempty,max=500"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=user admin superadmin"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active suspended unverified"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=2,max=100"`
|
||||
AvatarURL string `json:"avatar_url" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
type ListUserQuery struct {
|
||||
Page int `form:"page,default=1" binding:"omitempty,min=1"`
|
||||
Limit int `form:"limit,default=20" binding:"omitempty,min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
Role string `form:"role" binding:"omitempty"`
|
||||
Status string `form:"status" binding:"omitempty"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
IsEmailVerified bool `json:"is_email_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *uint `json:"created_by,omitempty"`
|
||||
UpdatedBy *uint `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
func ToUserResponse(u User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
AvatarURL: utils.FormatMediaURLPtr(u.AvatarURL),
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
IsEmailVerified: u.IsEmailVerified,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
CreatedBy: u.CreatedBy,
|
||||
UpdatedBy: u.UpdatedBy,
|
||||
}
|
||||
}
|
||||
|
||||
func ToUserResponseList(users []User) []UserResponse {
|
||||
result := make([]UserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, ToUserResponse(u))
|
||||
}
|
||||
return result
|
||||
}
|
||||
315
internal/modules/user/handler.go
Normal file
315
internal/modules/user/handler.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
}
|
||||
|
||||
func NewHandler(service Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) GetProfile(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
|
||||
return
|
||||
}
|
||||
userID := userIDVal.(uint)
|
||||
|
||||
u, err := h.service.GetByID(userID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil profil user", ToUserResponse(*u))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
|
||||
return
|
||||
}
|
||||
userID := userIDVal.(uint)
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.UpdateProfile(userID, req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengupdate profil", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "profil berhasil diupdate", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) UploadAvatar(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
|
||||
return
|
||||
}
|
||||
userID := userIDVal.(uint)
|
||||
|
||||
fileHeader, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file avatar wajib diunggah (form field: avatar)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Batas ukuran file 5 MB
|
||||
if fileHeader.Size > 5*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file maksimal 5MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membaca file avatar", err.Error())
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Dekode gambar (support JPG, JPEG, PNG, GIF)
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file yang diunggah bukan format gambar yang valid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Simpan ke direktori: ./public/images/avatars
|
||||
uploadDir := "./public/images/avatars"
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori penyimpanan avatar", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("avatar_%d_%d.png", userID, time.Now().UnixNano())
|
||||
filepathDst := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(filepathDst)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat file avatar", err.Error())
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Encode & kompresi ke format PNG
|
||||
encoder := png.Encoder{CompressionLevel: png.BestCompression}
|
||||
if err := encoder.Encode(out, img); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengompresi gambar ke format PNG", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
avatarURL := fmt.Sprintf("/images/avatars/%s", filename)
|
||||
updatedUser, err := h.service.UpdateAvatar(userID, avatarURL)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal memperbarui avatar user", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "avatar berhasil diunggah", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
creatorID := getContextUserID(c)
|
||||
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
newUser, err := h.service.Create(creatorID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
response.Error(c, http.StatusConflict, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrForbiddenRoleCreation) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat user", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "user berhasil dibuat", ToUserResponse(*newUser))
|
||||
}
|
||||
|
||||
func (h *Handler) GetAll(c *gin.Context) {
|
||||
var query ListUserQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
users, total, err := h.service.GetAllPaginated(query)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengambil data user", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pagination := response.NewPagination(query.Page, query.Limit, total)
|
||||
response.SuccessWithPagination(c, http.StatusOK, "berhasil mengambil data user", ToUserResponseList(users), pagination)
|
||||
}
|
||||
|
||||
func (h *Handler) GetByID(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data user", ToUserResponse(*u))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
modifierID := getContextUserIDValue(c)
|
||||
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.Update(modifierID, id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
response.Error(c, http.StatusConflict, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrForbiddenRoleCreation) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "user berhasil diupdate", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Suspend(c *gin.Context) {
|
||||
modifierID := getContextUserIDValue(c)
|
||||
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.SuspendUser(modifierID, id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "user berhasil disuspensi", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Unsuspend(c *gin.Context) {
|
||||
modifierID := getContextUserIDValue(c)
|
||||
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.UnsuspendUser(modifierID, id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "suspensi user berhasil dicabut", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(id); err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "user berhasil dihapus", nil)
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, error) {
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func getContextUserID(c *gin.Context) *uint {
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
if userID, ok := val.(uint); ok {
|
||||
return &userID
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getContextUserIDValue(c *gin.Context) uint {
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
if userID, ok := val.(uint); ok {
|
||||
return userID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
36
internal/modules/user/model.go
Normal file
36
internal/modules/user/model.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package user
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
|
||||
StatusActive = "active"
|
||||
StatusSuspended = "suspended"
|
||||
StatusUnverified = "unverified"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
Email string `json:"email" gorm:"type:varchar(150);uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"type:varchar(255)"`
|
||||
GoogleID *string `json:"google_id,omitempty" gorm:"type:varchar(255);uniqueIndex;default:null"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty" gorm:"type:varchar(500);default:null"`
|
||||
Role string `json:"role" gorm:"type:varchar(30);not null;default:'user';index"`
|
||||
Status string `json:"status" gorm:"type:varchar(30);not null;default:'active';index"`
|
||||
IsEmailVerified bool `json:"is_email_verified" gorm:"default:false"`
|
||||
VerificationToken string `json:"-" gorm:"type:varchar(255)"`
|
||||
ResetPasswordToken string `json:"-" gorm:"type:varchar(255)"`
|
||||
ResetPasswordExpiresAt *time.Time `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
CreatedBy *uint `json:"created_by" gorm:"default:null"`
|
||||
UpdatedBy *uint `json:"updated_by" gorm:"default:null"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
125
internal/modules/user/repository.go
Normal file
125
internal/modules/user/repository.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package user
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Repository interface {
|
||||
Create(u *User) error
|
||||
FindAll() ([]User, error)
|
||||
FindAllPaginated(page, limit int, query ListUserQuery) ([]User, int64, error)
|
||||
FindByID(id uint) (*User, error)
|
||||
FindByEmail(email string) (*User, error)
|
||||
FindByGoogleID(googleID string) (*User, error)
|
||||
FindByVerificationToken(token string) (*User, error)
|
||||
FindByResetToken(token string) (*User, error)
|
||||
Update(u *User) error
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
var userSelectColumns = []string{
|
||||
"id", "name", "email", "password", "role", "status", "avatar_url",
|
||||
"google_id", "is_email_verified", "verification_token",
|
||||
"reset_password_token", "reset_password_expires_at", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
func (r *repository) Create(u *User) error {
|
||||
return r.db.Create(u).Error
|
||||
}
|
||||
|
||||
func (r *repository) FindAll() ([]User, error) {
|
||||
var users []User
|
||||
err := r.db.Select(userSelectColumns).Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *repository) FindAllPaginated(page, limit int, queryParams ListUserQuery) ([]User, int64, error) {
|
||||
var users []User
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&User{}).Select(userSelectColumns)
|
||||
|
||||
if queryParams.Role != "" {
|
||||
query = query.Where("role = ?", queryParams.Role)
|
||||
}
|
||||
|
||||
if queryParams.Status != "" {
|
||||
query = query.Where("status = ?", queryParams.Status)
|
||||
}
|
||||
|
||||
if queryParams.Search != "" {
|
||||
pattern := "%" + queryParams.Search + "%"
|
||||
query = query.Where("name ILIKE ? OR email ILIKE ?", pattern, pattern)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
err := query.Order("id DESC").Limit(limit).Offset(offset).Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByID(id uint) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).First(&u, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByEmail(email string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("email = ?", email).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByGoogleID(googleID string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("google_id = ?", googleID).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByVerificationToken(token string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("verification_token = ?", token).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByResetToken(token string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("reset_password_token = ?", token).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) Update(u *User) error {
|
||||
return r.db.Save(u).Error
|
||||
}
|
||||
|
||||
func (r *repository) Delete(id uint) error {
|
||||
return r.db.Delete(&User{}, id).Error
|
||||
}
|
||||
84
internal/modules/user/repository_mock_test.go
Normal file
84
internal/modules/user/repository_mock_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package user
|
||||
|
||||
type mockRepository struct {
|
||||
createFunc func(u *User) error
|
||||
findAllFunc func() ([]User, error)
|
||||
findAllPaginatedFunc func(page, limit int, query ListUserQuery) ([]User, int64, error)
|
||||
findByIDFunc func(id uint) (*User, error)
|
||||
findByEmailFunc func(email string) (*User, error)
|
||||
findByGoogleIDFunc func(googleID string) (*User, error)
|
||||
findByVerificationTokenFunc func(token string) (*User, error)
|
||||
findByResetTokenFunc func(token string) (*User, error)
|
||||
updateFunc func(u *User) error
|
||||
deleteFunc func(id uint) error
|
||||
}
|
||||
|
||||
func (m *mockRepository) Create(u *User) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAll() ([]User, error) {
|
||||
if m.findAllFunc != nil {
|
||||
return m.findAllFunc()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAllPaginated(page, limit int, query ListUserQuery) ([]User, int64, error) {
|
||||
if m.findAllPaginatedFunc != nil {
|
||||
return m.findAllPaginatedFunc(page, limit, query)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByID(id uint) (*User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByEmail(email string) (*User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(email)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByGoogleID(googleID string) (*User, error) {
|
||||
if m.findByGoogleIDFunc != nil {
|
||||
return m.findByGoogleIDFunc(googleID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByVerificationToken(token string) (*User, error) {
|
||||
if m.findByVerificationTokenFunc != nil {
|
||||
return m.findByVerificationTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByResetToken(token string) (*User, error) {
|
||||
if m.findByResetTokenFunc != nil {
|
||||
return m.findByResetTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Update(u *User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Delete(id uint) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
internal/modules/user/routes.go
Normal file
37
internal/modules/user/routes.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"cardverse/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router *gin.RouterGroup, db *gorm.DB) {
|
||||
repo := NewRepository(db)
|
||||
svc := NewService(repo)
|
||||
handler := NewHandler(svc)
|
||||
|
||||
// Route khusus user yang sedang login untuk kelola profil sendiri
|
||||
me := router.Group("/users/me")
|
||||
me.Use(middleware.AuthRequired())
|
||||
{
|
||||
me.GET("", handler.GetProfile)
|
||||
me.PUT("", handler.UpdateProfile)
|
||||
me.POST("/avatar", handler.UploadAvatar)
|
||||
}
|
||||
|
||||
// Route khusus Admin/Superadmin untuk kelola semua data user
|
||||
admin := router.Group("/users")
|
||||
admin.Use(middleware.AuthRequired())
|
||||
admin.Use(middleware.RequireRoles(RoleAdmin, RoleSuperAdmin))
|
||||
{
|
||||
admin.GET("", handler.GetAll)
|
||||
admin.POST("", handler.Create)
|
||||
admin.GET("/:id", handler.GetByID)
|
||||
admin.PUT("/:id", handler.Update)
|
||||
admin.PUT("/:id/suspend", handler.Suspend)
|
||||
admin.PUT("/:id/unsuspend", handler.Unsuspend)
|
||||
admin.DELETE("/:id", handler.Delete)
|
||||
}
|
||||
}
|
||||
233
internal/modules/user/service.go
Normal file
233
internal/modules/user/service.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrEmailTaken = errors.New("email sudah terdaftar")
|
||||
var ErrUserNotFound = errors.New("user tidak ditemukan")
|
||||
var ErrUserSuspended = errors.New("akun anda sedang disuspensi")
|
||||
var ErrForbiddenRoleCreation = errors.New("hanya superadmin yang dapat membuat atau mengubah akun dengan role admin atau superadmin")
|
||||
|
||||
type Service interface {
|
||||
Create(creatorID *uint, req CreateUserRequest) (*User, error)
|
||||
GetAll() ([]User, error)
|
||||
GetAllPaginated(query ListUserQuery) ([]User, int64, error)
|
||||
GetByID(id uint) (*User, error)
|
||||
Update(modifierID uint, id uint, req UpdateUserRequest) (*User, error)
|
||||
UpdateProfile(userID uint, req UpdateProfileRequest) (*User, error)
|
||||
UpdateAvatar(userID uint, avatarURL string) (*User, error)
|
||||
SuspendUser(modifierID uint, id uint) (*User, error)
|
||||
UnsuspendUser(modifierID uint, id uint) (*User, error)
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) Service {
|
||||
return &service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *service) Create(creatorID *uint, req CreateUserRequest) (*User, error) {
|
||||
existing, _ := s.repo.FindByEmail(req.Email)
|
||||
if existing != nil {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = RoleUser
|
||||
}
|
||||
|
||||
// Batasan role: Hanya Superadmin yang boleh membuat akun dengan role admin atau superadmin
|
||||
if role == RoleAdmin || role == RoleSuperAdmin {
|
||||
if creatorID == nil {
|
||||
return nil, ErrForbiddenRoleCreation
|
||||
}
|
||||
creator, err := s.repo.FindByID(*creatorID)
|
||||
if err != nil || creator.Role != RoleSuperAdmin {
|
||||
return nil, ErrForbiddenRoleCreation
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newUser := &User{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Role: role,
|
||||
Status: StatusActive,
|
||||
IsEmailVerified: true,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(newUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
func (s *service) GetAll() ([]User, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
|
||||
func (s *service) GetAllPaginated(query ListUserQuery) ([]User, int64, error) {
|
||||
page := query.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
return s.repo.FindAllPaginated(page, limit, query)
|
||||
}
|
||||
|
||||
func (s *service) GetByID(id uint) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) Update(modifierID uint, id uint, req UpdateUserRequest) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
u.Name = req.Name
|
||||
}
|
||||
if req.Email != "" && req.Email != u.Email {
|
||||
existing, _ := s.repo.FindByEmail(req.Email)
|
||||
if existing != nil {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
u.Email = req.Email
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != req.AvatarURL {
|
||||
utils.DeleteLocalFile(*u.AvatarURL)
|
||||
}
|
||||
u.AvatarURL = &req.AvatarURL
|
||||
}
|
||||
if req.Role != "" && req.Role != u.Role {
|
||||
// Batasan role: Hanya Superadmin yang boleh mengubah role ke admin atau superadmin
|
||||
if req.Role == RoleAdmin || req.Role == RoleSuperAdmin {
|
||||
creator, err := s.repo.FindByID(modifierID)
|
||||
if err != nil || creator.Role != RoleSuperAdmin {
|
||||
return nil, ErrForbiddenRoleCreation
|
||||
}
|
||||
}
|
||||
u.Role = req.Role
|
||||
}
|
||||
if req.Status != "" {
|
||||
u.Status = req.Status
|
||||
}
|
||||
|
||||
u.UpdatedBy = &modifierID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdateProfile(userID uint, req UpdateProfileRequest) (*User, error) {
|
||||
u, err := s.repo.FindByID(userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
u.Name = req.Name
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != req.AvatarURL {
|
||||
utils.DeleteLocalFile(*u.AvatarURL)
|
||||
}
|
||||
u.AvatarURL = &req.AvatarURL
|
||||
}
|
||||
|
||||
u.UpdatedBy = &userID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdateAvatar(userID uint, avatarURL string) (*User, error) {
|
||||
u, err := s.repo.FindByID(userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// Hapus file avatar lama jika ada
|
||||
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != avatarURL {
|
||||
utils.DeleteLocalFile(*u.AvatarURL)
|
||||
}
|
||||
|
||||
u.AvatarURL = &avatarURL
|
||||
u.UpdatedBy = &userID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) SuspendUser(modifierID uint, id uint) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
u.Status = StatusSuspended
|
||||
u.UpdatedBy = &modifierID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) UnsuspendUser(modifierID uint, id uint) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
u.Status = StatusActive
|
||||
u.UpdatedBy = &modifierID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(id uint) error {
|
||||
_, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
264
internal/modules/user/service_test.go
Normal file
264
internal/modules/user/service_test.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
func TestService_Create_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
createFunc: func(u *User) error {
|
||||
u.ID = 1
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := CreateUserRequest{Name: "Budi", Email: "budi@mail.com", Password: "rahasia123"}
|
||||
newUser, err := svc.Create(nil, req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if newUser.Email != req.Email {
|
||||
t.Errorf("expected email %s, got %s", req.Email, newUser.Email)
|
||||
}
|
||||
if newUser.Password == req.Password {
|
||||
t.Error("stored password should be hashed, not plain text")
|
||||
}
|
||||
if !utils.CheckPassword(newUser.Password, req.Password) {
|
||||
t.Error("hashed password does not match original password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_AdminRole_ForbiddenForRegularAdmin(t *testing.T) {
|
||||
adminID := uint(5)
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Role: RoleAdmin}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := CreateUserRequest{Name: "New Admin", Email: "admin2@mail.com", Password: "rahasia123", Role: RoleAdmin}
|
||||
_, err := svc.Create(&adminID, req)
|
||||
|
||||
if !errors.Is(err, ErrForbiddenRoleCreation) {
|
||||
t.Fatalf("expected ErrForbiddenRoleCreation for regular admin creating another admin, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_AdminRole_AllowedForSuperAdmin(t *testing.T) {
|
||||
superAdminID := uint(1)
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Role: RoleSuperAdmin}, nil
|
||||
},
|
||||
createFunc: func(u *User) error {
|
||||
u.ID = 10
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := CreateUserRequest{Name: "New Admin", Email: "admin2@mail.com", Password: "rahasia123", Role: RoleAdmin}
|
||||
newAdmin, err := svc.Create(&superAdminID, req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for superadmin creating an admin, got: %v", err)
|
||||
}
|
||||
if newAdmin.Role != RoleAdmin {
|
||||
t.Errorf("expected role %s, got %s", RoleAdmin, newAdmin.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_EmailAlreadyTaken(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return &User{ID: 99, Email: email}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.Create(nil, CreateUserRequest{Name: "Budi", Email: "budi@mail.com", Password: "rahasia123"})
|
||||
|
||||
if !errors.Is(err, ErrEmailTaken) {
|
||||
t.Fatalf("expected ErrEmailTaken, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Name: "Budi"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
u, err := svc.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if u.ID != 1 {
|
||||
t.Errorf("expected ID 1, got: %d", u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.GetByID(999)
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Name: "Old Name"}, nil
|
||||
},
|
||||
updateFunc: func(u *User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
updated, err := svc.Update(1, 1, UpdateUserRequest{Name: "New Name"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.Name != "New Name" {
|
||||
t.Errorf("expected Name 'New Name', got: '%s'", updated.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateAvatar_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Name: "User Test"}, nil
|
||||
},
|
||||
updateFunc: func(u *User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
updated, err := svc.UpdateAvatar(1, "/images/avatars/avatar_1_123.png")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.AvatarURL == nil || *updated.AvatarURL != "/images/avatars/avatar_1_123.png" {
|
||||
t.Errorf("expected AvatarURL '/images/avatars/avatar_1_123.png', got: '%v'", updated.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.Update(1, 1, UpdateUserRequest{Name: "Anything"})
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SuspendAndUnsuspend_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Status: StatusActive}, nil
|
||||
},
|
||||
updateFunc: func(u *User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
suspended, err := svc.SuspendUser(1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if suspended.Status != StatusSuspended {
|
||||
t.Errorf("expected status 'suspended', got: '%s'", suspended.Status)
|
||||
}
|
||||
|
||||
unsuspended, err := svc.UnsuspendUser(1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if unsuspended.Status != StatusActive {
|
||||
t.Errorf("expected status 'active', got: '%s'", unsuspended.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Delete_Success(t *testing.T) {
|
||||
deleteCalled := false
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id}, nil
|
||||
},
|
||||
deleteFunc: func(id uint) error {
|
||||
deleteCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
if err := svc.Delete(1); err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if !deleteCalled {
|
||||
t.Error("repository.Delete should have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetAllPaginated_DefaultNormalization(t *testing.T) {
|
||||
var capturedPage, capturedLimit int
|
||||
var capturedSearch string
|
||||
|
||||
repo := &mockRepository{
|
||||
findAllPaginatedFunc: func(page, limit int, query ListUserQuery) ([]User, int64, error) {
|
||||
capturedPage, capturedLimit, capturedSearch = page, limit, query.Search
|
||||
return []User{{ID: 1}}, 1, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, total, err := svc.GetAllPaginated(ListUserQuery{Page: 0, Limit: 0, Search: "budi"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected total 1, got: %d", total)
|
||||
}
|
||||
if capturedPage != 1 {
|
||||
t.Errorf("expected page normalized to 1, got: %d", capturedPage)
|
||||
}
|
||||
if capturedLimit != 20 {
|
||||
t.Errorf("expected limit normalized to 20, got: %d", capturedLimit)
|
||||
}
|
||||
if capturedSearch != "budi" {
|
||||
t.Errorf("expected search 'budi', got: '%s'", capturedSearch)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user