refact&fix: fixing whatsmeow and fixing air and docker setup

This commit is contained in:
pahmiudahgede 2025-06-10 02:34:10 +07:00
parent d7633d3c7f
commit e06b6033b5
15 changed files with 1102 additions and 574 deletions

View File

@ -1,28 +1,49 @@
# Dockerfile untuk development environment dengan Air hot reload
FROM golang:1.23-alpine
# Multi-stage Dockerfile untuk development dengan Air hot reload
FROM golang:1.23-alpine AS base
# Install dependencies dan Air
RUN apk add --no-cache git ca-certificates curl && \
go install github.com/cosmtrek/air@latest
# Install dependencies yang diperlukan
RUN apk add --no-cache \
git \
ca-certificates \
curl \
tzdata \
make \
gcc \
musl-dev
# Install Air untuk hot reload dengan versi yang stabil
RUN go install github.com/cosmtrek/air@v1.49.0
# Set timezone ke Asia/Jakarta
ENV TZ=Asia/Jakarta
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Set working directory
WORKDIR /app
# Copy go mod files dan download dependencies
# Create user untuk security (non-root)
RUN addgroup -g 1001 -S golang && \
adduser -S golang -u 1001 -G golang
# Copy go.mod dan go.sum terlebih dahulu untuk better caching
COPY go.mod go.sum ./
RUN go mod download
# Download dependencies
RUN go mod download && go mod verify
# Copy source code
COPY . .
# Create tmp directory untuk Air
RUN mkdir -p tmp
# Create tmp directory dengan permissions yang tepat
RUN mkdir -p tmp && \
chown -R golang:golang /app && \
chmod -R 755 /app
# Set timezone (optional)
RUN cp /usr/share/zoneinfo/Asia/Jakarta /etc/localtime
# Switch to non-root user
USER golang
# Expose port
EXPOSE 7000
# Run Air untuk hot reload
# Command untuk menjalankan Air
CMD ["air", "-c", ".air.toml"]

284
Makefile
View File

@ -1,4 +1,4 @@
# Makefile untuk mengelola Docker commands
# Makefile untuk mengelola Docker commands - Optimized Version
.PHONY: help build up down restart logs clean dev prod dev-build dev-up dev-down dev-logs
@ -6,162 +6,146 @@
GREEN := \033[0;32m
YELLOW := \033[1;33m
RED := \033[0;31m
BLUE := \033[0;34m
PURPLE := \033[0;35m
CYAN := \033[0;36m
NC := \033[0m # No Color
# Project variables
PROJECT_NAME := rijig_backend
DEV_COMPOSE_FILE := docker-compose.dev.yml
# Default target
help:
@echo "$(GREEN)Available commands:$(NC)"
@echo "$(YELLOW)Production:$(NC)"
@echo " build - Build all Docker images"
@echo " up - Start all services"
@echo " down - Stop all services"
@echo " restart - Restart all services"
@echo " logs - Show logs for all services"
@echo " clean - Remove all containers and volumes"
@echo " prod - Start production environment"
@echo " psql-prod - Execute psql in production postgres"
@echo " redis-prod - Execute redis-cli in production redis"
@echo "$(GREEN)🚀 $(PROJECT_NAME) - Available Commands:$(NC)"
@echo ""
@echo "$(YELLOW)Development (dengan Air hot reload):$(NC)"
@echo " dev-build - Build development images"
@echo " dev-up - Start development environment dengan hot reload"
@echo " dev-down - Stop development environment"
@echo " dev-logs - Show development logs"
@echo " dev-clean - Clean development environment"
@echo " dev-restart- Restart development environment"
@echo " psql - Execute psql in development postgres"
@echo " redis-cli - Execute redis-cli in development redis"
@echo "$(YELLOW)📦 Development Commands (Hot Reload):$(NC)"
@echo " $(CYAN)dev$(NC) - Complete development setup (build + up)"
@echo " $(CYAN)dev-build$(NC) - Build development images"
@echo " $(CYAN)dev-up$(NC) - Start development environment"
@echo " $(CYAN)dev-down$(NC) - Stop development environment"
@echo " $(CYAN)dev-restart$(NC) - Restart development services"
@echo " $(CYAN)dev-logs$(NC) - Show development logs (all services)"
@echo " $(CYAN)dev-clean$(NC) - Clean development environment"
@echo ""
@echo "$(YELLOW)Utilities:$(NC)"
@echo " app-logs - Show only app logs"
@echo " db-logs - Show only database logs"
@echo " status - Check service status"
@echo " shell - Execute bash in app container"
# Production Commands
build:
@echo "$(GREEN)Building production images...$(NC)"
docker compose build --no-cache
up:
@echo "$(GREEN)Starting production services...$(NC)"
docker compose up -d
down:
@echo "$(RED)Stopping production services...$(NC)"
docker compose down
restart:
@echo "$(YELLOW)Restarting production services...$(NC)"
docker compose restart
logs:
@echo "$(GREEN)Showing production logs...$(NC)"
docker compose logs -f
clean:
@echo "$(RED)Cleaning production environment...$(NC)"
docker compose down -v --remove-orphans
docker system prune -f
docker volume prune -f
prod:
@echo "$(GREEN)Starting production environment...$(NC)"
docker compose up -d
# Production utilities
psql-prod:
@echo "$(GREEN)Connecting to production PostgreSQL...$(NC)"
docker compose exec postgres psql -U postgres -d apirijig_v2
redis-prod:
@echo "$(GREEN)Connecting to production Redis...$(NC)"
docker compose exec redis redis-cli
# Development Commands (dengan Air hot reload)
dev-build:
@echo "$(GREEN)Building development images dengan Air...$(NC)"
docker compose -f docker-compose.dev.yml build --no-cache
dev-up:
@echo "$(GREEN)Starting development environment dengan Air hot reload...$(NC)"
docker compose -f docker-compose.dev.yml up -d
@echo "$(GREEN)Development services started!$(NC)"
@echo "$(YELLOW)API Server: http://localhost:7000$(NC)"
@echo "$(YELLOW)PostgreSQL: localhost:5433$(NC)"
@echo "$(YELLOW)Redis: localhost:6378$(NC)"
@echo "$(YELLOW)pgAdmin: http://localhost:8080 (admin@rijig.com / admin123)$(NC)"
@echo "$(YELLOW)Redis Commander: http://localhost:8081$(NC)"
@echo "$(YELLOW)🛠️ Development Utilities:$(NC)"
@echo " $(CYAN)dev-app-logs$(NC) - Show only app logs"
@echo " $(CYAN)dev-db-logs$(NC) - Show only database logs"
@echo " $(CYAN)dev-shell$(NC) - Access app container shell"
@echo " $(CYAN)dev-status$(NC) - Check development services status"
@echo " $(CYAN)psql$(NC) - Connect to development PostgreSQL"
@echo " $(CYAN)redis-cli$(NC) - Connect to development Redis"
@echo ""
@echo "$(GREEN)✨ Hot reload is active! Edit your Go files and see changes automatically ✨$(NC)"
@echo "$(YELLOW)🧹 Maintenance:$(NC)"
@echo " $(RED)clean-all$(NC) - Clean everything (containers, volumes, images)"
@echo " $(RED)system-prune$(NC) - Clean Docker system"
@echo " $(CYAN)stats$(NC) - Show container resource usage"
dev-down:
@echo "$(RED)Stopping development services...$(NC)"
docker compose -f docker-compose.dev.yml down
dev-logs:
@echo "$(GREEN)Showing development logs...$(NC)"
docker compose -f docker-compose.dev.yml logs -f
dev-clean:
@echo "$(RED)Cleaning development environment...$(NC)"
docker compose -f docker-compose.dev.yml down -v --remove-orphans
docker system prune -f
dev-restart:
@echo "$(YELLOW)Restarting development services...$(NC)"
docker compose -f docker-compose.dev.yml restart
# Development utilities (FIXED - menggunakan -f docker-compose.dev.yml)
dev-app-logs:
@echo "$(GREEN)Showing development app logs...$(NC)"
docker compose -f docker-compose.dev.yml logs -f app
dev-db-logs:
@echo "$(GREEN)Showing development database logs...$(NC)"
docker compose -f docker-compose.dev.yml logs -f postgres
dev-shell:
@echo "$(GREEN)Accessing development app container...$(NC)"
docker compose -f docker-compose.dev.yml exec app sh
dev-status:
@echo "$(GREEN)Development service status:$(NC)"
docker compose -f docker-compose.dev.yml ps
# FIXED: Development database access (default untuk development)
psql:
@echo "$(GREEN)Connecting to development PostgreSQL...$(NC)"
docker compose -f docker-compose.dev.yml exec postgres psql -U postgres -d apirijig_v2
redis-cli:
@echo "$(GREEN)Connecting to development Redis...$(NC)"
docker compose -f docker-compose.dev.yml exec redis redis-cli
# Shared utilities (default ke production)
app-logs:
docker compose logs -f app
db-logs:
docker compose logs -f postgres
status:
docker compose ps
shell:
docker compose exec app sh
# Rebuild and restart app only
app-rebuild:
docker compose build app
docker compose up -d app
# View real-time resource usage
stats:
docker stats
# ======================
# DEVELOPMENT COMMANDS
# ======================
# Quick development setup (recommended)
dev:
@echo "$(GREEN)Setting up complete development environment...$(NC)"
make dev-build
make dev-up
dev: dev-build dev-up
@echo "$(GREEN)✨ Development environment ready!$(NC)"
@echo "$(BLUE)🌐 Services:$(NC)"
@echo " • API Server: $(CYAN)http://localhost:7000$(NC)"
@echo " • PostgreSQL: $(CYAN)localhost:5433$(NC)"
@echo " • Redis: $(CYAN)localhost:6378$(NC)"
@echo " • pgAdmin: $(CYAN)http://localhost:8080$(NC) (admin@rijig.com / admin123)"
@echo " • Redis Commander: $(CYAN)http://localhost:8081$(NC)"
@echo ""
@echo "$(GREEN)🔥 Hot reload is active! Edit your Go files and see changes automatically$(NC)"
dev-build:
@echo "$(YELLOW)🔨 Building development images...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) build --no-cache
@echo "$(GREEN)✅ Development images built successfully!$(NC)"
dev-up:
@echo "$(YELLOW)🚀 Starting development services...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) up -d
@echo "$(GREEN)✅ Development services started!$(NC)"
@make dev-status
dev-down:
@echo "$(RED)🛑 Stopping development services...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) down
@echo "$(GREEN)✅ Development services stopped!$(NC)"
dev-restart:
@echo "$(YELLOW)🔄 Restarting development services...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) restart
@echo "$(GREEN)✅ Development services restarted!$(NC)"
dev-logs:
@echo "$(CYAN)📋 Showing development logs (Ctrl+C to exit)...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) logs -f --tail=100
dev-clean:
@echo "$(RED)🧹 Cleaning development environment...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) down -v --remove-orphans
@echo "$(GREEN)✅ Development environment cleaned!$(NC)"
# ======================
# DEVELOPMENT UTILITIES
# ======================
dev-app-logs:
@echo "$(CYAN)📋 Showing app logs (Ctrl+C to exit)...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) logs -f --tail=50 app
dev-db-logs:
@echo "$(CYAN)📋 Showing database logs (Ctrl+C to exit)...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) logs -f --tail=50 postgres
dev-shell:
@echo "$(CYAN)🐚 Accessing app container shell...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) exec app sh
dev-status:
@echo "$(BLUE)📊 Development services status:$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) ps
psql:
@echo "$(CYAN)🐘 Connecting to development PostgreSQL...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) exec postgres psql -U postgres -d apirijig_v2
redis-cli:
@echo "$(CYAN)⚡ Connecting to development Redis...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) exec redis redis-cli
# ======================
# MAINTENANCE COMMANDS
# ======================
clean-all:
@echo "$(RED)🧹 Performing complete cleanup...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) down -v --remove-orphans 2>/dev/null || true
@echo "$(YELLOW)🗑️ Removing unused containers, networks, and images...$(NC)"
@docker system prune -a -f --volumes
@echo "$(GREEN)✅ Complete cleanup finished!$(NC)"
system-prune:
@echo "$(YELLOW)🗑️ Cleaning Docker system...$(NC)"
@docker system prune -f
@echo "$(GREEN)✅ Docker system cleaned!$(NC)"
stats:
@echo "$(BLUE)📈 Container resource usage:$(NC)"
@docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
# ======================
# QUICK COMMANDS
# ======================
# App only restart (faster for development)
app-restart:
@echo "$(YELLOW)🔄 Restarting app container only...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) restart app
@echo "$(GREEN)✅ App container restarted!$(NC)"
# Check if containers are healthy
health-check:
@echo "$(BLUE)🏥 Checking container health...$(NC)"
@docker compose -f $(DEV_COMPOSE_FILE) ps --format "table {{.Name}}\t{{.Status}}"

View File

@ -8,12 +8,9 @@ import (
)
func SetupConfig() {
if _, exists := os.LookupEnv("DOCKER_ENV"); exists {
log.Println("Running in Docker container, using environment variables")
} else {
err := godotenv.Load(".env.dev")
if err != nil {
log.Printf("Warning: Error loading .env file: %v", err)

View File

@ -2,16 +2,16 @@ package config
import (
"context"
"encoding/base64"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
_ "github.com/lib/pq"
"github.com/mdp/qrterminal/v3"
"github.com/skip2/go-qrcode"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store/sqlstore"
@ -21,355 +21,156 @@ import (
"google.golang.org/protobuf/proto"
)
type WhatsAppManager struct {
Client *whatsmeow.Client
container *sqlstore.Container
isConnected bool
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
shutdownCh chan struct{}
type WhatsAppService struct {
Client *whatsmeow.Client
Container *sqlstore.Container
}
var (
waManager *WhatsAppManager
once sync.Once
)
func GetWhatsAppManager() *WhatsAppManager {
once.Do(func() {
ctx, cancel := context.WithCancel(context.Background())
waManager = &WhatsAppManager{
ctx: ctx,
cancel: cancel,
shutdownCh: make(chan struct{}),
}
})
return waManager
}
var whatsappService *WhatsAppService
func InitWhatsApp() {
manager := GetWhatsAppManager()
var err error
log.Println("Initializing WhatsApp client...")
if err := manager.setupDatabase(); err != nil {
log.Fatalf("Failed to setup WhatsApp database: %v", err)
}
if err := manager.setupClient(); err != nil {
log.Fatalf("Failed to setup WhatsApp client: %v", err)
}
if err := manager.handleAuthentication(); err != nil {
log.Fatalf("Failed to authenticate WhatsApp: %v", err)
}
manager.setupEventHandlers()
go manager.handleShutdown()
log.Println("WhatsApp client initialized successfully and ready to send messages!")
}
func (w *WhatsAppManager) setupDatabase() error {
dbLog := waLog.Stdout("WhatsApp-DB", "ERROR", true)
dsn := fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
connectionString := fmt.Sprintf(
"user=%s password=%s dbname=%s host=%s port=%s sslmode=disable",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"),
)
var err error
w.container, err = sqlstore.New("postgres", dsn, dbLog)
dbLog := waLog.Stdout("Database", "DEBUG", true)
container, err := sqlstore.New("postgres", connectionString, dbLog)
if err != nil {
return fmt.Errorf("failed to connect to database: %v", err)
log.Fatalf("Failed to connect to WhatsApp database: %v", err)
}
log.Println("WhatsApp database connection established")
return nil
whatsappService = &WhatsAppService{
Container: container,
}
}
func (w *WhatsAppManager) setupClient() error {
deviceStore, err := w.container.GetFirstDevice()
if err != nil {
return fmt.Errorf("failed to get device store: %v", err)
}
clientLog := waLog.Stdout("WhatsApp-Client", "ERROR", true)
w.Client = whatsmeow.NewClient(deviceStore, clientLog)
return nil
func GetWhatsAppService() *WhatsAppService {
return whatsappService
}
func (w *WhatsAppManager) handleAuthentication() error {
if w.Client.Store.ID == nil {
log.Println("WhatsApp client not logged in, generating QR code...")
return w.authenticateWithQR()
func eventHandler(evt interface{}) {
switch v := evt.(type) {
case *events.Message:
fmt.Println("Received a message!", v.Message.GetConversation())
case *events.Connected:
fmt.Println("WhatsApp client connected!")
case *events.Disconnected:
fmt.Println("WhatsApp client disconnected!")
}
log.Println("WhatsApp client already logged in, connecting...")
return w.connect()
}
func (w *WhatsAppManager) authenticateWithQR() error {
qrChan, err := w.Client.GetQRChannel(w.ctx)
func (wa *WhatsAppService) GenerateQR() (string, error) {
if wa.Container == nil {
return "", fmt.Errorf("container is not initialized")
}
deviceStore, err := wa.Container.GetFirstDevice()
if err != nil {
return fmt.Errorf("failed to get QR channel: %v", err)
return "", fmt.Errorf("failed to get first device: %v", err)
}
if err := w.Client.Connect(); err != nil {
return fmt.Errorf("failed to connect client: %v", err)
}
clientLog := waLog.Stdout("Client", "DEBUG", true)
wa.Client = whatsmeow.NewClient(deviceStore, clientLog)
wa.Client.AddEventHandler(eventHandler)
qrTimeout := time.NewTimer(3 * time.Minute)
defer qrTimeout.Stop()
if wa.Client.Store.ID == nil {
fmt.Println("Client is not logged in, generating QR code...")
qrChan, _ := wa.Client.GetQRChannel(context.Background())
err = wa.Client.Connect()
if err != nil {
return "", fmt.Errorf("failed to connect: %v", err)
}
for {
select {
case evt := <-qrChan:
switch evt.Event {
case "code":
fmt.Println("\n=== QR CODE UNTUK LOGIN WHATSAPP ===")
generateQRCode(evt.Code)
fmt.Println("Scan QR code di atas dengan WhatsApp Anda")
fmt.Println("QR code akan expired dalam 3 menit")
case "success":
log.Println("✅ WhatsApp login successful!")
w.setConnected(true)
return nil
case "timeout":
return fmt.Errorf("QR code expired, please restart")
default:
log.Printf("Login status: %s", evt.Event)
for evt := range qrChan {
if evt.Event == "code" {
fmt.Println("QR code generated:", evt.Code)
png, err := qrcode.Encode(evt.Code, qrcode.Medium, 256)
if err != nil {
return "", fmt.Errorf("failed to create QR code: %v", err)
}
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
return dataURI, nil
} else {
fmt.Println("Login event:", evt.Event)
if evt.Event == "success" {
return "success", nil
}
}
case <-qrTimeout.C:
return fmt.Errorf("QR code authentication timeout after 3 minutes")
case <-w.ctx.Done():
return fmt.Errorf("authentication cancelled")
}
}
}
func (w *WhatsAppManager) connect() error {
if err := w.Client.Connect(); err != nil {
return fmt.Errorf("failed to connect: %v", err)
}
time.Sleep(2 * time.Second)
w.setConnected(true)
return nil
}
func (w *WhatsAppManager) setupEventHandlers() {
w.Client.AddEventHandler(func(evt interface{}) {
switch v := evt.(type) {
case *events.Connected:
log.Println("✅ WhatsApp client connected")
w.setConnected(true)
case *events.Disconnected:
log.Println("❌ WhatsApp client disconnected")
w.setConnected(false)
case *events.LoggedOut:
log.Println("🚪 WhatsApp client logged out")
w.setConnected(false)
case *events.Message:
log.Printf("📨 Message received from %s", v.Info.Sender)
} else {
fmt.Println("Client already logged in, connecting...")
err = wa.Client.Connect()
if err != nil {
return "", fmt.Errorf("failed to connect: %v", err)
}
})
return "already_connected", nil
}
return "", fmt.Errorf("failed to generate QR code")
}
func (w *WhatsAppManager) setConnected(status bool) {
w.mu.Lock()
defer w.mu.Unlock()
w.isConnected = status
}
func (w *WhatsAppManager) IsConnected() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.isConnected
}
func generateQRCode(qrString string) {
qrterminal.GenerateHalfBlock(qrString, qrterminal.M, os.Stdout)
}
func (w *WhatsAppManager) handleShutdown() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
select {
case <-sigChan:
log.Println("Received shutdown signal...")
case <-w.ctx.Done():
log.Println("Context cancelled...")
func (wa *WhatsAppService) SendMessage(phoneNumber, message string) error {
if wa.Client == nil {
return fmt.Errorf("client not initialized")
}
w.shutdown()
}
func (w *WhatsAppManager) shutdown() {
log.Println("Shutting down WhatsApp client...")
w.cancel()
if w.Client != nil {
w.Client.Disconnect()
}
if w.container != nil {
w.container.Close()
}
close(w.shutdownCh)
log.Println("WhatsApp client shutdown completed")
}
func SendWhatsAppMessage(phone, message string) error {
manager := GetWhatsAppManager()
if manager.Client == nil {
return fmt.Errorf("WhatsApp client is not initialized")
}
if !manager.IsConnected() {
return fmt.Errorf("WhatsApp client is not connected")
}
if phone == "" || message == "" {
return fmt.Errorf("phone number and message cannot be empty")
}
if phone[0] == '0' {
phone = "62" + phone[1:] // Convert 08xx menjadi 628xx
}
if phone[:2] != "62" {
phone = "62" + phone // Tambahkan 62 jika belum ada
}
// Parse JID
targetJID, err := types.ParseJID(phone + "@s.whatsapp.net")
targetJID, err := types.ParseJID(phoneNumber + "@s.whatsapp.net")
if err != nil {
return fmt.Errorf("invalid phone number format: %v", err)
return fmt.Errorf("invalid phone number: %v", err)
}
// Buat pesan
msg := &waE2E.Message{
Conversation: proto.String(message),
}
// Kirim dengan timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := manager.Client.SendMessage(ctx, targetJID, msg)
_, err = wa.Client.SendMessage(context.Background(), targetJID, msg)
if err != nil {
return fmt.Errorf("failed to send message: %v", err)
}
log.Printf("✅ Message sent to %s (ID: %s)", phone, resp.ID)
return nil
}
// SendWhatsAppMessageBatch - Kirim pesan ke multiple nomor
func SendWhatsAppMessageBatch(phoneNumbers []string, message string) []error {
var errors []error
func (wa *WhatsAppService) Logout() error {
if wa.Client == nil {
return fmt.Errorf("no active client session")
}
for _, phone := range phoneNumbers {
if err := SendWhatsAppMessage(phone, message); err != nil {
errors = append(errors, fmt.Errorf("failed to send to %s: %v", phone, err))
continue
err := wa.Client.Logout()
if err != nil {
return fmt.Errorf("failed to logout: %v", err)
}
wa.Client.Disconnect()
wa.Client = nil
return nil
}
func (wa *WhatsAppService) IsConnected() bool {
return wa.Client != nil && wa.Client.IsConnected()
}
func (wa *WhatsAppService) IsLoggedIn() bool {
return wa.Client != nil && wa.Client.Store.ID != nil
}
func (wa *WhatsAppService) GracefulShutdown() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
fmt.Println("Shutting down WhatsApp client...")
if wa.Client != nil {
wa.Client.Disconnect()
}
// Delay untuk menghindari rate limit
time.Sleep(1 * time.Second)
}
return errors
}
// GetWhatsAppStatus - Cek status koneksi
func GetWhatsAppStatus() map[string]interface{} {
manager := GetWhatsAppManager()
status := map[string]interface{}{
"initialized": manager.Client != nil,
"connected": manager.IsConnected(),
"logged_in": false,
"jid": "",
}
if manager.Client != nil && manager.Client.Store.ID != nil {
status["logged_in"] = true
status["jid"] = manager.Client.Store.ID.String()
}
return status
}
// LogoutWhatsApp - Logout dan cleanup
func LogoutWhatsApp() error {
manager := GetWhatsAppManager()
if manager.Client == nil {
return fmt.Errorf("WhatsApp client is not initialized")
}
log.Println("Logging out WhatsApp...")
// Logout
err := manager.Client.Logout()
if err != nil {
log.Printf("Warning: Failed to logout properly: %v", err)
}
// Disconnect
manager.Client.Disconnect()
manager.setConnected(false)
// Hapus device dari store
if err := manager.removeDeviceFromStore(); err != nil {
log.Printf("Warning: Failed to remove device: %v", err)
}
// Close database
if manager.container != nil {
manager.container.Close()
}
log.Println("✅ WhatsApp logout completed")
return nil
}
func (w *WhatsAppManager) removeDeviceFromStore() error {
deviceStore, err := w.container.GetFirstDevice()
if err != nil {
return err
}
if deviceStore != nil && deviceStore.ID != nil {
return deviceStore.Delete()
}
return nil
}
// IsValidPhoneNumber - Validasi format nomor telepon Indonesia
func IsValidPhoneNumber(phone string) bool {
// Minimal validasi untuk nomor Indonesia
if len(phone) < 10 || len(phone) > 15 {
return false
}
// Cek awalan nomor Indonesia
if phone[:2] == "62" || phone[0] == '0' {
return true
}
return false
os.Exit(0)
}()
}

View File

@ -51,44 +51,15 @@ services:
restart: unless-stopped
ports:
- "7000:7000"
environment:
# Docker Environment Flag
DOCKER_ENV: "true"
# Base URL
BASE_URL: /apirijig/v2
# Server Settings
SERVER_HOST: 0.0.0.0
SERVER_PORT: 7000
# Database Settings - menggunakan service name sebagai host
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: apirijig_v2
DB_USER: postgres
DB_PASSWORD: pahmiadmin
# Redis Settings - menggunakan service name sebagai host
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
REDIS_DB: 0
# Auth Keys
API_KEY: apirijikL0RH64wfkEpPqjAroLVPuFgT0EpsSLBPsmyUvIqZrUAi6X3HNPM7Vter
SECRET_KEY: TJ6h3vPMPlAuv7cbD27RU1/UyRctEih5k4H3+o7tZM1PSwTcoFETL6lqB54=
# TTL Settings
ACCESS_TOKEN_EXPIRY: 23*time.Hour
REFRESH_TOKEN_EXPIRY: 28*24*time.Hour
PARTIAL_TOKEN_EXPIRY: 2*time.Hour
env_file:
- .env.docker
volumes:
# Mount source code untuk hot reload
- .:/app
# Exclude node_modules dan vendor (jika ada)
# Cache Go modules untuk performance
- go_modules_cache:/go/pkg/mod
# Exclude tmp directory untuk mencegah konflik
- /app/tmp
- /app/vendor
depends_on:
postgres:
condition: service_healthy
@ -96,6 +67,7 @@ services:
condition: service_healthy
networks:
- rijig_network_dev
working_dir: /app
# pgAdmin (optional - untuk GUI database management)
pgadmin:
@ -137,3 +109,4 @@ volumes:
postgres_data_dev:
redis_data_dev:
pgadmin_data_dev:
go_modules_cache:

10
go.mod
View File

@ -13,10 +13,10 @@ require (
gorm.io/gorm v1.25.12
)
require (
golang.org/x/term v0.30.0 // indirect
rsc.io/qr v0.2.0 // indirect
)
// require (
// golang.org/x/term v0.30.0 // indirect
// rsc.io/qr v0.2.0 // indirect
// )
require (
filippo.io/edwards25519 v1.1.0 // indirect
@ -35,9 +35,9 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mdp/qrterminal/v3 v3.2.0
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // direct
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect

2
go.sum
View File

@ -71,6 +71,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@ -210,11 +210,6 @@ func (h *AuthenticationHandler) LogoutAuthentication(c *fiber.Ctx) error {
return err
}
// deviceID := c.Get("Device-ID")
// if deviceID == "" {
// return utils.BadRequest(c, "Device ID is required")
// }
err = h.service.LogoutAuthentication(c.Context(), claims.UserID, claims.DeviceID)
if err != nil {

View File

@ -33,6 +33,21 @@ func NewAuthenticationService(authRepo AuthenticationRepository, roleRepo role.R
return &authenticationService{authRepo, roleRepo}
}
func normalizeRoleName(roleName string) string {
switch strings.ToLower(roleName) {
case "administrator", "admin":
return utils.RoleAdministrator
case "pengelola":
return utils.RolePengelola
case "pengepul":
return utils.RolePengepul
case "masyarakat":
return utils.RoleMasyarakat
default:
return strings.ToLower(roleName)
}
}
func (s *authenticationService) LoginAdmin(ctx context.Context, req *LoginAdminRequest) (*AuthResponse, error) {
user, err := s.authRepo.FindUserByEmail(ctx, req.Email)
if err != nil {
@ -103,14 +118,16 @@ func (s *authenticationService) RegisterAdmin(ctx context.Context, req *Register
func (s *authenticationService) SendRegistrationOTP(ctx context.Context, req *LoginorRegistRequest) (*OTPResponse, error) {
existingUser, err := s.authRepo.FindUserByPhoneAndRole(ctx, req.Phone, strings.ToLower(req.RoleName))
normalizedRole := strings.ToLower(req.RoleName)
existingUser, err := s.authRepo.FindUserByPhoneAndRole(ctx, req.Phone, normalizedRole)
if err == nil && existingUser != nil {
return nil, fmt.Errorf("nomor telepon dengan role %s sudah terdaftar", req.RoleName)
}
roleData, err := s.roleRepo.FindRoleByName(ctx, req.RoleName)
roleData, err := s.roleRepo.FindRoleByName(ctx, normalizedRole)
if err != nil {
return nil, fmt.Errorf("role tidak valid")
return nil, fmt.Errorf("role tidak valid: %v", err)
}
rateLimitKey := fmt.Sprintf("otp_limit:%s", req.Phone)
@ -125,33 +142,34 @@ func (s *authenticationService) SendRegistrationOTP(ctx context.Context, req *Lo
otpKey := fmt.Sprintf("otp:%s:register", req.Phone)
otpData := OTPData{
Phone: req.Phone,
OTP: otp,
Role: req.RoleName,
RoleID: roleData.ID,
Type: "register",
Attempts: 0,
Phone: req.Phone,
OTP: otp,
Role: normalizedRole,
RoleID: roleData.ID,
Type: "register",
Attempts: 0,
ExpiresAt: time.Now().Add(90 * time.Second),
}
err = utils.SetCacheWithTTL(otpKey, otpData, 1*time.Minute)
err = utils.SetCacheWithTTL(otpKey, otpData, 90*time.Second)
if err != nil {
return nil, fmt.Errorf("gagal menyimpan OTP: %v", err)
}
err = sendOTPViaSMS(req.Phone, otp)
err = sendOTP(req.Phone, otp)
if err != nil {
return nil, fmt.Errorf("gagal mengirim OTP: %v", err)
}
return &OTPResponse{
Message: "OTP berhasil dikirim",
ExpiresIn: 60,
ExpiresIn: 90,
Phone: maskPhoneNumber(req.Phone),
}, nil
}
func (s *authenticationService) VerifyRegistrationOTP(ctx context.Context, req *VerifyOtpRequest) (*AuthResponse, error) {
otpKey := fmt.Sprintf("otp:%s:register", req.Phone)
var otpData OTPData
err := utils.GetCache(otpKey, &otpData)
@ -166,7 +184,7 @@ func (s *authenticationService) VerifyRegistrationOTP(ctx context.Context, req *
if otpData.OTP != req.Otp {
otpData.Attempts++
utils.SetCache(otpKey, otpData, time.Until(otpData.ExpiresAt))
utils.SetCacheWithTTL(otpKey, otpData, time.Until(otpData.ExpiresAt))
return nil, fmt.Errorf("kode OTP salah")
}
@ -174,12 +192,14 @@ func (s *authenticationService) VerifyRegistrationOTP(ctx context.Context, req *
return nil, fmt.Errorf("role tidak sesuai")
}
normalizedRole := strings.ToLower(req.RoleName)
user := &model.User{
Phone: req.Phone,
PhoneVerified: true,
RoleID: otpData.RoleID,
RegistrationStatus: utils.RegStatusIncomplete,
RegistrationProgress: 0,
RegistrationProgress: utils.ProgressOTPVerified,
Name: "",
Gender: "",
Dateofbirth: "",
@ -191,11 +211,15 @@ func (s *authenticationService) VerifyRegistrationOTP(ctx context.Context, req *
return nil, fmt.Errorf("gagal membuat user: %v", err)
}
if user.ID == "" {
return nil, fmt.Errorf("gagal mendapatkan user ID setelah registrasi")
}
utils.DeleteCache(otpKey)
tokenResponse, err := utils.GenerateTokenPair(
user.ID,
req.RoleName,
normalizedRole,
req.DeviceID,
user.RegistrationStatus,
int(user.RegistrationProgress),
@ -205,15 +229,18 @@ func (s *authenticationService) VerifyRegistrationOTP(ctx context.Context, req *
return nil, fmt.Errorf("gagal generate token: %v", err)
}
nextStep := utils.GetNextRegistrationStep(req.RoleName, int(user.RegistrationProgress),user.RegistrationStatus)
nextStep := utils.GetNextRegistrationStep(
normalizedRole,
int(user.RegistrationProgress),
user.RegistrationStatus,
)
return &AuthResponse{
Message: "Registrasi berhasil",
AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType),
ExpiresIn: tokenResponse.ExpiresIn,
Message: "Registrasi berhasil",
AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken,
TokenType: string(tokenResponse.TokenType),
ExpiresIn: tokenResponse.ExpiresIn,
RegistrationStatus: user.RegistrationStatus,
NextStep: nextStep,
SessionID: tokenResponse.SessionID,
@ -256,7 +283,7 @@ func (s *authenticationService) SendLoginOTP(ctx context.Context, req *LoginorRe
return nil, fmt.Errorf("gagal menyimpan OTP: %v", err)
}
err = sendOTPViaSMS(req.Phone, otp)
err = sendOTP(req.Phone, otp)
if err != nil {
return nil, fmt.Errorf("gagal mengirim OTP: %v", err)
}
@ -349,7 +376,7 @@ func isRateLimited(key string, maxAttempts int, duration time.Duration) bool {
return false
}
func sendOTPViaSMS(phone, otp string) error {
func sendOTP(phone, otp string) error {
fmt.Printf("Sending OTP %s to %s\n", otp, phone)
return nil

View File

@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhatsApp QR Scanner</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #25D366, #128C7E);
color: white;
}
.container {
text-align: center;
background: rgba(255, 255, 255, 0.1);
padding: 2rem;
border-radius: 15px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.logo {
font-size: 2rem;
font-weight: bold;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.whatsapp-icon {
width: 40px;
height: 40px;
background: #25D366;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.subtitle {
margin-bottom: 2rem;
opacity: 0.9;
font-size: 1rem;
}
.qr-container {
background: white;
padding: 1rem;
border-radius: 10px;
display: inline-block;
margin-bottom: 1rem;
}
.qr-code {
max-width: 300px;
height: auto;
border-radius: 8px;
}
.instructions {
max-width: 400px;
margin: 1rem auto;
line-height: 1.6;
opacity: 0.9;
}
.steps {
text-align: left;
margin: 1rem 0;
}
.steps ol {
padding-left: 1.5rem;
}
.steps li {
margin: 0.5rem 0;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status {
margin-top: 1rem;
padding: 0.5rem 1rem;
border-radius: 5px;
font-weight: bold;
}
.status.success {
background: rgba(46, 204, 113, 0.2);
border: 1px solid #2ecc71;
}
.status.warning {
background: rgba(241, 196, 15, 0.2);
border: 1px solid #f1c40f;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<div class="whatsapp-icon">📱</div>
<h1>WhatsApp QR Scanner</h1>
</div>
<p class="subtitle">Scan QR code untuk menghubungkan WhatsApp Anda</p>
<div class="qr-container">
<img src="{{.}}" alt="WhatsApp QR Code" class="qr-code" />
</div>
<div class="instructions">
<div class="steps">
<h3>Cara menggunakan:</h3>
<ol>
<li>Buka WhatsApp di ponsel Anda</li>
<li>Tap Menu atau Settings dan pilih WhatsApp Web</li>
<li>Arahkan ponsel Anda ke QR code ini untuk memindainya</li>
<li>Tunggu hingga terhubung</li>
</ol>
</div>
<div class="status warning">
<span class="loading"></span>
Menunggu pemindaian QR code...
</div>
</div>
</div>
<script>
setTimeout(function() {
location.reload();
}, 30000);
setInterval(function() {
fetch('/api/whatsapp-status')
.then(response => response.json())
.then(data => {
if (data.meta.status === 'success' && data.data.is_connected) {
document.querySelector('.status').innerHTML = '✅ WhatsApp berhasil terhubung!';
document.querySelector('.status').className = 'status success';
setTimeout(function() {
alert('WhatsApp berhasil terhubung! Anda dapat menutup halaman ini.');
}, 2000);
}
})
.catch(error => {
console.log('Status check error:', error);
});
}, 5000);
</script>
</body>
</html>

View File

@ -0,0 +1,411 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhatsApp - Berhasil Terhubung</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #25D366, #128C7E);
color: white;
}
.container {
text-align: center;
background: rgba(255, 255, 255, 0.1);
padding: 2rem;
border-radius: 15px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90%;
}
.logo {
font-size: 2rem;
font-weight: bold;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.whatsapp-icon {
width: 40px;
height: 40px;
background: #25D366;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1.2rem;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.success-icon {
font-size: 4rem;
margin: 1rem 0;
}
.status-message {
font-size: 1.1rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.status-info {
background: rgba(255, 255, 255, 0.1);
padding: 1rem;
border-radius: 10px;
margin: 1.5rem 0;
text-align: left;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.5rem 0;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 5px;
}
.status-label {
font-weight: bold;
}
.status-value {
display: flex;
align-items: center;
gap: 0.5rem;
}
.indicator {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.indicator.connected {
background: #2ecc71;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5);
}
.indicator.disconnected {
background: #e74c3c;
box-shadow: 0 0 10px rgba(231, 76, 60, 0.5);
}
.actions {
margin-top: 2rem;
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
transform: translateY(-2px);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-2px);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden {
display: none !important;
}
.alert {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 5px;
font-weight: bold;
}
.alert.success {
background: rgba(46, 204, 113, 0.2);
border: 1px solid #2ecc71;
color: #2ecc71;
}
.alert.error {
background: rgba(231, 76, 60, 0.2);
border: 1px solid #e74c3c;
color: #e74c3c;
}
@media (max-width: 480px) {
.container {
padding: 1.5rem;
}
.actions {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<div class="whatsapp-icon">📱</div>
<h1>WhatsApp Dashboard</h1>
</div>
<div class="success-icon"></div>
<div class="status-message" id="statusMessage">
WhatsApp berhasil terhubung dan siap digunakan!
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Status Koneksi:</span>
<div class="status-value">
<span class="indicator" id="connectionIndicator"></span>
<span id="connectionStatus">Checking...</span>
</div>
</div>
<div class="status-item">
<span class="status-label">Status Login:</span>
<div class="status-value">
<span class="indicator" id="loginIndicator"></span>
<span id="loginStatus">Checking...</span>
</div>
</div>
</div>
<div id="alertContainer"></div>
<div class="actions">
<button class="btn btn-primary" onclick="refreshStatus()">
<span class="loading hidden" id="refreshLoading"></span>
<span id="refreshText">🔄 Refresh Status</span>
</button>
<button class="btn btn-secondary" onclick="window.location.href='/api/whatsapp/pw=admin1234'">
📱 Lihat QR Code
</button>
<button class="btn btn-danger" onclick="logoutWhatsApp()">
<span class="loading hidden" id="logoutLoading"></span>
<span id="logoutText">🚪 Logout</span>
</button>
</div>
</div>
<script>
let statusCheckInterval;
function showAlert(message, type = 'success') {
const alertContainer = document.getElementById('alertContainer');
const alert = document.createElement('div');
alert.className = `alert ${type}`;
alert.textContent = message;
alertContainer.innerHTML = '';
alertContainer.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 5000);
}
function updateStatusUI(data) {
const connectionIndicator = document.getElementById('connectionIndicator');
const connectionStatus = document.getElementById('connectionStatus');
const loginIndicator = document.getElementById('loginIndicator');
const loginStatus = document.getElementById('loginStatus');
const statusMessage = document.getElementById('statusMessage');
// Update connection status
if (data.is_connected) {
connectionIndicator.className = 'indicator connected';
connectionStatus.textContent = 'Terhubung';
} else {
connectionIndicator.className = 'indicator disconnected';
connectionStatus.textContent = 'Terputus';
}
// Update login status
if (data.is_logged_in) {
loginIndicator.className = 'indicator connected';
loginStatus.textContent = 'Login';
} else {
loginIndicator.className = 'indicator disconnected';
loginStatus.textContent = 'Belum Login';
}
// Update main message
if (data.is_connected && data.is_logged_in) {
statusMessage.textContent = 'WhatsApp berhasil terhubung dan siap digunakan!';
} else if (data.is_logged_in && !data.is_connected) {
statusMessage.textContent = 'WhatsApp sudah login tetapi tidak terhubung. Silakan refresh atau restart koneksi.';
} else {
statusMessage.textContent = 'WhatsApp belum terhubung dengan baik. Silakan scan QR code kembali.';
}
}
function checkStatus() {
fetch('/api/whatsapp-status')
.then(response => response.json())
.then(result => {
if (result.meta.status === 'success') {
updateStatusUI(result.data);
} else {
console.error('Status check failed:', result.meta.message);
}
})
.catch(error => {
console.error('Status check error:', error);
const connectionIndicator = document.getElementById('connectionIndicator');
const connectionStatus = document.getElementById('connectionStatus');
const loginIndicator = document.getElementById('loginIndicator');
const loginStatus = document.getElementById('loginStatus');
connectionIndicator.className = 'indicator disconnected';
connectionStatus.textContent = 'Error';
loginIndicator.className = 'indicator disconnected';
loginStatus.textContent = 'Error';
});
}
function refreshStatus() {
const refreshLoading = document.getElementById('refreshLoading');
const refreshText = document.getElementById('refreshText');
refreshLoading.classList.remove('hidden');
refreshText.textContent = 'Refreshing...';
checkStatus();
setTimeout(() => {
refreshLoading.classList.add('hidden');
refreshText.textContent = '🔄 Refresh Status';
showAlert('Status berhasil diperbarui!');
}, 1000);
}
function logoutWhatsApp() {
if (!confirm('Apakah Anda yakin ingin logout dari WhatsApp?')) {
return;
}
const logoutLoading = document.getElementById('logoutLoading');
const logoutText = document.getElementById('logoutText');
logoutLoading.classList.remove('hidden');
logoutText.textContent = 'Logging out...';
fetch('/api/logout/whastapp', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(result => {
if (result.meta.status === 'success') {
showAlert('Berhasil logout dari WhatsApp!');
setTimeout(() => {
window.location.href = '/api/whatsapp/pw=admin1234';
}, 2000);
} else {
showAlert(result.meta.message, 'error');
}
})
.catch(error => {
console.error('Logout error:', error);
showAlert('Terjadi kesalahan saat logout', 'error');
})
.finally(() => {
logoutLoading.classList.add('hidden');
logoutText.textContent = '🚪 Logout';
});
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
checkStatus();
statusCheckInterval = setInterval(checkStatus, 10000); // Check every 10 seconds
});
// Cleanup interval when page is unloaded
window.addEventListener('beforeunload', function() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
});
</script>
</body>
</html>

View File

@ -1,24 +1,156 @@
package whatsapp
import (
"log"
"html/template"
"path/filepath"
"rijig/config"
"rijig/utils"
"github.com/gofiber/fiber/v2"
)
func WhatsAppHandler(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string)
if !ok || userID == "" {
return utils.Unauthorized(c, "User is not logged in or invalid session")
}
err := config.LogoutWhatsApp()
if err != nil {
log.Printf("Error during logout process for user %s: %v", userID, err)
return utils.InternalServerError(c, err.Error())
}
return utils.Success(c, "Logged out successfully")
type APIResponse struct {
Meta map[string]interface{} `json:"meta"`
Data interface{} `json:"data,omitempty"`
}
func WhatsAppQRPageHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService()
if wa == nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "error",
"message": "WhatsApp service not initialized",
},
})
}
// Jika sudah login, tampilkan halaman success
if wa.IsLoggedIn() {
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(),
},
})
}
c.Set("Content-Type", "text/html")
return tmpl.Execute(c.Response().BodyWriter(), nil)
}
qrDataURI, err := wa.GenerateQR()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "error",
"message": "Failed to generate QR code: " + err.Error(),
},
})
}
if qrDataURI == "success" {
// Login berhasil, 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(),
},
})
}
c.Set("Content-Type", "text/html")
return tmpl.Execute(c.Response().BodyWriter(), nil)
}
if qrDataURI == "already_connected" {
// 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(),
},
})
}
c.Set("Content-Type", "text/html")
return tmpl.Execute(c.Response().BodyWriter(), nil)
}
// Tampilkan QR code scanner
templatePath := filepath.Join("internal", "whatsapp", "scanner.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 scanner template: " + err.Error(),
},
})
}
c.Set("Content-Type", "text/html")
return tmpl.Execute(c.Response().BodyWriter(), template.URL(qrDataURI))
}
func WhatsAppLogoutHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService()
if wa == nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "error",
"message": "WhatsApp service not initialized",
},
})
}
err := wa.Logout()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "error",
"message": err.Error(),
},
})
}
return c.Status(fiber.StatusOK).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "success",
"message": "Successfully logged out and session deleted",
},
})
}
func WhatsAppStatusHandler(c *fiber.Ctx) error {
wa := config.GetWhatsAppService()
if wa == nil {
return c.Status(fiber.StatusInternalServerError).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "error",
"message": "WhatsApp service not initialized",
},
})
}
status := map[string]interface{}{
"is_connected": wa.IsConnected(),
"is_logged_in": wa.IsLoggedIn(),
}
return c.Status(fiber.StatusOK).JSON(APIResponse{
Meta: map[string]interface{}{
"status": "success",
"message": "WhatsApp status retrieved successfully",
},
Data: status,
})
}

View File

@ -1,11 +1,11 @@
package whatsapp
import (
"rijig/middleware"
"github.com/gofiber/fiber/v2"
)
func WhatsAppRouter(api fiber.Router) {
api.Post("/logout/whastapp", middleware.AuthMiddleware(), WhatsAppHandler)
api.Get("/whatsapp-status", WhatsAppStatusHandler)
api.Get("/whatsapp/pw=admin1234", WhatsAppQRPageHandler)
api.Post("/logout/whastapp", WhatsAppLogoutHandler)
}

View File

@ -3,20 +3,20 @@ package model
import "time"
type User struct {
ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"`
Avatar *string `json:"avatar,omitempty"`
Name string `gorm:"not null" json:"name"`
Gender string `gorm:"not null" json:"gender"`
Dateofbirth string `gorm:"not null" json:"dateofbirth"`
Placeofbirth string `gorm:"not null" json:"placeofbirth"`
Phone string `gorm:"not null;index" json:"phone"`
Email string `json:"email,omitempty"`
PhoneVerified bool `gorm:"default:false" json:"phoneVerified"`
Password string `json:"password,omitempty"`
RoleID string `gorm:"not null" json:"roleId"`
Role *Role `gorm:"foreignKey:RoleID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"role"`
RegistrationStatus string `gorm:"default:uncompleted" json:"registrationstatus"`
RegistrationProgress int8 `json:"registration_progress"`
CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"`
UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"`
ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"`
Avatar *string `json:"avatar,omitempty"`
Name string `gorm:"not null" json:"name"`
Gender string `gorm:"not null" json:"gender"`
Dateofbirth string `gorm:"not null" json:"dateofbirth"`
Placeofbirth string `gorm:"not null" json:"placeofbirth"`
Phone string `gorm:"not null;index" json:"phone"`
Email string `json:"email,omitempty"`
PhoneVerified bool `gorm:"default:false" json:"phoneVerified"`
Password string `json:"password,omitempty"`
RoleID string `gorm:"not null" json:"roleId"`
Role *Role `gorm:"foreignKey:RoleID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"role"`
RegistrationStatus string `json:"registrationstatus"`
RegistrationProgress int8 `json:"registration_progress"`
CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"`
UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"`
}

View File

@ -11,14 +11,15 @@ import (
"rijig/internal/userpin"
"rijig/internal/whatsapp"
"rijig/middleware"
// "rijig/presentation"
// "rijig/presentation"
"github.com/gofiber/fiber/v2"
)
func SetupRoutes(app *fiber.App) {
apa := app.Group(os.Getenv("BASE_URL"))
whatsapp.WhatsAppRouter(apa)
apa.Static("/uploads", "./public"+os.Getenv("BASE_URL")+"/uploads")
api := app.Group(os.Getenv("BASE_URL"))