refact: reafctor code for optimize

This commit is contained in:
pahmiudahgede 2025-06-27 19:26:26 +07:00
parent c26eee0ab9
commit baccdd696b
35 changed files with 1982 additions and 752 deletions

280
README.md
View File

@ -1,2 +1,280 @@
# build_api_golang # build_api_golang
this is rest API using go lang and go fiber with postgreql database for my personal project. # 🗂️ Waste Management System API
> **RESTful API untuk sistem pengelolaan sampah terintegrasi yang menghubungkan masyarakat, pengepul, dan pengelola dalam satu ekosistem digital.**
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=for-the-badge&logo=go)](https://golang.org/)
[![Fiber](https://img.shields.io/badge/Fiber-v2.52+-00ADD8?style=for-the-badge&logo=go)](https://gofiber.io/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-316192?style=for-the-badge&logo=postgresql)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7.0+-DC382D?style=for-the-badge&logo=redis)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-24.0+-2496ED?style=for-the-badge&logo=docker)](https://www.docker.com/)
[![GORM](https://img.shields.io/badge/GORM-Latest-00ADD8?style=for-the-badge)](https://gorm.io/)
## 📋 Deskripsi Aplikasi
Waste Management System adalah backend API yang dikembangkan untuk mendigitalisasi sistem pengelolaan sampah di Indonesia. Aplikasi ini menghubungkan tiga stakeholder utama dalam rantai pengelolaan sampah melalui platform terintegrasi yang efisien dan transparan.
### 🎯 Latar Belakang Masalah
Indonesia menghadapi krisis pengelolaan sampah dengan berbagai tantangan:
- **Volume Sampah Tinggi**: 67.8 juta ton sampah yang dihasilkan per tahun
- **Koordinasi Lemah**: Minimnya sinergi antar stakeholder pengelolaan sampah
- **Tracking Tidak Optimal**: Kurangnya visibility dalam proses pengelolaan sampah
- **Partisipasi Rendah**: Minimnya engagement masyarakat dalam program daur ulang
- **Inefisiensi Operasional**: Proses manual yang memakan waktu dan biaya tinggi
### 💡 Solusi yang Ditawarkan
Platform digital komprehensif yang menyediakan:
- **Koordinasi Terintegrasi**: Menghubungkan seluruh stakeholder dalam satu platform
- **Tracking System**: Pelacakan sampah dari sumber hingga pengolahan akhir
- **Optimasi Proses**: Automasi dan optimasi rute pengumpulan
- **Engagement Platform**: Sistem gamifikasi untuk meningkatkan partisipasi
- **Data-Driven Insights**: Analytics untuk pengambilan keputusan berbasis data
## 👥 Stakeholder Sistem
### 🏠 **Masyarakat (Citizens)**
*Pengguna akhir yang menghasilkan sampah rumah tangga*
**Peran dalam Sistem:**
- Melaporkan jenis dan volume sampah yang dihasilkan
- Mengakses informasi jadwal pengumpulan sampah
- Menerima edukasi tentang pemilahan sampah yang benar
- Berpartisipasi dalam program reward dan gamifikasi
- Melacak kontribusi personal terhadap lingkungan
**Manfaat yang Diperoleh:**
- Kemudahan dalam melaporkan sampah
- Reward dan insentif dari partisipasi aktif
- Edukasi lingkungan yang berkelanjutan
- Transparansi dalam proses pengelolaan sampah
### ♻️ **Pengepul (Collectors)**
*Pelaku usaha yang mengumpulkan dan mendistribusikan sampah*
**Peran dalam Sistem:**
- Mengelola rute dan jadwal pengumpulan sampah optimal
- Memvalidasi dan menimbang sampah yang dikumpulkan
- Melakukan pemilahan awal berdasarkan kategori sampah
- Mengatur distribusi sampah ke berbagai pengelola
- Melaporkan volume dan jenis sampah yang berhasil dikumpulkan
**Manfaat yang Diperoleh:**
- Optimasi rute untuk efisiensi operasional
- System tracking untuk akuntabilitas
- Platform untuk memperluas jangkauan bisnis
- Data analytics untuk business intelligence
### 🏭 **Pengelola (Processors)**
*Institusi atau perusahaan pengolahan akhir sampah*
**Peran dalam Sistem:**
- Mengelola fasilitas pengolahan sampah
- Memproses sampah menjadi produk daur ulang bernilai
- Melaporkan hasil pengolahan dan dampak lingkungan
- Memberikan feedback ke pengepul dan masyarakat
- Mengelola sistem pembayaran dan insentif
**Manfaat yang Diperoleh:**
- Supply chain management yang terorganisir
- Traceability sampah untuk quality control
- Data untuk compliance dan sustainability reporting
- Platform untuk program CSR dan community engagement
## ✨ Fitur Unggulan
### 🔄 **End-to-End Waste Tracking**
Sistem pelacakan komprehensif yang memungkinkan monitoring sampah dari sumber hingga pengolahan akhir, memberikan transparansi penuh dalam setiap tahap proses.
### 📊 **Real-time Analytics Dashboard**
Interface dashboard yang menampilkan data statistik, trend analysis, dan key performance indicators dengan visualisasi yang mudah dipahami semua stakeholder.
### 🗺️ **Geographic Information System**
Sistem pemetaan cerdas untuk optimasi rute pengumpulan, identifikasi titik pengumpulan strategis, dan monitoring coverage area secara real-time.
### 🎁 **Gamification & Reward System**
Program insentif untuk mendorong partisipasi aktif masyarakat melalui sistem poin, achievement badges, leaderboard, dan berbagai reward menarik.
### 🔔 **Smart Notification System**
Sistem notifikasi multi-channel yang memberikan informasi real-time tentang jadwal pengumpulan, status sampah, achievement unlock, dan update penting lainnya.
### 📈 **Comprehensive Reporting**
Modul pelaporan dengan kemampuan generate report otomatis, export dalam berbagai format, dan customizable dashboard untuk setiap role pengguna.
## 🛠️ Tech Stack & Architecture
### **Backend Development**
#### **🚀 Golang (Go)**
*Primary Backend Language*
**Mengapa Memilih Golang:**
- **Performance Excellence**: Compiled language dengan execution speed yang sangat tinggi
- **Concurrency Native**: Goroutines dan channels untuk handle ribuan concurrent requests
- **Memory Efficiency**: Garbage collector yang optimal dengan memory footprint rendah
- **Scalability Ready**: Mampu handle high-traffic dengan minimal resource consumption
- **Simple yet Powerful**: Syntax yang clean namun feature-rich untuk rapid development
**Keunggulan untuk Waste Management System:**
- Mampu menangani concurrent requests dari multiple stakeholders secara simultan
- Processing real-time data tracking dengan performa tinggi
- Ideal untuk microservices architecture dan distributed systems
- Strong typing system untuk data integrity dalam financial transactions
#### **⚡ Fiber Framework**
*High-Performance Web Framework*
**Mengapa Memilih Fiber:**
- **Speed Optimized**: Salah satu framework tercepat untuk Go dengan minimal overhead
- **Memory Efficient**: Extremely low memory usage bahkan pada high load
- **Express-like API**: Familiar syntax bagi developer dengan background Node.js/Express
- **Rich Middleware Ecosystem**: Built-in middleware untuk authentication, CORS, logging, rate limiting
- **Zero Allocation**: Optimized untuk minimize memory allocation
**Keunggulan untuk Waste Management System:**
- RESTful API development yang rapid dan efficient
- Middleware ecosystem yang mendukung complex business logic requirements
- Auto-recovery dan error handling untuk system reliability
- Built-in JSON serialization yang optimal untuk mobile app integration
### **Database & Data Management**
#### **🐘 PostgreSQL**
*Advanced Relational Database Management System*
**Mengapa Memilih PostgreSQL:**
- **ACID Compliance**: Full transactional integrity untuk financial dan tracking data
- **Advanced Data Types**: JSON, Array, Geographic data types untuk flexible schema
- **Geospatial Support**: PostGIS extension untuk location-based features
- **Full-Text Search**: Built-in search capabilities untuk content discovery
- **Scalability Options**: Horizontal dan vertical scaling dengan replication support
**Keunggulan untuk Waste Management System:**
- Geospatial data support untuk location tracking dan route optimization
- JSON storage untuk flexible metadata dan dynamic content
- Complex relationship handling untuk multi-stakeholder interactions
- Data consistency untuk transaction processing dan reward calculations
#### **🔧 GORM (Go ORM)**
*Developer-Friendly Object-Relational Mapping*
**Mengapa Memilih GORM:**
- **Auto Migration**: Automatic database schema migration dan versioning
- **Association Handling**: Powerful relationship management dengan lazy/eager loading
- **Hook System**: Lifecycle events untuk implement business rules
- **Query Builder**: Type-safe dan flexible query construction
- **Database Agnostic**: Support multiple database dengan same codebase
**Keunggulan untuk Waste Management System:**
- Model relationship yang complex untuk stakeholder interactions
- Data validation dan business rules enforcement di ORM level
- Performance optimization dengan intelligent query generation
- Schema evolution yang safe untuk production deployments
#### **⚡ Redis**
*In-Memory Data Structure Store*
**Mengapa Memilih Redis:**
- **Ultra-High Performance**: Sub-millisecond response times untuk real-time features
- **Rich Data Structures**: Strings, Hashes, Lists, Sets, Sorted Sets, Streams
- **Pub/Sub Messaging**: Real-time communication untuk notification system
- **Persistence Options**: Data durability dengan configurable persistence
- **Clustering Support**: Horizontal scaling dengan Redis Cluster
**Keunggulan untuk Waste Management System:**
- Session management untuk multi-role authentication system
- Real-time notifications dan messaging antar stakeholders
- Caching layer untuk frequently accessed data (routes, user profiles)
- Rate limiting untuk API protection dan fair usage
- Leaderboard dan ranking system untuk gamification features
### **Infrastructure & Deployment**
#### **🐳 Docker**
*Application Containerization Platform*
**Mengapa Memilih Docker:**
- **Environment Consistency**: Identical environment dari development hingga production
- **Scalability Ready**: Easy horizontal scaling dengan container orchestration
- **Resource Efficiency**: Lightweight containers dibanding traditional virtual machines
- **Deployment Simplicity**: One-command deployment dengan reproducible builds
- **Microservices Architecture**: Perfect untuk distributed system deployment
**Keunggulan untuk Waste Management System:**
- Development environment yang consistent untuk seluruh tim developer
- Production deployment yang reliable dan reproducible
- Easy scaling berdasarkan load dari multiple stakeholders
- Integration yang seamless dengan CI/CD pipeline
- Service isolation untuk better security dan debugging
## 🏗️ System Architecture
### **Layered Architecture Pattern**
```
┌─────────────────────────────────────┐
│ Presentation Layer │
│ (Mobile Apps, Web Dashboard) │
└─────────────────┬───────────────────┘
│ RESTful API
┌─────────────────▼───────────────────┐
│ API Gateway Layer │
│ (Fiber + Middleware) │
└─────────────────┬───────────────────┘
┌─────────────────▼───────────────────┐
│ Business Logic Layer │
│ (Service Components) │
└─────────────────┬───────────────────┘
┌─────────────────▼───────────────────┐
│ Data Access Layer │
│ (Repository Pattern + GORM) │
└─────────────────┬───────────────────┘
┌─────────────────▼───────────────────┐
│ Persistence Layer │
│ (PostgreSQL + Redis) │
└─────────────────────────────────────┘
```
### **Key Architectural Principles**
- **Separation of Concerns**: Clear separation antara business logic, data access, dan presentation
- **Dependency Injection**: Loose coupling antar components untuk better testability
- **Repository Pattern**: Abstraction layer untuk data access operations
- **Middleware Pattern**: Cross-cutting concerns seperti authentication, logging, validation
- **Event-Driven Architecture**: Pub/sub pattern untuk real-time notifications
## 🌟 Competitive Advantages
### **Technical Excellence**
- **High Performance**: Sub-100ms response time untuk critical operations
- **Scalability**: Ready untuk handle growth hingga millions of users
- **Security First**: Multi-layer security dengan encryption dan secure authentication
- **Real-time Capabilities**: Instant updates dan notifications untuk better user experience
### **Business Value**
- **Cost Efficiency**: Significant reduction dalam operational cost melalui automation
- **Environmental Impact**: Measurable contribution untuk sustainability goals
- **Stakeholder Engagement**: User-friendly platform yang mendorong active participation
- **Data-Driven Decision**: Comprehensive analytics untuk strategic planning
### **Innovation Features**
- **AI-Ready Architecture**: Prepared untuk integration dengan machine learning models
- **IoT Integration**: Ready untuk connect dengan smart waste bins dan sensors
- **Blockchain Compatibility**: Architecture yang support untuk blockchain integration
- **Multi-tenancy Support**: Scalable untuk multiple cities dan regions
---
<div align="center">
**Waste Management System** menggunakan cutting-edge technology stack untuk menciptakan solusi digital yang sustainable, scalable, dan user-centric dalam pengelolaan sampah di Indonesia.
🌱 **Built for Sustainability • Designed for Scale • Engineered for Impact** 🌱
</div>

View File

@ -1,7 +1,13 @@
package main package main
import ( import (
"log"
"rijig/config" "rijig/config"
"rijig/internal/cart"
"rijig/internal/trash"
"rijig/internal/worker"
"time"
// "rijig/internal/repositories" // "rijig/internal/repositories"
// "rijig/internal/services" // "rijig/internal/services"
@ -13,21 +19,21 @@ import (
func main() { func main() {
config.SetupConfig() config.SetupConfig()
// cartRepo := repositories.NewCartRepository() cartRepo := cart.NewCartRepository()
// trashRepo := repositories.NewTrashRepository(config.DB) trashRepo := trash.NewTrashRepository(config.DB)
// cartService := services.NewCartService(cartRepo, trashRepo) cartService := cart.NewCartService(cartRepo, trashRepo)
// worker := worker.NewCartWorker(cartService, cartRepo, trashRepo) worker := worker.NewCartWorker(cartService, cartRepo, trashRepo)
// go func() { go func() {
// ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
// defer ticker.Stop() defer ticker.Stop()
// for range ticker.C { for range ticker.C {
// if err := worker.AutoCommitExpiringCarts(); err != nil { if err := worker.AutoCommitExpiringCarts(); err != nil {
// log.Printf("Auto-commit error: %v", err) log.Printf("Auto-commit error: %v", err)
// } }
// } }
// }() }()
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error { ErrorHandler: func(c *fiber.Ctx, err error) error {

View File

@ -5,8 +5,6 @@ import (
"log" "log"
"os" "os"
"rijig/model"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -14,7 +12,6 @@ import (
var DB *gorm.DB var DB *gorm.DB
func ConnectDatabase() { func ConnectDatabase() {
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_HOST"),
@ -31,59 +28,7 @@ func ConnectDatabase() {
} }
log.Println("Database connected successfully!") log.Println("Database connected successfully!")
err = DB.AutoMigrate( if err := RunMigrations(DB); err != nil {
// ==wilayah indonesia==
&model.Province{},
&model.Regency{},
&model.District{},
&model.Village{},
// ==wilayah indonesia==
// ==============main feature==============
// =>user preparation<=
&model.User{},
&model.Collector{},
&model.AvaibleTrashByCollector{},
&model.Role{},
&model.UserPin{},
&model.Address{},
&model.IdentityCard{},
&model.CompanyProfile{},
// =>user preparation<=
// =>requestpickup preparation<=
&model.RequestPickup{},
&model.RequestPickupItem{},
&model.PickupStatusHistory{},
&model.PickupRating{},
&model.Cart{},
&model.CartItem{},
// =>requestpickup preparation<=
// =>store preparation<=
&model.Store{},
&model.Product{},
&model.ProductImage{},
// =>store preparation<=
// ==============main feature==============
// ==============additional content========
&model.Article{},
&model.Banner{},
&model.InitialCoint{},
&model.About{},
&model.AboutDetail{},
&model.CoverageArea{},
// =>Trash Model<=
&model.TrashCategory{},
&model.TrashDetail{},
// =>Trash Model<=
// ==============additional content========
)
if err != nil {
log.Fatalf("Error performing auto-migration: %v", err) log.Fatalf("Error performing auto-migration: %v", err)
} }
log.Println("Database migrated successfully!") }
}

66
config/migration.go Normal file
View File

@ -0,0 +1,66 @@
package config
import (
"log"
"rijig/model"
"gorm.io/gorm"
)
func RunMigrations(db *gorm.DB) error {
log.Println("Starting database migration...")
err := db.AutoMigrate(
// Location models
&model.Province{},
&model.Regency{},
&model.District{},
&model.Village{},
// User related models
&model.User{},
&model.Collector{},
&model.AvaibleTrashByCollector{},
&model.Role{},
&model.UserPin{},
&model.Address{},
&model.IdentityCard{},
&model.CompanyProfile{},
// Pickup related models
&model.RequestPickup{},
&model.RequestPickupItem{},
&model.PickupStatusHistory{},
&model.PickupRating{},
// Cart related models
&model.Cart{},
&model.CartItem{},
// Store related models
&model.Store{},
&model.Product{},
&model.ProductImage{},
// Content models
&model.Article{},
&model.Banner{},
&model.InitialCoint{},
&model.About{},
&model.AboutDetail{},
&model.CoverageArea{},
// Trash related models
&model.TrashCategory{},
&model.TrashDetail{},
)
if err != nil {
log.Printf("Error performing auto-migration: %v", err)
return err
}
log.Println("Database migrated successfully!")
return nil
}

View File

@ -1,5 +1,5 @@
package config
package config
import ( import (
"log" "log"
"os" "os"

View File

@ -322,8 +322,9 @@ func (r *RegisterAdminRequest) ValidateRegisterAdminRequest() (map[string][]stri
errors["name"] = append(errors["name"], "Name is required") errors["name"] = append(errors["name"], "Name is required")
} }
if r.Gender != "male" && r.Gender != "female" { genderLower := strings.ToLower(strings.TrimSpace(r.Gender))
errors["gender"] = append(errors["gender"], "Gender must be either 'male' or 'female'") if genderLower != "laki-laki" && genderLower != "perempuan" {
errors["gender"] = append(errors["gender"], "Gender must be either 'laki-laki' or 'perempuan'")
} }
if strings.TrimSpace(r.DateOfBirth) == "" { if strings.TrimSpace(r.DateOfBirth) == "" {

View File

@ -1,6 +1,7 @@
package authentication package authentication
import ( import (
"log"
"rijig/middleware" "rijig/middleware"
"rijig/utils" "rijig/utils"
@ -16,8 +17,11 @@ func NewAuthenticationHandler(service AuthenticationService) *AuthenticationHand
} }
func (h *AuthenticationHandler) RefreshToken(c *fiber.Ctx) error { func (h *AuthenticationHandler) RefreshToken(c *fiber.Ctx) error {
deviceID := c.Get("X-Device-ID") claims, err := middleware.GetUserFromContext(c)
if deviceID == "" { if err != nil {
return err
}
if claims.DeviceID == "" {
return utils.BadRequest(c, "Device ID is required") return utils.BadRequest(c, "Device ID is required")
} }
@ -31,12 +35,11 @@ func (h *AuthenticationHandler) RefreshToken(c *fiber.Ctx) error {
return utils.BadRequest(c, "Refresh token is required") return utils.BadRequest(c, "Refresh token is required")
} }
userID, ok := c.Locals("user_id").(string) if claims.UserID == "" {
if !ok || userID == "" { return utils.BadRequest(c, "userid is required")
return utils.Unauthorized(c, "Unauthorized or missing user ID")
} }
tokenData, err := utils.RefreshAccessToken(userID, deviceID, body.RefreshToken) tokenData, err := utils.RefreshAccessToken(claims.UserID, claims.DeviceID, body.RefreshToken)
if err != nil { if err != nil {
return utils.Unauthorized(c, err.Error()) return utils.Unauthorized(c, err.Error())
} }
@ -62,6 +65,21 @@ func (h *AuthenticationHandler) GetMe(c *fiber.Ctx) error {
} }
func (h *AuthenticationHandler) GetRegistrationStatus(c *fiber.Ctx) error {
claims, err := middleware.GetUserFromContext(c)
if err != nil {
log.Printf("Error getting user from context: %v", err)
return utils.Unauthorized(c, "unauthorized access")
}
res, err := h.service.GetRegistrationStatus(c.Context(), claims.UserID, claims.DeviceID)
if err != nil {
return utils.InternalServerError(c, err.Error())
}
return utils.SuccessWithData(c, "Registration status retrieved successfully", res)
}
func (h *AuthenticationHandler) Login(c *fiber.Ctx) error { func (h *AuthenticationHandler) Login(c *fiber.Ctx) error {
var req LoginAdminRequest var req LoginAdminRequest

View File

@ -30,6 +30,7 @@ func AuthenticationRouter(api fiber.Router) {
// authHandler.GetMe, // authHandler.GetMe,
// ) // )
authRoute.Get("/cekapproval", middleware.AuthMiddleware(), authHandler.GetRegistrationStatus)
authRoute.Post("/login/admin", authHandler.Login) authRoute.Post("/login/admin", authHandler.Login)
authRoute.Post("/register/admin", authHandler.Register) authRoute.Post("/register/admin", authHandler.Register)
authRoute.Post("/request-otp", authHandler.RequestOtpHandler) authRoute.Post("/request-otp", authHandler.RequestOtpHandler)

View File

@ -3,6 +3,7 @@ package authentication
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"strings" "strings"
"time" "time"
@ -12,6 +13,7 @@ import (
) )
type AuthenticationService interface { type AuthenticationService interface {
GetRegistrationStatus(ctx context.Context, userID, deviceID string) (*AuthResponse, error)
LoginAdmin(ctx context.Context, req *LoginAdminRequest) (*AuthResponse, error) LoginAdmin(ctx context.Context, req *LoginAdminRequest) (*AuthResponse, error)
RegisterAdmin(ctx context.Context, req *RegisterAdminRequest) error RegisterAdmin(ctx context.Context, req *RegisterAdminRequest) error
@ -48,6 +50,78 @@ func normalizeRoleName(roleName string) string {
} }
} }
type GetRegistrationStatusResponse struct {
UserID string `json:"userId"`
RegistrationStatus string `json:"registrationStatus"`
RegistrationProgress int8 `json:"registrationProgress"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
}
func (s *authenticationService) GetRegistrationStatus(ctx context.Context, userID, deviceID string) (*AuthResponse, error) {
user, err := s.authRepo.FindUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
if user.Role.RoleName == "" {
return nil, fmt.Errorf("user role not found")
}
if user.RegistrationStatus == utils.RegStatusPending {
log.Printf("⏳ User %s (%s) registration is still pending approval", user.Name, user.Phone)
return &AuthResponse{
Message: "Your registration is currently under review. Please wait for approval.",
RegistrationStatus: user.RegistrationStatus,
NextStep: "wait_for_approval",
}, nil
}
if user.RegistrationStatus == utils.RegStatusConfirmed || user.RegistrationStatus == utils.RegStatusRejected {
tokenResponse, err := utils.GenerateTokenPair(
user.ID,
user.Role.RoleName,
deviceID,
user.RegistrationStatus,
int(user.RegistrationProgress),
)
if err != nil {
log.Printf("GenerateTokenPair error: %v", err)
return nil, fmt.Errorf("failed to generate token: %v", err)
}
nextStep := utils.GetNextRegistrationStep(
user.Role.RoleName,
int(user.RegistrationProgress),
user.RegistrationStatus,
)
var message string
if user.RegistrationStatus == utils.RegStatusConfirmed {
message = "Registration approved successfully"
log.Printf("✅ User %s (%s) registration approved - generating tokens", user.Name, user.Phone)
} else if user.RegistrationStatus == utils.RegStatusRejected {
message = "Registration has been rejected"
log.Printf("❌ User %s (%s) registration rejected - generating tokens for rejection flow", user.Name, user.Phone)
}
return &AuthResponse{
Message: message,
AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType),
ExpiresIn: tokenResponse.ExpiresIn,
RegistrationStatus: user.RegistrationStatus,
NextStep: nextStep,
SessionID: tokenResponse.SessionID,
}, nil
}
return nil, fmt.Errorf("unsupported registration status: %s", user.RegistrationStatus)
}
func (s *authenticationService) LoginAdmin(ctx context.Context, req *LoginAdminRequest) (*AuthResponse, error) { func (s *authenticationService) LoginAdmin(ctx context.Context, req *LoginAdminRequest) (*AuthResponse, error) {
user, err := s.authRepo.FindUserByEmail(ctx, req.Email) user, err := s.authRepo.FindUserByEmail(ctx, req.Email)
if err != nil { if err != nil {
@ -341,8 +415,16 @@ func (s *authenticationService) VerifyLoginOTP(ctx context.Context, req *VerifyO
user.RegistrationStatus, user.RegistrationStatus,
) )
var message string
if user.RegistrationStatus == utils.RegStatusComplete {
message = "verif pin"
nextStep = "verif_pin"
} else {
message = "otp berhasil diverifikasi"
}
return &AuthResponse{ return &AuthResponse{
Message: "otp berhasil diverifikasi", Message: message,
AccessToken: tokenResponse.AccessToken, AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken, RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType), TokenType: string(tokenResponse.TokenType),

View File

@ -14,15 +14,15 @@ type RequestCartDTO struct {
CartItems []RequestCartItemDTO `json:"cart_items"` CartItems []RequestCartItemDTO `json:"cart_items"`
} }
type CartResponse struct { type ResponseCartDTO struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
EstimatedTotalPrice float64 `json:"estimated_total_price"` EstimatedTotalPrice float64 `json:"estimated_total_price"`
CartItems []CartItemResponse `json:"cart_items"` CartItems []ResponseCartItemDTO `json:"cart_items"`
} }
type CartItemResponse struct { type ResponseCartItemDTO struct {
ID string `json:"id"` ID string `json:"id"`
TrashID string `json:"trash_id"` TrashID string `json:"trash_id"`
TrashName string `json:"trash_name"` TrashName string `json:"trash_name"`

View File

@ -1 +1,91 @@
package cart package cart
import (
"rijig/utils"
"github.com/gofiber/fiber/v2"
)
type CartHandler struct {
cartService CartService
}
func NewCartHandler(cartService CartService) *CartHandler {
return &CartHandler{cartService: cartService}
}
func (h *CartHandler) AddOrUpdateItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
var req RequestCartItemDTO
if err := c.BodyParser(&req); err != nil {
return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Payload tidak valid", map[string][]string{
"request": {"Payload tidak valid"},
})
}
hasErrors := req.Amount <= 0 || req.TrashID == ""
if hasErrors {
errs := make(map[string][]string)
if req.Amount <= 0 {
errs["amount"] = append(errs["amount"], "Amount harus lebih dari 0")
}
if req.TrashID == "" {
errs["trash_id"] = append(errs["trash_id"], "Trash ID tidak boleh kosong")
}
return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validasi gagal", errs)
}
if err := h.cartService.AddOrUpdateItem(c.Context(), userID, req); err != nil {
return utils.InternalServerError(c, "Gagal menambahkan item ke keranjang")
}
return utils.Success(c, "Item berhasil ditambahkan ke keranjang")
}
func (h *CartHandler) GetCart(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
cart, err := h.cartService.GetCart(c.Context(), userID)
if err != nil {
return utils.InternalServerError(c, "Gagal mengambil data keranjang")
}
return utils.SuccessWithData(c, "Berhasil mengambil data keranjang", cart)
}
func (h *CartHandler) DeleteItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
trashID := c.Params("trash_id")
if trashID == "" {
return utils.BadRequest(c, "Trash ID tidak boleh kosong")
}
if err := h.cartService.DeleteItem(c.Context(), userID, trashID); err != nil {
return utils.InternalServerError(c, "Gagal menghapus item dari keranjang")
}
return utils.Success(c, "Item berhasil dihapus dari keranjang")
}
func (h *CartHandler) Checkout(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
if err := h.cartService.Checkout(c.Context(), userID); err != nil {
return utils.InternalServerError(c, "Gagal melakukan checkout keranjang")
}
return utils.Success(c, "Checkout berhasil. Permintaan pickup telah dibuat.")
}
func (h *CartHandler) ClearCart(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
err := h.cartService.ClearCart(c.Context(), userID)
if err != nil {
return utils.InternalServerError(c, "Gagal menghapus keranjang")
}
return utils.Success(c, "Keranjang berhasil dikosongkan")
}

View File

@ -0,0 +1,72 @@
package cart
import (
"context"
"encoding/json"
"fmt"
"time"
"rijig/config"
"github.com/go-redis/redis/v8"
)
const CartTTL = 30 * time.Minute
const CartKeyPrefix = "cart:"
func buildCartKey(userID string) string {
return fmt.Sprintf("%s%s", CartKeyPrefix, userID)
}
func SetCartToRedis(ctx context.Context, userID string, cart RequestCartDTO) error {
data, err := json.Marshal(cart)
if err != nil {
return err
}
return config.RedisClient.Set(ctx, buildCartKey(userID), data, CartTTL).Err()
}
func RefreshCartTTL(ctx context.Context, userID string) error {
return config.RedisClient.Expire(ctx, buildCartKey(userID), CartTTL).Err()
}
func GetCartFromRedis(ctx context.Context, userID string) (*RequestCartDTO, error) {
val, err := config.RedisClient.Get(ctx, buildCartKey(userID)).Result()
if err == redis.Nil {
return nil, nil
} else if err != nil {
return nil, err
}
var cart RequestCartDTO
if err := json.Unmarshal([]byte(val), &cart); err != nil {
return nil, err
}
return &cart, nil
}
func DeleteCartFromRedis(ctx context.Context, userID string) error {
return config.RedisClient.Del(ctx, buildCartKey(userID)).Err()
}
func GetExpiringCartKeys(ctx context.Context, threshold time.Duration) ([]string, error) {
keys, err := config.RedisClient.Keys(ctx, CartKeyPrefix+"*").Result()
if err != nil {
return nil, err
}
var expiringKeys []string
for _, key := range keys {
ttl, err := config.RedisClient.TTL(ctx, key).Result()
if err != nil {
continue
}
if ttl > 0 && ttl <= threshold {
expiringKeys = append(expiringKeys, key)
}
}
return expiringKeys, nil
}

View File

@ -1 +1,24 @@
package cart package cart
import (
"rijig/config"
"rijig/internal/trash"
"rijig/middleware"
"github.com/gofiber/fiber/v2"
)
func TrashCartRouter(api fiber.Router) {
repo := NewCartRepository()
trashRepo := trash.NewTrashRepository(config.DB)
cartService := NewCartService(repo, trashRepo)
cartHandler := NewCartHandler(cartService)
cart := api.Group("/cart")
cart.Use(middleware.AuthMiddleware())
cart.Get("/", cartHandler.GetCart)
cart.Post("/item", cartHandler.AddOrUpdateItem)
cart.Delete("/item/:trash_id", cartHandler.DeleteItem)
cart.Delete("/clear", cartHandler.ClearCart)
}

View File

@ -2,355 +2,266 @@ package cart
import ( import (
"context" "context"
"fmt" "errors"
"log" "log"
"time"
// "rijig/dto"
// "rijig/internal/repositories"
"rijig/internal/trash" "rijig/internal/trash"
"rijig/model" "rijig/model"
"rijig/utils"
"github.com/google/uuid"
) )
type CartService struct { type CartService interface {
cartRepo CartRepository AddOrUpdateItem(ctx context.Context, userID string, req RequestCartItemDTO) error
GetCart(ctx context.Context, userID string) (*ResponseCartDTO, error)
DeleteItem(ctx context.Context, userID string, trashID string) error
ClearCart(ctx context.Context, userID string) error
Checkout(ctx context.Context, userID string) error
}
type cartService struct {
repo CartRepository
trashRepo trash.TrashRepositoryInterface trashRepo trash.TrashRepositoryInterface
} }
func NewCartService(cartRepo CartRepository, trashRepo trash.TrashRepositoryInterface) *CartService { func NewCartService(repo CartRepository, trashRepo trash.TrashRepositoryInterface) CartService {
return &CartService{ return &cartService{repo, trashRepo}
cartRepo: cartRepo,
trashRepo: trashRepo,
}
} }
func (s *CartService) AddToCart(ctx context.Context, userID, trashCategoryID string, amount float64) error { func (s *cartService) AddOrUpdateItem(ctx context.Context, userID string, req RequestCartItemDTO) error {
cartKey := fmt.Sprintf("cart:%s", userID) if req.Amount <= 0 {
return errors.New("amount harus lebih dari 0")
var cartItems map[string]model.CartItem
err := utils.GetCache(cartKey, &cartItems)
if err != nil && err.Error() != "ErrCacheMiss" {
return fmt.Errorf("failed to get cart from cache: %w", err)
} }
if cartItems == nil { _, err := s.trashRepo.GetTrashCategoryByID(ctx, req.TrashID)
cartItems = make(map[string]model.CartItem)
}
trashCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, trashCategoryID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get trash category: %w", err) return err
} }
cartItems[trashCategoryID] = model.CartItem{ existingCart, err := GetCartFromRedis(ctx, userID)
TrashCategoryID: trashCategoryID,
Amount: amount,
SubTotalEstimatedPrice: amount * float64(trashCategory.EstimatedPrice),
}
return utils.SetCache(cartKey, cartItems, 24*time.Hour)
}
func (s *CartService) RemoveFromCart(ctx context.Context, userID, trashCategoryID string) error {
cartKey := fmt.Sprintf("cart:%s", userID)
var cartItems map[string]model.CartItem
err := utils.GetCache(cartKey, &cartItems)
if err != nil { if err != nil {
if err.Error() == "ErrCacheMiss" { return err
return nil
}
return fmt.Errorf("failed to get cart from cache: %w", err)
} }
delete(cartItems, trashCategoryID) if existingCart == nil {
existingCart = &RequestCartDTO{
if len(cartItems) == 0 { CartItems: []RequestCartItemDTO{},
return utils.DeleteCache(cartKey) }
} }
return utils.SetCache(cartKey, cartItems, 24*time.Hour) updated := false
} for i, item := range existingCart.CartItems {
if item.TrashID == req.TrashID {
func (s *CartService) ClearCart(userID string) error { existingCart.CartItems[i].Amount = req.Amount
cartKey := fmt.Sprintf("cart:%s", userID) updated = true
return utils.DeleteCache(cartKey) break
}
func (s *CartService) GetCartFromRedis(ctx context.Context, userID string) (*CartResponse, error) {
cartKey := fmt.Sprintf("cart:%s", userID)
var cartItems map[string]model.CartItem
err := utils.GetCache(cartKey, &cartItems)
if err != nil {
if err.Error() == "ErrCacheMiss" {
return &CartResponse{
ID: "N/A",
UserID: userID,
TotalAmount: 0,
EstimatedTotalPrice: 0,
CartItems: []CartItemResponse{},
}, nil
} }
return nil, fmt.Errorf("failed to get cart from cache: %w", err)
} }
var totalAmount float64 if !updated {
var estimatedTotal float64 existingCart.CartItems = append(existingCart.CartItems, RequestCartItemDTO{
var cartItemDTOs []CartItemResponse TrashID: req.TrashID,
Amount: req.Amount,
for _, item := range cartItems {
trashCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, item.TrashCategoryID)
if err != nil {
log.Printf("Failed to get trash category %s: %v", item.TrashCategoryID, err)
continue
}
totalAmount += item.Amount
estimatedTotal += item.SubTotalEstimatedPrice
cartItemDTOs = append(cartItemDTOs, CartItemResponse{
ID: uuid.NewString(),
TrashID: trashCategory.ID,
TrashName: trashCategory.Name,
TrashIcon: trashCategory.IconTrash,
TrashPrice: float64(trashCategory.EstimatedPrice),
Amount: item.Amount,
SubTotalEstimatedPrice: item.SubTotalEstimatedPrice,
}) })
} }
resp := &CartResponse{ return SetCartToRedis(ctx, userID, *existingCart)
ID: "N/A",
UserID: userID,
TotalAmount: totalAmount,
EstimatedTotalPrice: estimatedTotal,
CartItems: cartItemDTOs,
}
return resp, nil
} }
func (s *CartService) CommitCartToDatabase(ctx context.Context, userID string) error { func (s *cartService) GetCart(ctx context.Context, userID string) (*ResponseCartDTO, error) {
cartKey := fmt.Sprintf("cart:%s", userID)
var cartItems map[string]model.CartItem cached, err := GetCartFromRedis(ctx, userID)
err := utils.GetCache(cartKey, &cartItems)
if err != nil { if err != nil {
if err.Error() == "ErrCacheMiss" { return nil, err
log.Printf("No cart items found in Redis for user: %s", userID)
return fmt.Errorf("no cart items found")
}
return fmt.Errorf("failed to get cart from cache: %w", err)
} }
if len(cartItems) == 0 { if cached != nil {
log.Printf("No items to commit for user: %s", userID)
return fmt.Errorf("no items to commit")
}
hasCart, err := s.cartRepo.HasExistingCart(ctx, userID) if err := RefreshCartTTL(ctx, userID); err != nil {
if err != nil { log.Printf("Warning: Failed to refresh cart TTL for user %s: %v", userID, err)
return fmt.Errorf("failed to check existing cart: %w", err)
}
var cart *model.Cart
if hasCart {
cart, err = s.cartRepo.GetCartByUser(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get existing cart: %w", err)
}
} else {
cart, err = s.cartRepo.FindOrCreateCart(ctx, userID)
if err != nil {
return fmt.Errorf("failed to create cart: %w", err)
}
}
for _, item := range cartItems {
trashCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, item.TrashCategoryID)
if err != nil {
log.Printf("Trash category not found for trashID: %s", item.TrashCategoryID)
continue
} }
err = s.cartRepo.AddOrUpdateCartItem( return s.buildResponseFromCache(ctx, userID, cached)
ctx,
cart.ID,
item.TrashCategoryID,
item.Amount,
float64(trashCategory.EstimatedPrice),
)
if err != nil {
log.Printf("Failed to add/update cart item: %v", err)
continue
}
} }
if err := s.cartRepo.UpdateCartTotals(ctx, cart.ID); err != nil { cart, err := s.repo.GetCartByUser(ctx, userID)
return fmt.Errorf("failed to update cart totals: %w", err)
}
if err := utils.DeleteCache(cartKey); err != nil {
log.Printf("Failed to clear Redis cart: %v", err)
}
log.Printf("Cart committed successfully for user: %s", userID)
return nil
}
func (s *CartService) GetCart(ctx context.Context, userID string) (*CartResponse, error) {
cartRedis, err := s.GetCartFromRedis(ctx, userID)
if err == nil && len(cartRedis.CartItems) > 0 {
return cartRedis, nil
}
cartDB, err := s.cartRepo.GetCartByUser(ctx, userID)
if err != nil { if err != nil {
return &CartResponse{ return &ResponseCartDTO{
ID: "N/A", ID: "",
UserID: userID, UserID: userID,
TotalAmount: 0, TotalAmount: 0,
EstimatedTotalPrice: 0, EstimatedTotalPrice: 0,
CartItems: []CartItemResponse{}, CartItems: []ResponseCartItemDTO{},
}, nil }, nil
} }
var items []CartItemResponse response := s.buildResponseFromDB(cart)
for _, item := range cartDB.CartItems {
items = append(items, CartItemResponse{ cacheData := RequestCartDTO{CartItems: []RequestCartItemDTO{}}
for _, item := range cart.CartItems {
cacheData.CartItems = append(cacheData.CartItems, RequestCartItemDTO{
TrashID: item.TrashCategoryID,
Amount: item.Amount,
})
}
if err := SetCartToRedis(ctx, userID, cacheData); err != nil {
log.Printf("Warning: Failed to cache cart for user %s: %v", userID, err)
}
return response, nil
}
func (s *cartService) DeleteItem(ctx context.Context, userID string, trashID string) error {
existingCart, err := GetCartFromRedis(ctx, userID)
if err != nil {
return err
}
if existingCart == nil {
return errors.New("keranjang tidak ditemukan")
}
filtered := []RequestCartItemDTO{}
for _, item := range existingCart.CartItems {
if item.TrashID != trashID {
filtered = append(filtered, item)
}
}
existingCart.CartItems = filtered
return SetCartToRedis(ctx, userID, *existingCart)
}
func (s *cartService) ClearCart(ctx context.Context, userID string) error {
if err := DeleteCartFromRedis(ctx, userID); err != nil {
return err
}
return s.repo.DeleteCart(ctx, userID)
}
func (s *cartService) Checkout(ctx context.Context, userID string) error {
cachedCart, err := GetCartFromRedis(ctx, userID)
if err != nil {
return err
}
if cachedCart != nil {
if err := s.commitCartFromRedis(ctx, userID, cachedCart); err != nil {
return err
}
}
_, err = s.repo.GetCartByUser(ctx, userID)
if err != nil {
return err
}
DeleteCartFromRedis(ctx, userID)
return s.repo.DeleteCart(ctx, userID)
}
func (s *cartService) buildResponseFromCache(ctx context.Context, userID string, cached *RequestCartDTO) (*ResponseCartDTO, error) {
totalQty := 0.0
totalPrice := 0.0
items := []ResponseCartItemDTO{}
for _, item := range cached.CartItems {
trash, err := s.trashRepo.GetTrashCategoryByID(ctx, item.TrashID)
if err != nil {
log.Printf("Warning: Trash category %s not found for cached cart item", item.TrashID)
continue
}
subtotal := item.Amount * trash.EstimatedPrice
totalQty += item.Amount
totalPrice += subtotal
items = append(items, ResponseCartItemDTO{
ID: "",
TrashID: item.TrashID,
TrashName: trash.Name,
TrashIcon: trash.IconTrash,
TrashPrice: trash.EstimatedPrice,
Amount: item.Amount,
SubTotalEstimatedPrice: subtotal,
})
}
return &ResponseCartDTO{
ID: "-",
UserID: userID,
TotalAmount: totalQty,
EstimatedTotalPrice: totalPrice,
CartItems: items,
}, nil
}
func (s *cartService) buildResponseFromDB(cart *model.Cart) *ResponseCartDTO {
var items []ResponseCartItemDTO
for _, item := range cart.CartItems {
items = append(items, ResponseCartItemDTO{
ID: item.ID, ID: item.ID,
TrashID: item.TrashCategoryID, TrashID: item.TrashCategoryID,
TrashName: item.TrashCategory.Name, TrashName: item.TrashCategory.Name,
TrashIcon: item.TrashCategory.IconTrash, TrashIcon: item.TrashCategory.IconTrash,
TrashPrice: float64(item.TrashCategory.EstimatedPrice), TrashPrice: item.TrashCategory.EstimatedPrice,
Amount: item.Amount, Amount: item.Amount,
SubTotalEstimatedPrice: item.SubTotalEstimatedPrice, SubTotalEstimatedPrice: item.SubTotalEstimatedPrice,
}) })
} }
resp := &CartResponse{ return &ResponseCartDTO{
ID: cartDB.ID, ID: cart.ID,
UserID: cartDB.UserID, UserID: cart.UserID,
TotalAmount: cartDB.TotalAmount, TotalAmount: cart.TotalAmount,
EstimatedTotalPrice: cartDB.EstimatedTotalPrice, EstimatedTotalPrice: cart.EstimatedTotalPrice,
CartItems: items, CartItems: items,
} }
return resp, nil
} }
func (s *CartService) SyncCartFromDatabaseToRedis(ctx context.Context, userID string) error { func (s *cartService) commitCartFromRedis(ctx context.Context, userID string, cachedCart *RequestCartDTO) error {
if len(cachedCart.CartItems) == 0 {
cartDB, err := s.cartRepo.GetCartByUser(ctx, userID) return nil
if err != nil {
return fmt.Errorf("failed to get cart from database: %w", err)
} }
cartItems := make(map[string]model.CartItem) totalAmount := 0.0
for _, item := range cartDB.CartItems { totalPrice := 0.0
cartItems[item.TrashCategoryID] = model.CartItem{ var cartItems []model.CartItem
TrashCategoryID: item.TrashCategoryID,
for _, item := range cachedCart.CartItems {
trash, err := s.trashRepo.GetTrashCategoryByID(ctx, item.TrashID)
if err != nil {
log.Printf("Warning: Skipping invalid trash category %s during commit", item.TrashID)
continue
}
subtotal := item.Amount * trash.EstimatedPrice
totalAmount += item.Amount
totalPrice += subtotal
cartItems = append(cartItems, model.CartItem{
TrashCategoryID: item.TrashID,
Amount: item.Amount, Amount: item.Amount,
SubTotalEstimatedPrice: item.SubTotalEstimatedPrice,
}
}
cartKey := fmt.Sprintf("cart:%s", userID)
return utils.SetCache(cartKey, cartItems, 24*time.Hour)
}
func (s *CartService) GetCartItemCount(userID string) (int, error) {
cartKey := fmt.Sprintf("cart:%s", userID)
var cartItems map[string]model.CartItem
err := utils.GetCache(cartKey, &cartItems)
if err != nil {
if err.Error() == "ErrCacheMiss" {
return 0, nil
}
return 0, fmt.Errorf("failed to get cart from cache: %w", err)
}
return len(cartItems), nil
}
func (s *CartService) DeleteCart(ctx context.Context, userID string) error {
cartKey := fmt.Sprintf("cart:%s", userID)
if err := utils.DeleteCache(cartKey); err != nil {
log.Printf("Failed to delete cart from Redis: %v", err)
}
return s.cartRepo.DeleteCart(ctx, userID)
}
func (s *CartService) UpdateCartWithDTO(ctx context.Context, userID string, cartDTO *RequestCartDTO) error {
if errors, valid := cartDTO.ValidateRequestCartDTO(); !valid {
return fmt.Errorf("validation failed: %v", errors)
}
cartKey := fmt.Sprintf("cart:%s", userID)
cartItems := make(map[string]model.CartItem)
for _, itemDTO := range cartDTO.CartItems {
trashCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, itemDTO.TrashID)
if err != nil {
log.Printf("Failed to get trash category %s: %v", itemDTO.TrashID, err)
continue
}
subtotal := itemDTO.Amount * float64(trashCategory.EstimatedPrice)
cartItems[itemDTO.TrashID] = model.CartItem{
TrashCategoryID: itemDTO.TrashID,
Amount: itemDTO.Amount,
SubTotalEstimatedPrice: subtotal, SubTotalEstimatedPrice: subtotal,
} })
} }
return utils.SetCache(cartKey, cartItems, 24*time.Hour) if len(cartItems) == 0 {
} return nil
}
func (s *CartService) AddItemsToCart(ctx context.Context, userID string, items []RequestCartItemDTO) error {
cartKey := fmt.Sprintf("cart:%s", userID) newCart := &model.Cart{
UserID: userID,
var cartItems map[string]model.CartItem TotalAmount: totalAmount,
err := utils.GetCache(cartKey, &cartItems) EstimatedTotalPrice: totalPrice,
if err != nil && err.Error() != "ErrCacheMiss" { CartItems: cartItems,
return fmt.Errorf("failed to get cart from cache: %w", err) }
}
return s.repo.CreateCartWithItems(ctx, newCart)
if cartItems == nil {
cartItems = make(map[string]model.CartItem)
}
for _, itemDTO := range items {
if itemDTO.TrashID == "" {
continue
}
trashCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, itemDTO.TrashID)
if err != nil {
log.Printf("Failed to get trash category %s: %v", itemDTO.TrashID, err)
continue
}
subtotal := itemDTO.Amount * float64(trashCategory.EstimatedPrice)
cartItems[itemDTO.TrashID] = model.CartItem{
TrashCategoryID: itemDTO.TrashID,
Amount: itemDTO.Amount,
SubTotalEstimatedPrice: subtotal,
}
}
return utils.SetCache(cartKey, cartItems, 24*time.Hour)
} }

View File

@ -0,0 +1 @@
package model

View File

@ -7,6 +7,20 @@ import (
"strings" "strings"
"time" "time"
) )
type NearbyCollectorDTO struct {
CollectorID string `json:"collector_id"`
Name string `json:"name"`
Phone string `json:"phone"`
Rating float32 `json:"rating"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
DistanceKm float64 `json:"distance_km"`
MatchedTrash []string `json:"matched_trash_ids"`
}
type SelectCollectorRequest struct {
Collector_id string `json:"collector_id"`
}
type CreateCollectorRequest struct { type CreateCollectorRequest struct {
UserID string `json:"user_id" binding:"required"` UserID string `json:"user_id" binding:"required"`

View File

@ -30,6 +30,9 @@ type CollectorRepository interface {
BulkUpdateAvailableTrash(ctx context.Context, collectorID string, availableTrashList []*model.AvaibleTrashByCollector) error BulkUpdateAvailableTrash(ctx context.Context, collectorID string, availableTrashList []*model.AvaibleTrashByCollector) error
DeleteAvailableTrashByCollectorID(ctx context.Context, collectorID string) error DeleteAvailableTrashByCollectorID(ctx context.Context, collectorID string) error
GetActiveCollectorsWithTrashAndAddress(ctx context.Context) ([]model.Collector, error)
GetCollectorWithAddressAndTrash(ctx context.Context, collectorID string) (*model.Collector, error)
WithTx(tx *gorm.DB) CollectorRepository WithTx(tx *gorm.DB) CollectorRepository
} }
@ -56,6 +59,36 @@ func (r *collectorRepository) Create(ctx context.Context, collector *model.Colle
return nil return nil
} }
func (r *collectorRepository) GetActiveCollectorsWithTrashAndAddress(ctx context.Context) ([]model.Collector, error) {
var collectors []model.Collector
err := r.db.WithContext(ctx).
Preload("User").
Preload("Address").
Preload("AvaibleTrashbyCollector.TrashCategory").
Where("job_status = ?", "active").
Find(&collectors).Error
if err != nil {
return nil, err
}
return collectors, nil
}
func (r *collectorRepository) GetCollectorWithAddressAndTrash(ctx context.Context, collectorID string) (*model.Collector, error) {
var collector model.Collector
err := r.db.WithContext(ctx).
Preload("Address").
Preload("AvaibleTrashbyCollector").
Where("id = ?", collectorID).
First(&collector).Error
if err != nil {
return nil, err
}
return &collector, nil
}
func (r *collectorRepository) GetByID(ctx context.Context, id string) (*model.Collector, error) { func (r *collectorRepository) GetByID(ctx context.Context, id string) (*model.Collector, error) {
var collector model.Collector var collector model.Collector

View File

@ -39,19 +39,19 @@ func (r *RequestCompanyProfileDTO) ValidateCompanyProfileInput() (map[string][]s
errors := make(map[string][]string) errors := make(map[string][]string)
if strings.TrimSpace(r.CompanyName) == "" { if strings.TrimSpace(r.CompanyName) == "" {
errors["company_Name"] = append(errors["company_name"], "Company name is required") errors["company_name"] = append(errors["company_name"], "Company name is required")
} }
if strings.TrimSpace(r.CompanyAddress) == "" { if strings.TrimSpace(r.CompanyAddress) == "" {
errors["company_Address"] = append(errors["company_address"], "Company address is required") errors["company_address"] = append(errors["company_address"], "Company address is required")
} }
if !utils.IsValidPhoneNumber(r.CompanyPhone) { if !utils.IsValidPhoneNumber(r.CompanyPhone) {
errors["company_Phone"] = append(errors["company_phone"], "nomor harus dimulai 62.. dan 8-14 digit") errors["company_phone"] = append(errors["company_phone"], "nomor harus dimulai 62.. dan 8-14 digit")
} }
if strings.TrimSpace(r.CompanyDescription) == "" { if strings.TrimSpace(r.CompanyDescription) == "" {
errors["company_Description"] = append(errors["company_description"], "Company description is required") errors["company_description"] = append(errors["company_description"], "Company description is required")
} }
if len(errors) > 0 { if len(errors) > 0 {

View File

@ -2,7 +2,10 @@ package company
import ( import (
"context" "context"
"log"
"rijig/middleware"
"rijig/utils" "rijig/utils"
"strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@ -18,9 +21,10 @@ func NewCompanyProfileHandler(service CompanyProfileService) *CompanyProfileHand
} }
func (h *CompanyProfileHandler) CreateCompanyProfile(c *fiber.Ctx) error { func (h *CompanyProfileHandler) CreateCompanyProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(string) claims, err := middleware.GetUserFromContext(c)
if !ok || userID == "" { if err != nil {
return utils.Unauthorized(c, "User not authenticated") log.Printf("Error getting user from context: %v", err)
return utils.Unauthorized(c, "unauthorized access")
} }
var req RequestCompanyProfileDTO var req RequestCompanyProfileDTO
@ -32,9 +36,19 @@ func (h *CompanyProfileHandler) CreateCompanyProfile(c *fiber.Ctx) error {
return utils.ResponseErrorData(c, fiber.StatusBadRequest, "validation failed", errors) return utils.ResponseErrorData(c, fiber.StatusBadRequest, "validation failed", errors)
} }
res, err := h.service.CreateCompanyProfile(context.Background(), userID, &req) companyLogo, err := c.FormFile("company_logo")
if err != nil { if err != nil {
return utils.InternalServerError(c, err.Error()) log.Printf("Error getting company logo: %v", err)
return utils.BadRequest(c, "company logo is required")
}
res, err := h.service.CreateCompanyProfile(c.Context(), claims.UserID, claims.DeviceID, &req, companyLogo)
if err != nil {
log.Printf("Error creating identity card: %v", err)
if strings.Contains(err.Error(), "invalid file type") {
return utils.BadRequest(c, err.Error())
}
return utils.InternalServerError(c, "Failed to create company logo")
} }
return utils.SuccessWithData(c, "company profile created successfully", res) return utils.SuccessWithData(c, "company profile created successfully", res)

View File

@ -3,6 +3,7 @@ package company
import ( import (
"rijig/config" "rijig/config"
"rijig/internal/authentication" "rijig/internal/authentication"
"rijig/internal/userprofile"
"rijig/middleware" "rijig/middleware"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -11,7 +12,8 @@ import (
func CompanyRouter(api fiber.Router) { func CompanyRouter(api fiber.Router) {
companyProfileRepo := NewCompanyProfileRepository(config.DB) companyProfileRepo := NewCompanyProfileRepository(config.DB)
authRepo := authentication.NewAuthenticationRepository(config.DB) authRepo := authentication.NewAuthenticationRepository(config.DB)
companyProfileService := NewCompanyProfileService(companyProfileRepo, authRepo) userRepo := userprofile.NewUserProfileRepository(config.DB)
companyProfileService := NewCompanyProfileService(companyProfileRepo, authRepo, userRepo)
companyProfileHandler := NewCompanyProfileHandler(companyProfileService) companyProfileHandler := NewCompanyProfileHandler(companyProfileService)
companyProfileAPI := api.Group("/companyprofile") companyProfileAPI := api.Group("/companyprofile")

View File

@ -2,8 +2,13 @@ package company
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"log" "log"
"mime/multipart"
"os"
"path/filepath"
"rijig/internal/authentication" "rijig/internal/authentication"
"rijig/internal/role" "rijig/internal/role"
"rijig/internal/userprofile" "rijig/internal/userprofile"
@ -13,7 +18,7 @@ import (
) )
type CompanyProfileService interface { type CompanyProfileService interface {
CreateCompanyProfile(ctx context.Context, userID string, request *RequestCompanyProfileDTO) (*ResponseCompanyProfileDTO, error) CreateCompanyProfile(ctx context.Context, userID, deviceID string, request *RequestCompanyProfileDTO, companyLogo *multipart.FileHeader) (*authentication.AuthResponse, error)
GetCompanyProfileByID(ctx context.Context, id string) (*ResponseCompanyProfileDTO, error) GetCompanyProfileByID(ctx context.Context, id string) (*ResponseCompanyProfileDTO, error)
GetCompanyProfilesByUserID(ctx context.Context, userID string) ([]ResponseCompanyProfileDTO, error) GetCompanyProfilesByUserID(ctx context.Context, userID string) ([]ResponseCompanyProfileDTO, error)
UpdateCompanyProfile(ctx context.Context, userID string, request *RequestCompanyProfileDTO) (*ResponseCompanyProfileDTO, error) UpdateCompanyProfile(ctx context.Context, userID string, request *RequestCompanyProfileDTO) (*ResponseCompanyProfileDTO, error)
@ -25,12 +30,15 @@ type CompanyProfileService interface {
type companyProfileService struct { type companyProfileService struct {
companyRepo CompanyProfileRepository companyRepo CompanyProfileRepository
authRepo authentication.AuthenticationRepository authRepo authentication.AuthenticationRepository
userRepo userprofile.UserProfileRepository
} }
func NewCompanyProfileService(companyRepo CompanyProfileRepository, authRepo authentication.AuthenticationRepository) CompanyProfileService { func NewCompanyProfileService(companyRepo CompanyProfileRepository,
authRepo authentication.AuthenticationRepository,
userRepo userprofile.UserProfileRepository) CompanyProfileService {
return &companyProfileService{ return &companyProfileService{
companyRepo, authRepo, companyRepo, authRepo, userRepo,
} }
} }
@ -56,10 +64,73 @@ func FormatResponseCompanyProfile(companyProfile *model.CompanyProfile) (*Respon
}, nil }, nil
} }
func (s *companyProfileService) CreateCompanyProfile(ctx context.Context, userID string, request *RequestCompanyProfileDTO) (*ResponseCompanyProfileDTO, error) { func (s *companyProfileService) saveCompanyLogo(userID string, companyLogo *multipart.FileHeader) (string, error) {
// if errors, valid := request.ValidateCompanyProfileInput(); !valid { pathImage := "/uploads/companyprofile/"
// return nil, fmt.Errorf("validation failed: %v", errors) companyLogoDir := "./public" + os.Getenv("BASE_URL") + pathImage
// } if _, err := os.Stat(companyLogoDir); os.IsNotExist(err) {
if err := os.MkdirAll(companyLogoDir, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create directory for company logo: %v", err)
}
}
allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
extension := filepath.Ext(companyLogo.Filename)
if !allowedExtensions[extension] {
return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, and .png are allowed")
}
companyLogoFileName := fmt.Sprintf("%s_companylogo%s", userID, extension)
companyLogoPath := filepath.Join(companyLogoDir, companyLogoFileName)
src, err := companyLogo.Open()
if err != nil {
return "", fmt.Errorf("failed to open uploaded file: %v", err)
}
defer src.Close()
dst, err := os.Create(companyLogoPath)
if err != nil {
return "", fmt.Errorf("failed to create company logo: %v", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", fmt.Errorf("failed to save company logo: %v", err)
}
companyLogoURL := fmt.Sprintf("%s%s", pathImage, companyLogoFileName)
return companyLogoURL, nil
}
func deleteIdentityCardImage(imagePath string) error {
if imagePath == "" {
return nil
}
baseDir := "./public/" + os.Getenv("BASE_URL")
absolutePath := baseDir + imagePath
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
return fmt.Errorf("image file not found: %v", err)
}
err := os.Remove(absolutePath)
if err != nil {
return fmt.Errorf("failed to delete image: %v", err)
}
log.Printf("Image deleted successfully: %s", absolutePath)
return nil
}
func (s *companyProfileService) CreateCompanyProfile(ctx context.Context, userID, deviceID string, request *RequestCompanyProfileDTO, companyLogo *multipart.FileHeader) (*authentication.AuthResponse, error) {
companyLogoPath, err := s.saveCompanyLogo(userID, companyLogo)
if err != nil {
return nil, fmt.Errorf("failed to save company logo: %v", err)
}
companyProfile := &model.CompanyProfile{ companyProfile := &model.CompanyProfile{
UserID: userID, UserID: userID,
@ -67,7 +138,7 @@ func (s *companyProfileService) CreateCompanyProfile(ctx context.Context, userID
CompanyAddress: request.CompanyAddress, CompanyAddress: request.CompanyAddress,
CompanyPhone: request.CompanyPhone, CompanyPhone: request.CompanyPhone,
CompanyEmail: request.CompanyEmail, CompanyEmail: request.CompanyEmail,
CompanyLogo: request.CompanyLogo, CompanyLogo: companyLogoPath,
CompanyWebsite: request.CompanyWebsite, CompanyWebsite: request.CompanyWebsite,
TaxID: request.TaxID, TaxID: request.TaxID,
FoundedDate: request.FoundedDate, FoundedDate: request.FoundedDate,
@ -75,12 +146,72 @@ func (s *companyProfileService) CreateCompanyProfile(ctx context.Context, userID
CompanyDescription: request.CompanyDescription, CompanyDescription: request.CompanyDescription,
} }
created, err := s.companyRepo.CreateCompanyProfile(ctx, companyProfile) _, err = s.companyRepo.CreateCompanyProfile(ctx, companyProfile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return FormatResponseCompanyProfile(created) user, err := s.authRepo.FindUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to find user: %v", err)
}
if user.Role.RoleName == "" {
return nil, fmt.Errorf("user role not found")
}
updates := map[string]interface{}{
"registration_progress": utils.ProgressDataSubmitted,
"registration_status": utils.RegStatusPending,
}
err = s.authRepo.PatchUser(ctx, userID, updates)
if err != nil {
return nil, fmt.Errorf("failed to update user: %v", err)
}
updated, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
if errors.Is(err, userprofile.ErrUserNotFound) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("failed to get updated user: %w", err)
}
log.Printf("Token Generation Parameters:")
log.Printf("- UserID: '%s'", user.ID)
log.Printf("- Role: '%s'", user.Role.RoleName)
log.Printf("- DeviceID: '%s'", deviceID)
log.Printf("- Registration Status: '%s'", utils.RegStatusPending)
tokenResponse, err := utils.GenerateTokenPair(
updated.ID,
updated.Role.RoleName,
deviceID,
updated.RegistrationStatus,
int(updated.RegistrationProgress),
)
if err != nil {
log.Printf("GenerateTokenPair error: %v", err)
return nil, fmt.Errorf("failed to generate token: %v", err)
}
nextStep := utils.GetNextRegistrationStep(
updated.Role.RoleName,
int(updated.RegistrationProgress),
updated.RegistrationStatus,
)
return &authentication.AuthResponse{
Message: "data usaha anda berhasil diunggah, silakan tunggu konfirmasi dari admin dalam 1x24 jam",
AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType),
ExpiresIn: tokenResponse.ExpiresIn,
RegistrationStatus: updated.RegistrationStatus,
NextStep: nextStep,
SessionID: tokenResponse.SessionID,
}, nil
} }
func (s *companyProfileService) GetCompanyProfileByID(ctx context.Context, id string) (*ResponseCompanyProfileDTO, error) { func (s *companyProfileService) GetCompanyProfileByID(ctx context.Context, id string) (*ResponseCompanyProfileDTO, error) {

View File

@ -0,0 +1,34 @@
package requestpickup
import (
"context"
"rijig/config"
"rijig/model"
)
type PickupStatusHistoryRepository interface {
CreateStatusHistory(ctx context.Context, history model.PickupStatusHistory) error
GetStatusHistoryByRequestID(ctx context.Context, requestID string) ([]model.PickupStatusHistory, error)
}
type pickupStatusHistoryRepository struct{}
func NewPickupStatusHistoryRepository() PickupStatusHistoryRepository {
return &pickupStatusHistoryRepository{}
}
func (r *pickupStatusHistoryRepository) CreateStatusHistory(ctx context.Context, history model.PickupStatusHistory) error {
return config.DB.WithContext(ctx).Create(&history).Error
}
func (r *pickupStatusHistoryRepository) GetStatusHistoryByRequestID(ctx context.Context, requestID string) ([]model.PickupStatusHistory, error) {
var histories []model.PickupStatusHistory
err := config.DB.WithContext(ctx).
Where("request_id = ?", requestID).
Order("changed_at asc").
Find(&histories).Error
if err != nil {
return nil, err
}
return histories, nil
}

View File

@ -0,0 +1,146 @@
package requestpickup
import (
"context"
"fmt"
"rijig/internal/collector"
"rijig/utils"
)
type PickupMatchingService interface {
FindNearbyCollectorsForPickup(ctx context.Context, pickupID string) ([]collector.NearbyCollectorDTO, error)
FindAvailableRequestsForCollector(ctx context.Context, collectorID string) ([]PickupRequestForCollectorDTO, error)
}
type pickupMatchingService struct {
pickupRepo RequestPickupRepository
collectorRepo collector.CollectorRepository
}
func NewPickupMatchingService(pickupRepo RequestPickupRepository,
collectorRepo collector.CollectorRepository) PickupMatchingService {
return &pickupMatchingService{
pickupRepo: pickupRepo,
collectorRepo: collectorRepo,
}
}
func (s *pickupMatchingService) FindNearbyCollectorsForPickup(ctx context.Context, pickupID string) ([]collector.NearbyCollectorDTO, error) {
pickup, err := s.pickupRepo.GetPickupWithItemsAndAddress(ctx, pickupID)
if err != nil {
return nil, fmt.Errorf("pickup tidak ditemukan: %w", err)
}
userCoord := utils.Coord{
Lat: pickup.Address.Latitude,
Lon: pickup.Address.Longitude,
}
requestedTrash := make(map[string]bool)
for _, item := range pickup.RequestItems {
requestedTrash[item.TrashCategoryId] = true
}
collectors, err := s.collectorRepo.GetActiveCollectorsWithTrashAndAddress(ctx)
if err != nil {
return nil, fmt.Errorf("gagal mengambil data collector: %w", err)
}
var result []collector.NearbyCollectorDTO
for _, col := range collectors {
coord := utils.Coord{
Lat: col.Address.Latitude,
Lon: col.Address.Longitude,
}
_, km := utils.Distance(userCoord, coord)
if km > 10 {
continue
}
var matchedTrash []string
for _, item := range col.AvaibleTrashByCollector {
if requestedTrash[item.TrashCategoryID] {
matchedTrash = append(matchedTrash, item.TrashCategoryID)
}
}
if len(matchedTrash) == 0 {
continue
}
result = append(result, collector.NearbyCollectorDTO{
CollectorID: col.ID,
Name: col.User.Name,
Phone: col.User.Phone,
Rating: col.Rating,
Latitude: col.Address.Latitude,
Longitude: col.Address.Longitude,
DistanceKm: km,
MatchedTrash: matchedTrash,
})
}
return result, nil
}
// terdpaat error seperti ini: "undefined: dto.PickupRequestForCollectorDTO" dan seprti ini: s.collectorRepo.GetCollectorWithAddressAndTrash undefined (type repositories.CollectorRepository has no field or method GetCollectorWithAddressAndTrash) pada kode berikut:
func (s *pickupMatchingService) FindAvailableRequestsForCollector(ctx context.Context, collectorID string) ([]PickupRequestForCollectorDTO, error) {
collector, err := s.collectorRepo.GetCollectorWithAddressAndTrash(ctx, collectorID)
if err != nil {
return nil, fmt.Errorf("collector tidak ditemukan: %w", err)
}
pickupList, err := s.pickupRepo.GetAllAutomaticRequestsWithAddress(ctx)
if err != nil {
return nil, fmt.Errorf("gagal mengambil pickup otomatis: %w", err)
}
collectorCoord := utils.Coord{
Lat: collector.Address.Latitude,
Lon: collector.Address.Longitude,
}
// map trash collector
collectorTrash := make(map[string]bool)
for _, t := range collector.AvaibleTrashByCollector {
collectorTrash[t.TrashCategoryID] = true
}
var results []PickupRequestForCollectorDTO
for _, p := range pickupList {
if p.StatusPickup != "waiting_collector" {
continue
}
coord := utils.Coord{
Lat: p.Address.Latitude,
Lon: p.Address.Longitude,
}
_, km := utils.Distance(collectorCoord, coord)
if km > 10 {
continue
}
match := false
var matchedTrash []string
for _, item := range p.RequestItems {
if collectorTrash[item.TrashCategoryId] {
match = true
matchedTrash = append(matchedTrash, item.TrashCategoryId)
}
}
if match {
results = append(results, PickupRequestForCollectorDTO{
PickupID: p.ID,
UserID: p.UserId,
Latitude: p.Address.Latitude,
Longitude: p.Address.Longitude,
DistanceKm: km,
MatchedTrash: matchedTrash,
})
}
}
return results, nil
}

View File

@ -0,0 +1,50 @@
package requestpickup
import (
"context"
"rijig/middleware"
"rijig/utils"
"github.com/gofiber/fiber/v2"
)
type PickupMatchingHandler struct {
service PickupMatchingService
}
func NewPickupMatchingHandler(service PickupMatchingService) *PickupMatchingHandler {
return &PickupMatchingHandler{
service: service,
}
}
func (h *PickupMatchingHandler) GetNearbyCollectorsForPickup(c *fiber.Ctx) error {
pickupID := c.Params("pickupID")
if pickupID == "" {
return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validasi gagal", map[string][]string{
"pickup_id": {"pickup ID harus disertakan"},
})
}
collectors, err := h.service.FindNearbyCollectorsForPickup(context.Background(), pickupID)
if err != nil {
return utils.InternalServerError(c, err.Error())
}
return utils.SuccessWithData(c, "Data collector terdekat berhasil diambil", collectors)
}
func (h *PickupMatchingHandler) GetAvailablePickupForCollector(c *fiber.Ctx) error {
claims, err := middleware.GetUserFromContext(c)
if err != nil {
return err
}
pickups, err := h.service.FindAvailableRequestsForCollector(context.Background(), claims.UserID)
if err != nil {
return utils.InternalServerError(c, err.Error())
}
return utils.SuccessWithData(c, "Data request pickup otomatis berhasil diambil", pickups)
}

View File

@ -1 +1,74 @@
package requestpickup package requestpickup
import (
"strings"
)
type SelectCollectorDTO struct {
CollectorID string `json:"collector_id"`
}
type UpdateRequestPickupItemDTO struct {
ItemID string `json:"item_id"`
Amount float64 `json:"actual_amount"`
}
type UpdatePickupItemsRequest struct {
Items []UpdateRequestPickupItemDTO `json:"items"`
}
func (r *SelectCollectorDTO) Validate() (map[string][]string, bool) {
errors := make(map[string][]string)
if strings.TrimSpace(r.CollectorID) == "" {
errors["collector_id"] = append(errors["collector_id"], "collector_id tidak boleh kosong")
}
if len(errors) > 0 {
return errors, false
}
return nil, true
}
type AssignedPickupDTO struct {
PickupID string `json:"pickup_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Notes string `json:"notes"`
MatchedTrash []string `json:"matched_trash"`
}
type PickupRequestForCollectorDTO struct {
PickupID string `json:"pickup_id"`
UserID string `json:"user_id"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
DistanceKm float64 `json:"distance_km"`
MatchedTrash []string `json:"matched_trash"`
}
type RequestPickupDTO struct {
AddressID string `json:"address_id"`
RequestMethod string `json:"request_method"`
Notes string `json:"notes,omitempty"`
}
func (r *RequestPickupDTO) Validate() (map[string][]string, bool) {
errors := make(map[string][]string)
if strings.TrimSpace(r.AddressID) == "" {
errors["address_id"] = append(errors["address_id"], "alamat harus dipilih")
}
method := strings.ToLower(strings.TrimSpace(r.RequestMethod))
if method != "manual" && method != "otomatis" {
errors["request_method"] = append(errors["request_method"], "harus manual atau otomatis")
}
if len(errors) > 0 {
return errors, false
}
return nil, true
}

View File

@ -1 +1,142 @@
package requestpickup package requestpickup
import (
"context"
"rijig/config"
"rijig/model"
"time"
)
type RequestPickupRepository interface {
CreateRequestPickup(ctx context.Context, pickup *model.RequestPickup) error
GetPickupWithItemsAndAddress(ctx context.Context, id string) (*model.RequestPickup, error)
GetAllAutomaticRequestsWithAddress(ctx context.Context) ([]model.RequestPickup, error)
UpdateCollectorID(ctx context.Context, pickupID, collectorID string) error
GetRequestsAssignedToCollector(ctx context.Context, collectorID string) ([]model.RequestPickup, error)
UpdatePickupStatusAndConfirmationTime(ctx context.Context, pickupID string, status string, confirmedAt time.Time) error
UpdatePickupStatus(ctx context.Context, pickupID string, status string) error
UpdateRequestPickupItemsAmountAndPrice(ctx context.Context, pickupID string, items []UpdateRequestPickupItemDTO) error
}
type requestPickupRepository struct{}
func NewRequestPickupRepository() RequestPickupRepository {
return &requestPickupRepository{}
}
func (r *requestPickupRepository) CreateRequestPickup(ctx context.Context, pickup *model.RequestPickup) error {
return config.DB.WithContext(ctx).Create(pickup).Error
}
func (r *requestPickupRepository) GetPickupWithItemsAndAddress(ctx context.Context, id string) (*model.RequestPickup, error) {
var pickup model.RequestPickup
err := config.DB.WithContext(ctx).
Preload("RequestItems").
Preload("Address").
Where("id = ?", id).
First(&pickup).Error
if err != nil {
return nil, err
}
return &pickup, nil
}
func (r *requestPickupRepository) UpdateCollectorID(ctx context.Context, pickupID, collectorID string) error {
return config.DB.WithContext(ctx).
Model(&model.RequestPickup{}).
Where("id = ?", pickupID).
Update("collector_id", collectorID).
Error
}
func (r *requestPickupRepository) GetAllAutomaticRequestsWithAddress(ctx context.Context) ([]model.RequestPickup, error) {
var pickups []model.RequestPickup
err := config.DB.WithContext(ctx).
Preload("RequestItems").
Preload("Address").
Where("request_method = ?", "otomatis").
Find(&pickups).Error
if err != nil {
return nil, err
}
return pickups, nil
}
func (r *requestPickupRepository) GetRequestsAssignedToCollector(ctx context.Context, collectorID string) ([]model.RequestPickup, error) {
var pickups []model.RequestPickup
err := config.DB.WithContext(ctx).
Preload("User").
Preload("Address").
Preload("RequestItems").
Where("collector_id = ? AND status_pickup = ?", collectorID, "waiting_collector").
Find(&pickups).Error
if err != nil {
return nil, err
}
return pickups, nil
}
func (r *requestPickupRepository) UpdatePickupStatusAndConfirmationTime(ctx context.Context, pickupID string, status string, confirmedAt time.Time) error {
return config.DB.WithContext(ctx).
Model(&model.RequestPickup{}).
Where("id = ?", pickupID).
Updates(map[string]interface{}{
"status_pickup": status,
"confirmed_by_collector_at": confirmedAt,
}).Error
}
func (r *requestPickupRepository) UpdatePickupStatus(ctx context.Context, pickupID string, status string) error {
return config.DB.WithContext(ctx).
Model(&model.RequestPickup{}).
Where("id = ?", pickupID).
Update("status_pickup", status).
Error
}
func (r *requestPickupRepository) UpdateRequestPickupItemsAmountAndPrice(ctx context.Context, pickupID string, items []UpdateRequestPickupItemDTO) error {
// ambil collector_id dulu dari pickup
var pickup model.RequestPickup
if err := config.DB.WithContext(ctx).
Select("collector_id").
Where("id = ?", pickupID).
First(&pickup).Error; err != nil {
return err
}
for _, item := range items {
var pickupItem model.RequestPickupItem
err := config.DB.WithContext(ctx).
Where("id = ? AND request_pickup_id = ?", item.ItemID, pickupID).
First(&pickupItem).Error
if err != nil {
return err
}
var price float64
err = config.DB.WithContext(ctx).
Model(&model.AvaibleTrashByCollector{}).
Where("collector_id = ? AND trash_category_id = ?", pickup.CollectorID, pickupItem.TrashCategoryId).
Select("price").
Scan(&price).Error
if err != nil {
return err
}
finalPrice := item.Amount * price
err = config.DB.WithContext(ctx).
Model(&model.RequestPickupItem{}).
Where("id = ?", item.ItemID).
Updates(map[string]interface{}{
"estimated_amount": item.Amount,
"final_price": finalPrice,
}).Error
if err != nil {
return err
}
}
return nil
}

View File

@ -32,24 +32,24 @@ type TrashRepositoryInterface interface {
GetMaxStepOrderByCategory(ctx context.Context, categoryID string) (int, error) GetMaxStepOrderByCategory(ctx context.Context, categoryID string) (int, error)
} }
type TrashRepository struct { type trashRepository struct {
db *gorm.DB db *gorm.DB
} }
func NewTrashRepository(db *gorm.DB) TrashRepositoryInterface { func NewTrashRepository(db *gorm.DB) TrashRepositoryInterface {
return &TrashRepository{ return &trashRepository{
db: db, db,
} }
} }
func (r *TrashRepository) CreateTrashCategory(ctx context.Context, category *model.TrashCategory) error { func (r *trashRepository) CreateTrashCategory(ctx context.Context, category *model.TrashCategory) error {
if err := r.db.WithContext(ctx).Create(category).Error; err != nil { if err := r.db.WithContext(ctx).Create(category).Error; err != nil {
return fmt.Errorf("failed to create trash category: %w", err) return fmt.Errorf("failed to create trash category: %w", err)
} }
return nil return nil
} }
func (r *TrashRepository) CreateTrashCategoryWithDetails(ctx context.Context, category *model.TrashCategory, details []model.TrashDetail) error { func (r *trashRepository) CreateTrashCategoryWithDetails(ctx context.Context, category *model.TrashCategory, details []model.TrashDetail) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(category).Error; err != nil { if err := tx.Create(category).Error; err != nil {
@ -75,7 +75,7 @@ func (r *TrashRepository) CreateTrashCategoryWithDetails(ctx context.Context, ca
}) })
} }
func (r *TrashRepository) UpdateTrashCategory(ctx context.Context, id string, updates map[string]interface{}) error { func (r *trashRepository) UpdateTrashCategory(ctx context.Context, id string, updates map[string]interface{}) error {
exists, err := r.CheckTrashCategoryExists(ctx, id) exists, err := r.CheckTrashCategoryExists(ctx, id)
if err != nil { if err != nil {
@ -99,7 +99,7 @@ func (r *TrashRepository) UpdateTrashCategory(ctx context.Context, id string, up
return nil return nil
} }
func (r *TrashRepository) GetAllTrashCategories(ctx context.Context) ([]model.TrashCategory, error) { func (r *trashRepository) GetAllTrashCategories(ctx context.Context) ([]model.TrashCategory, error) {
var categories []model.TrashCategory var categories []model.TrashCategory
if err := r.db.WithContext(ctx).Find(&categories).Error; err != nil { if err := r.db.WithContext(ctx).Find(&categories).Error; err != nil {
@ -109,7 +109,7 @@ func (r *TrashRepository) GetAllTrashCategories(ctx context.Context) ([]model.Tr
return categories, nil return categories, nil
} }
func (r *TrashRepository) GetAllTrashCategoriesWithDetails(ctx context.Context) ([]model.TrashCategory, error) { func (r *trashRepository) GetAllTrashCategoriesWithDetails(ctx context.Context) ([]model.TrashCategory, error) {
var categories []model.TrashCategory var categories []model.TrashCategory
if err := r.db.WithContext(ctx).Preload("Details", func(db *gorm.DB) *gorm.DB { if err := r.db.WithContext(ctx).Preload("Details", func(db *gorm.DB) *gorm.DB {
@ -121,7 +121,7 @@ func (r *TrashRepository) GetAllTrashCategoriesWithDetails(ctx context.Context)
return categories, nil return categories, nil
} }
func (r *TrashRepository) GetTrashCategoryByID(ctx context.Context, id string) (*model.TrashCategory, error) { func (r *trashRepository) GetTrashCategoryByID(ctx context.Context, id string) (*model.TrashCategory, error) {
var category model.TrashCategory var category model.TrashCategory
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&category).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ?", id).First(&category).Error; err != nil {
@ -134,7 +134,7 @@ func (r *TrashRepository) GetTrashCategoryByID(ctx context.Context, id string) (
return &category, nil return &category, nil
} }
func (r *TrashRepository) GetTrashCategoryByIDWithDetails(ctx context.Context, id string) (*model.TrashCategory, error) { func (r *trashRepository) GetTrashCategoryByIDWithDetails(ctx context.Context, id string) (*model.TrashCategory, error) {
var category model.TrashCategory var category model.TrashCategory
if err := r.db.WithContext(ctx).Preload("Details", func(db *gorm.DB) *gorm.DB { if err := r.db.WithContext(ctx).Preload("Details", func(db *gorm.DB) *gorm.DB {
@ -149,7 +149,7 @@ func (r *TrashRepository) GetTrashCategoryByIDWithDetails(ctx context.Context, i
return &category, nil return &category, nil
} }
func (r *TrashRepository) DeleteTrashCategory(ctx context.Context, id string) error { func (r *trashRepository) DeleteTrashCategory(ctx context.Context, id string) error {
exists, err := r.CheckTrashCategoryExists(ctx, id) exists, err := r.CheckTrashCategoryExists(ctx, id)
if err != nil { if err != nil {
@ -171,7 +171,7 @@ func (r *TrashRepository) DeleteTrashCategory(ctx context.Context, id string) er
return nil return nil
} }
func (r *TrashRepository) CreateTrashDetail(ctx context.Context, detail *model.TrashDetail) error { func (r *trashRepository) CreateTrashDetail(ctx context.Context, detail *model.TrashDetail) error {
exists, err := r.CheckTrashCategoryExists(ctx, detail.TrashCategoryID) exists, err := r.CheckTrashCategoryExists(ctx, detail.TrashCategoryID)
if err != nil { if err != nil {
@ -196,7 +196,7 @@ func (r *TrashRepository) CreateTrashDetail(ctx context.Context, detail *model.T
return nil return nil
} }
func (r *TrashRepository) AddTrashDetailToCategory(ctx context.Context, categoryID string, detail *model.TrashDetail) error { func (r *trashRepository) AddTrashDetailToCategory(ctx context.Context, categoryID string, detail *model.TrashDetail) error {
exists, err := r.CheckTrashCategoryExists(ctx, categoryID) exists, err := r.CheckTrashCategoryExists(ctx, categoryID)
if err != nil { if err != nil {
@ -223,7 +223,7 @@ func (r *TrashRepository) AddTrashDetailToCategory(ctx context.Context, category
return nil return nil
} }
func (r *TrashRepository) UpdateTrashDetail(ctx context.Context, id string, updates map[string]interface{}) error { func (r *trashRepository) UpdateTrashDetail(ctx context.Context, id string, updates map[string]interface{}) error {
exists, err := r.CheckTrashDetailExists(ctx, id) exists, err := r.CheckTrashDetailExists(ctx, id)
if err != nil { if err != nil {
@ -247,7 +247,7 @@ func (r *TrashRepository) UpdateTrashDetail(ctx context.Context, id string, upda
return nil return nil
} }
func (r *TrashRepository) GetTrashDetailsByCategory(ctx context.Context, categoryID string) ([]model.TrashDetail, error) { func (r *trashRepository) GetTrashDetailsByCategory(ctx context.Context, categoryID string) ([]model.TrashDetail, error) {
var details []model.TrashDetail var details []model.TrashDetail
if err := r.db.WithContext(ctx).Where("trash_category_id = ?", categoryID).Order("step_order ASC").Find(&details).Error; err != nil { if err := r.db.WithContext(ctx).Where("trash_category_id = ?", categoryID).Order("step_order ASC").Find(&details).Error; err != nil {
@ -257,7 +257,7 @@ func (r *TrashRepository) GetTrashDetailsByCategory(ctx context.Context, categor
return details, nil return details, nil
} }
func (r *TrashRepository) GetTrashDetailByID(ctx context.Context, id string) (*model.TrashDetail, error) { func (r *trashRepository) GetTrashDetailByID(ctx context.Context, id string) (*model.TrashDetail, error) {
var detail model.TrashDetail var detail model.TrashDetail
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&detail).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ?", id).First(&detail).Error; err != nil {
@ -270,7 +270,7 @@ func (r *TrashRepository) GetTrashDetailByID(ctx context.Context, id string) (*m
return &detail, nil return &detail, nil
} }
func (r *TrashRepository) DeleteTrashDetail(ctx context.Context, id string) error { func (r *trashRepository) DeleteTrashDetail(ctx context.Context, id string) error {
exists, err := r.CheckTrashDetailExists(ctx, id) exists, err := r.CheckTrashDetailExists(ctx, id)
if err != nil { if err != nil {
@ -292,7 +292,7 @@ func (r *TrashRepository) DeleteTrashDetail(ctx context.Context, id string) erro
return nil return nil
} }
func (r *TrashRepository) CheckTrashCategoryExists(ctx context.Context, id string) (bool, error) { func (r *trashRepository) CheckTrashCategoryExists(ctx context.Context, id string) (bool, error) {
var count int64 var count int64
if err := r.db.WithContext(ctx).Model(&model.TrashCategory{}).Where("id = ?", id).Count(&count).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.TrashCategory{}).Where("id = ?", id).Count(&count).Error; err != nil {
@ -302,7 +302,7 @@ func (r *TrashRepository) CheckTrashCategoryExists(ctx context.Context, id strin
return count > 0, nil return count > 0, nil
} }
func (r *TrashRepository) CheckTrashDetailExists(ctx context.Context, id string) (bool, error) { func (r *trashRepository) CheckTrashDetailExists(ctx context.Context, id string) (bool, error) {
var count int64 var count int64
if err := r.db.WithContext(ctx).Model(&model.TrashDetail{}).Where("id = ?", id).Count(&count).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.TrashDetail{}).Where("id = ?", id).Count(&count).Error; err != nil {
@ -312,7 +312,7 @@ func (r *TrashRepository) CheckTrashDetailExists(ctx context.Context, id string)
return count > 0, nil return count > 0, nil
} }
func (r *TrashRepository) GetMaxStepOrderByCategory(ctx context.Context, categoryID string) (int, error) { func (r *trashRepository) GetMaxStepOrderByCategory(ctx context.Context, categoryID string) (int, error) {
var maxOrder int var maxOrder int
if err := r.db.WithContext(ctx).Model(&model.TrashDetail{}). if err := r.db.WithContext(ctx).Model(&model.TrashDetail{}).

View File

@ -1 +1,84 @@
package trash // ===internal/trash/trash_route.go===
package trash
import (
"rijig/config"
"rijig/middleware"
"github.com/gofiber/fiber/v2"
)
func TrashRouter(api fiber.Router) {
trashRepo := NewTrashRepository(config.DB)
trashService := NewTrashService(trashRepo)
trashHandler := NewTrashHandler(trashService)
trashAPI := api.Group("/trash")
trashAPI.Use(middleware.AuthMiddleware())
// ============= TRASH CATEGORY ROUTES =============
// Create trash category (JSON)
trashAPI.Post("/category", trashHandler.CreateTrashCategory)
// Create trash category with icon (form-data)
trashAPI.Post("/category/with-icon", trashHandler.CreateTrashCategoryWithIcon)
// Create trash category with details (JSON)
trashAPI.Post("/category/with-details", trashHandler.CreateTrashCategoryWithDetails)
// Get all trash categories (with optional query param: ?with_details=true)
trashAPI.Get("/category", trashHandler.GetAllTrashCategories)
// Get trash category by ID (with optional query param: ?with_details=true)
trashAPI.Get("/category/:id", trashHandler.GetTrashCategoryByID)
// Update trash category (JSON)
trashAPI.Put("/category/:id", trashHandler.UpdateTrashCategory)
// Update trash category with icon (form-data)
trashAPI.Put("/category/:id/with-icon", trashHandler.UpdateTrashCategoryWithIcon)
// Delete trash category
trashAPI.Delete("/category/:id", trashHandler.DeleteTrashCategory)
// ============= TRASH DETAIL ROUTES =============
// Create trash detail (JSON)
trashAPI.Post("/detail", trashHandler.CreateTrashDetail)
// Create trash detail with icon (form-data)
trashAPI.Post("/detail/with-icon", trashHandler.CreateTrashDetailWithIcon)
// Add trash detail to specific category (JSON)
trashAPI.Post("/category/:categoryId/detail", trashHandler.AddTrashDetailToCategory)
// Add trash detail to specific category with icon (form-data)
trashAPI.Post("/category/:categoryId/detail/with-icon", trashHandler.AddTrashDetailToCategoryWithIcon)
// Get trash details by category ID
trashAPI.Get("/category/:categoryId/details", trashHandler.GetTrashDetailsByCategory)
// Get trash detail by ID
trashAPI.Get("/detail/:id", trashHandler.GetTrashDetailByID)
// Update trash detail (JSON)
trashAPI.Put("/detail/:id", trashHandler.UpdateTrashDetail)
// Update trash detail with icon (form-data)
trashAPI.Put("/detail/:id/with-icon", trashHandler.UpdateTrashDetailWithIcon)
// Delete trash detail
trashAPI.Delete("/detail/:id", trashHandler.DeleteTrashDetail)
// ============= BULK OPERATIONS ROUTES =============
// Bulk create trash details for specific category
trashAPI.Post("/category/:categoryId/details/bulk", trashHandler.BulkCreateTrashDetails)
// Bulk delete trash details
trashAPI.Delete("/details/bulk", trashHandler.BulkDeleteTrashDetails)
// Reorder trash details within a category
trashAPI.Put("/category/:categoryId/details/reorder", trashHandler.ReorderTrashDetails)
}

View File

@ -8,14 +8,13 @@ import (
"rijig/internal/userprofile" "rijig/internal/userprofile"
"rijig/model" "rijig/model"
"rijig/utils" "rijig/utils"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
) )
type UserPinService interface { type UserPinService interface {
CreateUserPin(ctx context.Context, userID, deviceId string, dto *RequestPinDTO) (*authentication.AuthResponse, error) CreateUserPin(ctx context.Context, userID, deviceId string, dto *RequestPinDTO) (*authentication.AuthResponse, error)
VerifyUserPin(ctx context.Context, userID, deviceID string, pin *RequestPinDTO) (*utils.TokenResponse, error) VerifyUserPin(ctx context.Context, userID, deviceID string, pin *RequestPinDTO) (*authentication.AuthResponse, error)
} }
type userPinService struct { type userPinService struct {
@ -100,7 +99,7 @@ func (s *userPinService) CreateUserPin(ctx context.Context, userID, deviceId str
) )
return &authentication.AuthResponse{ return &authentication.AuthResponse{
Message: "Isi data diri berhasil", Message: "mantap semuanya completed",
AccessToken: tokenResponse.AccessToken, AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken, RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType), TokenType: string(tokenResponse.TokenType),
@ -111,7 +110,7 @@ func (s *userPinService) CreateUserPin(ctx context.Context, userID, deviceId str
}, nil }, nil
} }
func (s *userPinService) VerifyUserPin(ctx context.Context, userID, deviceID string, pin *RequestPinDTO) (*utils.TokenResponse, error) { func (s *userPinService) VerifyUserPin(ctx context.Context, userID, deviceID string, pin *RequestPinDTO) (*authentication.AuthResponse, error) {
user, err := s.authRepo.FindUserByID(ctx, userID) user, err := s.authRepo.FindUserByID(ctx, userID)
if err != nil { if err != nil {
return nil, fmt.Errorf("user not found") return nil, fmt.Errorf("user not found")
@ -126,6 +125,42 @@ func (s *userPinService) VerifyUserPin(ctx context.Context, userID, deviceID str
return nil, fmt.Errorf("PIN does not match, %s , %s", userPin.Pin, pin.Pin) return nil, fmt.Errorf("PIN does not match, %s , %s", userPin.Pin, pin.Pin)
} }
roleName := strings.ToLower(user.Role.RoleName) // roleName := strings.ToLower(user.Role.RoleName)
return utils.GenerateTokenPair(user.ID, roleName, deviceID, user.RegistrationStatus, int(user.RegistrationProgress))
// updated, err := s.userProfileRepo.GetByID(ctx, userID)
// if err != nil {
// if errors.Is(err, userprofile.ErrUserNotFound) {
// return nil, fmt.Errorf("user not found")
// }
// return nil, fmt.Errorf("failed to get updated user: %w", err)
// }
tokenResponse, err := utils.GenerateTokenPair(
user.ID,
user.Role.RoleName,
deviceID,
user.RegistrationStatus,
int(user.RegistrationProgress),
)
if err != nil {
return nil, fmt.Errorf("gagal generate token: %v", err)
}
nextStep := utils.GetNextRegistrationStep(
user.Role.RoleName,
int(user.RegistrationProgress),
user.RegistrationStatus,
)
return &authentication.AuthResponse{
Message: "mantap semuanya completed",
AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType),
ExpiresIn: tokenResponse.ExpiresIn,
RegistrationStatus: user.RegistrationStatus,
NextStep: nextStep,
SessionID: tokenResponse.SessionID,
}, nil
} }

View File

@ -1,156 +1,311 @@
package whatsapp package whatsapp
import ( import (
"html/template" "regexp"
"path/filepath"
"rijig/config" "rijig/config"
"rijig/utils"
"strings"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type APIResponse struct { type QRResponse struct {
Meta map[string]interface{} `json:"meta"` QRCode string `json:"qr_code,omitempty"`
Data interface{} `json:"data,omitempty"` Status string `json:"status"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
} }
func WhatsAppQRPageHandler(c *fiber.Ctx) error { type StatusResponse struct {
IsConnected bool `json:"is_connected"`
IsLoggedIn bool `json:"is_logged_in"`
Status string `json:"status"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
}
type SendMessageRequest struct {
PhoneNumber string `json:"phone_number" validate:"required"`
Message string `json:"message" validate:"required"`
}
type SendMessageResponse struct {
PhoneNumber string `json:"phone_number"`
Timestamp int64 `json:"timestamp"`
}
func GenerateQRHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService() wa := config.GetWhatsAppService()
if wa == nil { if wa == nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ return utils.InternalServerError(c, "WhatsApp service not initialized")
Meta: map[string]interface{}{
"status": "error",
"message": "WhatsApp service not initialized",
},
})
} }
// Jika sudah login, tampilkan halaman success
if wa.IsLoggedIn() { if wa.IsLoggedIn() {
templatePath := filepath.Join("internal", "whatsapp", "success_scan.html") data := QRResponse{
tmpl, err := template.ParseFiles(templatePath) Status: "logged_in",
if err != nil { Message: "WhatsApp is already connected and logged in",
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ Timestamp: time.Now().Unix(),
Meta: map[string]interface{}{
"status": "error",
"message": "Unable to load success template: " + err.Error(),
},
})
} }
return utils.SuccessWithData(c, "Already logged in", data)
c.Set("Content-Type", "text/html")
return tmpl.Execute(c.Response().BodyWriter(), nil)
} }
qrDataURI, err := wa.GenerateQR() qrDataURI, err := wa.GenerateQR()
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ return utils.InternalServerError(c, "Failed to generate QR code: "+err.Error())
Meta: map[string]interface{}{
"status": "error",
"message": "Failed to generate QR code: " + err.Error(),
},
})
} }
if qrDataURI == "success" { switch qrDataURI {
// Login berhasil, tampilkan halaman success case "success":
templatePath := filepath.Join("internal", "whatsapp", "success_scan.html") data := QRResponse{
tmpl, err := template.ParseFiles(templatePath) Status: "login_success",
if err != nil { Message: "WhatsApp login successful",
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ Timestamp: time.Now().Unix(),
Meta: map[string]interface{}{
"status": "error",
"message": "Unable to load success template: " + err.Error(),
},
})
} }
return utils.SuccessWithData(c, "Successfully logged in", data)
c.Set("Content-Type", "text/html") case "already_connected":
return tmpl.Execute(c.Response().BodyWriter(), nil) data := QRResponse{
} Status: "already_connected",
Message: "WhatsApp is already connected",
if qrDataURI == "already_connected" { Timestamp: time.Now().Unix(),
// Sudah terhubung, tampilkan halaman success
templatePath := filepath.Join("internal", "whatsapp", "success_scan.html")
tmpl, err := template.ParseFiles(templatePath)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "error",
"message": "Unable to load success template: " + err.Error(),
},
})
} }
return utils.SuccessWithData(c, "Already connected", data)
c.Set("Content-Type", "text/html") default:
return tmpl.Execute(c.Response().BodyWriter(), nil)
data := QRResponse{
QRCode: qrDataURI,
Status: "qr_generated",
Message: "Scan QR code with WhatsApp to login",
Timestamp: time.Now().Unix(),
}
return utils.SuccessWithData(c, "QR code generated successfully", data)
}
}
func CheckLoginStatusHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService()
if wa == nil {
return utils.InternalServerError(c, "WhatsApp service not initialized")
} }
// Tampilkan QR code scanner // if !wa.IsLoggedIn() {
templatePath := filepath.Join("internal", "whatsapp", "scanner.html") // return utils.Unauthorized(c, "WhatsApp not logged in")
tmpl, err := template.ParseFiles(templatePath) // }
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ isConnected := wa.IsConnected()
Meta: map[string]interface{}{ isLoggedIn := wa.IsLoggedIn()
"status": "error",
"message": "Unable to load scanner template: " + err.Error(), var status string
}, var message string
})
if isLoggedIn && isConnected {
status = "connected_and_logged_in"
message = "WhatsApp is connected and logged in"
} else if isLoggedIn {
status = "logged_in_but_disconnected"
message = "WhatsApp is logged in but disconnected"
} else if isConnected {
status = "connected_but_not_logged_in"
message = "WhatsApp is connected but not logged in"
} else {
status = "disconnected"
message = "WhatsApp is disconnected"
} }
c.Set("Content-Type", "text/html") data := StatusResponse{
return tmpl.Execute(c.Response().BodyWriter(), template.URL(qrDataURI)) IsConnected: isConnected,
IsLoggedIn: isLoggedIn,
Status: status,
Message: message,
Timestamp: time.Now().Unix(),
}
return utils.SuccessWithData(c, "Status retrieved successfully", data)
} }
func WhatsAppLogoutHandler(c *fiber.Ctx) error { func WhatsAppLogoutHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService() wa := config.GetWhatsAppService()
if wa == nil { if wa == nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ return utils.InternalServerError(c, "WhatsApp service not initialized")
Meta: map[string]interface{}{ }
"status": "error",
"message": "WhatsApp service not initialized", if !wa.IsLoggedIn() {
}, return utils.BadRequest(c, "No active session to logout")
})
} }
err := wa.Logout() err := wa.Logout()
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(APIResponse{ return utils.InternalServerError(c, "Failed to logout: "+err.Error())
Meta: map[string]interface{}{
"status": "error",
"message": err.Error(),
},
})
} }
return c.Status(fiber.StatusOK).JSON(APIResponse{ data := map[string]interface{}{
Meta: map[string]interface{}{ "timestamp": time.Now().Unix(),
"status": "success", }
"message": "Successfully logged out and session deleted",
}, return utils.SuccessWithData(c, "Successfully logged out and session deleted", data)
})
} }
func WhatsAppStatusHandler(c *fiber.Ctx) error { func SendMessageHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService() wa := config.GetWhatsAppService()
if wa == nil { if wa == nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{ return utils.InternalServerError(c, "WhatsApp service not initialized")
Meta: map[string]interface{}{
"status": "error",
"message": "WhatsApp service not initialized",
},
})
} }
status := map[string]interface{}{ if !wa.IsLoggedIn() {
"is_connected": wa.IsConnected(), return utils.Unauthorized(c, "WhatsApp not logged in")
"is_logged_in": wa.IsLoggedIn(),
} }
return c.Status(fiber.StatusOK).JSON(APIResponse{ req := GetValidatedSendMessageRequest(c)
Meta: map[string]interface{}{ if req == nil {
"status": "success", return utils.BadRequest(c, "Invalid request data")
"message": "WhatsApp status retrieved successfully", }
},
Data: status, err := wa.SendMessage(req.PhoneNumber, req.Message)
}) if err != nil {
return utils.InternalServerError(c, "Failed to send message: "+err.Error())
}
data := SendMessageResponse{
PhoneNumber: req.PhoneNumber,
Timestamp: time.Now().Unix(),
}
return utils.SuccessWithData(c, "Message sent successfully", data)
}
func GetDeviceInfoHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService()
if wa == nil {
return utils.InternalServerError(c, "WhatsApp service not initialized")
}
if !wa.IsLoggedIn() {
return utils.Unauthorized(c, "WhatsApp not logged in")
}
var deviceInfo map[string]interface{}
if wa.Client != nil && wa.Client.Store.ID != nil {
deviceInfo = map[string]interface{}{
"device_id": wa.Client.Store.ID.User,
"device_name": wa.Client.Store.ID.Device,
"is_logged_in": wa.IsLoggedIn(),
"is_connected": wa.IsConnected(),
"timestamp": time.Now().Unix(),
}
} else {
deviceInfo = map[string]interface{}{
"device_id": nil,
"device_name": nil,
"is_logged_in": false,
"is_connected": false,
"timestamp": time.Now().Unix(),
}
}
return utils.SuccessWithData(c, "Device info retrieved successfully", deviceInfo)
}
func HealthCheckHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService()
if wa == nil {
return utils.InternalServerError(c, "WhatsApp service not initialized")
}
healthData := map[string]interface{}{
"service_status": "running",
"container_status": wa.Container != nil,
"client_status": wa.Client != nil,
"is_connected": wa.IsConnected(),
"is_logged_in": wa.IsLoggedIn(),
"timestamp": time.Now().Unix(),
}
message := "WhatsApp service is healthy"
if !wa.IsConnected() || !wa.IsLoggedIn() {
message = "WhatsApp service is running but not fully operational"
}
return utils.SuccessWithData(c, message, healthData)
}
func validatePhoneNumber(phoneNumber string) error {
cleaned := strings.ReplaceAll(phoneNumber, " ", "")
cleaned = strings.ReplaceAll(cleaned, "-", "")
cleaned = strings.ReplaceAll(cleaned, "+", "")
if !regexp.MustCompile(`^\d+$`).MatchString(cleaned) {
return fiber.NewError(fiber.StatusBadRequest, "Phone number must contain only digits")
}
if len(cleaned) < 10 {
return fiber.NewError(fiber.StatusBadRequest, "Phone number too short. Include country code (e.g., 628123456789)")
}
if len(cleaned) > 15 {
return fiber.NewError(fiber.StatusBadRequest, "Phone number too long")
}
return nil
}
func validateMessage(message string) error {
if strings.TrimSpace(message) == "" {
return fiber.NewError(fiber.StatusBadRequest, "Message cannot be empty")
}
if len(message) > 4096 {
return fiber.NewError(fiber.StatusBadRequest, "Message too long. Maximum 4096 characters allowed")
}
return nil
}
func ValidateSendMessageRequest(c *fiber.Ctx) error {
var req SendMessageRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "Invalid JSON format: "+err.Error())
}
if err := validatePhoneNumber(req.PhoneNumber); err != nil {
return utils.BadRequest(c, err.Error())
}
if err := validateMessage(req.Message); err != nil {
return utils.BadRequest(c, err.Error())
}
req.PhoneNumber = strings.ReplaceAll(req.PhoneNumber, " ", "")
req.PhoneNumber = strings.ReplaceAll(req.PhoneNumber, "-", "")
req.PhoneNumber = strings.ReplaceAll(req.PhoneNumber, "+", "")
c.Locals("validatedRequest", req)
return c.Next()
}
func GetValidatedSendMessageRequest(c *fiber.Ctx) *SendMessageRequest {
if req, ok := c.Locals("validatedRequest").(SendMessageRequest); ok {
return &req
}
return nil
}
func ValidateContentType() fiber.Handler {
return func(c *fiber.Ctx) error {
if c.Method() == "GET" {
return c.Next()
}
contentType := c.Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
return utils.BadRequest(c, "Content-Type must be application/json")
}
return c.Next()
}
} }

View File

@ -1,11 +1,32 @@
package whatsapp package whatsapp
import ( import (
"rijig/middleware"
"rijig/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func WhatsAppRouter(api fiber.Router) { func WhatsAppRouter(api fiber.Router) {
api.Get("/whatsapp-status", WhatsAppStatusHandler)
api.Get("/whatsapp/pw=admin1234", WhatsAppQRPageHandler) whatsapp := api.Group("/whatsapp")
api.Post("/logout/whastapp", WhatsAppLogoutHandler)
whatsapp.Use(middleware.AuthMiddleware(), middleware.RequireAdminRole())
whatsapp.Post("/generate-qr", GenerateQRHandler)
whatsapp.Get("/status", CheckLoginStatusHandler)
whatsapp.Post("/logout", WhatsAppLogoutHandler)
messaging := whatsapp.Group("/message")
messaging.Use(middleware.AuthMiddleware(), middleware.RequireAdminRole())
messaging.Post("/send", ValidateSendMessageRequest, SendMessageHandler)
management := whatsapp.Group("/management")
management.Use(middleware.AuthMiddleware(), middleware.RequireAdminRole())
management.Get("/device-info", GetDeviceInfoHandler)
management.Get("/health", HealthCheckHandler)
api.Get("/whatsapp/ping", func(c *fiber.Ctx) error {
return utils.Success(c, "WhatsApp service is running")
})
} }

View File

@ -1,5 +1,5 @@
package worker package worker
/*
import ( import (
"context" "context"
"encoding/json" "encoding/json"
@ -8,19 +8,18 @@ import (
"time" "time"
"rijig/config" "rijig/config"
"rijig/dto" "rijig/internal/cart"
"rijig/internal/repositories" "rijig/internal/trash"
"rijig/internal/services"
"rijig/model" "rijig/model"
) )
type CartWorker struct { type CartWorker struct {
cartService services.CartService cartService cart.CartService
cartRepo repositories.CartRepository cartRepo cart.CartRepository
trashRepo repositories.TrashRepository trashRepo trash.TrashRepositoryInterface
} }
func NewCartWorker(cartService services.CartService, cartRepo repositories.CartRepository, trashRepo repositories.TrashRepository) *CartWorker { func NewCartWorker(cartService cart.CartService, cartRepo cart.CartRepository, trashRepo trash.TrashRepositoryInterface) *CartWorker {
return &CartWorker{ return &CartWorker{
cartService: cartService, cartService: cartService,
cartRepo: cartRepo, cartRepo: cartRepo,
@ -32,7 +31,7 @@ func (w *CartWorker) AutoCommitExpiringCarts() error {
ctx := context.Background() ctx := context.Background()
threshold := 1 * time.Minute threshold := 1 * time.Minute
keys, err := services.GetExpiringCartKeys(ctx, threshold) keys, err := cart.GetExpiringCartKeys(ctx, threshold)
if err != nil { if err != nil {
return err return err
} }
@ -59,7 +58,7 @@ func (w *CartWorker) AutoCommitExpiringCarts() error {
if hasCart { if hasCart {
if err := services.DeleteCartFromRedis(ctx, userID); err != nil { if err := cart.DeleteCartFromRedis(ctx, userID); err != nil {
log.Printf("[CART-WORKER] Failed to delete Redis cache for user %s: %v", userID, err) log.Printf("[CART-WORKER] Failed to delete Redis cache for user %s: %v", userID, err)
} else { } else {
log.Printf("[CART-WORKER] Deleted Redis cache for user %s (already has DB cart)", userID) log.Printf("[CART-WORKER] Deleted Redis cache for user %s (already has DB cart)", userID)
@ -78,7 +77,7 @@ func (w *CartWorker) AutoCommitExpiringCarts() error {
continue continue
} }
if err := services.DeleteCartFromRedis(ctx, userID); err != nil { if err := cart.DeleteCartFromRedis(ctx, userID); err != nil {
log.Printf("[CART-WORKER] Warning: Failed to delete Redis key after commit for user %s: %v", userID, err) log.Printf("[CART-WORKER] Warning: Failed to delete Redis key after commit for user %s: %v", userID, err)
} }
@ -98,13 +97,13 @@ func (w *CartWorker) extractUserIDFromKey(key string) string {
return "" return ""
} }
func (w *CartWorker) getCartFromRedis(ctx context.Context, key string) (*dto.RequestCartDTO, error) { func (w *CartWorker) getCartFromRedis(ctx context.Context, key string) (*cart.RequestCartDTO, error) {
val, err := config.RedisClient.Get(ctx, key).Result() val, err := config.RedisClient.Get(ctx, key).Result()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var cart dto.RequestCartDTO var cart cart.RequestCartDTO
if err := json.Unmarshal([]byte(val), &cart); err != nil { if err := json.Unmarshal([]byte(val), &cart); err != nil {
return nil, err return nil, err
} }
@ -112,7 +111,7 @@ func (w *CartWorker) getCartFromRedis(ctx context.Context, key string) (*dto.Req
return &cart, nil return &cart, nil
} }
func (w *CartWorker) commitCartToDB(ctx context.Context, userID string, cartData *dto.RequestCartDTO) error { func (w *CartWorker) commitCartToDB(ctx context.Context, userID string, cartData *cart.RequestCartDTO) error {
if len(cartData.CartItems) == 0 { if len(cartData.CartItems) == 0 {
return nil return nil
} }
@ -156,4 +155,3 @@ func (w *CartWorker) commitCartToDB(ctx context.Context, userID string, cartData
return w.cartRepo.CreateCartWithItems(ctx, newCart) return w.cartRepo.CreateCartWithItems(ctx, newCart)
} }
*/

View File

@ -1,199 +0,0 @@
package middleware
/*
import (
"fmt"
"time"
"rijig/utils"
"github.com/gofiber/fiber/v2"
)
func RateLimitByUser(maxRequests int, duration time.Duration) fiber.Handler {
return func(c *fiber.Ctx) error {
claims, err := GetUserFromContext(c)
if err != nil {
return err
}
key := fmt.Sprintf("rate_limit:%s:%s", claims.UserID, c.Route().Path)
count, err := utils.IncrementCounter(key, duration)
if err != nil {
return c.Next()
}
if count > int64(maxRequests) {
ttl, _ := utils.GetTTL(key)
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "Rate limit exceeded",
"message": "Terlalu banyak permintaan, silakan coba lagi nanti",
"retry_after": int64(ttl.Seconds()),
"limit": maxRequests,
"remaining": 0,
})
}
c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", maxRequests))
c.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", maxRequests-int(count)))
c.Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(duration).Unix()))
return c.Next()
}
}
func SessionValidation() fiber.Handler {
return func(c *fiber.Ctx) error {
claims, err := GetUserFromContext(c)
if err != nil {
return err
}
if claims.SessionID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Invalid session",
"message": "Session tidak valid",
})
}
sessionKey := fmt.Sprintf("session:%s", claims.SessionID)
var sessionData map[string]interface{}
err = utils.GetCache(sessionKey, &sessionData)
if err != nil {
if err.Error() == "ErrCacheMiss" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Session not found",
"message": "Session tidak ditemukan, silakan login kembali",
})
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Session error",
"message": "Terjadi kesalahan saat validasi session",
})
}
if sessionData["user_id"] != claims.UserID {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Session mismatch",
"message": "Session tidak sesuai dengan user",
})
}
if expiryInterface, exists := sessionData["expires_at"]; exists {
if expiry, ok := expiryInterface.(float64); ok {
if time.Now().Unix() > int64(expiry) {
utils.DeleteCache(sessionKey)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Session expired",
"message": "Session telah berakhir, silakan login kembali",
})
}
}
}
return c.Next()
}
}
func RequireApprovedRegistration() fiber.Handler {
return func(c *fiber.Ctx) error {
claims, err := GetUserFromContext(c)
if err != nil {
return err
}
if claims.RegistrationStatus == utils.RegStatusRejected {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Registration rejected",
"message": "Registrasi Anda ditolak, silakan hubungi admin",
})
}
if claims.RegistrationStatus == utils.RegStatusPending {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Registration pending",
"message": "Registrasi Anda masih menunggu persetujuan admin",
})
}
if claims.RegistrationStatus != utils.RegStatusComplete {
progress := utils.GetUserRegistrationProgress(claims.UserID)
nextStep := utils.GetNextRegistrationStep(claims.Role, progress)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Registration incomplete",
"message": "Silakan lengkapi registrasi terlebih dahulu",
"registration_status": claims.RegistrationStatus,
"next_step": nextStep,
})
}
return c.Next()
}
}
func ConditionalAuth(condition func(*utils.JWTClaims) bool, errorMessage string) fiber.Handler {
return func(c *fiber.Ctx) error {
claims, err := GetUserFromContext(c)
if err != nil {
return err
}
if !condition(claims) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Condition not met",
"message": errorMessage,
})
}
return c.Next()
}
}
func RequireSpecificRole(role string) fiber.Handler {
return ConditionalAuth(
func(claims *utils.JWTClaims) bool {
return claims.Role == role
},
fmt.Sprintf("Akses ini hanya untuk role %s", role),
)
}
func RequireCompleteRegistrationAndSpecificRole(role string) fiber.Handler {
return ConditionalAuth(
func(claims *utils.JWTClaims) bool {
return claims.Role == role && utils.IsRegistrationComplete(claims.RegistrationStatus)
},
fmt.Sprintf("Akses ini hanya untuk role %s dengan registrasi lengkap", role),
)
}
func DeviceValidation() fiber.Handler {
return func(c *fiber.Ctx) error {
claims, err := GetUserFromContext(c)
if err != nil {
return err
}
deviceID := c.Get("X-Device-ID")
if deviceID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Device ID required",
"message": "Device ID diperlukan",
})
}
if claims.DeviceID != deviceID {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Device mismatch",
"message": "Token tidak valid untuk device ini",
})
}
return c.Next()
}
}
*/

View File

@ -503,7 +503,7 @@ func DeviceValidation() fiber.Handler {
return err return err
} }
deviceID := c.Get("X-Device-ID") deviceID := claims.DeviceID
if deviceID == "" { if deviceID == "" {
return c.Status(fiber.StatusBadRequest).JSON(&AuthError{ return c.Status(fiber.StatusBadRequest).JSON(&AuthError{
Code: "MISSING_DEVICE_ID", Code: "MISSING_DEVICE_ID",

View File

@ -3,11 +3,13 @@ package router
import ( import (
"os" "os"
"rijig/internal/about"
"rijig/internal/article" "rijig/internal/article"
"rijig/internal/authentication" "rijig/internal/authentication"
"rijig/internal/company" "rijig/internal/company"
"rijig/internal/identitycart" "rijig/internal/identitycart"
"rijig/internal/role" "rijig/internal/role"
"rijig/internal/trash"
"rijig/internal/userpin" "rijig/internal/userpin"
"rijig/internal/userprofile" "rijig/internal/userprofile"
"rijig/internal/whatsapp" "rijig/internal/whatsapp"
@ -22,8 +24,8 @@ import (
func SetupRoutes(app *fiber.App) { func SetupRoutes(app *fiber.App) {
apa := app.Group(os.Getenv("BASE_URL")) apa := app.Group(os.Getenv("BASE_URL"))
apa.Static("/uploads", "./public"+os.Getenv("BASE_URL")+"/uploads") apa.Static("/uploads", "./public"+os.Getenv("BASE_URL")+"/uploads")
a := app.Group(os.Getenv("BASE_URL")) // a := app.Group(os.Getenv("BASE_URL"))
whatsapp.WhatsAppRouter(a) // whatsapp.WhatsAppRouter(a)
api := app.Group(os.Getenv("BASE_URL")) api := app.Group(os.Getenv("BASE_URL"))
api.Use(middleware.APIKeyMiddleware) api.Use(middleware.APIKeyMiddleware)
@ -37,6 +39,9 @@ func SetupRoutes(app *fiber.App) {
article.ArticleRouter(api) article.ArticleRouter(api)
userprofile.UserProfileRouter(api) userprofile.UserProfileRouter(api)
wilayahindo.WilayahRouter(api) wilayahindo.WilayahRouter(api)
trash.TrashRouter(api)
about.AboutRouter(api)
whatsapp.WhatsAppRouter(api)
// || auth router || // // || auth router || //
// presentation.AuthRouter(api) // presentation.AuthRouter(api)