package controllers import ( crand "crypto/rand" "encoding/hex" "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@xogmgo.com").Count(&count) if count == 0 { hashedPassword, _ := middleware.HashPassword("admin123") admin := models.User{ Name: "Admin XOGM Go", Email: "admin@xogmgo.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.") } } // SeedDefaultSetting inisialisasi setting bawaan di database dari konfigurasi .env func SeedDefaultSetting() { var count int64 config.DB.Model(&models.Setting{}).Count(&count) if count == 0 { setting := models.Setting{ ID: 1, TripayMerchantCode: config.GlobalConfig.TripayMerchantCode, TripayAPIKey: config.GlobalConfig.TripayAPIKey, TripayPrivateKey: config.GlobalConfig.TripayPrivateKey, TripayProxyURL: config.GlobalConfig.TripayProxyURL, TripayPaymentMethod: "QRISC", } if err := config.DB.Create(&setting).Error; err != nil { log.Printf("[SEED] Gagal membuat default setting: %v", err) } else { log.Println("[SEED] Sukses membuat default setting dari file .env!") } } else { log.Println("[SEED] Setting default sudah terbuat di DB.") } } // 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"` CompanySettings map[string]interface{} `json:"company_settings,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"` NPWP string `json:"npwp"` 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 ForgotPasswordRequest struct { Phone string `json:"phone" binding:"required"` } type ResetPasswordRequest struct { Phone string `json:"phone" binding:"required"` OTPCode string `json:"otp_code" binding:"required"` NewPassword string `json:"new_password" 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 resp.CompanySettings = map[string]interface{}{ "enable_member": user.Company.EnableMember, "enable_voucher": user.Company.EnableVoucher, "enable_multi_branch": user.Company.EnableMultiBranch, "enable_multi_kasir": user.Company.EnableMultiKasir, } } 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, enable_member, enable_voucher, enable_multi_branch, enable_multi_kasir") }). 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 } // --- Single Session Enforcement --- var sessionTokenStr string if user.Role == "kasir" && user.CompanyID != nil { // Ambil company settings (termasuk enforce_single_session) var company models.Company if err := config.DB.Select("id, enforce_single_session").First(&company, *user.CompanyID).Error; err == nil { if company.EnforceSingleSession { // Generate secure session token tokenBytes := make([]byte, 32) crand.Read(tokenBytes) sessionTokenStr = hex.EncodeToString(tokenBytes) // Hapus semua session lama milik user ini config.DB.Where("user_id = ?", user.ID).Delete(&models.UserSession{}) // Buat session baru newSession := models.UserSession{ UserID: user.ID, SessionToken: sessionTokenStr, UserAgent: c.GetHeader("User-Agent"), IPAddress: c.ClientIP(), ExpiresAt: time.Now().Add(24 * time.Hour), } config.DB.Create(&newSession) } } } token, err := middleware.CreateAccessToken(user.ID, user.Role, user.CompanyID, sessionTokenStr) 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 Logout(c *gin.Context) { userVal, exists := c.Get("user") if !exists { c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) return } user := userVal.(*models.User) // Hapus session dari DB (jika ada) config.DB.Where("user_id = ?", user.ID).Delete(&models.UserSession{}) c.JSON(http.StatusOK, gin.H{"message": "Berhasil logout"}) } 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, NPWP: req.NPWP, 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) // Kirim Notifikasi ke Admin (085789599065) ada pendaftar baru go services.SendNewRegistrationAdminWa("085789599065", req.CompanyName, req.OwnerName, req.OwnerEmail, req.OwnerPhone) 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 ForgotPassword(c *gin.Context) { var req ForgotPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } // Cari user berdasarkan nomor telepon (OwnerPhone atau Phone) var user models.User if err := config.DB.Where("phone = ?", req.Phone).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Nomor WhatsApp tidak terdaftar"}) return } // Generate OTP 6 digit code := fmt.Sprintf("%06d", rand.Intn(900000)+100000) // Simpan ke database otps otpEntry := models.OTP{ PhoneNumber: req.Phone, OTPCode: code, IsVerified: false, ExpiresAt: time.Now().Add(10 * time.Minute), // Kadaluarsa dalam 10 menit } if err := config.DB.Create(&otpEntry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat OTP reset password"}) return } // Kirim Notifikasi OTP Reset Password via WA go services.SendResetPasswordOtpWa(req.Phone, code) c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "Kode OTP reset password telah dikirim ke WhatsApp Anda", }) } func ResetPassword(c *gin.Context) { var req ResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } // Validasi OTP 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 } // Temukan user var user models.User if err := config.DB.Where("phone = ?", req.Phone).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "User tidak ditemukan"}) return } // Hash password baru hashed, err := middleware.HashPassword(req.NewPassword) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal enkripsi password"}) return } // Mulai Transaksi Database untuk memastikan data konsisten tx := config.DB.Begin() // Simpan password baru ke user if err := tx.Model(&user).Update("password", hashed).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mereset password"}) return } // Tandai OTP telah digunakan record.IsVerified = true if err := tx.Save(&record).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memperbarui status OTP"}) return } tx.Commit() c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "Password Anda berhasil diperbarui. Silakan login kembali dengan password baru 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 resp.CompanySettings = map[string]interface{}{ "enable_member": company.EnableMember, "enable_voucher": company.EnableVoucher, "enable_multi_branch": company.EnableMultiBranch, "enable_multi_kasir": company.EnableMultiKasir, } } } if user.BranchID != nil { var branch models.Branch if err := config.DB.Select("id, name").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 } 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 } var company models.Company if err := config.DB.First(&company, *owner.CompanyID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data perusahaan"}) return } if !company.EnableMultiKasir && company.MaxCashiers <= 0 { var kasirCount int64 config.DB.Model(&models.User{}).Where("company_id = ? AND role = ?", *owner.CompanyID, "kasir").Count(&kasirCount) if kasirCount >= 1 { c.JSON(http.StatusForbidden, gin.H{"detail": "Paket perusahaan Anda tidak mendukung pembuatan lebih dari 1 kasir"}) return } } else if company.MaxCashiers > 0 { var kasirCount int64 config.DB.Model(&models.User{}).Where("company_id = ? AND role = ?", *owner.CompanyID, "kasir").Count(&kasirCount) if kasirCount >= int64(company.MaxCashiers) { c.JSON(http.StatusForbidden, gin.H{"detail": fmt.Sprintf("Batas maksimal kasir (%d) telah tercapai untuk perusahaan Anda", company.MaxCashiers)}) 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)) }