Mhamdans17
perf: optimize Login GORM query with select-specific-columns, specific preloads, and remove the redundant First(company) query
46976cd | package controllers | |
| import ( | |
| "fmt" | |
| "log" | |
| "math/rand" | |
| "net/http" | |
| "strconv" | |
| "time" | |
| "service-warungpos-go/config" | |
| "service-warungpos-go/middleware" | |
| "service-warungpos-go/models" | |
| "service-warungpos-go/services" | |
| "github.com/gin-gonic/gin" | |
| "gorm.io/gorm" | |
| ) | |
| // SeedDefaultAdmin inisialisasi default super admin jika belum ada | |
| func SeedDefaultAdmin() { | |
| var count int64 | |
| config.DB.Model(&models.User{}).Where("email = ?", "admin@warungpos.com").Count(&count) | |
| if count == 0 { | |
| hashedPassword, _ := middleware.HashPassword("admin123") | |
| admin := models.User{ | |
| Name: "Admin WarungPOS", | |
| Email: "admin@warungpos.com", | |
| Password: hashedPassword, | |
| Role: "super_admin", | |
| CompanyID: nil, | |
| BranchID: nil, | |
| IsActive: true, | |
| } | |
| if err := config.DB.Create(&admin).Error; err != nil { | |
| log.Printf("[SEED] Gagal membuat default admin: %v", err) | |
| } else { | |
| log.Println("[SEED] Sukses membuat default admin!") | |
| } | |
| } else { | |
| log.Println("[SEED] Admin default sudah terbuat.") | |
| } | |
| } | |
| // Request & Response DTOs | |
| type LoginRequest struct { | |
| Email string `json:"email" binding:"required"` | |
| Password string `json:"password" binding:"required"` | |
| } | |
| type UserResponse struct { | |
| ID uint `json:"id"` | |
| Name string `json:"name"` | |
| Email string `json:"email"` | |
| Phone string `json:"phone"` | |
| Role string `json:"role"` | |
| CompanyID *uint `json:"company_id"` | |
| BranchID *uint `json:"branch_id"` | |
| IsActive bool `json:"is_active"` | |
| CompanyName string `json:"company_name,omitempty"` | |
| BranchName string `json:"branch_name,omitempty"` | |
| CreatedAt time.Time `json:"created_at"` | |
| } | |
| type TokenResponse struct { | |
| AccessToken string `json:"access_token"` | |
| TokenType string `json:"token_type"` | |
| User UserResponse `json:"user"` | |
| } | |
| type RegisterRequest struct { | |
| Name string `json:"name" binding:"required"` | |
| Email string `json:"email" binding:"required,email"` | |
| Password string `json:"password" binding:"required"` | |
| Role string `json:"role"` | |
| CompanyID *uint `json:"company_id"` | |
| BranchID *uint `json:"branch_id"` | |
| } | |
| type TenantRegistrationRequest struct { | |
| CompanyName string `json:"company_name" binding:"required"` | |
| CompanyAddress string `json:"company_address"` | |
| OwnerName string `json:"owner_name" binding:"required"` | |
| OwnerEmail string `json:"owner_email" binding:"required,email"` | |
| OwnerPhone string `json:"owner_phone" binding:"required"` | |
| OwnerPassword string `json:"owner_password" binding:"required"` | |
| BankAccountNumber string `json:"bank_account_number"` | |
| BankName string `json:"bank_name"` | |
| } | |
| type VerifyOTPRequest struct { | |
| Phone string `json:"phone" binding:"required"` | |
| OTPCode string `json:"otp_code" binding:"required"` | |
| } | |
| type ResendOTPRequest struct { | |
| Phone string `json:"phone" binding:"required"` | |
| } | |
| type UpdateUserRequest struct { | |
| Name string `json:"name"` | |
| Email string `json:"email"` | |
| Password string `json:"password"` | |
| Role string `json:"role"` | |
| IsActive *bool `json:"is_active"` | |
| } | |
| type UpdateKasirRequest struct { | |
| Name string `json:"name"` | |
| Email string `json:"email"` | |
| Password string `json:"password"` | |
| BranchID *uint `json:"branch_id"` | |
| IsActive *bool `json:"is_active"` | |
| } | |
| func toUserResponse(user *models.User) UserResponse { | |
| resp := UserResponse{ | |
| ID: user.ID, | |
| Name: user.Name, | |
| Email: user.Email, | |
| Phone: user.Phone, | |
| Role: user.Role, | |
| CompanyID: user.CompanyID, | |
| BranchID: user.BranchID, | |
| IsActive: user.IsActive, | |
| CreatedAt: user.CreatedAt, | |
| } | |
| if user.Company != nil { | |
| resp.CompanyName = user.Company.Name | |
| } | |
| if user.Branch != nil { | |
| resp.BranchName = user.Branch.Name | |
| } | |
| return resp | |
| } | |
| // Route Handlers | |
| func Login(c *gin.Context) { | |
| var req LoginRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Data tidak valid"}) | |
| return | |
| } | |
| var user models.User | |
| if err := config.DB.Select("id, name, email, password, phone, role, company_id, branch_id, is_active, created_at"). | |
| Preload("Company", func(db *gorm.DB) *gorm.DB { | |
| return db.Select("id, name, approved_at, is_active") | |
| }). | |
| Preload("Branch", func(db *gorm.DB) *gorm.DB { | |
| return db.Select("id, name") | |
| }). | |
| Where("email = ?", req.Email). | |
| First(&user).Error; err != nil { | |
| c.JSON(http.StatusUnauthorized, gin.H{"detail": "Email atau password salah"}) | |
| return | |
| } | |
| if !middleware.VerifyPassword(req.Password, user.Password) { | |
| c.JSON(http.StatusUnauthorized, gin.H{"detail": "Email atau password salah"}) | |
| return | |
| } | |
| // Cek status keaktifan user | |
| if !user.IsActive { | |
| if user.Role == "owner" && user.Company != nil { | |
| if user.Company.ApprovedAt != nil { | |
| // Pernah diapprove tapi sekarang dinonaktifkan | |
| c.JSON(http.StatusUnauthorized, gin.H{"detail": "ACCOUNT_DEACTIVATED"}) | |
| return | |
| } else { | |
| // Belum diapprove super_admin | |
| c.JSON(http.StatusUnauthorized, gin.H{"detail": "PENDING_APPROVAL"}) | |
| return | |
| } | |
| } | |
| c.JSON(http.StatusForbidden, gin.H{"detail": "Akun tidak aktif, hubungi admin"}) | |
| return | |
| } | |
| token, err := middleware.CreateAccessToken(user.ID, user.Role, user.CompanyID) | |
| if err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal men-generate token"}) | |
| return | |
| } | |
| c.JSON(http.StatusOK, TokenResponse{ | |
| AccessToken: token, | |
| TokenType: "bearer", | |
| User: toUserResponse(&user), | |
| }) | |
| } | |
| func Register(c *gin.Context) { | |
| var req RegisterRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var existingCount int64 | |
| config.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&existingCount) | |
| if existingCount > 0 { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah terdaftar"}) | |
| return | |
| } | |
| hashedPassword, _ := middleware.HashPassword(req.Password) | |
| role := "kasir" | |
| if req.Role != "" { | |
| role = req.Role | |
| } | |
| newUser := models.User{ | |
| Name: req.Name, | |
| Email: req.Email, | |
| Password: hashedPassword, | |
| Role: role, | |
| CompanyID: req.CompanyID, | |
| BranchID: req.BranchID, | |
| IsActive: true, | |
| } | |
| if err := config.DB.Create(&newUser).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan user"}) | |
| return | |
| } | |
| c.JSON(http.StatusCreated, toUserResponse(&newUser)) | |
| } | |
| func RegisterTenant(c *gin.Context) { | |
| var req TenantRegistrationRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var existingCount int64 | |
| config.DB.Model(&models.User{}).Where("email = ?", req.OwnerEmail).Count(&existingCount) | |
| if existingCount > 0 { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah digunakan!"}) | |
| return | |
| } | |
| tx := config.DB.Begin() | |
| defer func() { | |
| if r := recover(); r != nil { | |
| tx.Rollback() | |
| } | |
| }() | |
| // 1. Buat Company | |
| comp := models.Company{ | |
| Name: req.CompanyName, | |
| Address: req.CompanyAddress, | |
| BankAccountNumber: req.BankAccountNumber, | |
| BankName: req.BankName, | |
| Email: req.OwnerEmail, | |
| Phone: req.OwnerPhone, | |
| IsActive: false, | |
| } | |
| if err := tx.Create(&comp).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat perusahaan"}) | |
| return | |
| } | |
| // 2. Buat Owner User | |
| hashedPassword, _ := middleware.HashPassword(req.OwnerPassword) | |
| owner := models.User{ | |
| Name: req.OwnerName, | |
| Email: req.OwnerEmail, | |
| Password: hashedPassword, | |
| Phone: req.OwnerPhone, | |
| Role: "owner", | |
| CompanyID: &comp.ID, | |
| IsActive: false, | |
| } | |
| if err := tx.Create(&owner).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat akun owner"}) | |
| return | |
| } | |
| // 3. Buat OTP Entry | |
| code := fmt.Sprintf("%06d", rand.Intn(900000)+100000) | |
| otpEntry := models.OTP{ | |
| PhoneNumber: req.OwnerPhone, | |
| OTPCode: code, | |
| IsVerified: false, | |
| ExpiresAt: time.Now().Add(5 * time.Minute), | |
| } | |
| if err := tx.Create(&otpEntry).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat OTP"}) | |
| return | |
| } | |
| if err := tx.Commit().Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal melakukan registrasi"}) | |
| return | |
| } | |
| // Kirim Notifikasi OTP via WA secara async agar request HTTP cepat | |
| go services.SendOtpWa(req.OwnerPhone, code) | |
| c.JSON(http.StatusCreated, gin.H{ | |
| "status": "success", | |
| "message": "OTP sent to phone", | |
| }) | |
| } | |
| func VerifyOTP(c *gin.Context) { | |
| var req VerifyOTPRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var record models.OTP | |
| if err := config.DB.Where("phone_number = ? AND otp_code = ? AND is_verified = ?", req.Phone, req.OTPCode, false).Order("id desc").First(&record).Error; err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Kode OTP salah atau tidak ditemukan"}) | |
| return | |
| } | |
| if record.ExpiresAt.Before(time.Now()) { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Kode OTP sudah kadaluarsa"}) | |
| return | |
| } | |
| record.IsVerified = true | |
| config.DB.Save(&record) | |
| c.JSON(http.StatusOK, gin.H{ | |
| "status": "success", | |
| "message": "Nomor Hape diverifikasi! Tunggu Approve Super Admin.", | |
| }) | |
| } | |
| func ResendOTP(c *gin.Context) { | |
| var req ResendOTPRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var lastOTP models.OTP | |
| err := config.DB.Where("phone_number = ? AND is_verified = ?", req.Phone, false).Order("id desc").First(&lastOTP).Error | |
| if err == nil { | |
| // Cooldown 3 menit (ExpiresAt - 5 menit = CreatedAt) | |
| createdAt := lastOTP.ExpiresAt.Add(-5 * time.Minute) | |
| timeSince := time.Since(createdAt) | |
| if timeSince < 3*time.Minute { | |
| remaining := int(180 - timeSince.Seconds()) | |
| c.JSON(http.StatusTooManyRequests, gin.H{ | |
| "detail": fmt.Sprintf("Tunggu %d detik lagi sebelum kirim ulang OTP", remaining), | |
| }) | |
| return | |
| } | |
| } | |
| code := fmt.Sprintf("%06d", rand.Intn(900000)+100000) | |
| otpEntry := models.OTP{ | |
| PhoneNumber: req.Phone, | |
| OTPCode: code, | |
| IsVerified: false, | |
| ExpiresAt: time.Now().Add(5 * time.Minute), | |
| } | |
| if err := config.DB.Create(&otpEntry).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengirim ulang OTP"}) | |
| return | |
| } | |
| go services.SendOtpWa(req.Phone, code) | |
| c.JSON(http.StatusOK, gin.H{ | |
| "status": "success", | |
| "message": "OTP baru telah dikirim ke WhatsApp Anda", | |
| }) | |
| } | |
| func GetMe(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| resp := toUserResponse(user) | |
| // Attach Company & Branch name jika ada | |
| if user.CompanyID != nil { | |
| var company models.Company | |
| if err := config.DB.First(&company, *user.CompanyID).Error; err == nil { | |
| resp.CompanyName = company.Name | |
| } | |
| } | |
| if user.BranchID != nil { | |
| var branch models.Branch | |
| if err := config.DB.First(&branch, *user.BranchID).Error; err == nil { | |
| resp.BranchName = branch.Name | |
| } | |
| } | |
| c.JSON(http.StatusOK, resp) | |
| } | |
| func ListUsers(c *gin.Context) { | |
| limitStr := c.Query("limit") | |
| skipStr := c.Query("skip") | |
| searchQuery := c.Query("search") | |
| query := config.DB.Preload("Company").Preload("Branch") | |
| if searchQuery != "" { | |
| query = query.Where("name ILIKE ? OR email ILIKE ? OR phone ILIKE ?", | |
| "%"+searchQuery+"%", | |
| "%"+searchQuery+"%", | |
| "%"+searchQuery+"%") | |
| } | |
| if limitStr != "" { | |
| if limit, err := strconv.Atoi(limitStr); err == nil { | |
| query = query.Limit(limit) | |
| } | |
| } | |
| if skipStr != "" { | |
| if skip, err := strconv.Atoi(skipStr); err == nil { | |
| query = query.Offset(skip) | |
| } | |
| } | |
| var users []models.User | |
| if err := query.Find(&users).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data user"}) | |
| return | |
| } | |
| var resp []UserResponse | |
| for _, u := range users { | |
| resp = append(resp, toUserResponse(&u)) | |
| } | |
| c.JSON(http.StatusOK, resp) | |
| } | |
| func UpdateUser(c *gin.Context) { | |
| userIDStr := c.Param("user_id") | |
| userID, err := strconv.Atoi(userIDStr) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "ID user tidak valid"}) | |
| return | |
| } | |
| var req UpdateUserRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var user models.User | |
| if err := config.DB.First(&user, userID).Error; err != nil { | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "User tidak ditemukan"}) | |
| return | |
| } | |
| if req.Name != "" { | |
| user.Name = req.Name | |
| } | |
| if req.Email != "" { | |
| var count int64 | |
| config.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count) | |
| if count > 0 { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah digunakan user lain"}) | |
| return | |
| } | |
| user.Email = req.Email | |
| } | |
| if req.Password != "" { | |
| hashed, _ := middleware.HashPassword(req.Password) | |
| user.Password = hashed | |
| } | |
| if req.Role != "" { | |
| user.Role = req.Role | |
| } | |
| if req.IsActive != nil { | |
| user.IsActive = *req.IsActive | |
| } | |
| if err := config.DB.Save(&user).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update user"}) | |
| return | |
| } | |
| c.JSON(http.StatusOK, toUserResponse(&user)) | |
| } | |
| // Owner endpoints for Kasir Management | |
| func ListKasir(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| owner := userVal.(*models.User) | |
| var kasir []models.User | |
| if err := config.DB.Select("id, name, email, phone, role, company_id, branch_id, is_active, created_at").Preload("Branch").Where("company_id = ? AND role = ?", *owner.CompanyID, "kasir").Find(&kasir).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data kasir"}) | |
| return | |
| } | |
| var resp []UserResponse | |
| for _, k := range kasir { | |
| resp = append(resp, toUserResponse(&k)) | |
| } | |
| c.JSON(http.StatusOK, resp) | |
| } | |
| func CreateKasir(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| owner := userVal.(*models.User) | |
| var req RegisterRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| if req.Role != "" && req.Role != "kasir" { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Owner hanya bisa membuat role 'kasir'"}) | |
| return | |
| } | |
| var existingCount int64 | |
| config.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&existingCount) | |
| if existingCount > 0 { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah terdaftar"}) | |
| return | |
| } | |
| hashedPassword, _ := middleware.HashPassword(req.Password) | |
| newUser := models.User{ | |
| Name: req.Name, | |
| Email: req.Email, | |
| Password: hashedPassword, | |
| Role: "kasir", | |
| CompanyID: owner.CompanyID, | |
| BranchID: req.BranchID, | |
| IsActive: true, | |
| } | |
| if err := config.DB.Create(&newUser).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan kasir"}) | |
| return | |
| } | |
| c.JSON(http.StatusCreated, toUserResponse(&newUser)) | |
| } | |
| func UpdateKasir(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| owner := userVal.(*models.User) | |
| kasirIDStr := c.Param("user_id") | |
| kasirID, err := strconv.Atoi(kasirIDStr) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kasir tidak valid"}) | |
| return | |
| } | |
| var req UpdateKasirRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var kasir models.User | |
| if err := config.DB.Where("id = ? AND company_id = ? AND role = ?", kasirID, *owner.CompanyID, "kasir").First(&kasir).Error; err != nil { | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Kasir tidak ditemukan di perusahaan ini"}) | |
| return | |
| } | |
| if req.Name != "" { | |
| kasir.Name = req.Name | |
| } | |
| if req.Email != "" { | |
| var count int64 | |
| config.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, kasirID).Count(&count) | |
| if count > 0 { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah digunakan user lain"}) | |
| return | |
| } | |
| kasir.Email = req.Email | |
| } | |
| if req.Password != "" { | |
| hashed, _ := middleware.HashPassword(req.Password) | |
| kasir.Password = hashed | |
| } | |
| if req.BranchID != nil { | |
| kasir.BranchID = req.BranchID | |
| } | |
| if req.IsActive != nil { | |
| kasir.IsActive = *req.IsActive | |
| } | |
| if err := config.DB.Save(&kasir).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update kasir"}) | |
| return | |
| } | |
| c.JSON(http.StatusOK, toUserResponse(&kasir)) | |
| } | |