diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8f6fb7e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,71 @@ +# Git +.git +.gitignore +README.md +.gitattributes + +# Documentation +*.md +docs/ + +# Environment files (kecuali yang diperlukan) +.env +.env.local +.env.example +# Kita tetap include .env.dev dan .env.docker untuk development + +# Logs +*.log +logs/ + +# Dependencies +vendor/ + +# Test files +*_test.go +testdata/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +tmp/ + +# Build artifacts (untuk production) +main +*.exe + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# Air specific +.air.toml +tmp/ +*_templ.go + +# Coverage +*.out +coverage.html + +# Database +*.db +*.sqlite +*.sqlite3 + +# Public uploads (jika ada) +public/uploads/ + +# Makefile +Makefile \ No newline at end of file diff --git a/.env.example b/.env.example index 4b313a9..21d988c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +#BASE URL +BASE_URL= + # SERVER SETTINGS SERVER_HOST= SERVER_PORT= @@ -20,3 +23,8 @@ API_KEY= #SECRET_KEY SECRET_KEY= + +# TTL +ACCESS_TOKEN_EXPIRY= +REFRESH_TOKEN_EXPIRY= +PARTIAL_TOKEN_EXPIRY= diff --git a/.gitignore b/.gitignore index 2768314..73071d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# # Binaries for programs and plugins *.exe *.exe~ @@ -14,19 +11,61 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Dependency directories (remove the comment below to include it) -# vendor/ +# Dependency directories +vendor/ # Go workspace file go.work go.work.sum -# env file -.env -.env.prod -.env.dev +# Environment files - ignore all variations +.env* +!.env.example -# Ignore avatar images -/public/uploads/avatars/ -/public/uploads/articles/ -/public/uploads/banners/ \ No newline at end of file +# Logs +*.log +logs/ + +# Temporary files +tmp/ +*.tmp +*.temp + +# IDE/Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Air live reload tool +.air.toml + +# Public uploads - user generated content +/public/apirijig/v2/uploads/ + +# Build outputs +/bin/ +/build/ +/dist/ + +# Coverage reports +coverage.txt +coverage.html +*.cover + +# Debug files +debug +*.pprof + +# Local development files +*.local \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..3c66d10 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,49 @@ +# Multi-stage Dockerfile untuk development dengan Air hot reload +FROM golang:1.23-alpine AS base + +# 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 + +# 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 ./ + +# Download dependencies +RUN go mod download && go mod verify + +# Copy source code +COPY . . + +# Create tmp directory dengan permissions yang tepat +RUN mkdir -p tmp && \ + chown -R golang:golang /app && \ + chmod -R 755 /app + +# Switch to non-root user +USER golang + +# Expose port +EXPOSE 7000 + +# Command untuk menjalankan Air +CMD ["air", "-c", ".air.toml"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9c7d8d3 --- /dev/null +++ b/Makefile @@ -0,0 +1,151 @@ +# 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 + +# Color codes untuk output yang lebih menarik +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)πŸš€ $(PROJECT_NAME) - Available Commands:$(NC)" + @echo "" + @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)πŸ› οΈ 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 "$(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" + +# ====================== +# DEVELOPMENT COMMANDS +# ====================== + +# Quick development setup (recommended) +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}}" \ No newline at end of file diff --git a/README.md b/README.md index d7a7fc2..613aec0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,260 @@ # 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 + +--- + +
+ +**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** 🌱 + +
diff --git a/cmd/main.go b/cmd/main.go index 1c865ce..f3a4b92 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,17 +1,54 @@ package main import ( + "log" + "rijig/config" + "rijig/internal/cart" + "rijig/internal/trash" + "rijig/internal/worker" + "time" + + // "rijig/internal/repositories" + // "rijig/internal/services" + + "rijig/router" + "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/router" + "github.com/gofiber/fiber/v2/middleware/cors" ) func main() { config.SetupConfig() + cartRepo := cart.NewCartRepository() + trashRepo := trash.NewTrashRepository(config.DB) + cartService := cart.NewCartService(cartRepo, trashRepo) + worker := worker.NewCartWorker(cartService, cartRepo, trashRepo) - app := fiber.New() + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if err := worker.AutoCommitExpiringCarts(); err != nil { + log.Printf("Auto-commit error: %v", err) + } + } + }() + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{ + "error": err.Error(), + }) + }, + }) + + app.Use(cors.New()) router.SetupRoutes(app) - config.StartServer(app) -} +} \ No newline at end of file diff --git a/config/database.go b/config/database.go index 5322c65..abcb5ae 100644 --- a/config/database.go +++ b/config/database.go @@ -5,7 +5,6 @@ import ( "log" "os" - "github.com/pahmiudahgede/senggoldong/model" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -13,7 +12,6 @@ import ( var DB *gorm.DB func ConnectDatabase() { - dsn := fmt.Sprintf( "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", os.Getenv("DB_HOST"), @@ -30,28 +28,7 @@ func ConnectDatabase() { } log.Println("Database connected successfully!") - err = DB.AutoMigrate( - // ==wilayah indonesia== - &model.Province{}, - &model.Regency{}, - &model.District{}, - &model.Village{}, - // ==wilayah indonesia== - - // ==main feature== - &model.User{}, - &model.Role{}, - &model.UserPin{}, - &model.Address{}, - &model.Article{}, - &model.Banner{}, - &model.InitialCoint{}, - &model.TrashCategory{}, - &model.TrashDetail{}, - // ==main feature== - ) - if err != nil { + if err := RunMigrations(DB); err != nil { log.Fatalf("Error performing auto-migration: %v", err) } - log.Println("Database migrated successfully!") } diff --git a/config/migration.go b/config/migration.go new file mode 100644 index 0000000..d00f0e6 --- /dev/null +++ b/config/migration.go @@ -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 +} \ No newline at end of file diff --git a/config/redis.go b/config/redis.go index 1d93165..61801f0 100644 --- a/config/redis.go +++ b/config/redis.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "strconv" "github.com/go-redis/redis/v8" ) @@ -13,13 +14,21 @@ var RedisClient *redis.Client var Ctx = context.Background() func ConnectRedis() { + + redisDBStr := os.Getenv("REDIS_DB") + + redisDB, err := strconv.Atoi(redisDBStr) + if err != nil { + log.Fatalf("Error converting REDIS_DB to integer: %v", err) + } + RedisClient = redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), os.Getenv("REDIS_PORT")), Password: os.Getenv("REDIS_PASSWORD"), - DB: 0, + DB: redisDB, }) - _, err := RedisClient.Ping(Ctx).Result() + _, err = RedisClient.Ping(Ctx).Result() if err != nil { log.Fatalf("Error connecting to Redis: %v", err) } diff --git a/config/server.go b/config/server.go index 8fc1592..9f5cf5e 100644 --- a/config/server.go +++ b/config/server.go @@ -8,14 +8,18 @@ import ( "github.com/gofiber/fiber/v2" ) +func GetSecretKey() string { + return os.Getenv("SECRET_KEY") +} + func StartServer(app *fiber.App) { host := os.Getenv("SERVER_HOST") port := os.Getenv("SERVER_PORT") address := fmt.Sprintf("%s:%s", host, port) - log.Printf("Server is running on http://%s", address) + log.Printf("server berjalan di http://%s", address) if err := app.Listen(address); err != nil { - log.Fatalf("Error starting server: %v", err) + log.Fatalf("gagal saat menjalankan server: %v", err) } } diff --git a/config/setup_config.go b/config/setup_config.go index e6679a2..5464832 100644 --- a/config/setup_config.go +++ b/config/setup_config.go @@ -1,17 +1,27 @@ -package config +package config import ( "log" + "os" "github.com/joho/godotenv" ) func SetupConfig() { - err := godotenv.Load(".env.dev") - if err != nil { - log.Fatalf("Error loading .env file: %v", err) + 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) + log.Println("Trying to use system environment variables...") + } else { + log.Println("Loaded environment from .env.dev file") + } } - ConnectDatabase() ConnectRedis() + go func() { + InitWhatsApp() // Ini tidak akan blocking startup server + }() } diff --git a/config/whatsapp.go b/config/whatsapp.go new file mode 100644 index 0000000..9f1372d --- /dev/null +++ b/config/whatsapp.go @@ -0,0 +1,176 @@ +package config + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + _ "github.com/lib/pq" + + "github.com/skip2/go-qrcode" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" +) + +type WhatsAppService struct { + Client *whatsmeow.Client + Container *sqlstore.Container +} + +var whatsappService *WhatsAppService + +func InitWhatsApp() { + var err error + + 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"), + ) + + dbLog := waLog.Stdout("Database", "DEBUG", true) + container, err := sqlstore.New("postgres", connectionString, dbLog) + if err != nil { + log.Fatalf("Failed to connect to WhatsApp database: %v", err) + } + + whatsappService = &WhatsAppService{ + Container: container, + } +} + +func GetWhatsAppService() *WhatsAppService { + return whatsappService +} + +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!") + } +} + +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 first device: %v", err) + } + + clientLog := waLog.Stdout("Client", "DEBUG", true) + wa.Client = whatsmeow.NewClient(deviceStore, clientLog) + wa.Client.AddEventHandler(eventHandler) + + 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 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 + } + } + } + } 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 (wa *WhatsAppService) SendMessage(phoneNumber, message string) error { + if wa.Client == nil { + return fmt.Errorf("client not initialized") + } + + targetJID, err := types.ParseJID(phoneNumber + "@s.whatsapp.net") + if err != nil { + return fmt.Errorf("invalid phone number: %v", err) + } + + msg := &waE2E.Message{ + Conversation: proto.String(message), + } + + _, err = wa.Client.SendMessage(context.Background(), targetJID, msg) + if err != nil { + return fmt.Errorf("failed to send message: %v", err) + } + + return nil +} + +func (wa *WhatsAppService) Logout() error { + if wa.Client == nil { + return fmt.Errorf("no active client session") + } + + 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() + } + os.Exit(0) + }() +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ed90cd2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,112 @@ +# docker-compose.dev.yml - Development environment dengan Air hot reload +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: rijig_postgres_dev + restart: unless-stopped + environment: + POSTGRES_DB: apirijig_v2 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: pahmiadmin + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5433:5432" + volumes: + - postgres_data_dev:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d + networks: + - rijig_network_dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d apirijig_v2"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # Redis Cache + redis: + image: redis:7-alpine + container_name: rijig_redis_dev + restart: unless-stopped + ports: + - "6378:6379" + volumes: + - redis_data_dev:/data + networks: + - rijig_network_dev + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 15s + + # Go Application dengan Air hot reload + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: rijig_app_dev + restart: unless-stopped + ports: + - "7000:7000" + env_file: + - .env.docker + volumes: + # Mount source code untuk hot reload + - .:/app + # Cache Go modules untuk performance + - go_modules_cache:/go/pkg/mod + # Exclude tmp directory untuk mencegah konflik + - /app/tmp + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - rijig_network_dev + working_dir: /app + + # pgAdmin (optional - untuk GUI database management) + pgadmin: + image: dpage/pgadmin4:latest + container_name: rijig_pgadmin_dev + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: admin@rijig.com + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: "False" + ports: + - "8080:80" + volumes: + - pgadmin_data_dev:/var/lib/pgadmin + depends_on: + - postgres + networks: + - rijig_network_dev + + # Redis Commander (optional - untuk GUI redis management) + redis-commander: + image: rediscommander/redis-commander:latest + container_name: rijig_redis_commander_dev + restart: unless-stopped + environment: + REDIS_HOSTS: local:redis:6379 + ports: + - "8081:8081" + depends_on: + - redis + networks: + - rijig_network_dev + +networks: + rijig_network_dev: + driver: bridge + +volumes: + postgres_data_dev: + redis_data_dev: + pgadmin_data_dev: + go_modules_cache: diff --git a/dto/address_dto.go b/dto/address_dto.go deleted file mode 100644 index eef67f0..0000000 --- a/dto/address_dto.go +++ /dev/null @@ -1,61 +0,0 @@ -package dto - -import "strings" - -type AddressResponseDTO struct { - UserID string `json:"user_id"` - ID string `json:"address_id"` - Province string `json:"province"` - Regency string `json:"regency"` - District string `json:"district"` - Village string `json:"village"` - PostalCode string `json:"postalCode"` - Detail string `json:"detail"` - Geography string `json:"geography"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type CreateAddressDTO struct { - Province string `json:"province_id"` - Regency string `json:"regency_id"` - District string `json:"district_id"` - Village string `json:"village_id"` - PostalCode string `json:"postalCode"` - Detail string `json:"detail"` - Geography string `json:"geography"` -} - -func (r *CreateAddressDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.Province) == "" { - errors["province_id"] = append(errors["province_id"], "Province ID is required") - } - if strings.TrimSpace(r.Regency) == "" { - errors["regency_id"] = append(errors["regency_id"], "Regency ID is required") - } - if strings.TrimSpace(r.District) == "" { - errors["district_id"] = append(errors["district_id"], "District ID is required") - } - if strings.TrimSpace(r.Village) == "" { - errors["village_id"] = append(errors["village_id"], "Village ID is required") - } - if strings.TrimSpace(r.PostalCode) == "" { - errors["postalCode"] = append(errors["village_id"], "PostalCode ID is required") - } else if len(r.PostalCode) < 5 { - errors["postalCode"] = append(errors["postalCode"], "kode pos belum sesuai") - } - if strings.TrimSpace(r.Detail) == "" { - errors["detail"] = append(errors["detail"], "Detail address is required") - } - if strings.TrimSpace(r.Geography) == "" { - errors["geography"] = append(errors["geography"], "Geographic coordinates are required") - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} diff --git a/dto/auth_dto.go b/dto/auth_dto.go deleted file mode 100644 index 5a7e2ea..0000000 --- a/dto/auth_dto.go +++ /dev/null @@ -1,121 +0,0 @@ -package dto - -import ( - "regexp" - "strings" -) - -type LoginDTO struct { - RoleID string `json:"roleid"` - Identifier string `json:"identifier"` - Password string `json:"password"` -} - -type UserResponseWithToken struct { - UserID string `json:"user_id"` - RoleName string `json:"role_name"` - Token string `json:"token"` -} - -type RegisterDTO struct { - Username string `json:"username"` - Name string `json:"name"` - Phone string `json:"phone"` - Email string `json:"email"` - Password string `json:"password"` - ConfirmPassword string `json:"confirm_password"` - RoleID string `json:"roleId,omitempty"` -} - -func (l *LoginDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(l.RoleID) == "" { - errors["roleid"] = append(errors["roleid"], "Role ID is required") - } - if strings.TrimSpace(l.Identifier) == "" { - errors["identifier"] = append(errors["identifier"], "Identifier (username, email, or phone) is required") - } - if strings.TrimSpace(l.Password) == "" { - errors["password"] = append(errors["password"], "Password is required") - } - - if len(errors) > 0 { - return errors, false - } - return nil, true -} - -func (r *RegisterDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - r.validateRequiredFields(errors) - - if r.Phone != "" && !IsValidPhoneNumber(r.Phone) { - errors["phone"] = append(errors["phone"], "Invalid phone number format. Use +62 followed by 9-13 digits") - } - - if r.Email != "" && !IsValidEmail(r.Email) { - errors["email"] = append(errors["email"], "Invalid email format") - } - - if r.Password != "" && !IsValidPassword(r.Password) { - errors["password"] = append(errors["password"], "Password must be at least 8 characters long and contain at least one number") - } - - if r.ConfirmPassword != "" && r.Password != r.ConfirmPassword { - errors["confirm_password"] = append(errors["confirm_password"], "Password and confirm password do not match") - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} - -func (r *RegisterDTO) validateRequiredFields(errors map[string][]string) { - - if strings.TrimSpace(r.Username) == "" { - errors["username"] = append(errors["username"], "Username is required") - } - if strings.TrimSpace(r.Name) == "" { - errors["name"] = append(errors["name"], "Name is required") - } - if strings.TrimSpace(r.Phone) == "" { - errors["phone"] = append(errors["phone"], "Phone number is required") - } - if strings.TrimSpace(r.Email) == "" { - errors["email"] = append(errors["email"], "Email is required") - } - if strings.TrimSpace(r.Password) == "" { - errors["password"] = append(errors["password"], "Password is required") - } - if strings.TrimSpace(r.ConfirmPassword) == "" { - errors["confirm_password"] = append(errors["confirm_password"], "Confirm password is required") - } - if strings.TrimSpace(r.RoleID) == "" { - errors["roleId"] = append(errors["roleId"], "RoleID is required") - } -} - -func IsValidPhoneNumber(phone string) bool { - - re := regexp.MustCompile(`^\+62\d{9,13}$`) - return re.MatchString(phone) -} - -func IsValidEmail(email string) bool { - - re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) - return re.MatchString(email) -} - -func IsValidPassword(password string) bool { - if len(password) < 8 { - return false - } - - re := regexp.MustCompile(`\d`) - return re.MatchString(password) -} diff --git a/dto/banner_dto.go b/dto/banner_dto.go deleted file mode 100644 index 56f214c..0000000 --- a/dto/banner_dto.go +++ /dev/null @@ -1,29 +0,0 @@ -package dto - -import "strings" - -type ResponseBannerDTO struct { - ID string `json:"id"` - BannerName string `json:"bannername"` - BannerImage string `json:"bannerimage"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type RequestBannerDTO struct { - BannerName string `json:"bannername"` - BannerImage string `json:"bannerimage"` -} - -func (r *RequestBannerDTO) ValidateBannerInput() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.BannerName) == "" { - errors["bannername"] = append(errors["bannername"], "nama banner harus diisi") - } - if len(errors) > 0 { - return errors, false - } - - return nil, true -} diff --git a/dto/initialcoint_dto.go b/dto/initialcoint_dto.go deleted file mode 100644 index 06d1fb2..0000000 --- a/dto/initialcoint_dto.go +++ /dev/null @@ -1,34 +0,0 @@ -package dto - -import "strings" - -type ReponseInitialCointDTO struct { - ID string `json:"coin_id"` - CoinName string `json:"coin_name"` - ValuePerUnit float64 `json:"value_perunit"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type RequestInitialCointDTO struct { - CoinName string `json:"coin_name"` - ValuePerUnit float64 `json:"value_perunit"` -} - -func (r *RequestInitialCointDTO) ValidateCointInput() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.CoinName) == "" { - errors["coin_name"] = append(errors["coin_name"], "nama coin harus diisi") - } - - if r.ValuePerUnit <= 0 { - errors["value_perunit"] = append(errors["value_perunit"], "value per unit harus lebih besar dari 0") - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} diff --git a/dto/trash_dto.go b/dto/trash_dto.go deleted file mode 100644 index 085fe59..0000000 --- a/dto/trash_dto.go +++ /dev/null @@ -1,59 +0,0 @@ -package dto - -import "strings" - -type RequestTrashCategoryDTO struct { - Name string `json:"name"` -} - -type ResponseTrashCategoryDTO struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Details []ResponseTrashDetailDTO `json:"details,omitempty"` -} - -type ResponseTrashDetailDTO struct { - ID string `json:"id"` - CategoryID string `json:"category_id"` - Description string `json:"description"` - Price float64 `json:"price"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type RequestTrashDetailDTO struct { - CategoryID string `json:"category_id"` - Description string `json:"description"` - Price float64 `json:"price"` -} - -func (r *RequestTrashCategoryDTO) ValidateTrashCategoryInput() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.Name) == "" { - errors["name"] = append(errors["name"], "name is required") - } - if len(errors) > 0 { - return errors, false - } - - return nil, true -} - -func (r *RequestTrashDetailDTO) ValidateTrashDetailInput() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.Description) == "" { - errors["description"] = append(errors["description"], "description is required") - } - if r.Price <= 0 { - errors["price"] = append(errors["price"], "price must be greater than 0") - } - if len(errors) > 0 { - return errors, false - } - - return nil, true -} diff --git a/dto/user_dto.go b/dto/user_dto.go deleted file mode 100644 index cb2dc38..0000000 --- a/dto/user_dto.go +++ /dev/null @@ -1,95 +0,0 @@ -package dto - -import ( - "regexp" - "strings" -) - -type UserResponseDTO struct { - ID string `json:"id"` - Username string `json:"username"` - Avatar *string `json:"photoprofile,omitempty"` - Name string `json:"name"` - Phone string `json:"phone"` - Email string `json:"email"` - EmailVerified bool `json:"emailVerified"` - RoleName string `json:"role"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type UpdateUserDTO struct { - Name string `json:"name"` - Phone string `json:"phone"` - Email string `json:"email"` -} - -func (r *UpdateUserDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.Name) == "" { - errors["name"] = append(errors["name"], "Name is required") - } - - if strings.TrimSpace(r.Phone) == "" { - errors["phone"] = append(errors["phone"], "Phone number is required") - } else if !IsValidPhoneNumber(r.Phone) { - errors["phone"] = append(errors["phone"], "Invalid phone number format. Use +62 followed by 9-13 digits") - } - - if strings.TrimSpace(r.Email) == "" { - errors["email"] = append(errors["email"], "Email is required") - } else if !IsValidEmail(r.Email) { - errors["email"] = append(errors["email"], "Invalid email format") - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} - -func IsUpdateValidPhoneNumber(phone string) bool { - - re := regexp.MustCompile(`^\+62\d{9,13}$`) - return re.MatchString(phone) -} - -func IsUPdateValidEmail(email string) bool { - - re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) - return re.MatchString(email) -} - -type UpdatePasswordDTO struct { - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` - ConfirmNewPassword string `json:"confirm_new_password"` -} - -func (u *UpdatePasswordDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - if u.OldPassword == "" { - errors["old_password"] = append(errors["old_password"], "Old password is required") - } - - if u.NewPassword == "" { - errors["new_password"] = append(errors["new_password"], "New password is required") - } else if len(u.NewPassword) < 8 { - errors["new_password"] = append(errors["new_password"], "Password must be at least 8 characters long") - } - - if u.ConfirmNewPassword == "" { - errors["confirm_new_password"] = append(errors["confirm_new_password"], "Confirm new password is required") - } else if u.NewPassword != u.ConfirmNewPassword { - errors["confirm_new_password"] = append(errors["confirm_new_password"], "Passwords do not match") - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} diff --git a/dto/userpin_dto.go b/dto/userpin_dto.go deleted file mode 100644 index 146cc96..0000000 --- a/dto/userpin_dto.go +++ /dev/null @@ -1,66 +0,0 @@ -package dto - -import ( - "fmt" - "regexp" - "strings" -) - -type RequestUserPinDTO struct { - Pin string `json:"userpin"` -} - -func (r *RequestUserPinDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(r.Pin) == "" { - errors["pin"] = append(errors["pin"], "Pin is required") - } - - if err := validatePin(r.Pin); err != nil { - errors["pin"] = append(errors["pin"], err.Error()) - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} - -type UpdateUserPinDTO struct { - OldPin string `json:"old_pin"` - NewPin string `json:"new_pin"` -} - -func (u *UpdateUserPinDTO) Validate() (map[string][]string, bool) { - errors := make(map[string][]string) - - if strings.TrimSpace(u.OldPin) == "" { - errors["old_pin"] = append(errors["old_pin"], "Old pin is required") - } - - if err := validatePin(u.NewPin); err != nil { - errors["new_pin"] = append(errors["new_pin"], err.Error()) - } - - if len(errors) > 0 { - return errors, false - } - - return nil, true -} - -func isNumeric(s string) bool { - re := regexp.MustCompile(`^[0-9]+$`) - return re.MatchString(s) -} - -func validatePin(pin string) error { - if len(pin) != 6 { - return fmt.Errorf("pin harus terdiri dari 6 digit") - } else if !isNumeric(pin) { - return fmt.Errorf("pin harus berupa angka") - } - return nil -} diff --git a/go.mod b/go.mod index 634b41e..552a803 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,57 @@ -module github.com/pahmiudahgede/senggoldong +module rijig go 1.23.3 require ( + github.com/go-redis/redis/v8 v8.11.5 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.36.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // direct +) + +// require ( +// golang.org/x/term v0.30.0 // indirect +// rsc.io/qr v0.2.0 // indirect +// ) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/andybalholm/brotli v1.0.5 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.23.0 // indirect - github.com/go-redis/redis/v8 v8.11.5 // indirect - github.com/gofiber/fiber/v2 v2.52.5 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.17.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/lucsky/cuid v1.2.1 // indirect + github.com/lib/pq v1.10.9 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/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 - golang.org/x/crypto v0.19.0 // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/sync v0.9.0 // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/text v0.20.0 // indirect - gorm.io/driver/postgres v1.5.11 // indirect - gorm.io/gorm v1.25.12 // indirect + go.mau.fi/libsignal v0.1.2 // indirect + go.mau.fi/util v0.8.6 // indirect + go.mau.fi/whatsmeow v0.0.0-20250316144733-e7e263bf2175 + golang.org/x/net v0.37.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.36.5 ) diff --git a/go.sum b/go.sum index cf521f9..e6077af 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,30 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= -github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -37,48 +41,89 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lucsky/cuid v1.2.1 h1:MtJrL2OFhvYufUIn48d35QGXyeTC8tn0upumW9WwTHg= -github.com/lucsky/cuid v1.2.1/go.mod h1:QaaJqckboimOmhRSJXSx/+IT+VTfxfPGSo/6mfgUfmE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= +github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +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= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0= +go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE= +go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= +go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= +go.mau.fi/whatsmeow v0.0.0-20250316144733-e7e263bf2175 h1:BDShdc10qJzi3B0xPGA6HVQl+929wIFst8/W+8EnvbI= +go.mau.fi/whatsmeow v0.0.0-20250316144733-e7e263bf2175/go.mod h1:WNhj4JeQ6YR6dUOEiCXKqmE4LavSFkwRoKmu4atRrRs= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/about/about_dto.go b/internal/about/about_dto.go new file mode 100644 index 0000000..17f2917 --- /dev/null +++ b/internal/about/about_dto.go @@ -0,0 +1,66 @@ +package about + +import ( + "strings" +) + +type RequestAboutDTO struct { + Title string `json:"title"` + CoverImage string `json:"cover_image"` +} + +func (r *RequestAboutDTO) ValidateAbout() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.Title) == "" { + errors["title"] = append(errors["title"], "Title is required") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +type ResponseAboutDTO struct { + ID string `json:"id"` + Title string `json:"title"` + CoverImage string `json:"cover_image"` + AboutDetail *[]ResponseAboutDetailDTO `json:"about_detail"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type RequestAboutDetailDTO struct { + AboutId string `json:"about_id"` + ImageDetail string `json:"image_detail"` + Description string `json:"description"` +} + +func (r *RequestAboutDetailDTO) ValidateAboutDetail() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.AboutId) == "" { + errors["about_id"] = append(errors["about_id"], "about_id is required") + } + + if strings.TrimSpace(r.Description) == "" { + errors["description"] = append(errors["description"], "Description is required") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +type ResponseAboutDetailDTO struct { + ID string `json:"id"` + AboutID string `json:"about_id"` + ImageDetail string `json:"image_detail"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/about/about_handler.go b/internal/about/about_handler.go new file mode 100644 index 0000000..a5f1134 --- /dev/null +++ b/internal/about/about_handler.go @@ -0,0 +1,175 @@ +package about + +import ( + "fmt" + "log" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +type AboutHandler struct { + AboutService AboutService +} + +func NewAboutHandler(aboutService AboutService) *AboutHandler { + return &AboutHandler{ + AboutService: aboutService, + } +} + +func (h *AboutHandler) CreateAbout(c *fiber.Ctx) error { + var request RequestAboutDTO + if err := c.BodyParser(&request); err != nil { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Invalid request body", map[string][]string{"body": {"Invalid body"}}) + } + + errors, valid := request.ValidateAbout() + if !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + aboutCoverImage, err := c.FormFile("cover_image") + if err != nil { + return utils.BadRequest(c, "Cover image is required") + } + + response, err := h.AboutService.CreateAbout(c.Context(), request, aboutCoverImage) + if err != nil { + log.Printf("Error creating About: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to create About: %v", err)) + } + + return utils.CreateSuccessWithData(c, "Successfully created About", response) +} + +func (h *AboutHandler) UpdateAbout(c *fiber.Ctx) error { + id := c.Params("id") + + var request RequestAboutDTO + if err := c.BodyParser(&request); err != nil { + log.Printf("Error parsing request body: %v", err) + return utils.BadRequest(c, "Invalid input data") + } + + aboutCoverImage, err := c.FormFile("cover_image") + if err != nil { + log.Printf("Error retrieving cover image about from request: %v", err) + return utils.BadRequest(c, "cover_image is required") + } + + response, err := h.AboutService.UpdateAbout(c.Context(), id, request, aboutCoverImage) + if err != nil { + log.Printf("Error updating About: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to update About: %v", err)) + } + + return utils.SuccessWithData(c, "Successfully updated About", response) +} + +func (h *AboutHandler) GetAllAbout(c *fiber.Ctx) error { + response, err := h.AboutService.GetAllAbout(c.Context()) + if err != nil { + log.Printf("Error fetching all About: %v", err) + return utils.InternalServerError(c, "Failed to fetch About list") + } + + return utils.SuccessWithPagination(c, "Successfully fetched About list", response, 1, len(response)) +} + +func (h *AboutHandler) GetAboutByID(c *fiber.Ctx) error { + id := c.Params("id") + + response, err := h.AboutService.GetAboutByID(c.Context(), id) + if err != nil { + log.Printf("Error fetching About by ID: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to fetch About by ID: %v", err)) + } + + return utils.SuccessWithData(c, "Successfully fetched About", response) +} + +func (h *AboutHandler) GetAboutDetailById(c *fiber.Ctx) error { + id := c.Params("id") + + response, err := h.AboutService.GetAboutDetailById(c.Context(), id) + if err != nil { + log.Printf("Error fetching About detail by ID: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to fetch About by ID: %v", err)) + } + + return utils.SuccessWithData(c, "Successfully fetched About", response) +} + +func (h *AboutHandler) DeleteAbout(c *fiber.Ctx) error { + id := c.Params("id") + + if err := h.AboutService.DeleteAbout(c.Context(), id); err != nil { + log.Printf("Error deleting About: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to delete About: %v", err)) + } + + return utils.Success(c, "Successfully deleted About") +} + +func (h *AboutHandler) CreateAboutDetail(c *fiber.Ctx) error { + var request RequestAboutDetailDTO + if err := c.BodyParser(&request); err != nil { + log.Printf("Error parsing request body: %v", err) + return utils.BadRequest(c, "Invalid input data") + } + + errors, valid := request.ValidateAboutDetail() + if !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + aboutDetailImage, err := c.FormFile("image_detail") + if err != nil { + log.Printf("Error retrieving image detail from request: %v", err) + return utils.BadRequest(c, "image_detail is required") + } + + response, err := h.AboutService.CreateAboutDetail(c.Context(), request, aboutDetailImage) + if err != nil { + log.Printf("Error creating AboutDetail: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to create AboutDetail: %v", err)) + } + + return utils.CreateSuccessWithData(c, "Successfully created AboutDetail", response) +} + +func (h *AboutHandler) UpdateAboutDetail(c *fiber.Ctx) error { + id := c.Params("id") + + var request RequestAboutDetailDTO + if err := c.BodyParser(&request); err != nil { + log.Printf("Error parsing request body: %v", err) + return utils.BadRequest(c, "Invalid input data") + } + + aboutDetailImage, err := c.FormFile("image_detail") + if err != nil { + log.Printf("Error retrieving image detail from request: %v", err) + return utils.BadRequest(c, "image_detail is required") + } + + response, err := h.AboutService.UpdateAboutDetail(c.Context(), id, request, aboutDetailImage) + if err != nil { + log.Printf("Error updating AboutDetail: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to update AboutDetail: %v", err)) + } + + return utils.SuccessWithData(c, "Successfully updated AboutDetail", response) +} + +func (h *AboutHandler) DeleteAboutDetail(c *fiber.Ctx) error { + id := c.Params("id") + + if err := h.AboutService.DeleteAboutDetail(c.Context(), id); err != nil { + log.Printf("Error deleting AboutDetail: %v", err) + return utils.InternalServerError(c, fmt.Sprintf("Failed to delete AboutDetail: %v", err)) + } + + return utils.Success(c, "Successfully deleted AboutDetail") +} diff --git a/internal/about/about_repository.go b/internal/about/about_repository.go new file mode 100644 index 0000000..ab06e14 --- /dev/null +++ b/internal/about/about_repository.go @@ -0,0 +1,113 @@ +package about + +import ( + "context" + "fmt" + "rijig/model" + + "gorm.io/gorm" +) + +type AboutRepository interface { + CreateAbout(ctx context.Context, about *model.About) error + CreateAboutDetail(ctx context.Context, aboutDetail *model.AboutDetail) error + GetAllAbout(ctx context.Context) ([]model.About, error) + GetAboutByID(ctx context.Context, id string) (*model.About, error) + GetAboutByIDWithoutPrel(ctx context.Context, id string) (*model.About, error) + GetAboutDetailByID(ctx context.Context, id string) (*model.AboutDetail, error) + UpdateAbout(ctx context.Context, id string, about *model.About) (*model.About, error) + UpdateAboutDetail(ctx context.Context, id string, aboutDetail *model.AboutDetail) (*model.AboutDetail, error) + DeleteAbout(ctx context.Context, id string) error + DeleteAboutDetail(ctx context.Context, id string) error +} + +type aboutRepository struct { + db *gorm.DB +} + +func NewAboutRepository(db *gorm.DB) AboutRepository { + return &aboutRepository{db} +} + +func (r *aboutRepository) CreateAbout(ctx context.Context, about *model.About) error { + if err := r.db.WithContext(ctx).Create(&about).Error; err != nil { + return fmt.Errorf("failed to create About: %v", err) + } + return nil +} + +func (r *aboutRepository) CreateAboutDetail(ctx context.Context, aboutDetail *model.AboutDetail) error { + if err := r.db.WithContext(ctx).Create(&aboutDetail).Error; err != nil { + return fmt.Errorf("failed to create AboutDetail: %v", err) + } + return nil +} + +func (r *aboutRepository) GetAllAbout(ctx context.Context) ([]model.About, error) { + var abouts []model.About + if err := r.db.WithContext(ctx).Find(&abouts).Error; err != nil { + return nil, fmt.Errorf("failed to fetch all About records: %v", err) + } + return abouts, nil +} + +func (r *aboutRepository) GetAboutByID(ctx context.Context, id string) (*model.About, error) { + var about model.About + if err := r.db.WithContext(ctx).Preload("AboutDetail").Where("id = ?", id).First(&about).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("about with ID %s not found", id) + } + return nil, fmt.Errorf("failed to fetch About by ID: %v", err) + } + return &about, nil +} + +func (r *aboutRepository) GetAboutByIDWithoutPrel(ctx context.Context, id string) (*model.About, error) { + var about model.About + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&about).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("about with ID %s not found", id) + } + return nil, fmt.Errorf("failed to fetch About by ID: %v", err) + } + return &about, nil +} + +func (r *aboutRepository) GetAboutDetailByID(ctx context.Context, id string) (*model.AboutDetail, error) { + var aboutDetail model.AboutDetail + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&aboutDetail).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("aboutdetail with ID %s not found", id) + } + return nil, fmt.Errorf("failed to fetch AboutDetail by ID: %v", err) + } + return &aboutDetail, nil +} + +func (r *aboutRepository) UpdateAbout(ctx context.Context, id string, about *model.About) (*model.About, error) { + if err := r.db.WithContext(ctx).Model(&about).Where("id = ?", id).Updates(about).Error; err != nil { + return nil, fmt.Errorf("failed to update About: %v", err) + } + return about, nil +} + +func (r *aboutRepository) UpdateAboutDetail(ctx context.Context, id string, aboutDetail *model.AboutDetail) (*model.AboutDetail, error) { + if err := r.db.WithContext(ctx).Model(&aboutDetail).Where("id = ?", id).Updates(aboutDetail).Error; err != nil { + return nil, fmt.Errorf("failed to update AboutDetail: %v", err) + } + return aboutDetail, nil +} + +func (r *aboutRepository) DeleteAbout(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.About{}).Error; err != nil { + return fmt.Errorf("failed to delete About: %v", err) + } + return nil +} + +func (r *aboutRepository) DeleteAboutDetail(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.AboutDetail{}).Error; err != nil { + return fmt.Errorf("failed to delete AboutDetail: %v", err) + } + return nil +} diff --git a/internal/about/about_route.go b/internal/about/about_route.go new file mode 100644 index 0000000..03dfa0d --- /dev/null +++ b/internal/about/about_route.go @@ -0,0 +1,32 @@ +package about + +import ( + "rijig/config" + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +func AboutRouter(api fiber.Router) { + aboutRepo := NewAboutRepository(config.DB) + aboutService := NewAboutService(aboutRepo) + aboutHandler := NewAboutHandler(aboutService) + + aboutRoutes := api.Group("/about") + aboutRoutes.Use(middleware.AuthMiddleware()) + + aboutRoutes.Get("/", aboutHandler.GetAllAbout) + aboutRoutes.Get("/:id", aboutHandler.GetAboutByID) + aboutRoutes.Post("/", aboutHandler.CreateAbout) + aboutRoutes.Put("/:id", middleware.RequireRoles(utils.RoleAdministrator), aboutHandler.UpdateAbout) + aboutRoutes.Delete("/:id", aboutHandler.DeleteAbout) + + aboutDetailRoutes := api.Group("/about-detail") + aboutDetailRoutes.Use(middleware.AuthMiddleware()) + aboutDetailRoute := api.Group("/about-detail") + aboutDetailRoute.Get("/:id", aboutHandler.GetAboutDetailById) + aboutDetailRoutes.Post("/", aboutHandler.CreateAboutDetail) + aboutDetailRoutes.Put("/:id", middleware.RequireRoles(utils.RoleAdministrator), aboutHandler.UpdateAboutDetail) + aboutDetailRoutes.Delete("/:id", middleware.RequireRoles(utils.RoleAdministrator), aboutHandler.DeleteAboutDetail) +} diff --git a/internal/about/about_service.go b/internal/about/about_service.go new file mode 100644 index 0000000..c6653e7 --- /dev/null +++ b/internal/about/about_service.go @@ -0,0 +1,496 @@ +package about + +import ( + "context" + "fmt" + "log" + "mime/multipart" + "os" + "path/filepath" + "rijig/model" + "rijig/utils" + "time" + + "github.com/google/uuid" +) + +const ( + cacheKeyAllAbout = "about:all" + cacheKeyAboutByID = "about:id:%s" + cacheKeyAboutDetail = "about_detail:id:%s" + + cacheTTL = 30 * time.Minute +) + +type AboutService interface { + CreateAbout(ctx context.Context, request RequestAboutDTO, coverImageAbout *multipart.FileHeader) (*ResponseAboutDTO, error) + UpdateAbout(ctx context.Context, id string, request RequestAboutDTO, coverImageAbout *multipart.FileHeader) (*ResponseAboutDTO, error) + GetAllAbout(ctx context.Context) ([]ResponseAboutDTO, error) + GetAboutByID(ctx context.Context, id string) (*ResponseAboutDTO, error) + GetAboutDetailById(ctx context.Context, id string) (*ResponseAboutDetailDTO, error) + DeleteAbout(ctx context.Context, id string) error + + CreateAboutDetail(ctx context.Context, request RequestAboutDetailDTO, coverImageAboutDetail *multipart.FileHeader) (*ResponseAboutDetailDTO, error) + UpdateAboutDetail(ctx context.Context, id string, request RequestAboutDetailDTO, imageDetail *multipart.FileHeader) (*ResponseAboutDetailDTO, error) + DeleteAboutDetail(ctx context.Context, id string) error +} + +type aboutService struct { + aboutRepo AboutRepository +} + +func NewAboutService(aboutRepo AboutRepository) AboutService { + return &aboutService{aboutRepo: aboutRepo} +} + +func (s *aboutService) invalidateAboutCaches(aboutID string) { + + if err := utils.DeleteCache(cacheKeyAllAbout); err != nil { + log.Printf("Failed to invalidate all about cache: %v", err) + } + + aboutCacheKey := fmt.Sprintf(cacheKeyAboutByID, aboutID) + if err := utils.DeleteCache(aboutCacheKey); err != nil { + log.Printf("Failed to invalidate about cache for ID %s: %v", aboutID, err) + } +} + +func (s *aboutService) invalidateAboutDetailCaches(aboutDetailID, aboutID string) { + + detailCacheKey := fmt.Sprintf(cacheKeyAboutDetail, aboutDetailID) + if err := utils.DeleteCache(detailCacheKey); err != nil { + log.Printf("Failed to invalidate about detail cache for ID %s: %v", aboutDetailID, err) + } + + s.invalidateAboutCaches(aboutID) +} + +func formatResponseAboutDetailDTO(about *model.AboutDetail) (*ResponseAboutDetailDTO, error) { + createdAt, _ := utils.FormatDateToIndonesianFormat(about.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(about.UpdatedAt) + + response := &ResponseAboutDetailDTO{ + ID: about.ID, + AboutID: about.AboutID, + ImageDetail: about.ImageDetail, + Description: about.Description, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + + return response, nil +} + +func formatResponseAboutDTO(about *model.About) (*ResponseAboutDTO, error) { + createdAt, _ := utils.FormatDateToIndonesianFormat(about.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(about.UpdatedAt) + + response := &ResponseAboutDTO{ + ID: about.ID, + Title: about.Title, + CoverImage: about.CoverImage, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + + return response, nil +} + +func (s *aboutService) saveCoverImageAbout(coverImageAbout *multipart.FileHeader) (string, error) { + pathImage := "/uploads/coverabout/" + coverImageAboutDir := "./public" + os.Getenv("BASE_URL") + pathImage + if _, err := os.Stat(coverImageAboutDir); os.IsNotExist(err) { + if err := os.MkdirAll(coverImageAboutDir, os.ModePerm); err != nil { + return "", fmt.Errorf("gagal membuat direktori untuk cover image about: %v", err) + } + } + + allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".svg": true} + extension := filepath.Ext(coverImageAbout.Filename) + if !allowedExtensions[extension] { + return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, .png, and .svg are allowed") + } + + coverImageFileName := fmt.Sprintf("%s_coverabout%s", uuid.New().String(), extension) + coverImagePath := filepath.Join(coverImageAboutDir, coverImageFileName) + + src, err := coverImageAbout.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %v", err) + } + defer src.Close() + + dst, err := os.Create(coverImagePath) + if err != nil { + return "", fmt.Errorf("failed to create cover image about file: %v", err) + } + defer dst.Close() + + if _, err := dst.ReadFrom(src); err != nil { + return "", fmt.Errorf("failed to save cover image about: %v", err) + } + + coverImageAboutUrl := fmt.Sprintf("%s%s", pathImage, coverImageFileName) + return coverImageAboutUrl, nil +} + +func (s *aboutService) saveCoverImageAboutDetail(coverImageAbout *multipart.FileHeader) (string, error) { + pathImage := "/uploads/coverabout/coveraboutdetail/" + coverImageAboutDir := "./public" + os.Getenv("BASE_URL") + pathImage + if _, err := os.Stat(coverImageAboutDir); os.IsNotExist(err) { + if err := os.MkdirAll(coverImageAboutDir, os.ModePerm); err != nil { + return "", fmt.Errorf("gagal membuat direktori untuk cover image about detail: %v", err) + } + } + + allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".svg": true} + extension := filepath.Ext(coverImageAbout.Filename) + if !allowedExtensions[extension] { + return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, .png, and .svg are allowed") + } + + coverImageFileName := fmt.Sprintf("%s_coveraboutdetail%s", uuid.New().String(), extension) + coverImagePath := filepath.Join(coverImageAboutDir, coverImageFileName) + + src, err := coverImageAbout.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %v", err) + } + defer src.Close() + + dst, err := os.Create(coverImagePath) + if err != nil { + return "", fmt.Errorf("failed to create cover image about detail file: %v", err) + } + defer dst.Close() + + if _, err := dst.ReadFrom(src); err != nil { + return "", fmt.Errorf("failed to save cover image about detail: %v", err) + } + + coverImageAboutUrl := fmt.Sprintf("%s%s", pathImage, coverImageFileName) + return coverImageAboutUrl, nil +} + +func deleteCoverImageAbout(coverimageAboutPath string) error { + if coverimageAboutPath == "" { + return nil + } + + baseDir := "./public/" + os.Getenv("BASE_URL") + absolutePath := baseDir + coverimageAboutPath + + 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 *aboutService) CreateAbout(ctx context.Context, request RequestAboutDTO, coverImageAbout *multipart.FileHeader) (*ResponseAboutDTO, error) { + errors, valid := request.ValidateAbout() + if !valid { + return nil, fmt.Errorf("validation error: %v", errors) + } + + coverImageAboutPath, err := s.saveCoverImageAbout(coverImageAbout) + if err != nil { + return nil, fmt.Errorf("gagal menyimpan cover image about: %v", err) + } + + about := model.About{ + Title: request.Title, + CoverImage: coverImageAboutPath, + } + + if err := s.aboutRepo.CreateAbout(ctx, &about); err != nil { + return nil, fmt.Errorf("failed to create About: %v", err) + } + + response, err := formatResponseAboutDTO(&about) + if err != nil { + return nil, fmt.Errorf("error formatting About response: %v", err) + } + + s.invalidateAboutCaches("") + + return response, nil +} + +func (s *aboutService) UpdateAbout(ctx context.Context, id string, request RequestAboutDTO, coverImageAbout *multipart.FileHeader) (*ResponseAboutDTO, error) { + errors, valid := request.ValidateAbout() + if !valid { + return nil, fmt.Errorf("validation error: %v", errors) + } + + about, err := s.aboutRepo.GetAboutByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("about not found: %v", err) + } + + oldCoverImage := about.CoverImage + + var coverImageAboutPath string + if coverImageAbout != nil { + coverImageAboutPath, err = s.saveCoverImageAbout(coverImageAbout) + if err != nil { + return nil, fmt.Errorf("gagal menyimpan gambar baru: %v", err) + } + } + + about.Title = request.Title + if coverImageAboutPath != "" { + about.CoverImage = coverImageAboutPath + } + + updatedAbout, err := s.aboutRepo.UpdateAbout(ctx, id, about) + if err != nil { + return nil, fmt.Errorf("failed to update About: %v", err) + } + + if oldCoverImage != "" && coverImageAboutPath != "" { + if err := deleteCoverImageAbout(oldCoverImage); err != nil { + log.Printf("Warning: failed to delete old image: %v", err) + } + } + + response, err := formatResponseAboutDTO(updatedAbout) + if err != nil { + return nil, fmt.Errorf("error formatting About response: %v", err) + } + + s.invalidateAboutCaches(id) + + return response, nil +} + +func (s *aboutService) GetAllAbout(ctx context.Context) ([]ResponseAboutDTO, error) { + + var cachedAbouts []ResponseAboutDTO + if err := utils.GetCache(cacheKeyAllAbout, &cachedAbouts); err == nil { + return cachedAbouts, nil + } + + aboutList, err := s.aboutRepo.GetAllAbout(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get About list: %v", err) + } + + var aboutDTOList []ResponseAboutDTO + for _, about := range aboutList { + response, err := formatResponseAboutDTO(&about) + if err != nil { + log.Printf("Error formatting About response: %v", err) + continue + } + aboutDTOList = append(aboutDTOList, *response) + } + + if err := utils.SetCache(cacheKeyAllAbout, aboutDTOList, cacheTTL); err != nil { + log.Printf("Failed to cache all about data: %v", err) + } + + return aboutDTOList, nil +} + +func (s *aboutService) GetAboutByID(ctx context.Context, id string) (*ResponseAboutDTO, error) { + cacheKey := fmt.Sprintf(cacheKeyAboutByID, id) + + var cachedAbout ResponseAboutDTO + if err := utils.GetCache(cacheKey, &cachedAbout); err == nil { + return &cachedAbout, nil + } + + about, err := s.aboutRepo.GetAboutByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("about not found: %v", err) + } + + response, err := formatResponseAboutDTO(about) + if err != nil { + return nil, fmt.Errorf("error formatting About response: %v", err) + } + + var responseDetails []ResponseAboutDetailDTO + for _, detail := range about.AboutDetail { + formattedDetail, err := formatResponseAboutDetailDTO(&detail) + if err != nil { + return nil, fmt.Errorf("error formatting AboutDetail response: %v", err) + } + responseDetails = append(responseDetails, *formattedDetail) + } + + response.AboutDetail = &responseDetails + + if err := utils.SetCache(cacheKey, response, cacheTTL); err != nil { + log.Printf("Failed to cache about data for ID %s: %v", id, err) + } + + return response, nil +} + +func (s *aboutService) GetAboutDetailById(ctx context.Context, id string) (*ResponseAboutDetailDTO, error) { + cacheKey := fmt.Sprintf(cacheKeyAboutDetail, id) + + var cachedDetail ResponseAboutDetailDTO + if err := utils.GetCache(cacheKey, &cachedDetail); err == nil { + return &cachedDetail, nil + } + + aboutDetail, err := s.aboutRepo.GetAboutDetailByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("about detail not found: %v", err) + } + + response, err := formatResponseAboutDetailDTO(aboutDetail) + if err != nil { + return nil, fmt.Errorf("error formatting AboutDetail response: %v", err) + } + + if err := utils.SetCache(cacheKey, response, cacheTTL); err != nil { + log.Printf("Failed to cache about detail data for ID %s: %v", id, err) + } + + return response, nil +} + +func (s *aboutService) DeleteAbout(ctx context.Context, id string) error { + about, err := s.aboutRepo.GetAboutByID(ctx, id) + if err != nil { + return fmt.Errorf("about not found: %v", err) + } + + if about.CoverImage != "" { + if err := deleteCoverImageAbout(about.CoverImage); err != nil { + log.Printf("Warning: failed to delete cover image: %v", err) + } + } + + for _, detail := range about.AboutDetail { + if detail.ImageDetail != "" { + if err := deleteCoverImageAbout(detail.ImageDetail); err != nil { + log.Printf("Warning: failed to delete detail image: %v", err) + } + } + } + + if err := s.aboutRepo.DeleteAbout(ctx, id); err != nil { + return fmt.Errorf("failed to delete About: %v", err) + } + + s.invalidateAboutCaches(id) + + return nil +} + +func (s *aboutService) CreateAboutDetail(ctx context.Context, request RequestAboutDetailDTO, coverImageAboutDetail *multipart.FileHeader) (*ResponseAboutDetailDTO, error) { + errors, valid := request.ValidateAboutDetail() + if !valid { + return nil, fmt.Errorf("validation error: %v", errors) + } + + _, err := s.aboutRepo.GetAboutByIDWithoutPrel(ctx, request.AboutId) + if err != nil { + return nil, fmt.Errorf("about_id tidak ditemukan: %v", err) + } + + coverImageAboutDetailPath, err := s.saveCoverImageAboutDetail(coverImageAboutDetail) + if err != nil { + return nil, fmt.Errorf("gagal menyimpan cover image about detail: %v", err) + } + + aboutDetail := model.AboutDetail{ + AboutID: request.AboutId, + ImageDetail: coverImageAboutDetailPath, + Description: request.Description, + } + + if err := s.aboutRepo.CreateAboutDetail(ctx, &aboutDetail); err != nil { + return nil, fmt.Errorf("failed to create AboutDetail: %v", err) + } + + response, err := formatResponseAboutDetailDTO(&aboutDetail) + if err != nil { + return nil, fmt.Errorf("error formatting AboutDetail response: %v", err) + } + + s.invalidateAboutDetailCaches("", request.AboutId) + + return response, nil +} + +func (s *aboutService) UpdateAboutDetail(ctx context.Context, id string, request RequestAboutDetailDTO, imageDetail *multipart.FileHeader) (*ResponseAboutDetailDTO, error) { + errors, valid := request.ValidateAboutDetail() + if !valid { + return nil, fmt.Errorf("validation error: %v", errors) + } + + aboutDetail, err := s.aboutRepo.GetAboutDetailByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("about detail tidak ditemukan: %v", err) + } + + oldImageDetail := aboutDetail.ImageDetail + + var coverImageAboutDetailPath string + if imageDetail != nil { + coverImageAboutDetailPath, err = s.saveCoverImageAboutDetail(imageDetail) + if err != nil { + return nil, fmt.Errorf("gagal menyimpan gambar baru: %v", err) + } + } + + aboutDetail.Description = request.Description + if coverImageAboutDetailPath != "" { + aboutDetail.ImageDetail = coverImageAboutDetailPath + } + + updatedAboutDetail, err := s.aboutRepo.UpdateAboutDetail(ctx, id, aboutDetail) + if err != nil { + return nil, fmt.Errorf("failed to update AboutDetail: %v", err) + } + + if oldImageDetail != "" && coverImageAboutDetailPath != "" { + if err := deleteCoverImageAbout(oldImageDetail); err != nil { + log.Printf("Warning: failed to delete old detail image: %v", err) + } + } + + response, err := formatResponseAboutDetailDTO(updatedAboutDetail) + if err != nil { + return nil, fmt.Errorf("error formatting AboutDetail response: %v", err) + } + + s.invalidateAboutDetailCaches(id, aboutDetail.AboutID) + + return response, nil +} + +func (s *aboutService) DeleteAboutDetail(ctx context.Context, id string) error { + aboutDetail, err := s.aboutRepo.GetAboutDetailByID(ctx, id) + if err != nil { + return fmt.Errorf("about detail tidak ditemukan: %v", err) + } + + aboutID := aboutDetail.AboutID + + if aboutDetail.ImageDetail != "" { + if err := deleteCoverImageAbout(aboutDetail.ImageDetail); err != nil { + log.Printf("Warning: failed to delete detail image: %v", err) + } + } + + if err := s.aboutRepo.DeleteAboutDetail(ctx, id); err != nil { + return fmt.Errorf("failed to delete AboutDetail: %v", err) + } + + s.invalidateAboutDetailCaches(id, aboutID) + + return nil +} diff --git a/internal/address/address_dto.go b/internal/address/address_dto.go new file mode 100644 index 0000000..7877f4d --- /dev/null +++ b/internal/address/address_dto.go @@ -0,0 +1,73 @@ +package address + +import "strings" + +type AddressResponseDTO struct { + UserID string `json:"user_id,omitempty"` + ID string `json:"address_id,omitempty"` + Province string `json:"province,omitempty"` + Regency string `json:"regency,omitempty"` + District string `json:"district,omitempty"` + Village string `json:"village,omitempty"` + PostalCode string `json:"postalCode,omitempty"` + Detail string `json:"detail,omitempty"` + Latitude float64 `json:"latitude,omitempty"` + Longitude float64 `json:"longitude,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type CreateAddressDTO struct { + Province string `json:"province_id"` + Regency string `json:"regency_id"` + District string `json:"district_id"` + Village string `json:"village_id"` + PostalCode string `json:"postalCode"` + Detail string `json:"detail"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +func (r *CreateAddressDTO) ValidateAddress() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.Province) == "" { + errors["province_id"] = append(errors["province_id"], "Province ID is required") + } + + if strings.TrimSpace(r.Regency) == "" { + errors["regency_id"] = append(errors["regency_id"], "Regency ID is required") + } + + if strings.TrimSpace(r.District) == "" { + errors["district_id"] = append(errors["district_id"], "District ID is required") + } + + if strings.TrimSpace(r.Village) == "" { + errors["village_id"] = append(errors["village_id"], "Village ID is required") + } + + if strings.TrimSpace(r.PostalCode) == "" { + errors["postalCode"] = append(errors["postalCode"], "PostalCode is required") + } else if len(r.PostalCode) < 5 { + errors["postalCode"] = append(errors["postalCode"], "PostalCode must be at least 5 characters") + } + + if strings.TrimSpace(r.Detail) == "" { + errors["detail"] = append(errors["detail"], "Detail address is required") + } + + if r.Latitude == 0 { + errors["latitude"] = append(errors["latitude"], "Latitude is required") + } + + if r.Longitude == 0 { + errors["longitude"] = append(errors["longitude"], "Longitude is required") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} diff --git a/internal/address/address_handler.go b/internal/address/address_handler.go new file mode 100644 index 0000000..cdc4514 --- /dev/null +++ b/internal/address/address_handler.go @@ -0,0 +1,112 @@ +package address + +import ( + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +type AddressHandler struct { + AddressService AddressService +} + +func NewAddressHandler(addressService AddressService) *AddressHandler { + return &AddressHandler{AddressService: addressService} +} + +func (h *AddressHandler) CreateAddress(c *fiber.Ctx) error { + var request CreateAddressDTO + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + if err := c.BodyParser(&request); err != nil { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Invalid request body", map[string][]string{"body": {"Invalid body"}}) + } + + errors, valid := request.ValidateAddress() + if !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + addressResponse, err := h.AddressService.CreateAddress(c.Context(), claims.UserID, request) + if err != nil { + return utils.InternalServerError(c, err.Error()) + } + + return utils.CreateSuccessWithData(c, "user address created successfully", addressResponse) +} + +func (h *AddressHandler) GetAddressByUserID(c *fiber.Ctx) error { + + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + addresses, err := h.AddressService.GetAddressByUserID(c.Context(), claims.UserID) + if err != nil { + return utils.NotFound(c, err.Error()) + } + + return utils.SuccessWithData(c, "User addresses fetched successfully", addresses) +} + +func (h *AddressHandler) GetAddressByID(c *fiber.Ctx) error { + + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + addressID := c.Params("address_id") + + address, err := h.AddressService.GetAddressByID(c.Context(), claims.UserID, addressID) + if err != nil { + return utils.NotFound(c, err.Error()) + } + + return utils.SuccessWithData(c, "Address fetched successfully", address) +} + +func (h *AddressHandler) UpdateAddress(c *fiber.Ctx) error { + + addressID := c.Params("address_id") + + var request CreateAddressDTO + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + if err := c.BodyParser(&request); err != nil { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Invalid request body", map[string][]string{"body": {"Invalid body"}}) + } + + errors, valid := request.ValidateAddress() + if !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + updatedAddress, err := h.AddressService.UpdateAddress(c.Context(), claims.UserID, addressID, request) + if err != nil { + return utils.NotFound(c, err.Error()) + } + + return utils.SuccessWithData(c, "User address updated successfully", updatedAddress) +} + +func (h *AddressHandler) DeleteAddress(c *fiber.Ctx) error { + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + addressID := c.Params("address_id") + + err = h.AddressService.DeleteAddress(c.Context(), claims.UserID, addressID) + if err != nil { + return utils.Forbidden(c, err.Error()) + } + + return utils.Success(c, "Address deleted successfully") +} diff --git a/internal/address/address_repository.go b/internal/address/address_repository.go new file mode 100644 index 0000000..fb72457 --- /dev/null +++ b/internal/address/address_repository.go @@ -0,0 +1,62 @@ +package address + +import ( + "context" + "rijig/model" + + "gorm.io/gorm" +) + +type AddressRepository interface { + CreateAddress(ctx context.Context, address *model.Address) error + FindAddressByUserID(ctx context.Context, userID string) ([]model.Address, error) + FindAddressByID(ctx context.Context, id string) (*model.Address, error) + UpdateAddress(ctx context.Context, address *model.Address) error + DeleteAddress(ctx context.Context, id string) error +} + +type addressRepository struct { + db *gorm.DB +} + +func NewAddressRepository(db *gorm.DB) AddressRepository { + return &addressRepository{db} +} + +func (r *addressRepository) CreateAddress(ctx context.Context, address *model.Address) error { + return r.db.WithContext(ctx).Create(address).Error +} + +func (r *addressRepository) FindAddressByUserID(ctx context.Context, userID string) ([]model.Address, error) { + var addresses []model.Address + err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&addresses).Error + if err != nil { + return nil, err + } + return addresses, nil +} + +func (r *addressRepository) FindAddressByID(ctx context.Context, id string) (*model.Address, error) { + var address model.Address + err := r.db.WithContext(ctx).Where("id = ?", id).First(&address).Error + if err != nil { + return nil, err + } + return &address, nil +} + +func (r *addressRepository) UpdateAddress(ctx context.Context, address *model.Address) error { + err := r.db.WithContext(ctx).Save(address).Error + if err != nil { + return err + } + return nil +} + +func (r *addressRepository) DeleteAddress(ctx context.Context, id string) error { + err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Address{}).Error + if err != nil { + return err + } + return nil +} diff --git a/internal/address/address_route.go b/internal/address/address_route.go new file mode 100644 index 0000000..1eb7dd4 --- /dev/null +++ b/internal/address/address_route.go @@ -0,0 +1,24 @@ +package address + +import ( + "rijig/config" + "rijig/internal/wilayahindo" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func AddressRouter(api fiber.Router) { + addressRepo := NewAddressRepository(config.DB) + wilayahRepo := wilayahindo.NewWilayahIndonesiaRepository(config.DB) + addressService := NewAddressService(addressRepo, wilayahRepo) + addressHandler := NewAddressHandler(addressService) + + adddressAPI := api.Group("/user/address") + + adddressAPI.Post("/create-address", middleware.AuthMiddleware(), addressHandler.CreateAddress) + adddressAPI.Get("/get-address", middleware.AuthMiddleware(), addressHandler.GetAddressByUserID) + adddressAPI.Get("/get-address/:address_id", middleware.AuthMiddleware(), addressHandler.GetAddressByID) + adddressAPI.Put("/update-address/:address_id", middleware.AuthMiddleware(), addressHandler.UpdateAddress) + adddressAPI.Delete("/delete-address/:address_id", middleware.AuthMiddleware(), addressHandler.DeleteAddress) +} diff --git a/internal/address/address_service.go b/internal/address/address_service.go new file mode 100644 index 0000000..5189173 --- /dev/null +++ b/internal/address/address_service.go @@ -0,0 +1,249 @@ +package address + +import ( + "context" + "errors" + "fmt" + "time" + + "rijig/internal/wilayahindo" + "rijig/model" + "rijig/utils" +) + +const ( + cacheTTL = time.Hour * 24 + + userAddressesCacheKeyPattern = "user:%s:addresses" + addressCacheKeyPattern = "address:%s" +) + +type AddressService interface { + CreateAddress(ctx context.Context, userID string, request CreateAddressDTO) (*AddressResponseDTO, error) + GetAddressByUserID(ctx context.Context, userID string) ([]AddressResponseDTO, error) + GetAddressByID(ctx context.Context, userID, id string) (*AddressResponseDTO, error) + UpdateAddress(ctx context.Context, userID, id string, addressDTO CreateAddressDTO) (*AddressResponseDTO, error) + DeleteAddress(ctx context.Context, userID, id string) error +} + +type addressService struct { + addressRepo AddressRepository + wilayahRepo wilayahindo.WilayahIndonesiaRepository +} + +func NewAddressService(addressRepo AddressRepository, wilayahRepo wilayahindo.WilayahIndonesiaRepository) AddressService { + return &addressService{ + addressRepo: addressRepo, + wilayahRepo: wilayahRepo, + } +} + +func (s *addressService) validateWilayahIDs(ctx context.Context, addressDTO CreateAddressDTO) (string, string, string, string, error) { + + province, _, err := s.wilayahRepo.FindProvinceByID(ctx, addressDTO.Province, 0, 0) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid province_id: %w", err) + } + + regency, _, err := s.wilayahRepo.FindRegencyByID(ctx, addressDTO.Regency, 0, 0) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid regency_id: %w", err) + } + + district, _, err := s.wilayahRepo.FindDistrictByID(ctx, addressDTO.District, 0, 0) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid district_id: %w", err) + } + + village, err := s.wilayahRepo.FindVillageByID(ctx, addressDTO.Village) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid village_id: %w", err) + } + + return province.Name, regency.Name, district.Name, village.Name, nil +} + +func (s *addressService) mapToResponseDTO(address *model.Address) *AddressResponseDTO { + createdAt, _ := utils.FormatDateToIndonesianFormat(address.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(address.UpdatedAt) + + return &AddressResponseDTO{ + UserID: address.UserID, + ID: address.ID, + Province: address.Province, + Regency: address.Regency, + District: address.District, + Village: address.Village, + PostalCode: address.PostalCode, + Detail: address.Detail, + Latitude: address.Latitude, + Longitude: address.Longitude, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} + +func (s *addressService) invalidateAddressCaches(userID, addressID string) { + if addressID != "" { + addressCacheKey := fmt.Sprintf(addressCacheKeyPattern, addressID) + if err := utils.DeleteCache(addressCacheKey); err != nil { + fmt.Printf("Error deleting address cache: %v\n", err) + } + } + + userCacheKey := fmt.Sprintf(userAddressesCacheKeyPattern, userID) + if err := utils.DeleteCache(userCacheKey); err != nil { + fmt.Printf("Error deleting user addresses cache: %v\n", err) + } +} + +func (s *addressService) cacheAddress(addressDTO *AddressResponseDTO) { + cacheKey := fmt.Sprintf(addressCacheKeyPattern, addressDTO.ID) + if err := utils.SetCache(cacheKey, addressDTO, cacheTTL); err != nil { + fmt.Printf("Error caching address to Redis: %v\n", err) + } +} + +func (s *addressService) cacheUserAddresses(ctx context.Context, userID string) ([]AddressResponseDTO, error) { + addresses, err := s.addressRepo.FindAddressByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to fetch addresses: %w", err) + } + + var addressDTOs []AddressResponseDTO + for _, address := range addresses { + addressDTOs = append(addressDTOs, *s.mapToResponseDTO(&address)) + } + + cacheKey := fmt.Sprintf(userAddressesCacheKeyPattern, userID) + if err := utils.SetCache(cacheKey, addressDTOs, cacheTTL); err != nil { + fmt.Printf("Error caching addresses to Redis: %v\n", err) + } + + return addressDTOs, nil +} + +func (s *addressService) checkAddressOwnership(ctx context.Context, userID, addressID string) (*model.Address, error) { + address, err := s.addressRepo.FindAddressByID(ctx, addressID) + if err != nil { + return nil, fmt.Errorf("address not found: %w", err) + } + + if address.UserID != userID { + return nil, errors.New("you are not authorized to access this address") + } + + return address, nil +} + +func (s *addressService) CreateAddress(ctx context.Context, userID string, addressDTO CreateAddressDTO) (*AddressResponseDTO, error) { + + provinceName, regencyName, districtName, villageName, err := s.validateWilayahIDs(ctx, addressDTO) + if err != nil { + return nil, err + } + + address := model.Address{ + UserID: userID, + Province: provinceName, + Regency: regencyName, + District: districtName, + Village: villageName, + PostalCode: addressDTO.PostalCode, + Detail: addressDTO.Detail, + Latitude: addressDTO.Latitude, + Longitude: addressDTO.Longitude, + } + + if err := s.addressRepo.CreateAddress(ctx, &address); err != nil { + return nil, fmt.Errorf("failed to create address: %w", err) + } + + responseDTO := s.mapToResponseDTO(&address) + + s.cacheAddress(responseDTO) + s.invalidateAddressCaches(userID, "") + + return responseDTO, nil +} + +func (s *addressService) GetAddressByUserID(ctx context.Context, userID string) ([]AddressResponseDTO, error) { + + cacheKey := fmt.Sprintf(userAddressesCacheKeyPattern, userID) + var cachedAddresses []AddressResponseDTO + + if err := utils.GetCache(cacheKey, &cachedAddresses); err == nil { + return cachedAddresses, nil + } + + return s.cacheUserAddresses(ctx, userID) +} + +func (s *addressService) GetAddressByID(ctx context.Context, userID, id string) (*AddressResponseDTO, error) { + + address, err := s.checkAddressOwnership(ctx, userID, id) + if err != nil { + return nil, err + } + + cacheKey := fmt.Sprintf(addressCacheKeyPattern, id) + var cachedAddress AddressResponseDTO + + if err := utils.GetCache(cacheKey, &cachedAddress); err == nil { + return &cachedAddress, nil + } + + responseDTO := s.mapToResponseDTO(address) + s.cacheAddress(responseDTO) + + return responseDTO, nil +} + +func (s *addressService) UpdateAddress(ctx context.Context, userID, id string, addressDTO CreateAddressDTO) (*AddressResponseDTO, error) { + + address, err := s.checkAddressOwnership(ctx, userID, id) + if err != nil { + return nil, err + } + + provinceName, regencyName, districtName, villageName, err := s.validateWilayahIDs(ctx, addressDTO) + if err != nil { + return nil, err + } + + address.Province = provinceName + address.Regency = regencyName + address.District = districtName + address.Village = villageName + address.PostalCode = addressDTO.PostalCode + address.Detail = addressDTO.Detail + address.Latitude = addressDTO.Latitude + address.Longitude = addressDTO.Longitude + + if err := s.addressRepo.UpdateAddress(ctx, address); err != nil { + return nil, fmt.Errorf("failed to update address: %w", err) + } + + responseDTO := s.mapToResponseDTO(address) + + s.cacheAddress(responseDTO) + s.invalidateAddressCaches(userID, "") + + return responseDTO, nil +} + +func (s *addressService) DeleteAddress(ctx context.Context, userID, addressID string) error { + + address, err := s.checkAddressOwnership(ctx, userID, addressID) + if err != nil { + return err + } + + if err := s.addressRepo.DeleteAddress(ctx, addressID); err != nil { + return fmt.Errorf("failed to delete address: %w", err) + } + + s.invalidateAddressCaches(address.UserID, addressID) + + return nil +} diff --git a/dto/article_dto.go b/internal/article/article_dto.go similarity index 91% rename from dto/article_dto.go rename to internal/article/article_dto.go index 1df6ac2..1db8a16 100644 --- a/dto/article_dto.go +++ b/internal/article/article_dto.go @@ -1,4 +1,4 @@ -package dto +package article import ( "strings" @@ -23,7 +23,7 @@ type RequestArticleDTO struct { Content string `json:"content"` } -func (r *RequestArticleDTO) Validate() (map[string][]string, bool) { +func (r *RequestArticleDTO) ValidateRequestArticleDTO() (map[string][]string, bool) { errors := make(map[string][]string) if strings.TrimSpace(r.Title) == "" { diff --git a/internal/article/article_handler.go b/internal/article/article_handler.go new file mode 100644 index 0000000..3159b20 --- /dev/null +++ b/internal/article/article_handler.go @@ -0,0 +1,141 @@ +package article + +import ( + "mime/multipart" + "rijig/utils" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type ArticleHandler struct { + articleService ArticleService +} + +func NewArticleHandler(articleService ArticleService) *ArticleHandler { + return &ArticleHandler{articleService} +} + +func (h *ArticleHandler) CreateArticle(c *fiber.Ctx) error { + var request RequestArticleDTO + + if err := c.BodyParser(&request); err != nil { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Invalid request body", map[string][]string{"body": {"Invalid body"}}) + } + + errors, valid := request.ValidateRequestArticleDTO() + if !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + coverImage, err := c.FormFile("coverImage") + if err != nil { + return utils.BadRequest(c, "Cover image is required") + } + + articleResponse, err := h.articleService.CreateArticle(c.Context(), request, coverImage) + if err != nil { + return utils.InternalServerError(c, err.Error()) + } + + return utils.SuccessWithData(c, "Article created successfully", articleResponse) +} + +func (h *ArticleHandler) GetAllArticles(c *fiber.Ctx) error { + page, err := strconv.Atoi(c.Query("page", "0")) + if err != nil || page < 0 { + page = 0 + } + + limit, err := strconv.Atoi(c.Query("limit", "0")) + if err != nil || limit < 0 { + limit = 0 + } + + articles, totalArticles, err := h.articleService.GetAllArticles(c.Context(), page, limit) + if err != nil { + return utils.InternalServerError(c, "Failed to fetch articles") + } + + responseData := map[string]interface{}{ + "articles": articles, + "total": int(totalArticles), + } + + if page == 0 && limit == 0 { + return utils.SuccessWithData(c, "Articles fetched successfully", responseData) + } + + return utils.SuccessWithPagination(c, "Articles fetched successfully", responseData, page, limit) +} + +func (h *ArticleHandler) GetArticleByID(c *fiber.Ctx) error { + id := c.Params("article_id") + if id == "" { + return utils.BadRequest(c, "Article ID is required") + } + + article, err := h.articleService.GetArticleByID(c.Context(), id) + if err != nil { + return utils.NotFound(c, "Article not found") + } + + return utils.SuccessWithData(c, "Article fetched successfully", article) +} + +func (h *ArticleHandler) UpdateArticle(c *fiber.Ctx) error { + id := c.Params("article_id") + if id == "" { + return utils.BadRequest(c, "Article ID is required") + } + + var request RequestArticleDTO + if err := c.BodyParser(&request); err != nil { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Invalid request body", map[string][]string{"body": {"Invalid body"}}) + } + + errors, valid := request.ValidateRequestArticleDTO() + if !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + var coverImage *multipart.FileHeader + coverImage, err := c.FormFile("coverImage") + + if err != nil && err.Error() != "no such file" && err.Error() != "there is no uploaded file associated with the given key" { + return utils.BadRequest(c, "Invalid cover image") + } + + articleResponse, err := h.articleService.UpdateArticle(c.Context(), id, request, coverImage) + if err != nil { + if isNotFoundError(err) { + return utils.NotFound(c, err.Error()) + } + return utils.InternalServerError(c, err.Error()) + } + + return utils.SuccessWithData(c, "Article updated successfully", articleResponse) +} + +func (h *ArticleHandler) DeleteArticle(c *fiber.Ctx) error { + id := c.Params("article_id") + if id == "" { + return utils.BadRequest(c, "Article ID is required") + } + + err := h.articleService.DeleteArticle(c.Context(), id) + if err != nil { + if isNotFoundError(err) { + return utils.NotFound(c, err.Error()) + } + return utils.InternalServerError(c, err.Error()) + } + + return utils.Success(c, "Article deleted successfully") +} + +func isNotFoundError(err error) bool { + return err != nil && (err.Error() == "article not found" || + err.Error() == "failed to find article: record not found" || + false) +} diff --git a/internal/article/article_repository.go b/internal/article/article_repository.go new file mode 100644 index 0000000..eb8c367 --- /dev/null +++ b/internal/article/article_repository.go @@ -0,0 +1,148 @@ +package article + +import ( + "context" + "errors" + "fmt" + + "rijig/model" + + "gorm.io/gorm" +) + +type ArticleRepository interface { + CreateArticle(ctx context.Context, article *model.Article) error + FindArticleByID(ctx context.Context, id string) (*model.Article, error) + FindAllArticles(ctx context.Context, page, limit int) ([]model.Article, int64, error) + UpdateArticle(ctx context.Context, id string, article *model.Article) error + DeleteArticle(ctx context.Context, id string) error + ArticleExists(ctx context.Context, id string) (bool, error) +} + +type articleRepository struct { + db *gorm.DB +} + +func NewArticleRepository(db *gorm.DB) ArticleRepository { + return &articleRepository{db: db} +} + +func (r *articleRepository) CreateArticle(ctx context.Context, article *model.Article) error { + if article == nil { + return errors.New("article cannot be nil") + } + + if err := r.db.WithContext(ctx).Create(article).Error; err != nil { + return fmt.Errorf("failed to create article: %w", err) + } + return nil +} + +func (r *articleRepository) FindArticleByID(ctx context.Context, id string) (*model.Article, error) { + if id == "" { + return nil, errors.New("article ID cannot be empty") + } + + var article model.Article + err := r.db.WithContext(ctx).Where("id = ?", id).First(&article).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("article with ID %s not found", id) + } + return nil, fmt.Errorf("failed to fetch article: %w", err) + } + return &article, nil +} + +func (r *articleRepository) FindAllArticles(ctx context.Context, page, limit int) ([]model.Article, int64, error) { + var articles []model.Article + var total int64 + + if page < 0 || limit < 0 { + return nil, 0, errors.New("page and limit must be non-negative") + } + + if err := r.db.WithContext(ctx).Model(&model.Article{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count articles: %w", err) + } + + query := r.db.WithContext(ctx).Model(&model.Article{}) + + if page > 0 && limit > 0 { + offset := (page - 1) * limit + query = query.Offset(offset).Limit(limit) + } + + if err := query.Find(&articles).Error; err != nil { + return nil, 0, fmt.Errorf("failed to fetch articles: %w", err) + } + + return articles, total, nil +} + +func (r *articleRepository) UpdateArticle(ctx context.Context, id string, article *model.Article) error { + if id == "" { + return errors.New("article ID cannot be empty") + } + if article == nil { + return errors.New("article cannot be nil") + } + + exists, err := r.ArticleExists(ctx, id) + if err != nil { + return fmt.Errorf("failed to check article existence: %w", err) + } + if !exists { + return fmt.Errorf("article with ID %s not found", id) + } + + result := r.db.WithContext(ctx).Model(&model.Article{}).Where("id = ?", id).Updates(article) + if result.Error != nil { + return fmt.Errorf("failed to update article: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected when updating article with ID %s", id) + } + + return nil +} + +func (r *articleRepository) DeleteArticle(ctx context.Context, id string) error { + if id == "" { + return errors.New("article ID cannot be empty") + } + + exists, err := r.ArticleExists(ctx, id) + if err != nil { + return fmt.Errorf("failed to check article existence: %w", err) + } + if !exists { + return fmt.Errorf("article with ID %s not found", id) + } + + result := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Article{}) + if result.Error != nil { + return fmt.Errorf("failed to delete article: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected when deleting article with ID %s", id) + } + + return nil +} + +func (r *articleRepository) ArticleExists(ctx context.Context, id string) (bool, error) { + if id == "" { + return false, errors.New("article ID cannot be empty") + } + + var count int64 + err := r.db.WithContext(ctx).Model(&model.Article{}).Where("id = ?", id).Count(&count).Error + if err != nil { + return false, fmt.Errorf("failed to check article existence: %w", err) + } + + return count > 0, nil +} diff --git a/internal/article/article_route.go b/internal/article/article_route.go new file mode 100644 index 0000000..78179aa --- /dev/null +++ b/internal/article/article_route.go @@ -0,0 +1,23 @@ +package article + +import ( + "rijig/config" + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +func ArticleRouter(api fiber.Router) { + articleRepo := NewArticleRepository(config.DB) + articleService := NewArticleService(articleRepo) + articleHandler := NewArticleHandler(articleService) + + articleAPI := api.Group("/article") + + articleAPI.Post("/create", middleware.AuthMiddleware(), middleware.RequireRoles(utils.RoleAdministrator), articleHandler.CreateArticle) + articleAPI.Get("/view", articleHandler.GetAllArticles) + articleAPI.Get("/view/:article_id", articleHandler.GetArticleByID) + articleAPI.Put("/update/:article_id", middleware.AuthMiddleware(), middleware.RequireRoles(utils.RoleAdministrator), articleHandler.UpdateArticle) + articleAPI.Delete("/delete/:article_id", middleware.AuthMiddleware(), middleware.RequireRoles(utils.RoleAdministrator), articleHandler.DeleteArticle) +} diff --git a/internal/article/article_service.go b/internal/article/article_service.go new file mode 100644 index 0000000..04f1ef9 --- /dev/null +++ b/internal/article/article_service.go @@ -0,0 +1,337 @@ +package article + +import ( + "context" + "fmt" + "log" + "mime/multipart" + "os" + "path/filepath" + "time" + + "rijig/model" + "rijig/utils" + + "github.com/google/uuid" +) + +type ArticleService interface { + CreateArticle(ctx context.Context, request RequestArticleDTO, coverImage *multipart.FileHeader) (*ArticleResponseDTO, error) + GetAllArticles(ctx context.Context, page, limit int) ([]ArticleResponseDTO, int64, error) + GetArticleByID(ctx context.Context, id string) (*ArticleResponseDTO, error) + UpdateArticle(ctx context.Context, id string, request RequestArticleDTO, coverImage *multipart.FileHeader) (*ArticleResponseDTO, error) + DeleteArticle(ctx context.Context, id string) error +} + +type articleService struct { + articleRepo ArticleRepository +} + +func NewArticleService(articleRepo ArticleRepository) ArticleService { + return &articleService{articleRepo} +} + +func (s *articleService) transformToDTO(article *model.Article) (*ArticleResponseDTO, error) { + publishedAt, err := utils.FormatDateToIndonesianFormat(article.PublishedAt) + if err != nil { + publishedAt = "" + } + + updatedAt, err := utils.FormatDateToIndonesianFormat(article.UpdatedAt) + if err != nil { + updatedAt = "" + } + + return &ArticleResponseDTO{ + ID: article.ID, + Title: article.Title, + CoverImage: article.CoverImage, + Author: article.Author, + Heading: article.Heading, + Content: article.Content, + PublishedAt: publishedAt, + UpdatedAt: updatedAt, + }, nil +} + +func (s *articleService) transformToDTOs(articles []model.Article) ([]ArticleResponseDTO, error) { + var articleDTOs []ArticleResponseDTO + + for _, article := range articles { + dto, err := s.transformToDTO(&article) + if err != nil { + return nil, fmt.Errorf("failed to transform article %s: %w", article.ID, err) + } + articleDTOs = append(articleDTOs, *dto) + } + + return articleDTOs, nil +} + +func (s *articleService) saveCoverArticle(coverArticle *multipart.FileHeader) (string, error) { + if coverArticle == nil { + return "", fmt.Errorf("cover image is required") + } + + pathImage := "/uploads/articles/" + coverArticleDir := "./public" + os.Getenv("BASE_URL") + pathImage + + if err := os.MkdirAll(coverArticleDir, os.ModePerm); err != nil { + return "", fmt.Errorf("failed to create directory for cover article: %w", err) + } + + allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".svg": true} + extension := filepath.Ext(coverArticle.Filename) + if !allowedExtensions[extension] { + return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, .png, and .svg are allowed") + } + + coverArticleFileName := fmt.Sprintf("%s_coverarticle%s", uuid.New().String(), extension) + coverArticlePath := filepath.Join(coverArticleDir, coverArticleFileName) + + src, err := coverArticle.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %w", err) + } + defer src.Close() + + dst, err := os.Create(coverArticlePath) + if err != nil { + return "", fmt.Errorf("failed to create cover article file: %w", err) + } + defer dst.Close() + + if _, err := dst.ReadFrom(src); err != nil { + return "", fmt.Errorf("failed to save cover article: %w", err) + } + + return fmt.Sprintf("%s%s", pathImage, coverArticleFileName), nil +} + +func (s *articleService) deleteCoverArticle(imagePath string) error { + if imagePath == "" { + return nil + } + + baseDir := "./public/" + os.Getenv("BASE_URL") + absolutePath := baseDir + imagePath + + if _, err := os.Stat(absolutePath); os.IsNotExist(err) { + log.Printf("Image file not found (already deleted?): %s", absolutePath) + return nil + } + + if err := os.Remove(absolutePath); err != nil { + return fmt.Errorf("failed to delete image: %w", err) + } + + log.Printf("Image deleted successfully: %s", absolutePath) + return nil +} + +func (s *articleService) invalidateArticleCache(articleID string) { + + articleCacheKey := fmt.Sprintf("article:%s", articleID) + if err := utils.DeleteCache(articleCacheKey); err != nil { + log.Printf("Error deleting article cache: %v", err) + } + + if err := utils.ScanAndDelete("articles:*"); err != nil { + log.Printf("Error deleting articles cache: %v", err) + } +} + +func (s *articleService) CreateArticle(ctx context.Context, request RequestArticleDTO, coverImage *multipart.FileHeader) (*ArticleResponseDTO, error) { + coverArticlePath, err := s.saveCoverArticle(coverImage) + if err != nil { + return nil, fmt.Errorf("failed to save cover image: %w", err) + } + + article := model.Article{ + Title: request.Title, + CoverImage: coverArticlePath, + Author: request.Author, + Heading: request.Heading, + Content: request.Content, + } + + if err := s.articleRepo.CreateArticle(ctx, &article); err != nil { + + if deleteErr := s.deleteCoverArticle(coverArticlePath); deleteErr != nil { + log.Printf("Failed to clean up image after create failure: %v", deleteErr) + } + return nil, fmt.Errorf("failed to create article: %w", err) + } + + articleDTO, err := s.transformToDTO(&article) + if err != nil { + return nil, fmt.Errorf("failed to transform article: %w", err) + } + + cacheKey := fmt.Sprintf("article:%s", article.ID) + if err := utils.SetCache(cacheKey, articleDTO, time.Hour*24); err != nil { + log.Printf("Error caching article: %v", err) + } + + s.invalidateArticleCache("") + + return articleDTO, nil +} + +func (s *articleService) GetAllArticles(ctx context.Context, page, limit int) ([]ArticleResponseDTO, int64, error) { + + var cacheKey string + if page <= 0 || limit <= 0 { + cacheKey = "articles:all" + } else { + cacheKey = fmt.Sprintf("articles:page:%d:limit:%d", page, limit) + } + + type CachedArticlesData struct { + Articles []ArticleResponseDTO `json:"articles"` + Total int64 `json:"total"` + } + + var cachedData CachedArticlesData + if err := utils.GetCache(cacheKey, &cachedData); err == nil { + return cachedData.Articles, cachedData.Total, nil + } + + articles, total, err := s.articleRepo.FindAllArticles(ctx, page, limit) + if err != nil { + return nil, 0, fmt.Errorf("failed to fetch articles: %w", err) + } + + articleDTOs, err := s.transformToDTOs(articles) + if err != nil { + return nil, 0, fmt.Errorf("failed to transform articles: %w", err) + } + + cacheData := CachedArticlesData{ + Articles: articleDTOs, + Total: total, + } + if err := utils.SetCache(cacheKey, cacheData, time.Hour*24); err != nil { + log.Printf("Error caching articles: %v", err) + } + + return articleDTOs, total, nil +} + +func (s *articleService) GetArticleByID(ctx context.Context, id string) (*ArticleResponseDTO, error) { + if id == "" { + return nil, fmt.Errorf("article ID cannot be empty") + } + + cacheKey := fmt.Sprintf("article:%s", id) + + var cachedArticle ArticleResponseDTO + if err := utils.GetCache(cacheKey, &cachedArticle); err == nil { + return &cachedArticle, nil + } + + article, err := s.articleRepo.FindArticleByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch article: %w", err) + } + + articleDTO, err := s.transformToDTO(article) + if err != nil { + return nil, fmt.Errorf("failed to transform article: %w", err) + } + + if err := utils.SetCache(cacheKey, articleDTO, time.Hour*24); err != nil { + log.Printf("Error caching article: %v", err) + } + + return articleDTO, nil +} + +func (s *articleService) UpdateArticle(ctx context.Context, id string, request RequestArticleDTO, coverImage *multipart.FileHeader) (*ArticleResponseDTO, error) { + if id == "" { + return nil, fmt.Errorf("article ID cannot be empty") + } + + existingArticle, err := s.articleRepo.FindArticleByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("article not found: %w", err) + } + + oldCoverImage := existingArticle.CoverImage + var newCoverPath string + + if coverImage != nil { + newCoverPath, err = s.saveCoverArticle(coverImage) + if err != nil { + return nil, fmt.Errorf("failed to save new cover image: %w", err) + } + } + + updatedArticle := &model.Article{ + Title: request.Title, + Author: request.Author, + Heading: request.Heading, + Content: request.Content, + CoverImage: existingArticle.CoverImage, + } + + if newCoverPath != "" { + updatedArticle.CoverImage = newCoverPath + } + + if err := s.articleRepo.UpdateArticle(ctx, id, updatedArticle); err != nil { + + if newCoverPath != "" { + s.deleteCoverArticle(newCoverPath) + } + return nil, fmt.Errorf("failed to update article: %w", err) + } + + if newCoverPath != "" && oldCoverImage != "" { + if err := s.deleteCoverArticle(oldCoverImage); err != nil { + log.Printf("Warning: failed to delete old cover image: %v", err) + } + } + + article, err := s.articleRepo.FindArticleByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated article: %w", err) + } + + articleDTO, err := s.transformToDTO(article) + if err != nil { + return nil, fmt.Errorf("failed to transform updated article: %w", err) + } + + cacheKey := fmt.Sprintf("article:%s", id) + if err := utils.SetCache(cacheKey, articleDTO, time.Hour*24); err != nil { + log.Printf("Error caching updated article: %v", err) + } + + s.invalidateArticleCache(id) + + return articleDTO, nil +} + +func (s *articleService) DeleteArticle(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("article ID cannot be empty") + } + + article, err := s.articleRepo.FindArticleByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to find article: %w", err) + } + + if err := s.articleRepo.DeleteArticle(ctx, id); err != nil { + return fmt.Errorf("failed to delete article: %w", err) + } + + if err := s.deleteCoverArticle(article.CoverImage); err != nil { + log.Printf("Warning: failed to delete cover image: %v", err) + } + + s.invalidateArticleCache(id) + + return nil +} diff --git a/internal/authentication/authentication_dto.go b/internal/authentication/authentication_dto.go new file mode 100644 index 0000000..801ff71 --- /dev/null +++ b/internal/authentication/authentication_dto.go @@ -0,0 +1,416 @@ +package authentication + +import ( + "rijig/utils" + "strings" + "time" +) + +type LoginorRegistRequest struct { + Phone string `json:"phone" validate:"required,min=10,max=15"` + RoleName string `json:"role_name"` +} + +type VerifyOtpRequest struct { + DeviceID string `json:"device_id" validate:"required"` + RoleName string `json:"role_name" validate:"required,oneof=masyarakat pengepul pengelola"` + Phone string `json:"phone" validate:"required"` + Otp string `json:"otp" validate:"required,len=6"` +} + +type CreatePINRequest struct { + PIN string `json:"pin" validate:"required,len=6,numeric"` + ConfirmPIN string `json:"confirm_pin" validate:"required,len=6,numeric"` +} + +type VerifyPINRequest struct { + PIN string `json:"pin" validate:"required,len=6,numeric"` + DeviceID string `json:"device_id" validate:"required"` +} + +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" validate:"required"` + DeviceID string `json:"device_id" validate:"required"` + UserID string `json:"user_id" validate:"required"` +} + +type LogoutRequest struct { + DeviceID string `json:"device_id" validate:"required"` +} + +type OTPResponse struct { + Message string `json:"message"` + ExpiresIn int `json:"expires_in"` + Phone string `json:"phone"` +} + +type AuthResponse struct { + Message string `json:"message"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + User *UserResponse `json:"user,omitempty"` + RegistrationStatus string `json:"registration_status,omitempty"` + NextStep string `json:"next_step,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +type UserResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email,omitempty"` + Role string `json:"role"` + RegistrationStatus string `json:"registration_status"` + RegistrationProgress int8 `json:"registration_progress"` + PhoneVerified bool `json:"phone_verified"` + Avatar *string `json:"avatar,omitempty"` + Gender string `json:"gender,omitempty"` + DateOfBirth string `json:"date_of_birth,omitempty"` + PlaceOfBirth string `json:"place_of_birth,omitempty"` +} + +type RegistrationStatusResponse struct { + CurrentStep int `json:"current_step"` + TotalSteps int `json:"total_steps"` + CompletedSteps []RegistrationStep `json:"completed_steps"` + NextStep *RegistrationStep `json:"next_step,omitempty"` + RegistrationStatus string `json:"registration_status"` + IsComplete bool `json:"is_complete"` + RequiresApproval bool `json:"requires_approval"` + ApprovalMessage string `json:"approval_message,omitempty"` +} + +type RegistrationStep struct { + StepNumber int `json:"step_number"` + Title string `json:"title"` + Description string `json:"description"` + IsRequired bool `json:"is_required"` + IsCompleted bool `json:"is_completed"` + IsActive bool `json:"is_active"` +} + +type OTPData struct { + Phone string `json:"phone"` + OTP string `json:"otp"` + UserID string `json:"user_id,omitempty"` + Role string `json:"role"` + RoleID string `json:"role_id,omitempty"` + Type string `json:"type"` + ExpiresAt time.Time `json:"expires_at"` + Attempts int `json:"attempts"` +} + +type SessionData struct { + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + LastSeen time.Time `json:"last_seen"` + IsActive bool `json:"is_active"` +} + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Code string `json:"code,omitempty"` + Details interface{} `json:"details,omitempty"` +} + +type ValidationErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Fields map[string]string `json:"fields"` +} + +type ApproveRegistrationRequest struct { + UserID string `json:"user_id" validate:"required"` + Message string `json:"message,omitempty"` +} + +type RejectRegistrationRequest struct { + UserID string `json:"user_id" validate:"required"` + Reason string `json:"reason" validate:"required"` +} + +type PendingRegistrationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + RegistrationData RegistrationData `json:"registration_data"` + SubmittedAt time.Time `json:"submitted_at"` + DocumentsUploaded []DocumentInfo `json:"documents_uploaded"` +} + +type RegistrationData struct { + KTPNumber string `json:"ktp_number,omitempty"` + KTPImage string `json:"ktp_image,omitempty"` + FullName string `json:"full_name,omitempty"` + Address string `json:"address,omitempty"` + BusinessName string `json:"business_name,omitempty"` + BusinessType string `json:"business_type,omitempty"` + BusinessAddress string `json:"business_address,omitempty"` + BusinessPhone string `json:"business_phone,omitempty"` + TaxNumber string `json:"tax_number,omitempty"` + BusinessLicense string `json:"business_license,omitempty"` +} + +type DocumentInfo struct { + Type string `json:"type"` + FileName string `json:"file_name"` + UploadedAt time.Time `json:"uploaded_at"` + Status string `json:"status"` + FileSize int64 `json:"file_size"` + ContentType string `json:"content_type"` +} + +type AuthStatsResponse struct { + TotalUsers int64 `json:"total_users"` + ActiveUsers int64 `json:"active_users"` + PendingRegistrations int64 `json:"pending_registrations"` + UsersByRole map[string]int64 `json:"users_by_role"` + RegistrationStats RegistrationStatsData `json:"registration_stats"` + LoginStats LoginStatsData `json:"login_stats"` +} + +type RegistrationStatsData struct { + TotalRegistrations int64 `json:"total_registrations"` + CompletedToday int64 `json:"completed_today"` + CompletedThisWeek int64 `json:"completed_this_week"` + CompletedThisMonth int64 `json:"completed_this_month"` + PendingApproval int64 `json:"pending_approval"` + RejectedRegistrations int64 `json:"rejected_registrations"` +} + +type LoginStatsData struct { + TotalLogins int64 `json:"total_logins"` + LoginsToday int64 `json:"logins_today"` + LoginsThisWeek int64 `json:"logins_this_week"` + LoginsThisMonth int64 `json:"logins_this_month"` + UniqueUsersToday int64 `json:"unique_users_today"` + UniqueUsersWeek int64 `json:"unique_users_week"` + UniqueUsersMonth int64 `json:"unique_users_month"` +} + +type PaginationRequest struct { + Page int `json:"page" query:"page" validate:"min=1"` + Limit int `json:"limit" query:"limit" validate:"min=1,max=100"` + Sort string `json:"sort" query:"sort"` + Order string `json:"order" query:"order" validate:"oneof=asc desc"` + Search string `json:"search" query:"search"` + Filter string `json:"filter" query:"filter"` +} + +type PaginationResponse struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int64 `json:"total"` + TotalPages int `json:"total_pages"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +type PaginatedResponse struct { + Data interface{} `json:"data"` + Pagination PaginationResponse `json:"pagination"` +} + +type SMSWebhookRequest struct { + MessageID string `json:"message_id"` + Phone string `json:"phone"` + Status string `json:"status"` + Timestamp string `json:"timestamp"` +} + +type RateLimitInfo struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + ResetTime time.Time `json:"reset_time"` + RetryAfter time.Duration `json:"retry_after,omitempty"` +} + +type StepResponse struct { + UserID string `json:"user_id"` + Role string `json:"role"` + RegistrationStatus string `json:"registration_status"` + RegistrationProgress int `json:"registration_progress"` + NextStep string `json:"next_step"` +} + +type RegisterAdminRequest struct { + Name string `json:"name"` + Gender string `json:"gender"` + DateOfBirth string `json:"dateofbirth"` + PlaceOfBirth string `json:"placeofbirth"` + Phone string `json:"phone"` + Email string `json:"email"` + Password string `json:"password"` + PasswordConfirm string `json:"password_confirm"` +} + +type LoginAdminRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DeviceID string `json:"device_id"` +} + +type VerifyAdminOTPRequest struct { + Email string `json:"email" validate:"required,email"` + OTP string `json:"otp" validate:"required,len=6,numeric"` + DeviceID string `json:"device_id" validate:"required"` +} + +type ResendAdminOTPRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type OTPAdminResponse struct { + Message string `json:"message"` + Email string `json:"email"` + ExpiresIn time.Duration `json:"expires_in_seconds"` + RemainingTime string `json:"remaining_time"` + CanResend bool `json:"can_resend"` + MaxAttempts int `json:"max_attempts"` +} + +type ForgotPasswordRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type ResetPasswordRequest struct { + Email string `json:"email" validate:"required,email"` + Token string `json:"token" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=6"` +} + +type ResetPasswordResponse struct { + Message string `json:"message"` + Email string `json:"email"` + ExpiresIn time.Duration `json:"expires_in_seconds"` + RemainingTime string `json:"remaining_time"` +} + +type VerifyEmailRequest struct { + Email string `json:"email" validate:"required,email"` + Token string `json:"token" validate:"required"` +} + +type ResendVerificationRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type EmailVerificationResponse struct { + Message string `json:"message"` + Email string `json:"email"` + ExpiresIn time.Duration `json:"expires_in_seconds"` + RemainingTime string `json:"remaining_time"` +} + + +func (r *LoginorRegistRequest) ValidateLoginorRegistRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if !utils.IsValidPhoneNumber(r.Phone) { + errors["phone"] = append(errors["phone"], "nomor harus dimulai 62.. dan 8-14 digit") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} + +func (r *VerifyOtpRequest) ValidateVerifyOtpRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + if len(strings.TrimSpace(r.DeviceID)) < 10 { + errors["device_id"] = append(errors["device_id"], "Device ID must be at least 10 characters") + } + + validRoles := map[string]bool{"masyarakat": true, "pengepul": true, "pengelola": true} + if _, ok := validRoles[r.RoleName]; !ok { + errors["role"] = append(errors["role"], "Role tidak valid, hanya masyarakat, pengepul, atau pengelola") + } + + if !utils.IsValidPhoneNumber(r.Phone) { + errors["phone"] = append(errors["phone"], "nomor harus dimulai 62.. dan 8-14 digit") + } + + if len(r.Otp) != 4 || !utils.IsNumeric(r.Otp) { + errors["otp"] = append(errors["otp"], "OTP must be 4-digit number") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} + +func (r *LoginAdminRequest) ValidateLoginAdminRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if !utils.IsValidEmail(r.Email) { + errors["email"] = append(errors["email"], "Invalid email format") + } + + if strings.TrimSpace(r.Password) == "" { + errors["password"] = append(errors["password"], "Password is required") + } + + if len(strings.TrimSpace(r.DeviceID)) < 10 { + errors["device_id"] = append(errors["device_id"], "Device ID must be at least 10 characters") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} + +func (r *RegisterAdminRequest) ValidateRegisterAdminRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.Name) == "" { + errors["name"] = append(errors["name"], "Name is required") + } + + genderLower := strings.ToLower(strings.TrimSpace(r.Gender)) + if genderLower != "laki-laki" && genderLower != "perempuan" { + errors["gender"] = append(errors["gender"], "Gender must be either 'laki-laki' or 'perempuan'") + } + + if strings.TrimSpace(r.DateOfBirth) == "" { + errors["dateofbirth"] = append(errors["dateofbirth"], "Date of birth is required") + } else { + _, err := time.Parse("02-01-2006", r.DateOfBirth) + if err != nil { + errors["dateofbirth"] = append(errors["dateofbirth"], "Date of birth must be in DD-MM-YYYY format") + } + } + + if strings.TrimSpace(r.PlaceOfBirth) == "" { + errors["placeofbirth"] = append(errors["placeofbirth"], "Place of birth is required") + } + + if !utils.IsValidPhoneNumber(r.Phone) { + errors["phone"] = append(errors["phone"], "Phone must be valid, has 8-14 digit and start with '62..'") + } + + if !utils.IsValidEmail(r.Email) { + errors["email"] = append(errors["email"], "Invalid email format") + } + + if !utils.IsValidPassword(r.Password) { + errors["password"] = append(errors["password"], "Password must be at least 8 characters, with uppercase, number, and special character") + } + + if r.Password != r.PasswordConfirm { + errors["password_confirm"] = append(errors["password_confirm"], "Passwords do not match") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} diff --git a/internal/authentication/authentication_handler.go b/internal/authentication/authentication_handler.go new file mode 100644 index 0000000..613a4cc --- /dev/null +++ b/internal/authentication/authentication_handler.go @@ -0,0 +1,351 @@ +package authentication + +import ( + "log" + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +type AuthenticationHandler struct { + service AuthenticationService +} + +func NewAuthenticationHandler(service AuthenticationService) *AuthenticationHandler { + return &AuthenticationHandler{service} +} + +func (h *AuthenticationHandler) RefreshToken(c *fiber.Ctx) error { + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + if claims.DeviceID == "" { + return utils.BadRequest(c, "Device ID is required") + } + + var body struct { + RefreshToken string `json:"refresh_token"` + } + if err := c.BodyParser(&body); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + if body.RefreshToken == "" { + return utils.BadRequest(c, "Refresh token is required") + } + + if claims.UserID == "" { + return utils.BadRequest(c, "userid is required") + } + + tokenData, err := utils.RefreshAccessToken(claims.UserID, claims.DeviceID, body.RefreshToken) + if err != nil { + return utils.Unauthorized(c, err.Error()) + } + + return utils.SuccessWithData(c, "Token refreshed successfully", tokenData) + +} + +func (h *AuthenticationHandler) GetMe(c *fiber.Ctx) error { + userID, _ := c.Locals("user_id").(string) + role, _ := c.Locals("role").(string) + deviceID, _ := c.Locals("device_id").(string) + regStatus, _ := c.Locals("registration_status").(string) + + data := fiber.Map{ + "user_id": userID, + "role": role, + "device_id": deviceID, + "registration_status": regStatus, + } + + return utils.SuccessWithData(c, "User session data retrieved", data) + +} + +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 { + + var req LoginAdminRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + if errs, ok := req.ValidateLoginAdminRequest(); !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "meta": fiber.Map{ + "status": fiber.StatusBadRequest, + "message": "Validation failed", + }, + "errors": errs, + }) + } + + res, err := h.service.LoginAdmin(c.Context(), &req) + if err != nil { + return utils.Unauthorized(c, err.Error()) + } + + return utils.SuccessWithData(c, "Login successful", res) + +} + +func (h *AuthenticationHandler) RegisterAdmin(c *fiber.Ctx) error { + var req RegisterAdminRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if err := h.validator.Struct(&req); err != nil { + // return utils.BadRequest(c, "Validation failed: "+err.Error()) + // } + + response, err := h.service.RegisterAdmin(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "Admin registered successfully", response) +} + +// POST /auth/admin/verify-email - Verify email dari registration +func (h *AuthenticationHandler) VerifyEmail(c *fiber.Ctx) error { + var req VerifyEmailRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if err := h.validator.Struct(&req); err != nil { + // return utils.BadRequest(c, "Validation failed: "+err.Error()) + // } + + err := h.service.VerifyEmail(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "Email berhasil diverifikasi. Sekarang Anda dapat login", nil) +} + +// POST /auth/admin/resend-verification - Resend verification email +func (h *AuthenticationHandler) ResendEmailVerification(c *fiber.Ctx) error { + var req ResendVerificationRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if err := h.validator.Struct(&req); err != nil { + // return utils.BadRequest(c, "Validation failed: "+err.Error()) + // } + + response, err := h.service.ResendEmailVerification(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "Verification email resent", response) +} + + +func (h *AuthenticationHandler) VerifyAdminOTP(c *fiber.Ctx) error { + var req VerifyAdminOTPRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if errs, ok := req.Valida(); !ok { + // return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + // "meta": fiber.Map{ + // "status": fiber.StatusBadRequest, + // "message": "periksa lagi inputan", + // }, + // "errors": errs, + // }) + // } + + response, err := h.service.VerifyAdminOTP(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "OTP resent successfully", response) +} + +// POST /auth/admin/resend-otp - Resend OTP +func (h *AuthenticationHandler) ResendAdminOTP(c *fiber.Ctx) error { + var req ResendAdminOTPRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if err := h.validator.Struct(&req); err != nil { + // return utils.BadRequest(c, "Validation failed: "+err.Error()) + // } + + response, err := h.service.ResendAdminOTP(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "OTP resent successfully", response) +} + +func (h *AuthenticationHandler) ForgotPassword(c *fiber.Ctx) error { + var req ForgotPasswordRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if err := h.validator.Struct(&req); err != nil { + // return utils.BadRequest(c, "Validation failed: "+err.Error()) + // } + + response, err := h.service.ForgotPassword(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "Reset password email sent", response) +} + +// POST /auth/admin/reset-password - Step 2: Reset password dengan token +func (h *AuthenticationHandler) ResetPassword(c *fiber.Ctx) error { + var req ResetPasswordRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + // if err := h.validator.Struct(&req); err != nil { + // return utils.BadRequest(c, "Validation failed: "+err.Error()) + // } + + err := h.service.ResetPassword(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "Password berhasil direset", nil) +} + + +func (h *AuthenticationHandler) RequestOtpHandler(c *fiber.Ctx) error { + var req LoginorRegistRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + if errs, ok := req.ValidateLoginorRegistRequest(); !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "meta": fiber.Map{ + "status": fiber.StatusBadRequest, + "message": "Input tidak valid", + }, + "errors": errs, + }) + } + + _, err := h.service.SendLoginOTP(c.Context(), &req) + if err != nil { + return utils.InternalServerError(c, err.Error()) + } + + return utils.Success(c, "OTP sent successfully") +} + +func (h *AuthenticationHandler) VerifyOtpHandler(c *fiber.Ctx) error { + var req VerifyOtpRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + if errs, ok := req.ValidateVerifyOtpRequest(); !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "meta": fiber.Map{"status": fiber.StatusBadRequest, "message": "Validation error"}, + "errors": errs, + }) + } + + stepResp, err := h.service.VerifyLoginOTP(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "OTP verified successfully", stepResp) +} + +func (h *AuthenticationHandler) RequestOtpRegistHandler(c *fiber.Ctx) error { + var req LoginorRegistRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + if errs, ok := req.ValidateLoginorRegistRequest(); !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "meta": fiber.Map{ + "status": fiber.StatusBadRequest, + "message": "Input tidak valid", + }, + "errors": errs, + }) + } + + _, err := h.service.SendRegistrationOTP(c.Context(), &req) + if err != nil { + return utils.Forbidden(c, err.Error()) + } + + return utils.Success(c, "OTP sent successfully") +} + +func (h *AuthenticationHandler) VerifyOtpRegistHandler(c *fiber.Ctx) error { + var req VerifyOtpRequest + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + if errs, ok := req.ValidateVerifyOtpRequest(); !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "meta": fiber.Map{"status": fiber.StatusBadRequest, "message": "Validation error"}, + "errors": errs, + }) + } + + stepResp, err := h.service.VerifyRegistrationOTP(c.Context(), &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "OTP verified successfully", stepResp) +} + +func (h *AuthenticationHandler) LogoutAuthentication(c *fiber.Ctx) error { + + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + err = h.service.LogoutAuthentication(c.Context(), claims.UserID, claims.DeviceID) + if err != nil { + + return utils.InternalServerError(c, "Failed to logout") + } + + return utils.Success(c, "Logout successful") +} diff --git a/internal/authentication/authentication_repository.go b/internal/authentication/authentication_repository.go new file mode 100644 index 0000000..23d367e --- /dev/null +++ b/internal/authentication/authentication_repository.go @@ -0,0 +1,125 @@ +package authentication + +import ( + "context" + "fmt" + "log" + "rijig/model" + + "gorm.io/gorm" +) + +type AuthenticationRepository interface { + FindUserByPhone(ctx context.Context, phone string) (*model.User, error) + FindUserByPhoneAndRole(ctx context.Context, phone, rolename string) (*model.User, error) + FindUserByEmail(ctx context.Context, email string) (*model.User, error) + FindUserByID(ctx context.Context, userID string) (*model.User, error) + CreateUser(ctx context.Context, user *model.User) error + UpdateUser(ctx context.Context, user *model.User) error + PatchUser(ctx context.Context, userID string, updates map[string]interface{}) error + + GetIdentityCardsByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.IdentityCard, error) + GetCompanyProfilesByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.CompanyProfile, error) +} + +type authenticationRepository struct { + db *gorm.DB +} + +func NewAuthenticationRepository(db *gorm.DB) AuthenticationRepository { + return &authenticationRepository{db} +} + +func (r *authenticationRepository) FindUserByPhone(ctx context.Context, phone string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx). + Preload("Role"). + Where("phone = ?", phone).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *authenticationRepository) FindUserByPhoneAndRole(ctx context.Context, phone, rolename string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx). + Preload("Role"). + Joins("JOIN roles AS role ON role.id = users.role_id"). + Where("users.phone = ? AND role.role_name = ?", phone, rolename). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *authenticationRepository) FindUserByEmail(ctx context.Context, email string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx). + Preload("Role"). + Where("email = ?", email).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *authenticationRepository) FindUserByID(ctx context.Context, userID string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx). + Preload("Role"). + First(&user, "id = ?", userID).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *authenticationRepository) CreateUser(ctx context.Context, user *model.User) error { + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *authenticationRepository) UpdateUser(ctx context.Context, user *model.User) error { + return r.db.WithContext(ctx). + Model(&model.User{}). + Where("id = ?", user.ID). + Updates(user).Error +} + +func (r *authenticationRepository) PatchUser(ctx context.Context, userID string, updates map[string]interface{}) error { + return r.db.WithContext(ctx). + Model(&model.User{}). + Where("id = ?", userID). + Updates(updates).Error +} + +func (r *authenticationRepository) GetIdentityCardsByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.IdentityCard, error) { + var identityCards []model.IdentityCard + + if err := r.db.WithContext(ctx). + Joins("JOIN users ON identity_cards.user_id = users.id"). + Where("users.registration_status = ?", userRegStatus). + Preload("User"). + Preload("User.Role"). + Find(&identityCards).Error; err != nil { + log.Printf("Error fetching identity cards by user registration status: %v", err) + return nil, fmt.Errorf("error fetching identity cards by user registration status: %w", err) + } + + log.Printf("Found %d identity cards with registration status: %s", len(identityCards), userRegStatus) + return identityCards, nil +} + +func (r *authenticationRepository) GetCompanyProfilesByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.CompanyProfile, error) { + var companyProfiles []model.CompanyProfile + + if err := r.db.WithContext(ctx). + Joins("JOIN users ON company_profiles.user_id = users.id"). + Where("users.registration_status = ?", userRegStatus). + Preload("User"). + Preload("User.Role"). + Find(&companyProfiles).Error; err != nil { + log.Printf("Error fetching company profiles by user registration status: %v", err) + return nil, fmt.Errorf("error fetching company profiles by user registration status: %w", err) + } + + log.Printf("Found %d company profiles with registration status: %s", len(companyProfiles), userRegStatus) + return companyProfiles, nil +} diff --git a/internal/authentication/authentication_route.go b/internal/authentication/authentication_route.go new file mode 100644 index 0000000..bb5058f --- /dev/null +++ b/internal/authentication/authentication_route.go @@ -0,0 +1,51 @@ +package authentication + +import ( + "rijig/config" + "rijig/internal/role" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func AuthenticationRouter(api fiber.Router) { + repoAuth := NewAuthenticationRepository(config.DB) + repoRole := role.NewRoleRepository(config.DB) + + authService := NewAuthenticationService(repoAuth, repoRole) + authHandler := NewAuthenticationHandler(authService) + + authRoute := api.Group("/auth") + + authRoute.Post("/refresh-token", + middleware.AuthMiddleware(), + middleware.DeviceValidation(), + authHandler.RefreshToken, + ) + + // authRoute.Get("/me", + // middleware.AuthMiddleware(), + // middleware.CheckRefreshTokenTTL(30*time.Second), + // middleware.RequireApprovedRegistration(), + // authHandler.GetMe, + // ) + + authRoute.Get("/cekapproval", middleware.AuthMiddleware(), authHandler.GetRegistrationStatus) + authRoute.Post("/login/admin", authHandler.Login) + authRoute.Post("/register/admin", authHandler.RegisterAdmin) + + authRoute.Post("/verify-email", authHandler.VerifyEmail) + authRoute.Post("/resend-verification", authHandler.ResendEmailVerification) + + authRoute.Post("/verify-otp-admin", authHandler.VerifyAdminOTP) + authRoute.Post("/resend-otp-admin", authHandler.ResendAdminOTP) + + authRoute.Post("/forgot-password", authHandler.ForgotPassword) + authRoute.Post("/reset-password", authHandler.ResetPassword) + + authRoute.Post("/request-otp", authHandler.RequestOtpHandler) + authRoute.Post("/verif-otp", authHandler.VerifyOtpHandler) + authRoute.Post("/request-otp/register", authHandler.RequestOtpRegistHandler) + authRoute.Post("/verif-otp/register", authHandler.VerifyOtpRegistHandler) + authRoute.Post("/logout", middleware.AuthMiddleware(), authHandler.LogoutAuthentication) +} diff --git a/internal/authentication/authentication_service.go b/internal/authentication/authentication_service.go new file mode 100644 index 0000000..980c5c8 --- /dev/null +++ b/internal/authentication/authentication_service.go @@ -0,0 +1,814 @@ +package authentication + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "rijig/internal/role" + "rijig/model" + "rijig/utils" +) + +type AuthenticationService interface { + GetRegistrationStatus(ctx context.Context, userID, deviceID string) (*AuthResponse, error) + LoginAdmin(ctx context.Context, req *LoginAdminRequest) (*OTPAdminResponse, error) + RegisterAdmin(ctx context.Context, req *RegisterAdminRequest) (*EmailVerificationResponse, error) + + VerifyAdminOTP(ctx context.Context, req *VerifyAdminOTPRequest) (*AuthResponse, error) + ResendAdminOTP(ctx context.Context, req *ResendAdminOTPRequest) (*OTPAdminResponse, error) + + ForgotPassword(ctx context.Context, req *ForgotPasswordRequest) (*ResetPasswordResponse, error) + ResetPassword(ctx context.Context, req *ResetPasswordRequest) error + + VerifyEmail(ctx context.Context, req *VerifyEmailRequest) error + ResendEmailVerification(ctx context.Context, req *ResendVerificationRequest) (*EmailVerificationResponse, error) + + SendRegistrationOTP(ctx context.Context, req *LoginorRegistRequest) (*OTPResponse, error) + VerifyRegistrationOTP(ctx context.Context, req *VerifyOtpRequest) (*AuthResponse, error) + + SendLoginOTP(ctx context.Context, req *LoginorRegistRequest) (*OTPResponse, error) + VerifyLoginOTP(ctx context.Context, req *VerifyOtpRequest) (*AuthResponse, error) + + LogoutAuthentication(ctx context.Context, userID, deviceID string) error +} + +type authenticationService struct { + authRepo AuthenticationRepository + roleRepo role.RoleRepository + emailService *utils.EmailService +} + +func NewAuthenticationService(authRepo AuthenticationRepository, roleRepo role.RoleRepository) AuthenticationService { + return &authenticationService{ + authRepo: authRepo, + roleRepo: roleRepo, + emailService: utils.NewEmailService(), + } +} + +// 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) +// } +// } + +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) (*OTPAdminResponse, error) { + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("invalid credentials") + } + + if user.Role == nil || user.Role.RoleName != "administrator" { + return nil, fmt.Errorf("invalid credentials") + } + + if user.RegistrationStatus != "completed" { + return nil, fmt.Errorf("account not activated") + } + + if !utils.CompareHashAndPlainText(user.Password, req.Password) { + return nil, fmt.Errorf("invalid credentials") + } + + if utils.IsOTPValid(req.Email) { + remaining, _ := utils.GetOTPRemainingTime(req.Email) + return &OTPAdminResponse{ + Message: "OTP sudah dikirim sebelumnya", + Email: req.Email, + ExpiresIn: remaining, + RemainingTime: formatDuration(remaining), + CanResend: false, + MaxAttempts: utils.MAX_OTP_ATTEMPTS, + }, nil + } + + otp, err := utils.GenerateOTP() + if err != nil { + return nil, fmt.Errorf("failed to generate OTP") + } + + if err := utils.StoreOTP(req.Email, otp); err != nil { + return nil, fmt.Errorf("failed to store OTP") + } + + if err := s.emailService.SendOTPEmail(req.Email, user.Name, otp); err != nil { + log.Printf("Failed to send OTP email: %v", err) + return nil, fmt.Errorf("failed to send OTP email") + } + + return &OTPAdminResponse{ + Message: "Kode OTP berhasil dikirim ke email Anda", + Email: req.Email, + ExpiresIn: utils.OTP_EXPIRY, + RemainingTime: formatDuration(utils.OTP_EXPIRY), + CanResend: false, + MaxAttempts: utils.MAX_OTP_ATTEMPTS, + }, nil +} + +func (s *authenticationService) VerifyAdminOTP(ctx context.Context, req *VerifyAdminOTPRequest) (*AuthResponse, error) { + + if err := utils.ValidateOTP(req.Email, req.OTP); err != nil { + return nil, err + } + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("user not found") + } + + if !user.EmailVerified { + user.EmailVerified = true + if err := s.authRepo.UpdateUser(ctx, user); err != nil { + log.Printf("Failed to update email verification status: %v", err) + } + } + + token, err := utils.GenerateTokenPair( + user.ID, + user.Role.RoleName, + req.DeviceID, + user.RegistrationStatus, + int(user.RegistrationProgress), + ) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + return &AuthResponse{ + Message: "Login berhasil", + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + RegistrationStatus: user.RegistrationStatus, + SessionID: token.SessionID, + }, nil +} + +func (s *authenticationService) ResendAdminOTP(ctx context.Context, req *ResendAdminOTPRequest) (*OTPAdminResponse, error) { + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("email not found") + } + + if user.Role == nil || user.Role.RoleName != "administrator" { + return nil, fmt.Errorf("not authorized") + } + + if utils.IsOTPValid(req.Email) { + remaining, _ := utils.GetOTPRemainingTime(req.Email) + return nil, fmt.Errorf("OTP masih berlaku. Tunggu %s untuk kirim ulang", formatDuration(remaining)) + } + + otp, err := utils.GenerateOTP() + if err != nil { + return nil, fmt.Errorf("failed to generate OTP") + } + + if err := utils.StoreOTP(req.Email, otp); err != nil { + return nil, fmt.Errorf("failed to store OTP") + } + + if err := s.emailService.SendOTPEmail(req.Email, user.Name, otp); err != nil { + log.Printf("Failed to send OTP email: %v", err) + return nil, fmt.Errorf("failed to send OTP email") + } + + return &OTPAdminResponse{ + Message: "Kode OTP baru berhasil dikirim", + Email: req.Email, + ExpiresIn: utils.OTP_EXPIRY, + RemainingTime: formatDuration(utils.OTP_EXPIRY), + CanResend: false, + MaxAttempts: utils.MAX_OTP_ATTEMPTS, + }, nil +} + +func (s *authenticationService) VerifyEmail(ctx context.Context, req *VerifyEmailRequest) error { + + verificationData, err := utils.ValidateEmailVerificationToken(req.Email, req.Token) + if err != nil { + return err + } + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + return fmt.Errorf("user not found") + } + + if user.ID != verificationData.UserID { + return fmt.Errorf("invalid verification token") + } + + if user.EmailVerified { + return fmt.Errorf("email sudah terverifikasi sebelumnya") + } + + user.EmailVerified = true + user.RegistrationStatus = utils.RegStatusComplete + // user.RegistrationProgress = 3 + + if err := s.authRepo.UpdateUser(ctx, user); err != nil { + return fmt.Errorf("failed to update user verification status: %w", err) + } + + if err := utils.MarkEmailVerificationTokenAsUsed(req.Email); err != nil { + log.Printf("Failed to mark verification token as used: %v", err) + } + + return nil +} + +func (s *authenticationService) ResendEmailVerification(ctx context.Context, req *ResendVerificationRequest) (*EmailVerificationResponse, error) { + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("email not found") + } + + if user.Role == nil || user.Role.RoleName != "administrator" { + return nil, fmt.Errorf("not authorized") + } + + if user.EmailVerified { + return nil, fmt.Errorf("email sudah terverifikasi") + } + + if utils.IsEmailVerificationTokenValid(req.Email) { + remaining, _ := utils.GetEmailVerificationTokenRemainingTime(req.Email) + return &EmailVerificationResponse{ + Message: "Email verifikasi sudah dikirim sebelumnya", + Email: req.Email, + ExpiresIn: remaining, + RemainingTime: formatDuration(remaining), + }, nil + } + + token, err := utils.GenerateEmailVerificationToken() + if err != nil { + return nil, fmt.Errorf("failed to generate verification token") + } + + if err := utils.StoreEmailVerificationToken(req.Email, user.ID, token); err != nil { + return nil, fmt.Errorf("failed to store verification token") + } + + if err := s.emailService.SendEmailVerificationEmail(req.Email, user.Name, token); err != nil { + log.Printf("Failed to send verification email: %v", err) + return nil, fmt.Errorf("failed to send verification email") + } + + return &EmailVerificationResponse{ + Message: "Email verifikasi berhasil dikirim ulang", + Email: req.Email, + ExpiresIn: utils.EMAIL_VERIFICATION_TOKEN_EXPIRY, + RemainingTime: formatDuration(utils.EMAIL_VERIFICATION_TOKEN_EXPIRY), + }, nil +} + +func (s *authenticationService) ForgotPassword(ctx context.Context, req *ForgotPasswordRequest) (*ResetPasswordResponse, error) { + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + + return &ResetPasswordResponse{ + Message: "Jika email terdaftar, link reset password akan dikirim", + Email: req.Email, + ExpiresIn: utils.RESET_TOKEN_EXPIRY, + RemainingTime: formatDuration(utils.RESET_TOKEN_EXPIRY), + }, nil + } + + if user.Role == nil || user.Role.RoleName != "administrator" { + + return &ResetPasswordResponse{ + Message: "Jika email terdaftar, link reset password akan dikirim", + Email: req.Email, + ExpiresIn: utils.RESET_TOKEN_EXPIRY, + RemainingTime: formatDuration(utils.RESET_TOKEN_EXPIRY), + }, nil + } + + if utils.IsResetTokenValid(req.Email) { + remaining, _ := utils.GetResetTokenRemainingTime(req.Email) + return &ResetPasswordResponse{ + Message: "Link reset password sudah dikirim sebelumnya", + Email: req.Email, + ExpiresIn: remaining, + RemainingTime: formatDuration(remaining), + }, nil + } + + token, err := utils.GenerateResetToken() + if err != nil { + return nil, fmt.Errorf("failed to generate reset token") + } + + if err := utils.StoreResetToken(req.Email, user.ID, token); err != nil { + return nil, fmt.Errorf("failed to store reset token") + } + + if err := s.emailService.SendResetPasswordEmail(req.Email, user.Name, token); err != nil { + log.Printf("Failed to send reset password email: %v", err) + return nil, fmt.Errorf("failed to send reset password email") + } + + return &ResetPasswordResponse{ + Message: "Link reset password berhasil dikirim ke email Anda", + Email: req.Email, + ExpiresIn: utils.RESET_TOKEN_EXPIRY, + RemainingTime: formatDuration(utils.RESET_TOKEN_EXPIRY), + }, nil +} + +func (s *authenticationService) ResetPassword(ctx context.Context, req *ResetPasswordRequest) error { + + resetData, err := utils.ValidateResetToken(req.Email, req.Token) + if err != nil { + return err + } + + user, err := s.authRepo.FindUserByEmail(ctx, req.Email) + if err != nil { + return fmt.Errorf("user not found") + } + + if user.ID != resetData.UserID { + return fmt.Errorf("invalid reset token") + } + + hashedPassword, err := utils.HashingPlainText(req.NewPassword) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + user.Password = hashedPassword + if err := s.authRepo.UpdateUser(ctx, user); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + if err := utils.MarkResetTokenAsUsed(req.Email); err != nil { + log.Printf("Failed to mark reset token as used: %v", err) + } + + if err := utils.RevokeAllRefreshTokens(user.ID); err != nil { + log.Printf("Failed to revoke refresh tokens: %v", err) + } + + return nil +} + +func (s *authenticationService) RegisterAdmin(ctx context.Context, req *RegisterAdminRequest) (*EmailVerificationResponse, error) { + + existingUser, _ := s.authRepo.FindUserByEmail(ctx, req.Email) + if existingUser != nil { + return nil, fmt.Errorf("email already in use") + } + + hashedPassword, err := utils.HashingPlainText(req.Password) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + role, err := s.roleRepo.FindRoleByName(ctx, "administrator") + if err != nil { + return nil, fmt.Errorf("role name not found: %w", err) + } + + user := &model.User{ + Name: req.Name, + Phone: req.Phone, + Email: req.Email, + Gender: req.Gender, + Dateofbirth: req.DateOfBirth, + Placeofbirth: req.PlaceOfBirth, + Password: hashedPassword, + RoleID: role.ID, + RegistrationStatus: "pending_email_verification", + RegistrationProgress: 1, + EmailVerified: false, + } + + if err := s.authRepo.CreateUser(ctx, user); err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + token, err := utils.GenerateEmailVerificationToken() + if err != nil { + return nil, fmt.Errorf("failed to generate verification token") + } + + if err := utils.StoreEmailVerificationToken(req.Email, user.ID, token); err != nil { + return nil, fmt.Errorf("failed to store verification token") + } + + if err := s.emailService.SendEmailVerificationEmail(req.Email, user.Name, token); err != nil { + log.Printf("Failed to send verification email: %v", err) + return nil, fmt.Errorf("failed to send verification email") + } + + return &EmailVerificationResponse{ + Message: "Admin berhasil didaftarkan. Silakan cek email untuk verifikasi", + Email: req.Email, + ExpiresIn: utils.EMAIL_VERIFICATION_TOKEN_EXPIRY, + RemainingTime: formatDuration(utils.EMAIL_VERIFICATION_TOKEN_EXPIRY), + }, nil +} + +func formatDuration(d time.Duration) string { + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%d:%02d", minutes, seconds) +} + +func (s *authenticationService) SendRegistrationOTP(ctx context.Context, req *LoginorRegistRequest) (*OTPResponse, error) { + + 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, normalizedRole) + if err != nil { + return nil, fmt.Errorf("role tidak valid: %v", err) + } + + rateLimitKey := fmt.Sprintf("otp_limit:%s", req.Phone) + if isRateLimited(rateLimitKey, 3, 5*time.Minute) { + return nil, fmt.Errorf("terlalu banyak permintaan OTP, coba lagi dalam 5 menit") + } + + otp, err := utils.GenerateOTP() + if err != nil { + return nil, fmt.Errorf("gagal generate OTP: %v", err) + } + + otpKey := fmt.Sprintf("otp:%s:register", req.Phone) + otpData := OTPData{ + 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, 90*time.Second) + if err != nil { + return nil, fmt.Errorf("gagal menyimpan OTP: %v", err) + } + + err = sendOTP(req.Phone, otp) + if err != nil { + return nil, fmt.Errorf("gagal mengirim OTP: %v", err) + } + + return &OTPResponse{ + Message: "OTP berhasil dikirim", + 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) + if err != nil { + return nil, fmt.Errorf("OTP tidak ditemukan atau sudah kadaluarsa") + } + + if otpData.Attempts >= 3 { + utils.DeleteCache(otpKey) + return nil, fmt.Errorf("terlalu banyak percobaan, silakan minta OTP baru") + } + + if otpData.OTP != req.Otp { + otpData.Attempts++ + utils.SetCacheWithTTL(otpKey, otpData, time.Until(otpData.ExpiresAt)) + return nil, fmt.Errorf("kode OTP salah") + } + + if otpData.Role != req.RoleName { + 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: utils.ProgressOTPVerified, + Name: "", + Gender: "", + Dateofbirth: "", + Placeofbirth: "", + } + + err = s.authRepo.CreateUser(ctx, user) + if err != nil { + 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, + normalizedRole, + req.DeviceID, + user.RegistrationStatus, + int(user.RegistrationProgress), + ) + + if err != nil { + return nil, fmt.Errorf("gagal generate token: %v", err) + } + + 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, + RegistrationStatus: user.RegistrationStatus, + NextStep: nextStep, + SessionID: tokenResponse.SessionID, + }, nil +} + +func (s *authenticationService) SendLoginOTP(ctx context.Context, req *LoginorRegistRequest) (*OTPResponse, error) { + + user, err := s.authRepo.FindUserByPhone(ctx, req.Phone) + if err != nil { + return nil, fmt.Errorf("nomor telepon tidak terdaftar") + } + + if !user.PhoneVerified { + return nil, fmt.Errorf("nomor telepon belum diverifikasi") + } + + rateLimitKey := fmt.Sprintf("otp_limit:%s", req.Phone) + if isRateLimited(rateLimitKey, 3, 5*time.Minute) { + return nil, fmt.Errorf("terlalu banyak permintaan OTP, coba lagi dalam 5 menit") + } + + otp, err := utils.GenerateOTP() + if err != nil { + return nil, fmt.Errorf("gagal generate OTP: %v", err) + } + + otpKey := fmt.Sprintf("otp:%s:login", req.Phone) + otpData := OTPData{ + Phone: req.Phone, + OTP: otp, + UserID: user.ID, + Role: user.Role.RoleName, + Type: "login", + Attempts: 0, + } + + err = utils.SetCacheWithTTL(otpKey, otpData, 1*time.Minute) + if err != nil { + return nil, fmt.Errorf("gagal menyimpan OTP: %v", err) + } + + err = sendOTP(req.Phone, otp) + if err != nil { + return nil, fmt.Errorf("gagal mengirim OTP: %v", err) + } + + return &OTPResponse{ + Message: "OTP berhasil dikirim", + ExpiresIn: 300, + Phone: maskPhoneNumber(req.Phone), + }, nil +} + +func (s *authenticationService) VerifyLoginOTP(ctx context.Context, req *VerifyOtpRequest) (*AuthResponse, error) { + + otpKey := fmt.Sprintf("otp:%s:login", req.Phone) + var otpData OTPData + err := utils.GetCache(otpKey, &otpData) + if err != nil { + return nil, fmt.Errorf("OTP tidak ditemukan atau sudah kadaluarsa") + } + + if otpData.Attempts >= 3 { + utils.DeleteCache(otpKey) + return nil, fmt.Errorf("terlalu banyak percobaan, silakan minta OTP baru") + } + + if otpData.OTP != req.Otp { + otpData.Attempts++ + utils.SetCache(otpKey, otpData, time.Until(otpData.ExpiresAt)) + return nil, fmt.Errorf("kode OTP salah") + } + + normalizedRole := strings.ToLower(req.RoleName) + + user, err := s.authRepo.FindUserByPhoneAndRole(ctx, req.Phone, normalizedRole) + if err != nil { + return nil, fmt.Errorf("user tidak ditemukan") + } + + utils.DeleteCache(otpKey) + + tokenResponse, err := utils.GenerateTokenPair( + user.ID, + normalizedRole, + req.DeviceID, + user.RegistrationStatus, + int(user.RegistrationProgress), + ) + if err != nil { + return nil, fmt.Errorf("gagal generate token: %v", err) + } + + nextStep := utils.GetNextRegistrationStep( + normalizedRole, + int(user.RegistrationProgress), + user.RegistrationStatus, + ) + + var message string + if user.RegistrationStatus == utils.RegStatusComplete { + message = "verif pin" + nextStep = "verif_pin" + } else { + message = "otp berhasil diverifikasi" + } + + 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 +} + +func (s *authenticationService) LogoutAuthentication(ctx context.Context, userID, deviceID string) error { + if err := utils.RevokeRefreshToken(userID, deviceID); err != nil { + return fmt.Errorf("failed to revoke token: %w", err) + } + return nil +} + +func maskPhoneNumber(phone string) string { + if len(phone) < 4 { + return phone + } + return phone[:4] + strings.Repeat("*", len(phone)-8) + phone[len(phone)-4:] +} + +func isRateLimited(key string, maxAttempts int, duration time.Duration) bool { + var count int + err := utils.GetCache(key, &count) + if err != nil { + count = 0 + } + + if count >= maxAttempts { + return true + } + + count++ + utils.SetCache(key, count, duration) + return false +} + +func sendOTP(phone, otp string) error { + + fmt.Printf("Sending OTP %s to %s\n", otp, phone) + return nil +} + +// func convertUserToResponse(user *model.User) *UserResponse { +// return &UserResponse{ +// ID: user.ID, +// Name: user.Name, +// Phone: user.Phone, +// Email: user.Email, +// Role: user.Role.RoleName, +// RegistrationStatus: user.RegistrationStatus, +// RegistrationProgress: user.RegistrationProgress, +// PhoneVerified: user.PhoneVerified, +// Avatar: user.Avatar, +// } +// } + +func IsRegistrationComplete(role string, progress int) bool { + switch role { + case "masyarakat": + return progress >= 1 + case "pengepul": + return progress >= 2 + case "pengelola": + return progress >= 3 + } + return false +} diff --git a/internal/cart/cart_dto.go b/internal/cart/cart_dto.go new file mode 100644 index 0000000..2505092 --- /dev/null +++ b/internal/cart/cart_dto.go @@ -0,0 +1,49 @@ +package cart + +import ( + "fmt" + "strings" +) + +type RequestCartItemDTO struct { + TrashID string `json:"trash_id"` + Amount float64 `json:"amount"` +} + +type RequestCartDTO struct { + CartItems []RequestCartItemDTO `json:"cart_items"` +} + +type ResponseCartDTO struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TotalAmount float64 `json:"total_amount"` + EstimatedTotalPrice float64 `json:"estimated_total_price"` + CartItems []ResponseCartItemDTO `json:"cart_items"` +} + +type ResponseCartItemDTO struct { + ID string `json:"id"` + TrashID string `json:"trash_id"` + TrashName string `json:"trash_name"` + TrashIcon string `json:"trash_icon"` + TrashPrice float64 `json:"trash_price"` + Amount float64 `json:"amount"` + SubTotalEstimatedPrice float64 `json:"subtotal_estimated_price"` +} + +func (r *RequestCartDTO) ValidateRequestCartDTO() (map[string][]string, bool) { + errors := make(map[string][]string) + + for i, item := range r.CartItems { + if strings.TrimSpace(item.TrashID) == "" { + errors[fmt.Sprintf("cart_items[%d].trash_id", i)] = append(errors[fmt.Sprintf("cart_items[%d].trash_id", i)], "trash_id tidak boleh kosong") + } + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} diff --git a/internal/cart/cart_handler.go b/internal/cart/cart_handler.go new file mode 100644 index 0000000..a7ea250 --- /dev/null +++ b/internal/cart/cart_handler.go @@ -0,0 +1,91 @@ +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") +} \ No newline at end of file diff --git a/internal/cart/cart_redis.go b/internal/cart/cart_redis.go new file mode 100644 index 0000000..cd91d47 --- /dev/null +++ b/internal/cart/cart_redis.go @@ -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 +} diff --git a/internal/cart/cart_repository.go b/internal/cart/cart_repository.go new file mode 100644 index 0000000..f68d1b2 --- /dev/null +++ b/internal/cart/cart_repository.go @@ -0,0 +1,162 @@ +package cart + +import ( + "context" + "errors" + "fmt" + + "rijig/config" + "rijig/model" + + "gorm.io/gorm" +) + +type CartRepository interface { + FindOrCreateCart(ctx context.Context, userID string) (*model.Cart, error) + AddOrUpdateCartItem(ctx context.Context, cartID, trashCategoryID string, amount float64, estimatedPrice float64) error + DeleteCartItem(ctx context.Context, cartID, trashCategoryID string) error + GetCartByUser(ctx context.Context, userID string) (*model.Cart, error) + UpdateCartTotals(ctx context.Context, cartID string) error + DeleteCart(ctx context.Context, userID string) error + + CreateCartWithItems(ctx context.Context, cart *model.Cart) error + HasExistingCart(ctx context.Context, userID string) (bool, error) +} + +type cartRepository struct{} + +func NewCartRepository() CartRepository { + return &cartRepository{} +} + +func (r *cartRepository) FindOrCreateCart(ctx context.Context, userID string) (*model.Cart, error) { + var cart model.Cart + db := config.DB.WithContext(ctx) + + err := db. + Preload("CartItems.TrashCategory"). + Where("user_id = ?", userID). + First(&cart).Error + + if err == nil { + return &cart, nil + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + newCart := model.Cart{ + UserID: userID, + TotalAmount: 0, + EstimatedTotalPrice: 0, + } + if err := db.Create(&newCart).Error; err != nil { + return nil, err + } + return &newCart, nil + } + + return nil, err +} + +func (r *cartRepository) AddOrUpdateCartItem(ctx context.Context, cartID, trashCategoryID string, amount float64, estimatedPrice float64) error { + db := config.DB.WithContext(ctx) + + var item model.CartItem + err := db. + Where("cart_id = ? AND trash_category_id = ?", cartID, trashCategoryID). + First(&item).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + newItem := model.CartItem{ + CartID: cartID, + TrashCategoryID: trashCategoryID, + Amount: amount, + SubTotalEstimatedPrice: amount * estimatedPrice, + } + return db.Create(&newItem).Error + } + + if err != nil { + return err + } + + item.Amount = amount + item.SubTotalEstimatedPrice = amount * estimatedPrice + return db.Save(&item).Error +} + +func (r *cartRepository) DeleteCartItem(ctx context.Context, cartID, trashCategoryID string) error { + db := config.DB.WithContext(ctx) + return db.Where("cart_id = ? AND trash_category_id = ?", cartID, trashCategoryID). + Delete(&model.CartItem{}).Error +} + +func (r *cartRepository) GetCartByUser(ctx context.Context, userID string) (*model.Cart, error) { + var cart model.Cart + db := config.DB.WithContext(ctx) + + err := db. + Preload("CartItems.TrashCategory"). + Where("user_id = ?", userID). + First(&cart).Error + + if err != nil { + return nil, err + } + return &cart, nil +} + +func (r *cartRepository) UpdateCartTotals(ctx context.Context, cartID string) error { + db := config.DB.WithContext(ctx) + + var items []model.CartItem + if err := db.Where("cart_id = ?", cartID).Find(&items).Error; err != nil { + return err + } + + var totalAmount float64 + var totalPrice float64 + + for _, item := range items { + totalAmount += item.Amount + totalPrice += item.SubTotalEstimatedPrice + } + + return db.Model(&model.Cart{}). + Where("id = ?", cartID). + Updates(map[string]interface{}{ + "total_amount": totalAmount, + "estimated_total_price": totalPrice, + }).Error +} + +func (r *cartRepository) DeleteCart(ctx context.Context, userID string) error { + db := config.DB.WithContext(ctx) + var cart model.Cart + if err := db.Where("user_id = ?", userID).First(&cart).Error; err != nil { + return err + } + return db.Delete(&cart).Error +} + +func (r *cartRepository) CreateCartWithItems(ctx context.Context, cart *model.Cart) error { + db := config.DB.WithContext(ctx) + + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(cart).Error; err != nil { + return fmt.Errorf("failed to create cart: %w", err) + } + return nil + }) +} + +func (r *cartRepository) HasExistingCart(ctx context.Context, userID string) (bool, error) { + db := config.DB.WithContext(ctx) + + var count int64 + err := db.Model(&model.Cart{}).Where("user_id = ?", userID).Count(&count).Error + if err != nil { + return false, err + } + + return count > 0, nil +} diff --git a/internal/cart/cart_route.go b/internal/cart/cart_route.go new file mode 100644 index 0000000..76bbc2b --- /dev/null +++ b/internal/cart/cart_route.go @@ -0,0 +1,24 @@ +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) +} diff --git a/internal/cart/cart_service.go b/internal/cart/cart_service.go new file mode 100644 index 0000000..ab2eb6a --- /dev/null +++ b/internal/cart/cart_service.go @@ -0,0 +1,267 @@ +package cart + +import ( + "context" + "errors" + "log" + + // "rijig/dto" + // "rijig/internal/repositories" + "rijig/internal/trash" + "rijig/model" +) + +type CartService interface { + 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 +} + +func NewCartService(repo CartRepository, trashRepo trash.TrashRepositoryInterface) CartService { + return &cartService{repo, trashRepo} +} + +func (s *cartService) AddOrUpdateItem(ctx context.Context, userID string, req RequestCartItemDTO) error { + if req.Amount <= 0 { + return errors.New("amount harus lebih dari 0") + } + + _, err := s.trashRepo.GetTrashCategoryByID(ctx, req.TrashID) + if err != nil { + return err + } + + existingCart, err := GetCartFromRedis(ctx, userID) + if err != nil { + return err + } + + if existingCart == nil { + existingCart = &RequestCartDTO{ + CartItems: []RequestCartItemDTO{}, + } + } + + updated := false + for i, item := range existingCart.CartItems { + if item.TrashID == req.TrashID { + existingCart.CartItems[i].Amount = req.Amount + updated = true + break + } + } + + if !updated { + existingCart.CartItems = append(existingCart.CartItems, RequestCartItemDTO{ + TrashID: req.TrashID, + Amount: req.Amount, + }) + } + + return SetCartToRedis(ctx, userID, *existingCart) +} + +func (s *cartService) GetCart(ctx context.Context, userID string) (*ResponseCartDTO, error) { + + cached, err := GetCartFromRedis(ctx, userID) + if err != nil { + return nil, err + } + + if cached != nil { + + if err := RefreshCartTTL(ctx, userID); err != nil { + log.Printf("Warning: Failed to refresh cart TTL for user %s: %v", userID, err) + } + + return s.buildResponseFromCache(ctx, userID, cached) + } + + cart, err := s.repo.GetCartByUser(ctx, userID) + if err != nil { + + return &ResponseCartDTO{ + ID: "", + UserID: userID, + TotalAmount: 0, + EstimatedTotalPrice: 0, + CartItems: []ResponseCartItemDTO{}, + }, nil + + } + + response := s.buildResponseFromDB(cart) + + 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, + TrashID: item.TrashCategoryID, + TrashName: item.TrashCategory.Name, + TrashIcon: item.TrashCategory.IconTrash, + TrashPrice: item.TrashCategory.EstimatedPrice, + Amount: item.Amount, + SubTotalEstimatedPrice: item.SubTotalEstimatedPrice, + }) + } + + return &ResponseCartDTO{ + ID: cart.ID, + UserID: cart.UserID, + TotalAmount: cart.TotalAmount, + EstimatedTotalPrice: cart.EstimatedTotalPrice, + CartItems: items, + } +} + +func (s *cartService) commitCartFromRedis(ctx context.Context, userID string, cachedCart *RequestCartDTO) error { + if len(cachedCart.CartItems) == 0 { + return nil + } + + totalAmount := 0.0 + totalPrice := 0.0 + var cartItems []model.CartItem + + 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, + SubTotalEstimatedPrice: subtotal, + }) + } + + if len(cartItems) == 0 { + return nil + } + + newCart := &model.Cart{ + UserID: userID, + TotalAmount: totalAmount, + EstimatedTotalPrice: totalPrice, + CartItems: cartItems, + } + + return s.repo.CreateCartWithItems(ctx, newCart) +} diff --git a/internal/chat/model/chat_model.go b/internal/chat/model/chat_model.go new file mode 100644 index 0000000..0c3b4f2 --- /dev/null +++ b/internal/chat/model/chat_model.go @@ -0,0 +1 @@ +package model \ No newline at end of file diff --git a/internal/collector/collector_dto.go b/internal/collector/collector_dto.go new file mode 100644 index 0000000..955cd39 --- /dev/null +++ b/internal/collector/collector_dto.go @@ -0,0 +1,266 @@ +package collector + +import ( + "fmt" + "rijig/internal/address" + "rijig/internal/trash" + "strings" + "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 { + UserID string `json:"user_id" binding:"required"` + JobStatus string `json:"job_status,omitempty"` + AddressID string `json:"address_id" binding:"required"` + AvailableTrashItems []CreateAvailableTrashRequest `json:"available_trash_items,omitempty"` +} + +type UpdateCollectorRequest struct { + JobStatus string `json:"job_status,omitempty"` + AddressID string `json:"address_id,omitempty"` + AvailableTrashItems []CreateAvailableTrashRequest `json:"available_trash_items,omitempty"` +} + +type CreateAvailableTrashRequest struct { + TrashCategoryID string `json:"trash_category_id" binding:"required"` + Price float32 `json:"price" binding:"required"` +} + +type UpdateAvailableTrashRequest struct { + ID string `json:"id,omitempty"` + TrashCategoryID string `json:"trash_category_id,omitempty"` + Price float32 `json:"price,omitempty"` +} + +type CollectorResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + JobStatus string `json:"job_status"` + Rating float32 `json:"rating"` + AddressID string `json:"address_id"` + Address *address.AddressResponseDTO `json:"address,omitempty"` + AvailableTrash []AvailableTrashResponse `json:"available_trash"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type AvailableTrashResponse struct { + ID string `json:"id"` + CollectorID string `json:"collector_id"` + TrashCategoryID string `json:"trash_category_id"` + TrashCategory *trash.ResponseTrashCategoryDTO `json:"trash_category,omitempty"` + Price float32 `json:"price"` +} + +func (r *CreateCollectorRequest) ValidateCreateCollectorRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.UserID) == "" { + errors["user_id"] = append(errors["user_id"], "User ID tidak boleh kosong") + } + + if strings.TrimSpace(r.AddressID) == "" { + errors["address_id"] = append(errors["address_id"], "Address ID tidak boleh kosong") + } + + if r.JobStatus != "" { + r.JobStatus = strings.ToLower(strings.TrimSpace(r.JobStatus)) + if r.JobStatus != "active" && r.JobStatus != "inactive" { + errors["job_status"] = append(errors["job_status"], "Job status hanya boleh 'active' atau 'inactive'") + } + } else { + r.JobStatus = "inactive" + } + + if len(r.AvailableTrashItems) > 0 { + trashCategoryMap := make(map[string]bool) + for i, item := range r.AvailableTrashItems { + fieldPrefix := fmt.Sprintf("available_trash_items[%d]", i) + + if strings.TrimSpace(item.TrashCategoryID) == "" { + errors[fieldPrefix+".trash_category_id"] = append(errors[fieldPrefix+".trash_category_id"], "Trash category ID tidak boleh kosong") + } else { + + if trashCategoryMap[item.TrashCategoryID] { + errors[fieldPrefix+".trash_category_id"] = append(errors[fieldPrefix+".trash_category_id"], "Trash category ID sudah ada dalam daftar") + } else { + trashCategoryMap[item.TrashCategoryID] = true + } + } + + if item.Price <= 0 { + errors[fieldPrefix+".price"] = append(errors[fieldPrefix+".price"], "Harga harus lebih dari 0") + } + } + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +func (r *UpdateCollectorRequest) ValidateUpdateCollectorRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if r.JobStatus != "" { + r.JobStatus = strings.ToLower(strings.TrimSpace(r.JobStatus)) + if r.JobStatus != "active" && r.JobStatus != "inactive" { + errors["job_status"] = append(errors["job_status"], "Job status hanya boleh 'active' atau 'inactive'") + } + } + + if r.AddressID != "" && strings.TrimSpace(r.AddressID) == "" { + errors["address_id"] = append(errors["address_id"], "Address ID tidak boleh kosong jika disediakan") + } + + if len(r.AvailableTrashItems) > 0 { + trashCategoryMap := make(map[string]bool) + for i, item := range r.AvailableTrashItems { + fieldPrefix := fmt.Sprintf("available_trash_items[%d]", i) + + if strings.TrimSpace(item.TrashCategoryID) == "" { + errors[fieldPrefix+".trash_category_id"] = append(errors[fieldPrefix+".trash_category_id"], "Trash category ID tidak boleh kosong") + } else { + + if trashCategoryMap[item.TrashCategoryID] { + errors[fieldPrefix+".trash_category_id"] = append(errors[fieldPrefix+".trash_category_id"], "Trash category ID sudah ada dalam daftar") + } else { + trashCategoryMap[item.TrashCategoryID] = true + } + } + + if item.Price <= 0 { + errors[fieldPrefix+".price"] = append(errors[fieldPrefix+".price"], "Harga harus lebih dari 0") + } + } + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +func (r *CreateAvailableTrashRequest) ValidateCreateAvailableTrashRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.TrashCategoryID) == "" { + errors["trash_category_id"] = append(errors["trash_category_id"], "Trash category ID tidak boleh kosong") + } + + if r.Price <= 0 { + errors["price"] = append(errors["price"], "Harga harus lebih dari 0") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +func (r *UpdateAvailableTrashRequest) ValidateUpdateAvailableTrashRequest() (map[string][]string, bool) { + errors := make(map[string][]string) + + if r.TrashCategoryID != "" && strings.TrimSpace(r.TrashCategoryID) == "" { + errors["trash_category_id"] = append(errors["trash_category_id"], "Trash category ID tidak boleh kosong jika disediakan") + } + + if r.Price != 0 && r.Price <= 0 { + errors["price"] = append(errors["price"], "Harga harus lebih dari 0 jika disediakan") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +func (r *CreateCollectorRequest) IsValidJobStatus(status string) bool { + status = strings.ToLower(strings.TrimSpace(status)) + return status == "active" || status == "inactive" +} + +func (r *UpdateCollectorRequest) IsValidJobStatus(status string) bool { + status = strings.ToLower(strings.TrimSpace(status)) + return status == "active" || status == "inactive" +} + +func (r *CollectorResponse) FormatTimestamp(t time.Time) string { + return t.Format(time.RFC3339) +} + +func (r *CreateCollectorRequest) SetDefaults() { + if r.JobStatus == "" { + r.JobStatus = "inactive" + } else { + r.JobStatus = strings.ToLower(strings.TrimSpace(r.JobStatus)) + } +} + +func (r *UpdateCollectorRequest) NormalizeJobStatus() { + if r.JobStatus != "" { + r.JobStatus = strings.ToLower(strings.TrimSpace(r.JobStatus)) + } +} + +type BulkUpdateAvailableTrashRequest struct { + CollectorID string `json:"collector_id" binding:"required"` + AvailableTrashItems []CreateAvailableTrashRequest `json:"available_trash_items" binding:"required"` +} + +func (r *BulkUpdateAvailableTrashRequest) ValidateBulkUpdateAvailableTrashRequest() (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(r.AvailableTrashItems) == 0 { + errors["available_trash_items"] = append(errors["available_trash_items"], "Minimal harus ada 1 item sampah") + } else { + trashCategoryMap := make(map[string]bool) + for i, item := range r.AvailableTrashItems { + fieldPrefix := fmt.Sprintf("available_trash_items[%d]", i) + + if strings.TrimSpace(item.TrashCategoryID) == "" { + errors[fieldPrefix+".trash_category_id"] = append(errors[fieldPrefix+".trash_category_id"], "Trash category ID tidak boleh kosong") + } else { + + if trashCategoryMap[item.TrashCategoryID] { + errors[fieldPrefix+".trash_category_id"] = append(errors[fieldPrefix+".trash_category_id"], "Trash category ID sudah ada dalam daftar") + } else { + trashCategoryMap[item.TrashCategoryID] = true + } + } + + if item.Price <= 0 { + errors[fieldPrefix+".price"] = append(errors[fieldPrefix+".price"], "Harga harus lebih dari 0") + } + } + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} diff --git a/internal/collector/collector_handler.go b/internal/collector/collector_handler.go new file mode 100644 index 0000000..d9a23b1 --- /dev/null +++ b/internal/collector/collector_handler.go @@ -0,0 +1,360 @@ +package collector + +import ( + "rijig/middleware" + "rijig/utils" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type CollectorHandler struct { + collectorService CollectorService +} + +func NewCollectorHandler(collectorService CollectorService) *CollectorHandler { + return &CollectorHandler{ + collectorService: collectorService, + } +} + +func (h *CollectorHandler) CreateCollector(c *fiber.Ctx) error { + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + var req CreateCollectorRequest + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + errors, isValid := req.ValidateCreateCollectorRequest() + if !isValid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + req.SetDefaults() + + collector, err := h.collectorService.CreateCollector(c.Context(), &req, claims.UserID) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to create collector") + } + + return utils.CreateSuccessWithData(c, "Collector created successfully", collector) +} + +func (h *CollectorHandler) GetCollectorByID(c *fiber.Ctx) error { + id := c.Params("id") + if strings.TrimSpace(id) == "" { + return utils.BadRequest(c, "Collector ID is required") + } + + collector, err := h.collectorService.GetCollectorByID(c.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found") + } + return utils.InternalServerError(c, "Failed to get collector") + } + + return utils.SuccessWithData(c, "Collector retrieved successfully", collector) +} + +func (h *CollectorHandler) GetCollectorByUserID(c *fiber.Ctx) error { + // userID := c.Params("userID") + // if strings.TrimSpace(userID) == "" { + // return utils.BadRequest(c, "User ID is required") + // } + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + collector, err := h.collectorService.GetCollectorByUserID(c.Context(), claims.UserID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found for this user") + } + return utils.InternalServerError(c, "Failed to get collector") + } + + return utils.SuccessWithData(c, "Collector retrieved successfully", collector) +} + +func (h *CollectorHandler) UpdateCollector(c *fiber.Ctx) error { + /* id := c.Params("id") + if strings.TrimSpace(id) == "" { + return utils.BadRequest(c, "Collector ID is required") + } */ + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + var req UpdateCollectorRequest + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + errors, isValid := req.ValidateUpdateCollectorRequest() + if !isValid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + req.NormalizeJobStatus() + + collector, err := h.collectorService.UpdateCollector(c.Context(), claims.UserID, &req) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found") + } + return utils.InternalServerError(c, "Failed to update collector") + } + + return utils.SuccessWithData(c, "Collector updated successfully", collector) +} + +func (h *CollectorHandler) DeleteCollector(c *fiber.Ctx) error { + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + err = h.collectorService.DeleteCollector(c.Context(), claims.UserID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found") + } + return utils.InternalServerError(c, "Failed to delete collector") + } + + return utils.Success(c, "Collector deleted successfully") +} + +func (h *CollectorHandler) ListCollectors(c *fiber.Ctx) error { + + limit, offset, page := h.parsePaginationParams(c) + + collectors, total, err := h.collectorService.ListCollectors(c.Context(), limit, offset) + if err != nil { + return utils.InternalServerError(c, "Failed to get collectors") + } + + responseData := map[string]interface{}{ + "collectors": collectors, + "total": total, + } + + return utils.SuccessWithPagination(c, "Collectors retrieved successfully", responseData, page, limit) +} + +func (h *CollectorHandler) GetActiveCollectors(c *fiber.Ctx) error { + + limit, offset, page := h.parsePaginationParams(c) + + collectors, total, err := h.collectorService.GetActiveCollectors(c.Context(), limit, offset) + if err != nil { + return utils.InternalServerError(c, "Failed to get active collectors") + } + + responseData := map[string]interface{}{ + "collectors": collectors, + "total": total, + } + + return utils.SuccessWithPagination(c, "Active collectors retrieved successfully", responseData, page, limit) +} + +func (h *CollectorHandler) GetCollectorsByAddress(c *fiber.Ctx) error { + addressID := c.Params("addressID") + if strings.TrimSpace(addressID) == "" { + return utils.BadRequest(c, "Address ID is required") + } + + limit, offset, page := h.parsePaginationParams(c) + + collectors, total, err := h.collectorService.GetCollectorsByAddress(c.Context(), addressID, limit, offset) + if err != nil { + return utils.InternalServerError(c, "Failed to get collectors by address") + } + + responseData := map[string]interface{}{ + "collectors": collectors, + "total": total, + "address_id": addressID, + } + + return utils.SuccessWithPagination(c, "Collectors by address retrieved successfully", responseData, page, limit) +} + +func (h *CollectorHandler) GetCollectorsByTrashCategory(c *fiber.Ctx) error { + trashCategoryID := c.Params("trashCategoryID") + if strings.TrimSpace(trashCategoryID) == "" { + return utils.BadRequest(c, "Trash category ID is required") + } + + limit, offset, page := h.parsePaginationParams(c) + + collectors, total, err := h.collectorService.GetCollectorsByTrashCategory(c.Context(), trashCategoryID, limit, offset) + if err != nil { + return utils.InternalServerError(c, "Failed to get collectors by trash category") + } + + responseData := map[string]interface{}{ + "collectors": collectors, + "total": total, + "trash_category_id": trashCategoryID, + } + + return utils.SuccessWithPagination(c, "Collectors by trash category retrieved successfully", responseData, page, limit) +} + +func (h *CollectorHandler) UpdateJobStatus(c *fiber.Ctx) error { + id := c.Params("id") + if strings.TrimSpace(id) == "" { + return utils.BadRequest(c, "Collector ID is required") + } + + var req struct { + JobStatus string `json:"job_status" binding:"required"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + if strings.TrimSpace(req.JobStatus) == "" { + return utils.BadRequest(c, "Job status is required") + } + + jobStatus := strings.ToLower(strings.TrimSpace(req.JobStatus)) + validStatuses := []string{"active", "inactive", "busy"} + if !h.isValidJobStatus(jobStatus, validStatuses) { + return utils.BadRequest(c, "Invalid job status. Valid statuses: active, inactive, busy") + } + + err := h.collectorService.UpdateJobStatus(c.Context(), id, jobStatus) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found") + } + return utils.InternalServerError(c, "Failed to update job status") + } + + return utils.Success(c, "Job status updated successfully") +} + +func (h *CollectorHandler) UpdateRating(c *fiber.Ctx) error { + id := c.Params("id") + if strings.TrimSpace(id) == "" { + return utils.BadRequest(c, "Collector ID is required") + } + + var req struct { + Rating float32 `json:"rating" binding:"required"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + if req.Rating < 1.0 || req.Rating > 5.0 { + return utils.BadRequest(c, "Rating must be between 1.0 and 5.0") + } + + err := h.collectorService.UpdateRating(c.Context(), id, req.Rating) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found") + } + return utils.InternalServerError(c, "Failed to update rating") + } + + return utils.Success(c, "Rating updated successfully") +} + +func (h *CollectorHandler) UpdateAvailableTrash(c *fiber.Ctx) error { + id := c.Params("id") + if strings.TrimSpace(id) == "" { + return utils.BadRequest(c, "Collector ID is required") + } + + var req BulkUpdateAvailableTrashRequest + req.CollectorID = id + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + errors, isValid := req.ValidateBulkUpdateAvailableTrashRequest() + if !isValid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", errors) + } + + err := h.collectorService.UpdateAvailableTrash(c.Context(), id, req.AvailableTrashItems) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Collector not found") + } + return utils.InternalServerError(c, "Failed to update available trash") + } + + return utils.Success(c, "Available trash updated successfully") +} + +func (h *CollectorHandler) parsePaginationParams(c *fiber.Ctx) (limit, offset, page int) { + + limitStr := c.Query("limit", "10") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + + pageStr := c.Query("page", "1") + page, err = strconv.Atoi(pageStr) + if err != nil || page <= 0 { + page = 1 + } + + offset = (page - 1) * limit + + return limit, offset, page +} + +func (h *CollectorHandler) isValidJobStatus(status string, validStatuses []string) bool { + for _, validStatus := range validStatuses { + if status == validStatus { + return true + } + } + return false +} + +func (h *CollectorHandler) RegisterRoutes(app *fiber.App) { + + collectors := app.Group("/api/v1/collectors") + + collectors.Post("/", h.CreateCollector) + collectors.Get("/:id", h.GetCollectorByID) + collectors.Put("/:id", h.UpdateCollector) + collectors.Delete("/:id", h.DeleteCollector) + + collectors.Get("/", h.ListCollectors) + collectors.Get("/active", h.GetActiveCollectors) + collectors.Get("/user/:userID", h.GetCollectorByUserID) + collectors.Get("/address/:addressID", h.GetCollectorsByAddress) + collectors.Get("/trash-category/:trashCategoryID", h.GetCollectorsByTrashCategory) + + collectors.Patch("/:id/job-status", h.UpdateJobStatus) + collectors.Patch("/:id/rating", h.UpdateRating) + collectors.Put("/:id/available-trash", h.UpdateAvailableTrash) +} diff --git a/internal/collector/collector_repository.go b/internal/collector/collector_repository.go new file mode 100644 index 0000000..5f9dbc7 --- /dev/null +++ b/internal/collector/collector_repository.go @@ -0,0 +1,370 @@ +package collector + +import ( + "context" + "fmt" + "rijig/model" + + "gorm.io/gorm" +) + +type CollectorRepository interface { + Create(ctx context.Context, collector *model.Collector) error + GetByID(ctx context.Context, id string) (*model.Collector, error) + GetByUserID(ctx context.Context, userID string) (*model.Collector, error) + Update(ctx context.Context, collector *model.Collector) error + Delete(ctx context.Context, UserID string) error + List(ctx context.Context, limit, offset int) ([]*model.Collector, int64, error) + + GetActiveCollectors(ctx context.Context, limit, offset int) ([]*model.Collector, int64, error) + GetCollectorsByAddress(ctx context.Context, addressID string, limit, offset int) ([]*model.Collector, int64, error) + GetCollectorsByTrashCategory(ctx context.Context, trashCategoryID string, limit, offset int) ([]*model.Collector, int64, error) + UpdateJobStatus(ctx context.Context, id string, jobStatus string) error + UpdateRating(ctx context.Context, id string, rating float32) error + + CreateAvailableTrash(ctx context.Context, availableTrash *model.AvaibleTrashByCollector) error + GetAvailableTrashByCollectorID(ctx context.Context, collectorID string) ([]*model.AvaibleTrashByCollector, error) + UpdateAvailableTrash(ctx context.Context, availableTrash *model.AvaibleTrashByCollector) error + DeleteAvailableTrash(ctx context.Context, id string) error + BulkCreateAvailableTrash(ctx context.Context, availableTrashList []*model.AvaibleTrashByCollector) error + BulkUpdateAvailableTrash(ctx context.Context, collectorID string, availableTrashList []*model.AvaibleTrashByCollector) 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 +} + +type collectorRepository struct { + db *gorm.DB +} + +func NewCollectorRepository(db *gorm.DB) CollectorRepository { + return &collectorRepository{ + db: db, + } +} + +func (r *collectorRepository) WithTx(tx *gorm.DB) CollectorRepository { + return &collectorRepository{ + db: tx, + } +} + +func (r *collectorRepository) Create(ctx context.Context, collector *model.Collector) error { + if err := r.db.WithContext(ctx).Create(collector).Error; err != nil { + return fmt.Errorf("failed to create collector: %w", err) + } + 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) { + var collector model.Collector + + err := r.db.WithContext(ctx). + Preload("Address"). + Preload("AvaibleTrashByCollector"). + Preload("AvaibleTrashByCollector.TrashCategory"). + Where("id = ?", id). + First(&collector).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("collector with id %s not found", id) + } + return nil, fmt.Errorf("failed to get collector by id: %w", err) + } + + return &collector, nil +} + +func (r *collectorRepository) GetByUserID(ctx context.Context, userID string) (*model.Collector, error) { + var collector model.Collector + + err := r.db.WithContext(ctx). + Preload("Address"). + Preload("AvaibleTrashByCollector"). + Preload("AvaibleTrashByCollector.TrashCategory"). + Where("user_id = ?", userID). + First(&collector).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("collector with user_id %s not found", userID) + } + return nil, fmt.Errorf("failed to get collector by user_id: %w", err) + } + + return &collector, nil +} + +func (r *collectorRepository) Update(ctx context.Context, collector *model.Collector) error { + if err := r.db.WithContext(ctx).Save(collector).Error; err != nil { + return fmt.Errorf("failed to update collector: %w", err) + } + return nil +} + +func (r *collectorRepository) Delete(ctx context.Context, UserID string) error { + result := r.db.WithContext(ctx).Delete(&model.Collector{}, "user_id = ?", UserID) + if result.Error != nil { + return fmt.Errorf("failed to delete collector: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("collector with user_id %s not found", UserID) + } + + return nil +} + +func (r *collectorRepository) List(ctx context.Context, limit, offset int) ([]*model.Collector, int64, error) { + var collectors []*model.Collector + var total int64 + + if err := r.db.WithContext(ctx).Model(&model.Collector{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count collectors: %w", err) + } + + err := r.db.WithContext(ctx). + Preload("Address"). + Preload("AvaibleTrashByCollector"). + Preload("AvaibleTrashByCollector.TrashCategory"). + Limit(limit). + Offset(offset). + Find(&collectors).Error + + if err != nil { + return nil, 0, fmt.Errorf("failed to list collectors: %w", err) + } + + return collectors, total, nil +} + +func (r *collectorRepository) GetActiveCollectors(ctx context.Context, limit, offset int) ([]*model.Collector, int64, error) { + var collectors []*model.Collector + var total int64 + + query := r.db.WithContext(ctx).Where("job_status = ?", "active") + + if err := query.Model(&model.Collector{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count active collectors: %w", err) + } + + err := query. + Preload("Address"). + Preload("AvaibleTrashByCollector"). + Preload("AvaibleTrashByCollector.TrashCategory"). + Limit(limit). + Offset(offset). + Find(&collectors).Error + + if err != nil { + return nil, 0, fmt.Errorf("failed to get active collectors: %w", err) + } + + return collectors, total, nil +} + +func (r *collectorRepository) GetCollectorsByAddress(ctx context.Context, addressID string, limit, offset int) ([]*model.Collector, int64, error) { + var collectors []*model.Collector + var total int64 + + query := r.db.WithContext(ctx).Where("address_id = ?", addressID) + + if err := query.Model(&model.Collector{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count collectors by address: %w", err) + } + + err := query. + Preload("Address"). + Preload("AvaibleTrashByCollector"). + Preload("AvaibleTrashByCollector.TrashCategory"). + Limit(limit). + Offset(offset). + Find(&collectors).Error + + if err != nil { + return nil, 0, fmt.Errorf("failed to get collectors by address: %w", err) + } + + return collectors, total, nil +} + +func (r *collectorRepository) GetCollectorsByTrashCategory(ctx context.Context, trashCategoryID string, limit, offset int) ([]*model.Collector, int64, error) { + var collectors []*model.Collector + var total int64 + + subQuery := r.db.WithContext(ctx). + Table("avaible_trash_by_collectors"). + Select("collector_id"). + Where("trash_category_id = ?", trashCategoryID) + + query := r.db.WithContext(ctx). + Where("id IN (?)", subQuery) + + if err := query.Model(&model.Collector{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count collectors by trash category: %w", err) + } + + err := query. + Preload("Address"). + Preload("AvaibleTrashByCollector"). + Preload("AvaibleTrashByCollector.TrashCategory"). + Limit(limit). + Offset(offset). + Find(&collectors).Error + + if err != nil { + return nil, 0, fmt.Errorf("failed to get collectors by trash category: %w", err) + } + + return collectors, total, nil +} + +func (r *collectorRepository) UpdateJobStatus(ctx context.Context, id string, jobStatus string) error { + result := r.db.WithContext(ctx). + Model(&model.Collector{}). + Where("id = ?", id). + Update("job_status", jobStatus) + + if result.Error != nil { + return fmt.Errorf("failed to update job status: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("collector with id %s not found", id) + } + + return nil +} + +func (r *collectorRepository) UpdateRating(ctx context.Context, id string, rating float32) error { + result := r.db.WithContext(ctx). + Model(&model.Collector{}). + Where("id = ?", id). + Update("rating", rating) + + if result.Error != nil { + return fmt.Errorf("failed to update rating: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("collector with id %s not found", id) + } + + return nil +} + +func (r *collectorRepository) CreateAvailableTrash(ctx context.Context, availableTrash *model.AvaibleTrashByCollector) error { + if err := r.db.WithContext(ctx).Create(availableTrash).Error; err != nil { + return fmt.Errorf("failed to create available trash: %w", err) + } + return nil +} + +func (r *collectorRepository) GetAvailableTrashByCollectorID(ctx context.Context, collectorID string) ([]*model.AvaibleTrashByCollector, error) { + var availableTrash []*model.AvaibleTrashByCollector + + err := r.db.WithContext(ctx). + Preload("TrashCategory"). + Where("collector_id = ?", collectorID). + Find(&availableTrash).Error + + if err != nil { + return nil, fmt.Errorf("failed to get available trash by collector id: %w", err) + } + + return availableTrash, nil +} + +func (r *collectorRepository) UpdateAvailableTrash(ctx context.Context, availableTrash *model.AvaibleTrashByCollector) error { + if err := r.db.WithContext(ctx).Save(availableTrash).Error; err != nil { + return fmt.Errorf("failed to update available trash: %w", err) + } + return nil +} + +func (r *collectorRepository) DeleteAvailableTrash(ctx context.Context, id string) error { + result := r.db.WithContext(ctx).Delete(&model.AvaibleTrashByCollector{}, "id = ?", id) + if result.Error != nil { + return fmt.Errorf("failed to delete available trash: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("available trash with id %s not found", id) + } + + return nil +} + +func (r *collectorRepository) BulkCreateAvailableTrash(ctx context.Context, availableTrashList []*model.AvaibleTrashByCollector) error { + if len(availableTrashList) == 0 { + return nil + } + + if err := r.db.WithContext(ctx).CreateInBatches(availableTrashList, 100).Error; err != nil { + return fmt.Errorf("failed to bulk create available trash: %w", err) + } + + return nil +} + +func (r *collectorRepository) BulkUpdateAvailableTrash(ctx context.Context, collectorID string, availableTrashList []*model.AvaibleTrashByCollector) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + + if err := tx.Where("collector_id = ?", collectorID).Delete(&model.AvaibleTrashByCollector{}).Error; err != nil { + return fmt.Errorf("failed to delete existing available trash: %w", err) + } + + if len(availableTrashList) > 0 { + for _, item := range availableTrashList { + item.CollectorID = collectorID + } + + if err := tx.CreateInBatches(availableTrashList, 100).Error; err != nil { + return fmt.Errorf("failed to create new available trash: %w", err) + } + } + + return nil + }) +} + +func (r *collectorRepository) DeleteAvailableTrashByCollectorID(ctx context.Context, collectorID string) error { + if err := r.db.WithContext(ctx).Where("collector_id = ?", collectorID).Delete(&model.AvaibleTrashByCollector{}).Error; err != nil { + return fmt.Errorf("failed to delete available trash by collector id: %w", err) + } + return nil +} diff --git a/internal/collector/collector_route.go b/internal/collector/collector_route.go new file mode 100644 index 0000000..c87b2bd --- /dev/null +++ b/internal/collector/collector_route.go @@ -0,0 +1 @@ +package collector \ No newline at end of file diff --git a/internal/collector/collector_service.go b/internal/collector/collector_service.go new file mode 100644 index 0000000..b1776ea --- /dev/null +++ b/internal/collector/collector_service.go @@ -0,0 +1,349 @@ +package collector + +import ( + "context" + "fmt" + "rijig/internal/address" + "rijig/internal/trash" + "rijig/model" + "strings" + "time" + + "gorm.io/gorm" +) + +type CollectorService interface { + CreateCollector(ctx context.Context, req *CreateCollectorRequest, UserID string) (*CollectorResponse, error) + GetCollectorByID(ctx context.Context, id string) (*CollectorResponse, error) + GetCollectorByUserID(ctx context.Context, userID string) (*CollectorResponse, error) + UpdateCollector(ctx context.Context, UserID string, req *UpdateCollectorRequest) (*CollectorResponse, error) + DeleteCollector(ctx context.Context, UserID string) error + ListCollectors(ctx context.Context, limit, offset int) ([]*CollectorResponse, int64, error) + + GetActiveCollectors(ctx context.Context, limit, offset int) ([]*CollectorResponse, int64, error) + GetCollectorsByAddress(ctx context.Context, addressID string, limit, offset int) ([]*CollectorResponse, int64, error) + GetCollectorsByTrashCategory(ctx context.Context, trashCategoryID string, limit, offset int) ([]*CollectorResponse, int64, error) + + UpdateJobStatus(ctx context.Context, id string, jobStatus string) error + UpdateRating(ctx context.Context, id string, rating float32) error + UpdateAvailableTrash(ctx context.Context, collectorID string, availableTrashItems []CreateAvailableTrashRequest) error +} + +type collectorService struct { + collectorRepo CollectorRepository + db *gorm.DB +} + +func NewCollectorService(collectorRepo CollectorRepository, db *gorm.DB) CollectorService { + return &collectorService{ + collectorRepo: collectorRepo, + db: db, + } +} + +func (s *collectorService) CreateCollector(ctx context.Context, req *CreateCollectorRequest, UserID string) (*CollectorResponse, error) { + + existingCollector, err := s.collectorRepo.GetByUserID(ctx, UserID) + if err != nil && !strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("failed to check existing collector: %w", err) + } + if existingCollector != nil { + return nil, fmt.Errorf("collector already exists for user_id: %s", req.UserID) + } + + collector := &model.Collector{ + UserID: UserID, + JobStatus: "inactive", + AddressID: req.AddressID, + Rating: 5.0, + } + + if req.JobStatus != "" { + collector.JobStatus = req.JobStatus + } + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + collectorRepoTx := s.collectorRepo.WithTx(tx) + + if err := collectorRepoTx.Create(ctx, collector); err != nil { + return fmt.Errorf("failed to create collector: %w", err) + } + + if len(req.AvailableTrashItems) > 0 { + availableTrashList := s.buildAvailableTrashList(collector.ID, req.AvailableTrashItems) + if err := collectorRepoTx.BulkCreateAvailableTrash(ctx, availableTrashList); err != nil { + return fmt.Errorf("failed to create available trash items: %w", err) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + createdCollector, err := s.collectorRepo.GetByID(ctx, collector.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch created collector: %w", err) + } + + return s.toCollectorResponse(createdCollector), nil +} + +func (s *collectorService) GetCollectorByID(ctx context.Context, id string) (*CollectorResponse, error) { + collector, err := s.collectorRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return s.toCollectorResponse(collector), nil +} + +func (s *collectorService) GetCollectorByUserID(ctx context.Context, userID string) (*CollectorResponse, error) { + collector, err := s.collectorRepo.GetByUserID(ctx, userID) + if err != nil { + return nil, err + } + + return s.toCollectorResponse(collector), nil +} + +func (s *collectorService) UpdateCollector(ctx context.Context, UserID string, req *UpdateCollectorRequest) (*CollectorResponse, error) { + + collector, err := s.collectorRepo.GetByUserID(ctx, UserID) + if err != nil { + return nil, fmt.Errorf("failed to get collector: %w", err) + } + + needsUpdate := s.checkCollectorNeedsUpdate(collector, req) + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + collectorRepoTx := s.collectorRepo.WithTx(tx) + + if needsUpdate { + s.applyCollectorUpdates(collector, req) + collector.UpdatedAt = time.Now() + + if err := collectorRepoTx.Update(ctx, collector); err != nil { + return fmt.Errorf("failed to update collector: %w", err) + } + } + + if len(req.AvailableTrashItems) > 0 { + availableTrashList := s.buildAvailableTrashList(collector.ID, req.AvailableTrashItems) + if err := collectorRepoTx.BulkUpdateAvailableTrash(ctx, collector.ID, availableTrashList); err != nil { + return fmt.Errorf("failed to update available trash items: %w", err) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + updatedCollector, err := s.collectorRepo.GetByUserID(ctx, UserID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated collector: %w", err) + } + + return s.toCollectorResponse(updatedCollector), nil +} + +func (s *collectorService) DeleteCollector(ctx context.Context, UserID string) error { + + _, err := s.collectorRepo.GetByUserID(ctx, UserID) + if err != nil { + return fmt.Errorf("collector not found: %w", err) + } + + if err := s.collectorRepo.Delete(ctx, UserID); err != nil { + return fmt.Errorf("failed to delete collector: %w", err) + } + + return nil +} + +func (s *collectorService) ListCollectors(ctx context.Context, limit, offset int) ([]*CollectorResponse, int64, error) { + + limit, offset = s.normalizePagination(limit, offset) + + collectors, total, err := s.collectorRepo.List(ctx, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list collectors: %w", err) + } + + return s.buildCollectorResponseList(collectors), total, nil +} + +func (s *collectorService) GetActiveCollectors(ctx context.Context, limit, offset int) ([]*CollectorResponse, int64, error) { + + limit, offset = s.normalizePagination(limit, offset) + + collectors, total, err := s.collectorRepo.GetActiveCollectors(ctx, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get active collectors: %w", err) + } + + return s.buildCollectorResponseList(collectors), total, nil +} + +func (s *collectorService) GetCollectorsByAddress(ctx context.Context, addressID string, limit, offset int) ([]*CollectorResponse, int64, error) { + + limit, offset = s.normalizePagination(limit, offset) + + collectors, total, err := s.collectorRepo.GetCollectorsByAddress(ctx, addressID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get collectors by address: %w", err) + } + + return s.buildCollectorResponseList(collectors), total, nil +} + +func (s *collectorService) GetCollectorsByTrashCategory(ctx context.Context, trashCategoryID string, limit, offset int) ([]*CollectorResponse, int64, error) { + + limit, offset = s.normalizePagination(limit, offset) + + collectors, total, err := s.collectorRepo.GetCollectorsByTrashCategory(ctx, trashCategoryID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get collectors by trash category: %w", err) + } + + return s.buildCollectorResponseList(collectors), total, nil +} + +func (s *collectorService) UpdateJobStatus(ctx context.Context, id string, jobStatus string) error { + if err := s.collectorRepo.UpdateJobStatus(ctx, id, jobStatus); err != nil { + return fmt.Errorf("failed to update job status: %w", err) + } + + return nil +} + +func (s *collectorService) UpdateRating(ctx context.Context, id string, rating float32) error { + if err := s.collectorRepo.UpdateRating(ctx, id, rating); err != nil { + return fmt.Errorf("failed to update rating: %w", err) + } + + return nil +} + +func (s *collectorService) UpdateAvailableTrash(ctx context.Context, collectorID string, availableTrashItems []CreateAvailableTrashRequest) error { + availableTrashList := s.buildAvailableTrashList(collectorID, availableTrashItems) + + if err := s.collectorRepo.BulkUpdateAvailableTrash(ctx, collectorID, availableTrashList); err != nil { + return fmt.Errorf("failed to update available trash: %w", err) + } + + return nil +} + +func (s *collectorService) buildAvailableTrashList(collectorID string, items []CreateAvailableTrashRequest) []*model.AvaibleTrashByCollector { + availableTrashList := make([]*model.AvaibleTrashByCollector, 0, len(items)) + for _, item := range items { + availableTrash := &model.AvaibleTrashByCollector{ + CollectorID: collectorID, + TrashCategoryID: item.TrashCategoryID, + Price: item.Price, + } + availableTrashList = append(availableTrashList, availableTrash) + } + return availableTrashList +} + +func (s *collectorService) checkCollectorNeedsUpdate(collector *model.Collector, req *UpdateCollectorRequest) bool { + if req.JobStatus != "" && req.JobStatus != collector.JobStatus { + return true + } + if req.AddressID != "" && req.AddressID != collector.AddressID { + return true + } + return false +} + +func (s *collectorService) applyCollectorUpdates(collector *model.Collector, req *UpdateCollectorRequest) { + if req.JobStatus != "" { + collector.JobStatus = req.JobStatus + } + if req.AddressID != "" { + collector.AddressID = req.AddressID + } +} + +func (s *collectorService) normalizePagination(limit, offset int) (int, int) { + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + if limit > 100 { + limit = 100 + } + return limit, offset +} + +func (s *collectorService) buildCollectorResponseList(collectors []*model.Collector) []*CollectorResponse { + responses := make([]*CollectorResponse, 0, len(collectors)) + for _, collector := range collectors { + responses = append(responses, s.toCollectorResponse(collector)) + } + return responses +} + +func (s *collectorService) toCollectorResponse(collector *model.Collector) *CollectorResponse { + response := &CollectorResponse{ + ID: collector.ID, + UserID: collector.UserID, + JobStatus: collector.JobStatus, + Rating: collector.Rating, + AddressID: collector.AddressID, + AvailableTrash: make([]AvailableTrashResponse, 0), + CreatedAt: collector.CreatedAt.Format(time.RFC3339), + UpdatedAt: collector.UpdatedAt.Format(time.RFC3339), + } + + if collector.Address.ID != "" { + response.Address = &address.AddressResponseDTO{ + ID: collector.Address.ID, + UserID: collector.Address.UserID, + Province: collector.Address.Province, + Regency: collector.Address.Regency, + District: collector.Address.District, + Village: collector.Address.Village, + PostalCode: collector.Address.PostalCode, + Detail: collector.Address.Detail, + Latitude: collector.Address.Latitude, + Longitude: collector.Address.Longitude, + CreatedAt: collector.Address.CreatedAt.Format(time.RFC3339), + UpdatedAt: collector.Address.UpdatedAt.Format(time.RFC3339), + } + } + + for _, availableTrash := range collector.AvaibleTrashByCollector { + trashResponse := AvailableTrashResponse{ + ID: availableTrash.ID, + CollectorID: availableTrash.CollectorID, + TrashCategoryID: availableTrash.TrashCategoryID, + Price: availableTrash.Price, + } + + if availableTrash.TrashCategory.ID != "" { + trashResponse.TrashCategory = &trash.ResponseTrashCategoryDTO{ + ID: availableTrash.TrashCategory.ID, + TrashName: availableTrash.TrashCategory.Name, + TrashIcon: availableTrash.TrashCategory.IconTrash, + EstimatedPrice: availableTrash.TrashCategory.EstimatedPrice, + Variety: availableTrash.TrashCategory.Variety, + CreatedAt: availableTrash.TrashCategory.CreatedAt.Format(time.RFC3339), + UpdatedAt: availableTrash.TrashCategory.UpdatedAt.Format(time.RFC3339), + } + } + + response.AvailableTrash = append(response.AvailableTrash, trashResponse) + } + + return response +} diff --git a/internal/company/company_dto.go b/internal/company/company_dto.go new file mode 100644 index 0000000..979f73b --- /dev/null +++ b/internal/company/company_dto.go @@ -0,0 +1,62 @@ +package company + +import ( + "rijig/utils" + "strings" +) + +type ResponseCompanyProfileDTO struct { + ID string `json:"id"` + UserID string `json:"userId"` + CompanyName string `json:"company_name"` + CompanyAddress string `json:"company_address"` + CompanyPhone string `json:"company_phone"` + CompanyEmail string `json:"company_email"` + CompanyLogo string `json:"company_logo,omitempty"` + CompanyWebsite string `json:"company_website,omitempty"` + TaxID string `json:"taxId,omitempty"` + FoundedDate string `json:"founded_date,omitempty"` + CompanyType string `json:"company_type,omitempty"` + CompanyDescription string `json:"company_description"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type RequestCompanyProfileDTO struct { + CompanyName string `json:"company_name"` + CompanyAddress string `json:"company_address"` + CompanyPhone string `json:"company_phone"` + CompanyEmail string `json:"company_email"` + CompanyLogo string `json:"company_logo,omitempty"` + CompanyWebsite string `json:"company_website,omitempty"` + TaxID string `json:"taxId,omitempty"` + FoundedDate string `json:"founded_date,omitempty"` + CompanyType string `json:"company_type,omitempty"` + CompanyDescription string `json:"company_description"` +} + +func (r *RequestCompanyProfileDTO) ValidateCompanyProfileInput() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.CompanyName) == "" { + errors["company_name"] = append(errors["company_name"], "Company name is required") + } + + if strings.TrimSpace(r.CompanyAddress) == "" { + errors["company_address"] = append(errors["company_address"], "Company address is required") + } + + if !utils.IsValidPhoneNumber(r.CompanyPhone) { + errors["company_phone"] = append(errors["company_phone"], "nomor harus dimulai 62.. dan 8-14 digit") + } + + if strings.TrimSpace(r.CompanyDescription) == "" { + errors["company_description"] = append(errors["company_description"], "Company description is required") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} diff --git a/internal/company/company_handler.go b/internal/company/company_handler.go new file mode 100644 index 0000000..bdf6ee0 --- /dev/null +++ b/internal/company/company_handler.go @@ -0,0 +1,125 @@ +package company + +import ( + "context" + "log" + "rijig/middleware" + "rijig/utils" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type CompanyProfileHandler struct { + service CompanyProfileService +} + +func NewCompanyProfileHandler(service CompanyProfileService) *CompanyProfileHandler { + return &CompanyProfileHandler{ + service: service, + } +} + +func (h *CompanyProfileHandler) CreateCompanyProfile(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") + } + + var req RequestCompanyProfileDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if errors, valid := req.ValidateCompanyProfileInput(); !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "validation failed", errors) + } + + companyLogo, err := c.FormFile("company_logo") + if err != nil { + 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) +} + +func (h *CompanyProfileHandler) GetCompanyProfileByID(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(string) + if !ok || userID == "" { + return utils.Unauthorized(c, "User not authenticated") + } + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "id is required") + } + + res, err := h.service.GetCompanyProfileByID(context.Background(), id) + if err != nil { + return utils.NotFound(c, err.Error()) + } + + return utils.SuccessWithData(c, "company profile retrieved", res) +} + +func (h *CompanyProfileHandler) GetCompanyProfilesByUserID(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(string) + if !ok || userID == "" { + return utils.Unauthorized(c, "User not authenticated") + } + + res, err := h.service.GetCompanyProfilesByUserID(context.Background(), userID) + if err != nil { + return utils.InternalServerError(c, err.Error()) + } + + return utils.SuccessWithData(c, "company profiles retrieved", res) +} + +func (h *CompanyProfileHandler) UpdateCompanyProfile(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(string) + if !ok || userID == "" { + return utils.Unauthorized(c, "User not authenticated") + } + + var req RequestCompanyProfileDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if errors, valid := req.ValidateCompanyProfileInput(); !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "validation failed", errors) + } + + res, err := h.service.UpdateCompanyProfile(context.Background(), userID, &req) + if err != nil { + return utils.InternalServerError(c, err.Error()) + } + + return utils.SuccessWithData(c, "company profile updated", res) +} + +func (h *CompanyProfileHandler) DeleteCompanyProfile(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(string) + if !ok || userID == "" { + return utils.Unauthorized(c, "User not authenticated") + } + + err := h.service.DeleteCompanyProfile(context.Background(), userID) + if err != nil { + return utils.InternalServerError(c, err.Error()) + } + + return utils.Success(c, "company profile deleted") +} diff --git a/internal/company/company_repository.go b/internal/company/company_repository.go new file mode 100644 index 0000000..cbea2a1 --- /dev/null +++ b/internal/company/company_repository.go @@ -0,0 +1,89 @@ +package company + +import ( + "context" + "fmt" + "rijig/model" + + "gorm.io/gorm" +) + +type CompanyProfileRepository interface { + CreateCompanyProfile(ctx context.Context, companyProfile *model.CompanyProfile) (*model.CompanyProfile, error) + GetCompanyProfileByID(ctx context.Context, id string) (*model.CompanyProfile, error) + GetCompanyProfilesByUserID(ctx context.Context, userID string) ([]model.CompanyProfile, error) + UpdateCompanyProfile(ctx context.Context, company *model.CompanyProfile) error + DeleteCompanyProfileByUserID(ctx context.Context, userID string) error + ExistsByUserID(ctx context.Context, userID string) (bool, error) +} + +type companyProfileRepository struct { + db *gorm.DB +} + +func NewCompanyProfileRepository(db *gorm.DB) CompanyProfileRepository { + return &companyProfileRepository{db} +} + +func (r *companyProfileRepository) CreateCompanyProfile(ctx context.Context, companyProfile *model.CompanyProfile) (*model.CompanyProfile, error) { + err := r.db.WithContext(ctx).Create(companyProfile).Error + if err != nil { + return nil, fmt.Errorf("failed to create company profile: %v", err) + } + return companyProfile, nil +} + +func (r *companyProfileRepository) GetCompanyProfileByID(ctx context.Context, id string) (*model.CompanyProfile, error) { + var companyProfile model.CompanyProfile + err := r.db.WithContext(ctx).Preload("User").First(&companyProfile, "id = ?", id).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("company profile with ID %s not found", id) + } + return nil, fmt.Errorf("error fetching company profile: %v", err) + } + return &companyProfile, nil +} + +func (r *companyProfileRepository) GetCompanyProfilesByUserID(ctx context.Context, userID string) ([]model.CompanyProfile, error) { + var companyProfiles []model.CompanyProfile + err := r.db.WithContext(ctx).Preload("User").Where("user_id = ?", userID).Find(&companyProfiles).Error + if err != nil { + return nil, fmt.Errorf("error fetching company profiles for userID %s: %v", userID, err) + } + return companyProfiles, nil +} + +func (r *companyProfileRepository) UpdateCompanyProfile(ctx context.Context, company *model.CompanyProfile) error { + var existing model.CompanyProfile + if err := r.db.WithContext(ctx).First(&existing, "user_id = ?", company.UserID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("company profile not found for user_id %s", company.UserID) + } + return fmt.Errorf("failed to fetch company profile: %v", err) + } + + err := r.db.WithContext(ctx).Model(&existing).Updates(company).Error + if err != nil { + return fmt.Errorf("failed to update company profile: %v", err) + } + return nil +} + +func (r *companyProfileRepository) DeleteCompanyProfileByUserID(ctx context.Context, userID string) error { + err := r.db.WithContext(ctx).Delete(&model.CompanyProfile{}, "user_id = ?", userID).Error + if err != nil { + return fmt.Errorf("failed to delete company profile: %v", err) + } + return nil +} + +func (r *companyProfileRepository) ExistsByUserID(ctx context.Context, userID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.CompanyProfile{}). + Where("user_id = ?", userID).Count(&count).Error + if err != nil { + return false, fmt.Errorf("failed to check existence: %v", err) + } + return count > 0, nil +} diff --git a/internal/company/company_route.go b/internal/company/company_route.go new file mode 100644 index 0000000..8476568 --- /dev/null +++ b/internal/company/company_route.go @@ -0,0 +1,27 @@ +package company + +import ( + "rijig/config" + "rijig/internal/authentication" + "rijig/internal/userprofile" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func CompanyRouter(api fiber.Router) { + companyProfileRepo := NewCompanyProfileRepository(config.DB) + authRepo := authentication.NewAuthenticationRepository(config.DB) + userRepo := userprofile.NewUserProfileRepository(config.DB) + companyProfileService := NewCompanyProfileService(companyProfileRepo, authRepo, userRepo) + companyProfileHandler := NewCompanyProfileHandler(companyProfileService) + + companyProfileAPI := api.Group("/companyprofile") + companyProfileAPI.Use(middleware.AuthMiddleware()) + + companyProfileAPI.Post("/create", companyProfileHandler.CreateCompanyProfile) + companyProfileAPI.Get("/get/:id", companyProfileHandler.GetCompanyProfileByID) + companyProfileAPI.Get("/get", companyProfileHandler.GetCompanyProfilesByUserID) + companyProfileAPI.Put("/update", companyProfileHandler.UpdateCompanyProfile) + companyProfileAPI.Delete("/delete", companyProfileHandler.DeleteCompanyProfile) +} diff --git a/internal/company/company_service.go b/internal/company/company_service.go new file mode 100644 index 0000000..bad3652 --- /dev/null +++ b/internal/company/company_service.go @@ -0,0 +1,373 @@ +package company + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "os" + "path/filepath" + "rijig/internal/authentication" + "rijig/internal/role" + "rijig/internal/userprofile" + "rijig/model" + "rijig/utils" + "time" +) + +type CompanyProfileService interface { + CreateCompanyProfile(ctx context.Context, userID, deviceID string, request *RequestCompanyProfileDTO, companyLogo *multipart.FileHeader) (*authentication.AuthResponse, error) + GetCompanyProfileByID(ctx context.Context, id string) (*ResponseCompanyProfileDTO, error) + GetCompanyProfilesByUserID(ctx context.Context, userID string) ([]ResponseCompanyProfileDTO, error) + UpdateCompanyProfile(ctx context.Context, userID string, request *RequestCompanyProfileDTO) (*ResponseCompanyProfileDTO, error) + DeleteCompanyProfile(ctx context.Context, userID string) error + + GetAllCompanyProfilesByRegStatus(ctx context.Context, userRegStatus string) ([]ResponseCompanyProfileDTO, error) + UpdateUserRegistrationStatusByCompany(ctx context.Context, companyUserID string, newStatus string) error +} + +type companyProfileService struct { + companyRepo CompanyProfileRepository + authRepo authentication.AuthenticationRepository + userRepo userprofile.UserProfileRepository +} + +func NewCompanyProfileService(companyRepo CompanyProfileRepository, + authRepo authentication.AuthenticationRepository, + userRepo userprofile.UserProfileRepository) CompanyProfileService { + return &companyProfileService{ + companyRepo, authRepo, userRepo, + } +} + +func FormatResponseCompanyProfile(companyProfile *model.CompanyProfile) (*ResponseCompanyProfileDTO, error) { + createdAt, _ := utils.FormatDateToIndonesianFormat(companyProfile.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(companyProfile.UpdatedAt) + + return &ResponseCompanyProfileDTO{ + ID: companyProfile.ID, + UserID: companyProfile.UserID, + CompanyName: companyProfile.CompanyName, + CompanyAddress: companyProfile.CompanyAddress, + CompanyPhone: companyProfile.CompanyPhone, + CompanyEmail: companyProfile.CompanyEmail, + CompanyLogo: companyProfile.CompanyLogo, + CompanyWebsite: companyProfile.CompanyWebsite, + TaxID: companyProfile.TaxID, + FoundedDate: companyProfile.FoundedDate, + CompanyType: companyProfile.CompanyType, + CompanyDescription: companyProfile.CompanyDescription, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func (s *companyProfileService) saveCompanyLogo(userID string, companyLogo *multipart.FileHeader) (string, error) { + pathImage := "/uploads/companyprofile/" + 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{ + UserID: userID, + CompanyName: request.CompanyName, + CompanyAddress: request.CompanyAddress, + CompanyPhone: request.CompanyPhone, + CompanyEmail: request.CompanyEmail, + CompanyLogo: companyLogoPath, + CompanyWebsite: request.CompanyWebsite, + TaxID: request.TaxID, + FoundedDate: request.FoundedDate, + CompanyType: request.CompanyType, + CompanyDescription: request.CompanyDescription, + } + + _, err = s.companyRepo.CreateCompanyProfile(ctx, companyProfile) + if err != nil { + return nil, err + } + + 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) { + profile, err := s.companyRepo.GetCompanyProfileByID(ctx, id) + if err != nil { + return nil, err + } + return FormatResponseCompanyProfile(profile) +} + +func (s *companyProfileService) GetCompanyProfilesByUserID(ctx context.Context, userID string) ([]ResponseCompanyProfileDTO, error) { + profiles, err := s.companyRepo.GetCompanyProfilesByUserID(ctx, userID) + if err != nil { + return nil, err + } + + var responses []ResponseCompanyProfileDTO + for _, p := range profiles { + dto, err := FormatResponseCompanyProfile(&p) + if err != nil { + continue + } + responses = append(responses, *dto) + } + + return responses, nil +} + +func (s *companyProfileService) UpdateCompanyProfile(ctx context.Context, userID string, request *RequestCompanyProfileDTO) (*ResponseCompanyProfileDTO, error) { + if errors, valid := request.ValidateCompanyProfileInput(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + company := &model.CompanyProfile{ + UserID: userID, + CompanyName: request.CompanyName, + CompanyAddress: request.CompanyAddress, + CompanyPhone: request.CompanyPhone, + CompanyEmail: request.CompanyEmail, + CompanyLogo: request.CompanyLogo, + CompanyWebsite: request.CompanyWebsite, + TaxID: request.TaxID, + FoundedDate: request.FoundedDate, + CompanyType: request.CompanyType, + CompanyDescription: request.CompanyDescription, + } + + if err := s.companyRepo.UpdateCompanyProfile(ctx, company); err != nil { + return nil, err + } + + updated, err := s.companyRepo.GetCompanyProfilesByUserID(ctx, userID) + if err != nil || len(updated) == 0 { + return nil, fmt.Errorf("failed to retrieve updated company profile") + } + + return FormatResponseCompanyProfile(&updated[0]) +} + +func (s *companyProfileService) DeleteCompanyProfile(ctx context.Context, userID string) error { + return s.companyRepo.DeleteCompanyProfileByUserID(ctx, userID) +} + +func (s *companyProfileService) GetAllCompanyProfilesByRegStatus(ctx context.Context, userRegStatus string) ([]ResponseCompanyProfileDTO, error) { + companyProfiles, err := s.authRepo.GetCompanyProfilesByUserRegStatus(ctx, userRegStatus) + if err != nil { + log.Printf("Error getting company profiles by registration status: %v", err) + return nil, fmt.Errorf("failed to get company profiles: %w", err) + } + + var response []ResponseCompanyProfileDTO + for _, profile := range companyProfiles { + dto := ResponseCompanyProfileDTO{ + ID: profile.ID, + UserID: profile.UserID, + CompanyName: profile.CompanyName, + CompanyAddress: profile.CompanyAddress, + CompanyPhone: profile.CompanyPhone, + CompanyEmail: profile.CompanyEmail, + CompanyLogo: profile.CompanyLogo, + CompanyWebsite: profile.CompanyWebsite, + TaxID: profile.TaxID, + FoundedDate: profile.FoundedDate, + CompanyType: profile.CompanyType, + CompanyDescription: profile.CompanyDescription, + CreatedAt: profile.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: profile.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + response = append(response, dto) + } + + return response, nil +} + +func (s *companyProfileService) UpdateUserRegistrationStatusByCompany(ctx context.Context, companyUserID string, newStatus string) error { + + user, err := s.authRepo.FindUserByID(ctx, companyUserID) + if err != nil { + log.Printf("Error finding user by ID %s: %v", companyUserID, err) + return fmt.Errorf("user not found: %w", err) + } + + updates := map[string]interface{}{ + "registration_status": newStatus, + "updated_at": time.Now(), + } + + switch newStatus { + case utils.RegStatusConfirmed: + updates["registration_progress"] = utils.ProgressDataSubmitted + case utils.RegStatusRejected: + updates["registration_progress"] = utils.ProgressOTPVerified + } + + err = s.authRepo.PatchUser(ctx, user.ID, updates) + if err != nil { + log.Printf("Error updating user registration status for user ID %s: %v", user.ID, err) + return fmt.Errorf("failed to update user registration status: %w", err) + } + + log.Printf("Successfully updated registration status for user ID %s to %s", user.ID, newStatus) + return nil +} + +func (s *companyProfileService) GetUserProfile(ctx context.Context, userID string) (*userprofile.UserProfileResponseDTO, error) { + user, err := s.authRepo.FindUserByID(ctx, userID) + if err != nil { + log.Printf("Error getting user profile for ID %s: %v", userID, err) + return nil, fmt.Errorf("failed to get user profile: %w", err) + } + + response := &userprofile.UserProfileResponseDTO{ + ID: user.ID, + Name: user.Name, + Gender: user.Gender, + Dateofbirth: user.Dateofbirth, + Placeofbirth: user.Placeofbirth, + Phone: user.Phone, + Email: user.Email, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + if user.Avatar != nil { + response.Avatar = *user.Avatar + } + + if user.Role != nil { + response.Role = role.RoleResponseDTO{ + ID: user.Role.ID, + RoleName: user.Role.RoleName, + CreatedAt: user.Role.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: user.Role.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return response, nil +} diff --git a/internal/handler/address_handler.go b/internal/handler/address_handler.go deleted file mode 100644 index 44dfe9d..0000000 --- a/internal/handler/address_handler.go +++ /dev/null @@ -1,92 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type AddressHandler struct { - AddressService services.AddressService -} - -func NewAddressHandler(addressService services.AddressService) *AddressHandler { - return &AddressHandler{AddressService: addressService} -} - -func (h *AddressHandler) CreateAddress(c *fiber.Ctx) error { - var requestAddressDTO dto.CreateAddressDTO - if err := c.BodyParser(&requestAddressDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := requestAddressDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - addressResponse, err := h.AddressService.CreateAddress(c.Locals("userID").(string), requestAddressDTO) - if err != nil { - return utils.GenericResponse(c, fiber.StatusBadRequest, err.Error()) - } - - return utils.CreateResponse(c, addressResponse, "user address created successfully") -} - -func (h *AddressHandler) GetAddressByUserID(c *fiber.Ctx) error { - userID := c.Locals("userID").(string) - - addresses, err := h.AddressService.GetAddressByUserID(userID) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.SuccessResponse(c, addresses, "User addresses fetched successfully") -} - -func (h *AddressHandler) GetAddressByID(c *fiber.Ctx) error { - userID := c.Locals("userID").(string) - addressID := c.Params("address_id") - - address, err := h.AddressService.GetAddressByID(userID, addressID) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.SuccessResponse(c, address, "Address fetched successfully") -} - -func (h *AddressHandler) UpdateAddress(c *fiber.Ctx) error { - userID := c.Locals("userID").(string) - addressID := c.Params("address_id") - - var addressDTO dto.CreateAddressDTO - if err := c.BodyParser(&addressDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := addressDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - updatedAddress, err := h.AddressService.UpdateAddress(userID, addressID, addressDTO) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.SuccessResponse(c, updatedAddress, "User address updated successfully") -} - -func (h *AddressHandler) DeleteAddress(c *fiber.Ctx) error { - userID := c.Locals("userID").(string) - addressID := c.Params("address_id") - - err := h.AddressService.DeleteAddress(userID, addressID) - if err != nil { - return utils.GenericResponse(c, fiber.StatusForbidden, err.Error()) - } - - return utils.SuccessResponse(c, nil, "Address deleted successfully") -} diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go deleted file mode 100644 index 6462870..0000000 --- a/internal/handler/article_handler.go +++ /dev/null @@ -1,137 +0,0 @@ -package handler - -import ( - "fmt" - "mime/multipart" - "strconv" - - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type ArticleHandler struct { - ArticleService services.ArticleService -} - -func NewArticleHandler(articleService services.ArticleService) *ArticleHandler { - return &ArticleHandler{ArticleService: articleService} -} - -func (h *ArticleHandler) CreateArticle(c *fiber.Ctx) error { - var request dto.RequestArticleDTO - - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := request.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - coverImage, err := c.FormFile("coverImage") - if err != nil { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Cover image is required") - } - - articleResponse, err := h.ArticleService.CreateArticle(request, coverImage) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.CreateResponse(c, articleResponse, "Article created successfully") -} - -func (h *ArticleHandler) GetAllArticles(c *fiber.Ctx) error { - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil || page < 1 { - page = 0 - } - - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil || limit < 1 { - limit = 0 - } - - articles, totalArticles, err := h.ArticleService.GetAllArticles(page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch articles") - } - - fmt.Printf("Total Articles: %d\n", totalArticles) - - if page == 0 && limit == 0 { - return utils.NonPaginatedResponse(c, articles, totalArticles, "Articles fetched successfully") - } - - return utils.PaginatedResponse(c, articles, page, limit, totalArticles, "Articles fetched successfully") -} - -func (h *ArticleHandler) GetArticleByID(c *fiber.Ctx) error { - id := c.Params("article_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Article ID is required") - } - - article, err := h.ArticleService.GetArticleByID(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, "Article not found") - } - - return utils.SuccessResponse(c, article, "Article fetched successfully") -} - -func (h *ArticleHandler) UpdateArticle(c *fiber.Ctx) error { - id := c.Params("article_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Article ID is required") - } - - var request dto.RequestArticleDTO - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := request.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - var coverImage *multipart.FileHeader - coverImage, err := c.FormFile("coverImage") - if err != nil && err.Error() != "no such file" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Cover image is required") - } - - articleResponse, err := h.ArticleService.UpdateArticle(id, request, coverImage) - if err != nil { - if err.Error() == fmt.Sprintf("article with ID %s not found", id) { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - - } - - return utils.SuccessResponse(c, articleResponse, "Article updated successfully") -} - -func (h *ArticleHandler) DeleteArticle(c *fiber.Ctx) error { - id := c.Params("article_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Article ID is required") - } - - err := h.ArticleService.DeleteArticle(id) - if err != nil { - - if err.Error() == fmt.Sprintf("article with ID %s not found", id) { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, "Article deleted successfully") -} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go deleted file mode 100644 index 3f48822..0000000 --- a/internal/handler/auth_handler.go +++ /dev/null @@ -1,72 +0,0 @@ -package handler - -import ( - "log" - - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type UserHandler struct { - UserService services.UserService -} - -func NewUserHandler(userService services.UserService) *UserHandler { - return &UserHandler{UserService: userService} -} - -func (h *UserHandler) Login(c *fiber.Ctx) error { - var loginDTO dto.LoginDTO - if err := c.BodyParser(&loginDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - validationErrors, valid := loginDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, validationErrors) - } - - user, err := h.UserService.Login(loginDTO) - if err != nil { - return utils.GenericResponse(c, fiber.StatusUnauthorized, err.Error()) - } - - return utils.SuccessResponse(c, user, "Login successful") -} - -func (h *UserHandler) Register(c *fiber.Ctx) error { - - var registerDTO dto.RegisterDTO - if err := c.BodyParser(®isterDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid request body"}}) - } - - errors, valid := registerDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - userResponse, err := h.UserService.Register(registerDTO) - if err != nil { - return utils.GenericResponse(c, fiber.StatusConflict, err.Error()) - } - - return utils.CreateResponse(c, userResponse, "Registration successful") -} - -func (h *UserHandler) Logout(c *fiber.Ctx) error { - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - log.Println("Unauthorized access: User ID not found in session") - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - err := utils.DeleteSessionData(userID) - if err != nil { - return utils.InternalServerErrorResponse(c, "Error logging out") - } - - return utils.SuccessResponse(c, nil, "Logout successful") -} diff --git a/internal/handler/banner_handler.go b/internal/handler/banner_handler.go deleted file mode 100644 index ac5bd9e..0000000 --- a/internal/handler/banner_handler.go +++ /dev/null @@ -1,108 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type BannerHandler struct { - BannerService services.BannerService -} - -func NewBannerHandler(bannerService services.BannerService) *BannerHandler { - return &BannerHandler{BannerService: bannerService} -} - -func (h *BannerHandler) CreateBanner(c *fiber.Ctx) error { - var request dto.RequestBannerDTO - - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := request.ValidateBannerInput() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - bannerImage, err := c.FormFile("bannerimage") - if err != nil { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Banner image is required") - } - - bannerResponse, err := h.BannerService.CreateBanner(request, bannerImage) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.CreateResponse(c, bannerResponse, "Banner created successfully") -} - -func (h *BannerHandler) GetAllBanners(c *fiber.Ctx) error { - banners, err := h.BannerService.GetAllBanners() - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch banners") - } - - return utils.NonPaginatedResponse(c, banners, len(banners), "Banners fetched successfully") -} - -func (h *BannerHandler) GetBannerByID(c *fiber.Ctx) error { - id := c.Params("banner_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Banner ID is required") - } - - banner, err := h.BannerService.GetBannerByID(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, "invalid banner id") - } - - return utils.SuccessResponse(c, banner, "Banner fetched successfully") -} - -func (h *BannerHandler) UpdateBanner(c *fiber.Ctx) error { - id := c.Params("banner_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Banner ID is required") - } - - var request dto.RequestBannerDTO - - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := request.ValidateBannerInput() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - bannerImage, err := c.FormFile("bannerimage") - if err != nil && err.Error() != "no such file" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Banner image is required") - } - - bannerResponse, err := h.BannerService.UpdateBanner(id, request, bannerImage) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.SuccessResponse(c, bannerResponse, "Banner updated successfully") -} - -func (h *BannerHandler) DeleteBanner(c *fiber.Ctx) error { - id := c.Params("banner_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Banner ID is required") - } - - err := h.BannerService.DeleteBanner(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, "Banner deleted successfully") -} diff --git a/internal/handler/initialcoint_handler.go b/internal/handler/initialcoint_handler.go deleted file mode 100644 index 2cf275a..0000000 --- a/internal/handler/initialcoint_handler.go +++ /dev/null @@ -1,98 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type InitialCointHandler struct { - InitialCointService services.InitialCointService -} - -func NewInitialCointHandler(initialCointService services.InitialCointService) *InitialCointHandler { - return &InitialCointHandler{InitialCointService: initialCointService} -} - -func (h *InitialCointHandler) CreateInitialCoint(c *fiber.Ctx) error { - var request dto.RequestInitialCointDTO - - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := request.ValidateCointInput() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - initialCointResponse, err := h.InitialCointService.CreateInitialCoint(request) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.CreateResponse(c, initialCointResponse, "Initial coint created successfully") -} - -func (h *InitialCointHandler) GetAllInitialCoints(c *fiber.Ctx) error { - initialCoints, err := h.InitialCointService.GetAllInitialCoints() - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch initial coints") - } - - return utils.NonPaginatedResponse(c, initialCoints, len(initialCoints), "Initial coints fetched successfully") -} - -func (h *InitialCointHandler) GetInitialCointByID(c *fiber.Ctx) error { - id := c.Params("coin_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Coin ID is required") - } - - initialCoint, err := h.InitialCointService.GetInitialCointByID(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, "Invalid coin ID") - } - - return utils.SuccessResponse(c, initialCoint, "Initial coint fetched successfully") -} - -func (h *InitialCointHandler) UpdateInitialCoint(c *fiber.Ctx) error { - id := c.Params("coin_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Coin ID is required") - } - - var request dto.RequestInitialCointDTO - - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := request.ValidateCointInput() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - initialCointResponse, err := h.InitialCointService.UpdateInitialCoint(id, request) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.SuccessResponse(c, initialCointResponse, "Initial coint updated successfully") -} - -func (h *InitialCointHandler) DeleteInitialCoint(c *fiber.Ctx) error { - id := c.Params("coin_id") - if id == "" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Coin ID is required") - } - - err := h.InitialCointService.DeleteInitialCoint(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, "Initial coint deleted successfully") -} diff --git a/internal/handler/role_handler.go b/internal/handler/role_handler.go deleted file mode 100644 index 623b979..0000000 --- a/internal/handler/role_handler.go +++ /dev/null @@ -1,46 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type RoleHandler struct { - RoleService services.RoleService -} - -func NewRoleHandler(roleService services.RoleService) *RoleHandler { - return &RoleHandler{RoleService: roleService} -} - -func (h *RoleHandler) GetRoles(c *fiber.Ctx) error { - - roleID, ok := c.Locals("roleID").(string) - if !ok || roleID != utils.RoleAdministrator { - return utils.GenericResponse(c, fiber.StatusForbidden, "Forbidden: You don't have permission to access this resource") - } - - roles, err := h.RoleService.GetRoles() - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.SuccessResponse(c, roles, "Roles fetched successfully") -} - -func (h *RoleHandler) GetRoleByID(c *fiber.Ctx) error { - roleID := c.Params("role_id") - - roleIDFromSession, ok := c.Locals("roleID").(string) - if !ok || roleIDFromSession != utils.RoleAdministrator { - return utils.GenericResponse(c, fiber.StatusForbidden, "Forbidden: You don't have permission to access this resource") - } - - role, err := h.RoleService.GetRoleByID(roleID) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, "role id tidak ditemukan") - } - - return utils.SuccessResponse(c, role, "Role fetched successfully") -} diff --git a/internal/handler/trash_handler.go b/internal/handler/trash_handler.go deleted file mode 100644 index d07c8d4..0000000 --- a/internal/handler/trash_handler.go +++ /dev/null @@ -1,128 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type TrashHandler struct { - TrashService services.TrashService -} - -func NewTrashHandler(trashService services.TrashService) *TrashHandler { - return &TrashHandler{TrashService: trashService} -} - -func (h *TrashHandler) CreateCategory(c *fiber.Ctx) error { - var request dto.RequestTrashCategoryDTO - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - categoryResponse, err := h.TrashService.CreateCategory(request) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to create category: "+err.Error()) - } - - return utils.CreateResponse(c, categoryResponse, "Category created successfully") -} - -func (h *TrashHandler) AddDetailToCategory(c *fiber.Ctx) error { - var request dto.RequestTrashDetailDTO - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - detailResponse, err := h.TrashService.AddDetailToCategory(request) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to add detail to category: "+err.Error()) - } - - return utils.CreateResponse(c, detailResponse, "Trash detail added successfully") -} - -func (h *TrashHandler) GetCategories(c *fiber.Ctx) error { - - categories, err := h.TrashService.GetCategories() - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch categories: "+err.Error()) - } - - return utils.NonPaginatedResponse(c, categories, len(categories), "Categories retrieved successfully") -} - -func (h *TrashHandler) GetCategoryByID(c *fiber.Ctx) error { - id := c.Params("category_id") - - category, err := h.TrashService.GetCategoryByID(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, "Category not found: "+err.Error()) - } - - return utils.SuccessResponse(c, category, "Category retrieved successfully") -} - -func (h *TrashHandler) GetTrashDetailByID(c *fiber.Ctx) error { - id := c.Params("detail_id") - - detail, err := h.TrashService.GetTrashDetailByID(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, "Trash detail not found: "+err.Error()) - } - - return utils.SuccessResponse(c, detail, "Trash detail retrieved successfully") -} - -func (h *TrashHandler) UpdateCategory(c *fiber.Ctx) error { - id := c.Params("category_id") - - var request dto.RequestTrashCategoryDTO - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid request body"}}) - } - - updatedCategory, err := h.TrashService.UpdateCategory(id, request) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Error updating category: "+err.Error()) - } - - return utils.SuccessResponse(c, updatedCategory, "Category updated successfully") -} - -func (h *TrashHandler) UpdateDetail(c *fiber.Ctx) error { - id := c.Params("detail_id") - - var request dto.RequestTrashDetailDTO - if err := c.BodyParser(&request); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid request body"}}) - } - - updatedDetail, err := h.TrashService.UpdateDetail(id, request) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Error updating detail: "+err.Error()) - } - - return utils.SuccessResponse(c, updatedDetail, "Trash detail updated successfully") -} - -func (h *TrashHandler) DeleteCategory(c *fiber.Ctx) error { - id := c.Params("category_id") - - if err := h.TrashService.DeleteCategory(id); err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Error deleting category: "+err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, "Category deleted successfully") -} - -func (h *TrashHandler) DeleteDetail(c *fiber.Ctx) error { - id := c.Params("detail_id") - - if err := h.TrashService.DeleteDetail(id); err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Error deleting detail: "+err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, "Trash detail deleted successfully") -} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go deleted file mode 100644 index 158fab8..0000000 --- a/internal/handler/user_handler.go +++ /dev/null @@ -1,98 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type UserProfileHandler struct { - UserProfileService services.UserProfileService -} - -func NewUserProfileHandler(userProfileService services.UserProfileService) *UserProfileHandler { - return &UserProfileHandler{UserProfileService: userProfileService} -} - -func (h *UserProfileHandler) GetUserProfile(c *fiber.Ctx) error { - - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - userProfile, err := h.UserProfileService.GetUserProfile(userID) - if err != nil { - return utils.GenericResponse(c, fiber.StatusNotFound, err.Error()) - } - - return utils.SuccessResponse(c, userProfile, "User profile retrieved successfully") -} - -func (h *UserProfileHandler) UpdateUserProfile(c *fiber.Ctx) error { - var updateData dto.UpdateUserDTO - if err := c.BodyParser(&updateData); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - errors, valid := updateData.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - userResponse, err := h.UserProfileService.UpdateUserProfile(userID, updateData) - if err != nil { - return utils.GenericResponse(c, fiber.StatusConflict, err.Error()) - } - - return utils.SuccessResponse(c, userResponse, "User profile updated successfully") -} - -func (h *UserProfileHandler) UpdateUserPassword(c *fiber.Ctx) error { - var passwordData dto.UpdatePasswordDTO - if err := c.BodyParser(&passwordData); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - errors, valid := passwordData.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - message, err := h.UserProfileService.UpdateUserPassword(userID, passwordData) - if err != nil { - return utils.GenericResponse(c, fiber.StatusBadRequest, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, message) -} -func (h *UserProfileHandler) UpdateUserAvatar(c *fiber.Ctx) error { - - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - file, err := c.FormFile("avatar") - if err != nil { - return utils.GenericResponse(c, fiber.StatusBadRequest, "No avatar file uploaded") - } - - message, err := h.UserProfileService.UpdateUserAvatar(userID, file) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, message) -} diff --git a/internal/handler/userpin_handler.go b/internal/handler/userpin_handler.go deleted file mode 100644 index f0e2908..0000000 --- a/internal/handler/userpin_handler.go +++ /dev/null @@ -1,101 +0,0 @@ -package handler - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type UserPinHandler struct { - UserPinService services.UserPinService -} - -func NewUserPinHandler(userPinService services.UserPinService) *UserPinHandler { - return &UserPinHandler{UserPinService: userPinService} -} - -func (h *UserPinHandler) VerifyUserPin(c *fiber.Ctx) error { - var requestUserPinDTO dto.RequestUserPinDTO - if err := c.BodyParser(&requestUserPinDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := requestUserPinDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - message, err := h.UserPinService.VerifyUserPin(userID, requestUserPinDTO.Pin) - if err != nil { - return utils.GenericResponse(c, fiber.StatusUnauthorized, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, message) -} - -func (h *UserPinHandler) CheckPinStatus(c *fiber.Ctx) error { - userID, ok := c.Locals("userID").(string) - if !ok || userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: User session not found") - } - - status, err := h.UserPinService.CheckPinStatus(userID) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - if status == "Pin not created" { - return utils.GenericResponse(c, fiber.StatusBadRequest, "Pin belum dibuat") - } - - return utils.GenericResponse(c, fiber.StatusOK, "Pin sudah dibuat") -} - -func (h *UserPinHandler) CreateUserPin(c *fiber.Ctx) error { - var requestUserPinDTO dto.RequestUserPinDTO - if err := c.BodyParser(&requestUserPinDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := requestUserPinDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - userID := c.Locals("userID").(string) - - message, err := h.UserPinService.CreateUserPin(userID, requestUserPinDTO.Pin) - if err != nil { - - return utils.GenericResponse(c, fiber.StatusConflict, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusCreated, message) -} - -func (h *UserPinHandler) UpdateUserPin(c *fiber.Ctx) error { - var requestUserPinDTO dto.UpdateUserPinDTO - if err := c.BodyParser(&requestUserPinDTO); err != nil { - return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}}) - } - - errors, valid := requestUserPinDTO.Validate() - if !valid { - return utils.ValidationErrorResponse(c, errors) - } - - userID := c.Locals("userID").(string) - - message, err := h.UserPinService.UpdateUserPin(userID, requestUserPinDTO.OldPin, requestUserPinDTO.NewPin) - if err != nil { - return utils.GenericResponse(c, fiber.StatusBadRequest, err.Error()) - } - - return utils.GenericResponse(c, fiber.StatusOK, message) -} diff --git a/internal/handler/wilayah_indonesia_handler.go b/internal/handler/wilayah_indonesia_handler.go deleted file mode 100644 index 02ae1bb..0000000 --- a/internal/handler/wilayah_indonesia_handler.go +++ /dev/null @@ -1,199 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type WilayahIndonesiaHandler struct { - WilayahService services.WilayahIndonesiaService -} - -func NewWilayahImportHandler(wilayahService services.WilayahIndonesiaService) *WilayahIndonesiaHandler { - return &WilayahIndonesiaHandler{WilayahService: wilayahService} -} - -func (h *WilayahIndonesiaHandler) ImportWilayahData(c *fiber.Ctx) error { - - err := h.WilayahService.ImportDataFromCSV() - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.SuccessResponse(c, fiber.StatusCreated, "Data imported successfully") -} - -func (h *WilayahIndonesiaHandler) GetProvinces(c *fiber.Ctx) error { - - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - provinces, totalProvinces, err := h.WilayahService.GetAllProvinces(page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch provinces") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, provinces, page, limit, totalProvinces, "Provinces fetched successfully") - } - - return utils.NonPaginatedResponse(c, provinces, totalProvinces, "Provinces fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetProvinceByID(c *fiber.Ctx) error { - provinceID := c.Params("provinceid") - - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - province, totalRegencies, err := h.WilayahService.GetProvinceByID(provinceID, page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch province") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, province, page, limit, totalRegencies, "Province fetched successfully") - } - - return utils.NonPaginatedResponse(c, province, totalRegencies, "Province fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetAllRegencies(c *fiber.Ctx) error { - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - regencies, totalRegencies, err := h.WilayahService.GetAllRegencies(page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch regency") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, regencies, page, limit, totalRegencies, "regency fetched successfully") - } - - return utils.NonPaginatedResponse(c, regencies, totalRegencies, "Provinces fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetRegencyByID(c *fiber.Ctx) error { - regencyId := c.Params("regencyid") - - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - regency, totalDistrict, err := h.WilayahService.GetRegencyByID(regencyId, page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch regency") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, regency, page, limit, totalDistrict, "regency fetched successfully") - } - - return utils.NonPaginatedResponse(c, regency, totalDistrict, "regency fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetAllDistricts(c *fiber.Ctx) error { - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - districts, totalDistricts, err := h.WilayahService.GetAllDistricts(page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch districts") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, districts, page, limit, totalDistricts, "districts fetched successfully") - } - - return utils.NonPaginatedResponse(c, districts, totalDistricts, "districts fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetDistrictByID(c *fiber.Ctx) error { - districtId := c.Params("districtid") - - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - district, totalVillages, err := h.WilayahService.GetDistrictByID(districtId, page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch district") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, district, page, limit, totalVillages, "district fetched successfully") - } - - return utils.NonPaginatedResponse(c, district, totalVillages, "district fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetAllVillages(c *fiber.Ctx) error { - page, err := strconv.Atoi(c.Query("page", "0")) - if err != nil { - page = 0 - } - limit, err := strconv.Atoi(c.Query("limit", "0")) - if err != nil { - limit = 0 - } - - villages, totalVillages, err := h.WilayahService.GetAllVillages(page, limit) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, "Failed to fetch villages") - } - - if page > 0 && limit > 0 { - return utils.PaginatedResponse(c, villages, page, limit, totalVillages, "villages fetched successfully") - } - - return utils.NonPaginatedResponse(c, villages, totalVillages, "villages fetched successfully") -} - -func (h *WilayahIndonesiaHandler) GetVillageByID(c *fiber.Ctx) error { - id := c.Params("villageid") - - village, err := h.WilayahService.GetVillageByID(id) - if err != nil { - return utils.GenericResponse(c, fiber.StatusInternalServerError, err.Error()) - } - - return utils.SuccessResponse(c, village, "Village fetched successfully") -} diff --git a/internal/identitycart/identitycart_dto.go b/internal/identitycart/identitycart_dto.go new file mode 100644 index 0000000..ea7a4d7 --- /dev/null +++ b/internal/identitycart/identitycart_dto.go @@ -0,0 +1,150 @@ +package identitycart + +import ( + "rijig/utils" + "strings" +) + +type ResponseIdentityCardDTO struct { + ID string `json:"id"` + UserID string `json:"userId"` + Identificationumber string `json:"identificationumber"` + Fullname string `json:"fullname"` + Placeofbirth string `json:"placeofbirth"` + Dateofbirth string `json:"dateofbirth"` + Gender string `json:"gender"` + BloodType string `json:"bloodtype"` + Province string `json:"province"` + District string `json:"district"` + SubDistrict string `json:"subdistrict"` + Hamlet string `json:"hamlet"` + Village string `json:"village"` + Neighbourhood string `json:"neighbourhood"` + PostalCode string `json:"postalcode"` + Religion string `json:"religion"` + Maritalstatus string `json:"maritalstatus"` + Job string `json:"job"` + Citizenship string `json:"citizenship"` + Validuntil string `json:"validuntil"` + Cardphoto string `json:"cardphoto"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type RequestIdentityCardDTO struct { + // DeviceID string `json:"device_id"` + UserID string `json:"userId"` + Identificationumber string `json:"identificationumber"` + Fullname string `json:"fullname"` + Placeofbirth string `json:"placeofbirth"` + Dateofbirth string `json:"dateofbirth"` + Gender string `json:"gender"` + BloodType string `json:"bloodtype"` + Province string `json:"province"` + District string `json:"district"` + SubDistrict string `json:"subdistrict"` + Hamlet string `json:"hamlet"` + Village string `json:"village"` + Neighbourhood string `json:"neighbourhood"` + PostalCode string `json:"postalcode"` + Religion string `json:"religion"` + Maritalstatus string `json:"maritalstatus"` + Job string `json:"job"` + Citizenship string `json:"citizenship"` + Validuntil string `json:"validuntil"` + Cardphoto string `json:"cardphoto"` +} + +func (r *RequestIdentityCardDTO) ValidateIdentityCardInput() (map[string][]string, bool) { + errors := make(map[string][]string) + + r.Placeofbirth = strings.ToLower(r.Placeofbirth) + r.Dateofbirth = strings.ToLower(r.Dateofbirth) + r.Gender = strings.ToLower(r.Gender) + r.BloodType = strings.ToUpper(r.BloodType) + r.Province = strings.ToLower(r.Province) + r.District = strings.ToLower(r.District) + r.SubDistrict = strings.ToLower(r.SubDistrict) + r.Hamlet = strings.ToLower(r.Hamlet) + r.Village = strings.ToLower(r.Village) + r.Neighbourhood = strings.ToLower(r.Neighbourhood) + r.PostalCode = strings.ToLower(r.PostalCode) + r.Religion = strings.ToLower(r.Religion) + r.Maritalstatus = strings.ToLower(r.Maritalstatus) + r.Job = strings.ToLower(r.Job) + r.Citizenship = strings.ToLower(r.Citizenship) + r.Validuntil = strings.ToLower(r.Validuntil) + + nikData := utils.FetchNIKData(r.Identificationumber) + if strings.ToLower(nikData.Status) != "sukses" { + errors["identificationumber"] = append(errors["identificationumber"], "NIK yang anda masukkan tidak valid") + } else { + + if r.Dateofbirth != strings.ToLower(nikData.Ttl) { + errors["dateofbirth"] = append(errors["dateofbirth"], "Tanggal lahir tidak sesuai dengan NIK") + } + + if r.Gender != strings.ToLower(nikData.Sex) { + errors["gender"] = append(errors["gender"], "Jenis kelamin tidak sesuai dengan NIK") + } + + if r.Province != strings.ToLower(nikData.Provinsi) { + errors["province"] = append(errors["province"], "Provinsi tidak sesuai dengan NIK") + } + + if r.District != strings.ToLower(nikData.Kabkot) { + errors["district"] = append(errors["district"], "Kabupaten/Kota tidak sesuai dengan NIK") + } + + if r.SubDistrict != strings.ToLower(nikData.Kecamatan) { + errors["subdistrict"] = append(errors["subdistrict"], "Kecamatan tidak sesuai dengan NIK") + } + + if r.PostalCode != strings.ToLower(nikData.KodPos) { + errors["postalcode"] = append(errors["postalcode"], "Kode pos tidak sesuai dengan NIK") + } + } + + if r.Placeofbirth == "" { + errors["placeofbirth"] = append(errors["placeofbirth"], "Tempat lahir wajib diisi") + } + if r.Hamlet == "" { + errors["hamlet"] = append(errors["hamlet"], "Dusun/RW wajib diisi") + } + if r.Village == "" { + errors["village"] = append(errors["village"], "Desa/Kelurahan wajib diisi") + } + if r.Neighbourhood == "" { + errors["neighbourhood"] = append(errors["neighbourhood"], "RT wajib diisi") + } + if r.Job == "" { + errors["job"] = append(errors["job"], "Pekerjaan wajib diisi") + } + if r.Citizenship == "" { + errors["citizenship"] = append(errors["citizenship"], "Kewarganegaraan wajib diisi") + } + if r.Validuntil == "" { + errors["validuntil"] = append(errors["validuntil"], "Berlaku hingga wajib diisi") + } + + validBloodTypes := map[string]bool{"A": true, "B": true, "O": true, "AB": true} + if _, ok := validBloodTypes[r.BloodType]; !ok { + errors["bloodtype"] = append(errors["bloodtype"], "Golongan darah harus A, B, O, atau AB") + } + + validReligions := map[string]bool{ + "islam": true, "kristen": true, "katolik": true, "hindu": true, "buddha": true, "konghucu": true, + } + if _, ok := validReligions[r.Religion]; !ok { + errors["religion"] = append(errors["religion"], "Agama harus salah satu dari Islam, Kristen, Katolik, Hindu, Buddha, atau Konghucu") + } + + if r.Maritalstatus != "kawin" && r.Maritalstatus != "belum kawin" { + errors["maritalstatus"] = append(errors["maritalstatus"], "Status perkawinan harus 'kawin' atau 'belum kawin'") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} diff --git a/internal/identitycart/identitycart_handler.go b/internal/identitycart/identitycart_handler.go new file mode 100644 index 0000000..325ca37 --- /dev/null +++ b/internal/identitycart/identitycart_handler.go @@ -0,0 +1,212 @@ +package identitycart + +import ( + "log" + "rijig/middleware" + "rijig/utils" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type IdentityCardHandler struct { + service IdentityCardService +} + +func NewIdentityCardHandler(service IdentityCardService) *IdentityCardHandler { + return &IdentityCardHandler{service: service} +} + +func (h *IdentityCardHandler) CreateIdentityCardHandler(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") + } + + var input RequestIdentityCardDTO + if err := c.BodyParser(&input); err != nil { + log.Printf("Error parsing body: %v", err) + return utils.BadRequest(c, "Invalid input format") + } + + if errs, valid := input.ValidateIdentityCardInput(); !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Input validation failed", errs) + } + + cardPhoto, err := c.FormFile("cardphoto") + if err != nil { + log.Printf("Error getting card photo: %v", err) + return utils.BadRequest(c, "KTP photo is required") + } + + response, err := h.service.CreateIdentityCard(c.Context(), claims.UserID, claims.DeviceID, &input, cardPhoto) + 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 identity card") + } + + return utils.SuccessWithData(c, "KTP successfully submitted", response) +} + +func (h *IdentityCardHandler) GetIdentityByID(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "ID is required") + } + + result, err := h.service.GetIdentityCardByID(c.Context(), id) + if err != nil { + log.Printf("Error getting identity card by ID %s: %v", id, err) + return utils.NotFound(c, "Identity card not found") + } + + return utils.SuccessWithData(c, "Successfully retrieved identity card", result) +} + +func (h *IdentityCardHandler) GetIdentityByUserId(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") + } + + result, err := h.service.GetIdentityCardsByUserID(c.Context(), claims.UserID) + if err != nil { + log.Printf("Error getting identity cards for user %s: %v", claims.UserID, err) + return utils.InternalServerError(c, "Failed to fetch your identity card data") + } + + return utils.SuccessWithData(c, "Successfully retrieved your identity cards", result) +} + +func (h *IdentityCardHandler) UpdateIdentityCardHandler(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") + } + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Identity card ID is required") + } + + var input RequestIdentityCardDTO + if err := c.BodyParser(&input); err != nil { + log.Printf("Error parsing body: %v", err) + return utils.BadRequest(c, "Invalid input format") + } + + if errs, valid := input.ValidateIdentityCardInput(); !valid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Input validation failed", errs) + } + + cardPhoto, err := c.FormFile("cardphoto") + if err != nil && err.Error() != "there is no uploaded file associated with the given key" { + log.Printf("Error getting card photo: %v", err) + return utils.BadRequest(c, "Invalid card photo") + } + + if cardPhoto != nil && cardPhoto.Size > 5*1024*1024 { + return utils.BadRequest(c, "File size must be less than 5MB") + } + + response, err := h.service.UpdateIdentityCard(c.Context(), claims.UserID, id, &input, cardPhoto) + if err != nil { + log.Printf("Error updating identity card: %v", err) + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Identity card not found") + } + if strings.Contains(err.Error(), "invalid file type") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to update identity card") + } + + return utils.SuccessWithData(c, "Identity card successfully updated", response) +} + +func (h *IdentityCardHandler) GetAllIdentityCardsByRegStatus(c *fiber.Ctx) error { + _, err := middleware.GetUserFromContext(c) + if err != nil { + log.Printf("Error getting user from context: %v", err) + return utils.Unauthorized(c, "Unauthorized access") + } + + // if claims.Role != "admin" { + // return utils.Forbidden(c, "Access denied: admin role required") + // } + + status := c.Query("status", utils.RegStatusPending) + + validStatuses := map[string]bool{ + utils.RegStatusPending: true, + "confirmed": true, + "rejected": true, + } + + if !validStatuses[status] { + return utils.BadRequest(c, "Invalid status. Valid values: pending, confirmed, rejected") + } + + result, err := h.service.GetAllIdentityCardsByRegStatus(c.Context(), status) + if err != nil { + log.Printf("Error getting identity cards by status %s: %v", status, err) + return utils.InternalServerError(c, "Failed to fetch identity cards") + } + + return utils.SuccessWithData(c, "Successfully retrieved identity cards", result) +} + +func (h *IdentityCardHandler) UpdateUserRegistrationStatusByIdentityCard(c *fiber.Ctx) error { + _, err := middleware.GetUserFromContext(c) + if err != nil { + log.Printf("Error getting user from context: %v", err) + return utils.Unauthorized(c, "Unauthorized access") + } + + userID := c.Params("userId") + if userID == "" { + return utils.BadRequest(c, "User ID is required") + } + + type StatusUpdateRequest struct { + Status string `json:"status" validate:"required,oneof=confirmed rejected"` + } + + var input StatusUpdateRequest + if err := c.BodyParser(&input); err != nil { + log.Printf("Error parsing body: %v", err) + return utils.BadRequest(c, "Invalid input format") + } + + if input.Status != "confirmed" && input.Status != "rejected" { + return utils.BadRequest(c, "Invalid status. Valid values: confirmed, rejected") + } + + err = h.service.UpdateUserRegistrationStatusByIdentityCard(c.Context(), userID, input.Status) + if err != nil { + log.Printf("Error updating user registration status: %v", err) + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "User not found") + } + return utils.InternalServerError(c, "Failed to update registration status") + } + + message := "User registration status successfully updated to " + input.Status + return utils.Success(c, message) +} + +func (h *IdentityCardHandler) DeleteIdentityCardHandler(c *fiber.Ctx) error { + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Identity card ID is required") + } + + return utils.Success(c, "Identity card successfully deleted") +} diff --git a/internal/identitycart/identitycart_repo.go b/internal/identitycart/identitycart_repo.go new file mode 100644 index 0000000..e59ee5e --- /dev/null +++ b/internal/identitycart/identitycart_repo.go @@ -0,0 +1,64 @@ +package identitycart + +import ( + "context" + "errors" + "fmt" + "log" + "rijig/model" + + "gorm.io/gorm" +) + +type IdentityCardRepository interface { + CreateIdentityCard(ctx context.Context, identityCard *model.IdentityCard) (*model.IdentityCard, error) + GetIdentityCardByID(ctx context.Context, id string) (*model.IdentityCard, error) + GetIdentityCardsByUserID(ctx context.Context, userID string) ([]model.IdentityCard, error) + UpdateIdentityCard(ctx context.Context, identity *model.IdentityCard) error +} + +type identityCardRepository struct { + db *gorm.DB +} + +func NewIdentityCardRepository(db *gorm.DB) IdentityCardRepository { + return &identityCardRepository{ + db: db, + } +} + +func (r *identityCardRepository) CreateIdentityCard(ctx context.Context, identityCard *model.IdentityCard) (*model.IdentityCard, error) { + if err := r.db.WithContext(ctx).Create(identityCard).Error; err != nil { + log.Printf("Error creating identity card: %v", err) + return nil, fmt.Errorf("failed to create identity card: %w", err) + } + return identityCard, nil +} + +func (r *identityCardRepository) GetIdentityCardByID(ctx context.Context, id string) (*model.IdentityCard, error) { + var identityCard model.IdentityCard + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&identityCard).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("identity card not found with id %s", id) + } + log.Printf("Error fetching identity card by ID: %v", err) + return nil, fmt.Errorf("error fetching identity card by ID: %w", err) + } + return &identityCard, nil +} + +func (r *identityCardRepository) GetIdentityCardsByUserID(ctx context.Context, userID string) ([]model.IdentityCard, error) { + var identityCards []model.IdentityCard + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&identityCards).Error; err != nil { + log.Printf("Error fetching identity cards by userID: %v", err) + return nil, fmt.Errorf("error fetching identity cards by userID: %w", err) + } + return identityCards, nil +} + +func (r *identityCardRepository) UpdateIdentityCard(ctx context.Context, identity *model.IdentityCard) error { + return r.db.WithContext(ctx). + Model(&model.IdentityCard{}). + Where("user_id = ?", identity.UserID). + Updates(identity).Error +} diff --git a/internal/identitycart/identitycart_route.go b/internal/identitycart/identitycart_route.go new file mode 100644 index 0000000..51fd8ca --- /dev/null +++ b/internal/identitycart/identitycart_route.go @@ -0,0 +1,46 @@ +package identitycart + +import ( + "rijig/config" + "rijig/internal/authentication" + "rijig/internal/userprofile" + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +func UserIdentityCardRoute(api fiber.Router) { + identityRepo := NewIdentityCardRepository(config.DB) + authRepo := authentication.NewAuthenticationRepository(config.DB) + userRepo := userprofile.NewUserProfileRepository(config.DB) + identityService := NewIdentityCardService(identityRepo, authRepo, userRepo) + identityHandler := NewIdentityCardHandler(identityService) + + identity := api.Group("/identity") + + identity.Post("/create", + middleware.AuthMiddleware(), + middleware.RequireRoles(utils.RolePengepul), + identityHandler.CreateIdentityCardHandler, + ) + identity.Get("/:id", + middleware.AuthMiddleware(), + identityHandler.GetIdentityByID, + ) + identity.Get("/s", + middleware.AuthMiddleware(), + identityHandler.GetIdentityByUserId, + ) + identity.Get("/", + middleware.AuthMiddleware(), + middleware.RequireRoles(utils.RoleAdministrator), + identityHandler.GetAllIdentityCardsByRegStatus, + ) + identity.Patch("/:userId/status", + middleware.AuthMiddleware(), + middleware.RequireRoles(utils.RoleAdministrator), + identityHandler.UpdateUserRegistrationStatusByIdentityCard, + ) + +} diff --git a/internal/identitycart/identitycart_service.go b/internal/identitycart/identitycart_service.go new file mode 100644 index 0000000..0c557f9 --- /dev/null +++ b/internal/identitycart/identitycart_service.go @@ -0,0 +1,478 @@ +package identitycart + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "os" + "path/filepath" + "rijig/internal/authentication" + "rijig/internal/role" + "rijig/internal/userprofile" + "rijig/model" + "rijig/utils" + "time" +) + +type IdentityCardService interface { + CreateIdentityCard(ctx context.Context, userID, deviceID string, request *RequestIdentityCardDTO, cardPhoto *multipart.FileHeader) (*authentication.AuthResponse, error) + GetIdentityCardByID(ctx context.Context, id string) (*ResponseIdentityCardDTO, error) + GetIdentityCardsByUserID(ctx context.Context, userID string) ([]ResponseIdentityCardDTO, error) + UpdateIdentityCard(ctx context.Context, userID string, id string, request *RequestIdentityCardDTO, cardPhoto *multipart.FileHeader) (*ResponseIdentityCardDTO, error) + + GetAllIdentityCardsByRegStatus(ctx context.Context, userRegStatus string) ([]ResponseIdentityCardDTO, error) + UpdateUserRegistrationStatusByIdentityCard(ctx context.Context, identityCardUserID string, newStatus string) error +} + +type identityCardService struct { + identityRepo IdentityCardRepository + authRepo authentication.AuthenticationRepository + userRepo userprofile.UserProfileRepository +} + +func NewIdentityCardService(identityRepo IdentityCardRepository, authRepo authentication.AuthenticationRepository, userRepo userprofile.UserProfileRepository) IdentityCardService { + return &identityCardService{ + identityRepo, + authRepo, userRepo, + } +} + +type IdentityCardWithUserDTO struct { + IdentityCard ResponseIdentityCardDTO `json:"identity_card"` + User userprofile.UserProfileResponseDTO `json:"user"` +} + +func FormatResponseIdentityCard(identityCard *model.IdentityCard) (*ResponseIdentityCardDTO, error) { + createdAt, _ := utils.FormatDateToIndonesianFormat(identityCard.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(identityCard.UpdatedAt) + + return &ResponseIdentityCardDTO{ + ID: identityCard.ID, + UserID: identityCard.UserID, + Identificationumber: identityCard.Identificationumber, + Placeofbirth: identityCard.Placeofbirth, + Dateofbirth: identityCard.Dateofbirth, + Gender: identityCard.Gender, + BloodType: identityCard.BloodType, + Province: identityCard.Province, + District: identityCard.District, + SubDistrict: identityCard.SubDistrict, + Hamlet: identityCard.Hamlet, + Village: identityCard.Village, + Neighbourhood: identityCard.Neighbourhood, + PostalCode: identityCard.PostalCode, + Religion: identityCard.Religion, + Maritalstatus: identityCard.Maritalstatus, + Job: identityCard.Job, + Citizenship: identityCard.Citizenship, + Validuntil: identityCard.Validuntil, + Cardphoto: identityCard.Cardphoto, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func (s *identityCardService) saveIdentityCardImage(userID string, cardPhoto *multipart.FileHeader) (string, error) { + pathImage := "/uploads/identitycards/" + cardPhotoDir := "./public" + os.Getenv("BASE_URL") + pathImage + if _, err := os.Stat(cardPhotoDir); os.IsNotExist(err) { + + if err := os.MkdirAll(cardPhotoDir, os.ModePerm); err != nil { + return "", fmt.Errorf("failed to create directory for identity card photo: %v", err) + } + } + + allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true} + extension := filepath.Ext(cardPhoto.Filename) + if !allowedExtensions[extension] { + return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, and .png are allowed") + } + + cardPhotoFileName := fmt.Sprintf("%s_cardphoto%s", userID, extension) + cardPhotoPath := filepath.Join(cardPhotoDir, cardPhotoFileName) + + src, err := cardPhoto.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %v", err) + } + defer src.Close() + + dst, err := os.Create(cardPhotoPath) + if err != nil { + return "", fmt.Errorf("failed to create card photo file: %v", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return "", fmt.Errorf("failed to save card photo: %v", err) + } + + cardPhotoURL := fmt.Sprintf("%s%s", pathImage, cardPhotoFileName) + + return cardPhotoURL, 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 *identityCardService) CreateIdentityCard(ctx context.Context, userID, deviceID string, request *RequestIdentityCardDTO, cardPhoto *multipart.FileHeader) (*authentication.AuthResponse, error) { + + cardPhotoPath, err := s.saveIdentityCardImage(userID, cardPhoto) + if err != nil { + return nil, fmt.Errorf("failed to save card photo: %v", err) + } + + identityCard := &model.IdentityCard{ + UserID: userID, + Identificationumber: request.Identificationumber, + Placeofbirth: request.Placeofbirth, + Dateofbirth: request.Dateofbirth, + Gender: request.Gender, + BloodType: request.BloodType, + Province: request.Province, + District: request.District, + SubDistrict: request.SubDistrict, + Hamlet: request.Hamlet, + Village: request.Village, + Neighbourhood: request.Neighbourhood, + PostalCode: request.PostalCode, + Religion: request.Religion, + Maritalstatus: request.Maritalstatus, + Job: request.Job, + Citizenship: request.Citizenship, + Validuntil: request.Validuntil, + Cardphoto: cardPhotoPath, + } + + _, err = s.identityRepo.CreateIdentityCard(ctx, identityCard) + if err != nil { + log.Printf("Error creating identity card: %v", err) + return nil, fmt.Errorf("failed to create identity card: %v", err) + } + + 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: "identity card 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 *identityCardService) GetIdentityCardByID(ctx context.Context, id string) (*ResponseIdentityCardDTO, error) { + identityCard, err := s.identityRepo.GetIdentityCardByID(ctx, id) + if err != nil { + log.Printf("Error fetching identity card: %v", err) + return nil, fmt.Errorf("failed to fetch identity card") + } + return FormatResponseIdentityCard(identityCard) +} + +func (s *identityCardService) GetIdentityCardsByUserID(ctx context.Context, userID string) ([]ResponseIdentityCardDTO, error) { + identityCards, err := s.identityRepo.GetIdentityCardsByUserID(ctx, userID) + if err != nil { + log.Printf("Error fetching identity cards by userID: %v", err) + return nil, fmt.Errorf("failed to fetch identity cards by userID") + } + + var response []ResponseIdentityCardDTO + for _, card := range identityCards { + dto, _ := FormatResponseIdentityCard(&card) + response = append(response, *dto) + } + return response, nil +} + +func (s *identityCardService) UpdateIdentityCard(ctx context.Context, userID string, id string, request *RequestIdentityCardDTO, cardPhoto *multipart.FileHeader) (*ResponseIdentityCardDTO, error) { + + errors, valid := request.ValidateIdentityCardInput() + if !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + identityCard, err := s.identityRepo.GetIdentityCardByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("identity card not found: %v", err) + } + + if identityCard.Cardphoto != "" { + err := deleteIdentityCardImage(identityCard.Cardphoto) + if err != nil { + return nil, fmt.Errorf("failed to delete old image: %v", err) + } + } + + var cardPhotoPath string + if cardPhoto != nil { + cardPhotoPath, err = s.saveIdentityCardImage(userID, cardPhoto) + if err != nil { + return nil, fmt.Errorf("failed to save card photo: %v", err) + } + } + + identityCard.Identificationumber = request.Identificationumber + identityCard.Placeofbirth = request.Placeofbirth + identityCard.Dateofbirth = request.Dateofbirth + identityCard.Gender = request.Gender + identityCard.BloodType = request.BloodType + identityCard.Province = request.Province + identityCard.District = request.District + identityCard.SubDistrict = request.SubDistrict + identityCard.Hamlet = request.Hamlet + identityCard.Village = request.Village + identityCard.Neighbourhood = request.Neighbourhood + identityCard.PostalCode = request.PostalCode + identityCard.Religion = request.Religion + identityCard.Maritalstatus = request.Maritalstatus + identityCard.Job = request.Job + identityCard.Citizenship = request.Citizenship + identityCard.Validuntil = request.Validuntil + if cardPhotoPath != "" { + identityCard.Cardphoto = cardPhotoPath + } + + if err != nil { + log.Printf("Error updating identity card: %v", err) + return nil, fmt.Errorf("failed to update identity card: %v", err) + } + + idcardResponseDTO, _ := FormatResponseIdentityCard(identityCard) + + return idcardResponseDTO, nil +} + +func (s *identityCardService) GetAllIdentityCardsByRegStatus(ctx context.Context, userRegStatus string) ([]ResponseIdentityCardDTO, error) { + identityCards, err := s.authRepo.GetIdentityCardsByUserRegStatus(ctx, userRegStatus) + if err != nil { + log.Printf("Error getting identity cards by registration status: %v", err) + return nil, fmt.Errorf("failed to get identity cards: %w", err) + } + + var response []ResponseIdentityCardDTO + for _, card := range identityCards { + createdAt, _ := utils.FormatDateToIndonesianFormat(card.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(card.UpdatedAt) + dto := ResponseIdentityCardDTO{ + ID: card.ID, + UserID: card.UserID, + Identificationumber: card.Identificationumber, + Placeofbirth: card.Placeofbirth, + Dateofbirth: card.Dateofbirth, + Gender: card.Gender, + BloodType: card.BloodType, + Province: card.Province, + District: card.District, + SubDistrict: card.SubDistrict, + Hamlet: card.Hamlet, + Village: card.Village, + Neighbourhood: card.Neighbourhood, + PostalCode: card.PostalCode, + Religion: card.Religion, + Maritalstatus: card.Maritalstatus, + Job: card.Job, + Citizenship: card.Citizenship, + Validuntil: card.Validuntil, + Cardphoto: card.Cardphoto, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + response = append(response, dto) + } + + return response, nil +} + +func (s *identityCardService) UpdateUserRegistrationStatusByIdentityCard(ctx context.Context, identityCardUserID string, newStatus string) error { + + user, err := s.authRepo.FindUserByID(ctx, identityCardUserID) + if err != nil { + log.Printf("Error finding user by ID %s: %v", identityCardUserID, err) + return fmt.Errorf("user not found: %w", err) + } + + updates := map[string]interface{}{ + "registration_status": newStatus, + "updated_at": time.Now(), + } + + switch newStatus { + case utils.RegStatusConfirmed: + updates["registration_progress"] = utils.ProgressDataSubmitted + + identityCards, err := s.GetIdentityCardsByUserID(ctx, identityCardUserID) + if err != nil { + log.Printf("Error fetching identity cards for user ID %s: %v", identityCardUserID, err) + return fmt.Errorf("failed to fetch identity card data: %w", err) + } + + if len(identityCards) == 0 { + log.Printf("No identity card found for user ID %s", identityCardUserID) + return fmt.Errorf("no identity card found for user") + } + + identityCard := identityCards[0] + + updates["name"] = identityCard.Fullname + updates["gender"] = identityCard.Gender + updates["dateofbirth"] = identityCard.Dateofbirth + updates["placeofbirth"] = identityCard.District + + log.Printf("Syncing user data for ID %s: name=%s, gender=%s, dob=%s, pob=%s", + identityCardUserID, identityCard.Fullname, identityCard.Gender, + identityCard.Dateofbirth, identityCard.District) + + case utils.RegStatusRejected: + updates["registration_progress"] = utils.ProgressOTPVerified + + } + + err = s.authRepo.PatchUser(ctx, user.ID, updates) + if err != nil { + log.Printf("Error updating user registration status for user ID %s: %v", user.ID, err) + return fmt.Errorf("failed to update user registration status: %w", err) + } + + log.Printf("Successfully updated registration status for user ID %s to %s", user.ID, newStatus) + + if newStatus == utils.RegStatusConfirmed { + log.Printf("User profile data synced successfully for user ID %s", user.ID) + } + + return nil +} + +func (s *identityCardService) mapIdentityCardToDTO(card model.IdentityCard) ResponseIdentityCardDTO { + createdAt, _ := utils.FormatDateToIndonesianFormat(card.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(card.UpdatedAt) + return ResponseIdentityCardDTO{ + ID: card.ID, + UserID: card.UserID, + Identificationumber: card.Identificationumber, + Placeofbirth: card.Placeofbirth, + Dateofbirth: card.Dateofbirth, + Gender: card.Gender, + BloodType: card.BloodType, + Province: card.Province, + District: card.District, + SubDistrict: card.SubDistrict, + Hamlet: card.Hamlet, + Village: card.Village, + Neighbourhood: card.Neighbourhood, + PostalCode: card.PostalCode, + Religion: card.Religion, + Maritalstatus: card.Maritalstatus, + Job: card.Job, + Citizenship: card.Citizenship, + Validuntil: card.Validuntil, + Cardphoto: card.Cardphoto, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} + +func (s *identityCardService) mapUserToDTO(user model.User) userprofile.UserProfileResponseDTO { + avatar := "" + if user.Avatar != nil { + avatar = *user.Avatar + } + + var roleDTO role.RoleResponseDTO + if user.Role != nil { + roleDTO = role.RoleResponseDTO{ + ID: user.Role.ID, + RoleName: user.Role.RoleName, + } + } + + createdAt, _ := utils.FormatDateToIndonesianFormat(user.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(user.UpdatedAt) + return userprofile.UserProfileResponseDTO{ + ID: user.ID, + Avatar: avatar, + Name: user.Name, + Gender: user.Gender, + Dateofbirth: user.Dateofbirth, + Placeofbirth: user.Placeofbirth, + Phone: user.Phone, + Email: user.Email, + PhoneVerified: user.PhoneVerified, + Role: roleDTO, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} diff --git a/internal/repositories/address_repo.go b/internal/repositories/address_repo.go deleted file mode 100644 index 1661b6b..0000000 --- a/internal/repositories/address_repo.go +++ /dev/null @@ -1,61 +0,0 @@ -package repositories - -import ( - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type AddressRepository interface { - CreateAddress(address *model.Address) error - FindAddressByUserID(userID string) ([]model.Address, error) - FindAddressByID(id string) (*model.Address, error) - UpdateAddress(address *model.Address) error - DeleteAddress(id string) error -} - -type addressRepository struct { - DB *gorm.DB -} - -func NewAddressRepository(db *gorm.DB) AddressRepository { - return &addressRepository{DB: db} -} - -func (r *addressRepository) CreateAddress(address *model.Address) error { - return r.DB.Create(address).Error -} - - -func (r *addressRepository) FindAddressByUserID(userID string) ([]model.Address, error) { - var addresses []model.Address - err := r.DB.Where("user_id = ?", userID).Find(&addresses).Error - if err != nil { - return nil, err - } - return addresses, nil -} - -func (r *addressRepository) FindAddressByID(id string) (*model.Address, error) { - var address model.Address - err := r.DB.Where("id = ?", id).First(&address).Error - if err != nil { - return nil, err - } - return &address, nil -} - -func (r *addressRepository) UpdateAddress(address *model.Address) error { - err := r.DB.Save(address).Error - if err != nil { - return err - } - return nil -} - -func (r *addressRepository) DeleteAddress(id string) error { - err := r.DB.Where("id = ?", id).Delete(&model.Address{}).Error - if err != nil { - return err - } - return nil -} \ No newline at end of file diff --git a/internal/repositories/article_repo.go b/internal/repositories/article_repo.go deleted file mode 100644 index 79f2fcb..0000000 --- a/internal/repositories/article_repo.go +++ /dev/null @@ -1,74 +0,0 @@ -package repositories - -import ( - "fmt" - - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type ArticleRepository interface { - CreateArticle(article *model.Article) error - FindArticleByID(id string) (*model.Article, error) - FindAllArticles(page, limit int) ([]model.Article, int, error) - UpdateArticle(id string, article *model.Article) error - DeleteArticle(id string) error -} - -type articleRepository struct { - DB *gorm.DB -} - -func NewArticleRepository(db *gorm.DB) ArticleRepository { - return &articleRepository{DB: db} -} - -func (r *articleRepository) CreateArticle(article *model.Article) error { - return r.DB.Create(article).Error -} - -func (r *articleRepository) FindArticleByID(id string) (*model.Article, error) { - var article model.Article - err := r.DB.Where("id = ?", id).First(&article).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("article with ID %s not found", id) - } - return nil, fmt.Errorf("failed to fetch article: %v", err) - } - return &article, nil -} - -func (r *articleRepository) FindAllArticles(page, limit int) ([]model.Article, int, error) { - var articles []model.Article - var total int64 - - if err := r.DB.Model(&model.Article{}).Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count articles: %v", err) - } - - fmt.Printf("Total Articles Count: %d\n", total) - - if page > 0 && limit > 0 { - err := r.DB.Offset((page - 1) * limit).Limit(limit).Find(&articles).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch articles: %v", err) - } - } else { - err := r.DB.Find(&articles).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch articles: %v", err) - } - } - - return articles, int(total), nil -} - -func (r *articleRepository) UpdateArticle(id string, article *model.Article) error { - return r.DB.Model(&model.Article{}).Where("id = ?", id).Updates(article).Error -} - -func (r *articleRepository) DeleteArticle(id string) error { - result := r.DB.Delete(&model.Article{}, "id = ?", id) - return result.Error -} \ No newline at end of file diff --git a/internal/repositories/auth_repo.go b/internal/repositories/auth_repo.go deleted file mode 100644 index 2b0622f..0000000 --- a/internal/repositories/auth_repo.go +++ /dev/null @@ -1,82 +0,0 @@ -package repositories - -import ( - "fmt" - - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type UserRepository interface { - FindByIdentifierAndRole(identifier, roleID string) (*model.User, error) - FindByEmailOrUsernameOrPhone(identifier string) (*model.User, error) - FindByUsername(username string) (*model.User, error) - FindByPhoneAndRole(phone, roleID string) (*model.User, error) - FindByEmailAndRole(email, roleID string) (*model.User, error) - - Create(user *model.User) error -} - -type userRepository struct { - DB *gorm.DB -} - -func NewUserRepository(db *gorm.DB) UserRepository { - return &userRepository{DB: db} -} - -func (r *userRepository) FindByIdentifierAndRole(identifier, roleID string) (*model.User, error) { - var user model.User - err := r.DB.Preload("Role").Where("(email = ? OR username = ? OR phone = ?) AND role_id = ?", identifier, identifier, identifier, roleID).First(&user).Error - if err != nil { - return nil, err - } - if user.Role == nil { - return nil, fmt.Errorf("role not found for this user") - } - return &user, nil -} - -func (r *userRepository) FindByUsername(username string) (*model.User, error) { - var user model.User - err := r.DB.Where("username = ?", username).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *userRepository) FindByPhoneAndRole(phone, roleID string) (*model.User, error) { - var user model.User - err := r.DB.Where("phone = ? AND role_id = ?", phone, roleID).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *userRepository) FindByEmailAndRole(email, roleID string) (*model.User, error) { - var user model.User - err := r.DB.Where("email = ? AND role_id = ?", email, roleID).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *userRepository) FindByEmailOrUsernameOrPhone(identifier string) (*model.User, error) { - var user model.User - err := r.DB.Where("email = ? OR username = ? OR phone = ?", identifier, identifier, identifier).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *userRepository) Create(user *model.User) error { - err := r.DB.Create(user).Error - if err != nil { - return err - } - return nil -} diff --git a/internal/repositories/banner_repo.go b/internal/repositories/banner_repo.go deleted file mode 100644 index 8f553f9..0000000 --- a/internal/repositories/banner_repo.go +++ /dev/null @@ -1,69 +0,0 @@ -package repositories - -import ( - "fmt" - - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type BannerRepository interface { - CreateBanner(banner *model.Banner) error - FindBannerByID(id string) (*model.Banner, error) - FindAllBanners() ([]model.Banner, error) - UpdateBanner(id string, banner *model.Banner) error - DeleteBanner(id string) error -} - -type bannerRepository struct { - DB *gorm.DB -} - -func NewBannerRepository(db *gorm.DB) BannerRepository { - return &bannerRepository{DB: db} -} - -func (r *bannerRepository) CreateBanner(banner *model.Banner) error { - if err := r.DB.Create(banner).Error; err != nil { - return fmt.Errorf("failed to create banner: %v", err) - } - return nil -} - -func (r *bannerRepository) FindBannerByID(id string) (*model.Banner, error) { - var banner model.Banner - err := r.DB.Where("id = ?", id).First(&banner).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("banner with ID %s not found", id) - } - return nil, fmt.Errorf("failed to fetch banner by ID: %v", err) - } - return &banner, nil -} - -func (r *bannerRepository) FindAllBanners() ([]model.Banner, error) { - var banners []model.Banner - err := r.DB.Find(&banners).Error - if err != nil { - return nil, fmt.Errorf("failed to fetch banners: %v", err) - } - - return banners, nil -} - -func (r *bannerRepository) UpdateBanner(id string, banner *model.Banner) error { - err := r.DB.Model(&model.Banner{}).Where("id = ?", id).Updates(banner).Error - if err != nil { - return fmt.Errorf("failed to update banner: %v", err) - } - return nil -} - -func (r *bannerRepository) DeleteBanner(id string) error { - result := r.DB.Delete(&model.Banner{}, "id = ?", id) - if result.Error != nil { - return fmt.Errorf("failed to delete banner: %v", result.Error) - } - return nil -} diff --git a/internal/repositories/initialcoint_repo.go b/internal/repositories/initialcoint_repo.go deleted file mode 100644 index 0a64709..0000000 --- a/internal/repositories/initialcoint_repo.go +++ /dev/null @@ -1,67 +0,0 @@ -package repositories - -import ( - "fmt" - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type InitialCointRepository interface { - CreateInitialCoint(coint *model.InitialCoint) error - FindInitialCointByID(id string) (*model.InitialCoint, error) - FindAllInitialCoints() ([]model.InitialCoint, error) - UpdateInitialCoint(id string, coint *model.InitialCoint) error - DeleteInitialCoint(id string) error -} - -type initialCointRepository struct { - DB *gorm.DB -} - -func NewInitialCointRepository(db *gorm.DB) InitialCointRepository { - return &initialCointRepository{DB: db} -} - -func (r *initialCointRepository) CreateInitialCoint(coint *model.InitialCoint) error { - if err := r.DB.Create(coint).Error; err != nil { - return fmt.Errorf("failed to create initial coint: %v", err) - } - return nil -} - -func (r *initialCointRepository) FindInitialCointByID(id string) (*model.InitialCoint, error) { - var coint model.InitialCoint - err := r.DB.Where("id = ?", id).First(&coint).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("initial coint with ID %s not found", id) - } - return nil, fmt.Errorf("failed to fetch initial coint by ID: %v", err) - } - return &coint, nil -} - -func (r *initialCointRepository) FindAllInitialCoints() ([]model.InitialCoint, error) { - var coints []model.InitialCoint - err := r.DB.Find(&coints).Error - if err != nil { - return nil, fmt.Errorf("failed to fetch initial coints: %v", err) - } - return coints, nil -} - -func (r *initialCointRepository) UpdateInitialCoint(id string, coint *model.InitialCoint) error { - err := r.DB.Model(&model.InitialCoint{}).Where("id = ?", id).Updates(coint).Error - if err != nil { - return fmt.Errorf("failed to update initial coint: %v", err) - } - return nil -} - -func (r *initialCointRepository) DeleteInitialCoint(id string) error { - result := r.DB.Delete(&model.InitialCoint{}, "id = ?", id) - if result.Error != nil { - return fmt.Errorf("failed to delete initial coint: %v", result.Error) - } - return nil -} diff --git a/internal/repositories/role_repo.go b/internal/repositories/role_repo.go deleted file mode 100644 index 7abea10..0000000 --- a/internal/repositories/role_repo.go +++ /dev/null @@ -1,37 +0,0 @@ -package repositories - -import ( - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type RoleRepository interface { - FindByID(id string) (*model.Role, error) - FindAll() ([]model.Role, error) -} - -type roleRepository struct { - DB *gorm.DB -} - -func NewRoleRepository(db *gorm.DB) RoleRepository { - return &roleRepository{DB: db} -} - -func (r *roleRepository) FindByID(id string) (*model.Role, error) { - var role model.Role - err := r.DB.Where("id = ?", id).First(&role).Error - if err != nil { - return nil, err - } - return &role, nil -} - -func (r *roleRepository) FindAll() ([]model.Role, error) { - var roles []model.Role - err := r.DB.Find(&roles).Error - if err != nil { - return nil, err - } - return roles, nil -} diff --git a/internal/repositories/trash_repo.go b/internal/repositories/trash_repo.go deleted file mode 100644 index a41e84d..0000000 --- a/internal/repositories/trash_repo.go +++ /dev/null @@ -1,105 +0,0 @@ -package repositories - -import ( - "fmt" - - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type TrashRepository interface { - CreateCategory(category *model.TrashCategory) error - AddDetailToCategory(detail *model.TrashDetail) error - GetCategories() ([]model.TrashCategory, error) - GetCategoryByID(id string) (*model.TrashCategory, error) - GetTrashDetailByID(id string) (*model.TrashDetail, error) - GetDetailsByCategoryID(categoryID string) ([]model.TrashDetail, error) - UpdateCategoryName(id string, newName string) error - UpdateTrashDetail(id string, description string, price float64) error - DeleteCategory(id string) error - DeleteTrashDetail(id string) error -} - -type trashRepository struct { - DB *gorm.DB -} - -func NewTrashRepository(db *gorm.DB) TrashRepository { - return &trashRepository{DB: db} -} - -func (r *trashRepository) CreateCategory(category *model.TrashCategory) error { - if err := r.DB.Create(category).Error; err != nil { - return fmt.Errorf("failed to create category: %v", err) - } - return nil -} - -func (r *trashRepository) AddDetailToCategory(detail *model.TrashDetail) error { - if err := r.DB.Create(detail).Error; err != nil { - return fmt.Errorf("failed to add detail to category: %v", err) - } - return nil -} - -func (r *trashRepository) GetCategories() ([]model.TrashCategory, error) { - var categories []model.TrashCategory - if err := r.DB.Preload("Details").Find(&categories).Error; err != nil { - return nil, fmt.Errorf("failed to fetch categories: %v", err) - } - return categories, nil -} - -func (r *trashRepository) GetCategoryByID(id string) (*model.TrashCategory, error) { - var category model.TrashCategory - - if err := r.DB.Preload("Details").First(&category, "id = ?", id).Error; err != nil { - return nil, fmt.Errorf("category not found: %v", err) - } - return &category, nil -} - -func (r *trashRepository) GetTrashDetailByID(id string) (*model.TrashDetail, error) { - var detail model.TrashDetail - if err := r.DB.First(&detail, "id = ?", id).Error; err != nil { - return nil, fmt.Errorf("trash detail not found: %v", err) - } - return &detail, nil -} - -func (r *trashRepository) GetDetailsByCategoryID(categoryID string) ([]model.TrashDetail, error) { - var details []model.TrashDetail - - if err := r.DB.Where("category_id = ?", categoryID).Find(&details).Error; err != nil { - return nil, fmt.Errorf("failed to fetch details for category %s: %v", categoryID, err) - } - return details, nil -} - -func (r *trashRepository) UpdateCategoryName(id string, newName string) error { - if err := r.DB.Model(&model.TrashCategory{}).Where("id = ?", id).Update("name", newName).Error; err != nil { - return fmt.Errorf("failed to update category name: %v", err) - } - return nil -} - -func (r *trashRepository) UpdateTrashDetail(id string, description string, price float64) error { - if err := r.DB.Model(&model.TrashDetail{}).Where("id = ?", id).Updates(model.TrashDetail{Description: description, Price: price}).Error; err != nil { - return fmt.Errorf("failed to update trash detail: %v", err) - } - return nil -} - -func (r *trashRepository) DeleteCategory(id string) error { - if err := r.DB.Delete(&model.TrashCategory{}, "id = ?", id).Error; err != nil { - return fmt.Errorf("failed to delete category: %v", err) - } - return nil -} - -func (r *trashRepository) DeleteTrashDetail(id string) error { - if err := r.DB.Delete(&model.TrashDetail{}, "id = ?", id).Error; err != nil { - return fmt.Errorf("failed to delete trash detail: %v", err) - } - return nil -} diff --git a/internal/repositories/user_repo.go b/internal/repositories/user_repo.go deleted file mode 100644 index 604d2db..0000000 --- a/internal/repositories/user_repo.go +++ /dev/null @@ -1,56 +0,0 @@ -package repositories - -import ( - "fmt" - - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type UserProfileRepository interface { - FindByID(userID string) (*model.User, error) - Update(user *model.User) error - UpdateAvatar(userID, avatarURL string) error -} - -type userProfileRepository struct { - DB *gorm.DB -} - -func NewUserProfileRepository(db *gorm.DB) UserProfileRepository { - return &userProfileRepository{DB: db} -} - -func (r *userProfileRepository) FindByID(userID string) (*model.User, error) { - var user model.User - err := r.DB.Preload("Role").Where("id = ?", userID).First(&user).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("user with ID %s not found", userID) - } - return nil, err - } - - if user.Role == nil { - return nil, fmt.Errorf("role not found for this user") - } - - return &user, nil -} - -func (r *userProfileRepository) Update(user *model.User) error { - err := r.DB.Save(user).Error - if err != nil { - return err - } - return nil -} - -func (r *userProfileRepository) UpdateAvatar(userID, avatarURL string) error { - var user model.User - err := r.DB.Model(&user).Where("id = ?", userID).Update("avatar", avatarURL).Error - if err != nil { - return err - } - return nil -} diff --git a/internal/repositories/userpin_repo.go b/internal/repositories/userpin_repo.go deleted file mode 100644 index 057964e..0000000 --- a/internal/repositories/userpin_repo.go +++ /dev/null @@ -1,59 +0,0 @@ -package repositories - -import ( - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type UserPinRepository interface { - FindByUserID(userID string) (*model.UserPin, error) - FindByPin(userPin string) (*model.UserPin, error) - Create(userPin *model.UserPin) error - Update(userPin *model.UserPin) error -} - -type userPinRepository struct { - DB *gorm.DB -} - -func NewUserPinRepository(db *gorm.DB) UserPinRepository { - return &userPinRepository{DB: db} -} - -func (r *userPinRepository) FindByUserID(userID string) (*model.UserPin, error) { - var userPin model.UserPin - err := r.DB.Where("user_id = ?", userID).First(&userPin).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - - return nil, nil - } - return nil, err - } - return &userPin, nil -} - -func (r *userPinRepository) FindByPin(pin string) (*model.UserPin, error) { - var userPin model.UserPin - err := r.DB.Where("pin = ?", pin).First(&userPin).Error - if err != nil { - return nil, err - } - return &userPin, nil -} - -func (r *userPinRepository) Create(userPin *model.UserPin) error { - err := r.DB.Create(userPin).Error - if err != nil { - return err - } - return nil -} - -func (r *userPinRepository) Update(userPin *model.UserPin) error { - err := r.DB.Save(userPin).Error - if err != nil { - return err - } - return nil -} diff --git a/internal/repositories/wilayah_indonesia_repo.go b/internal/repositories/wilayah_indonesia_repo.go deleted file mode 100644 index ea4bd89..0000000 --- a/internal/repositories/wilayah_indonesia_repo.go +++ /dev/null @@ -1,243 +0,0 @@ -package repositories - -import ( - "github.com/pahmiudahgede/senggoldong/model" - "gorm.io/gorm" -) - -type WilayahIndonesiaRepository interface { - ImportProvinces(provinces []model.Province) error - ImportRegencies(regencies []model.Regency) error - ImportDistricts(districts []model.District) error - ImportVillages(villages []model.Village) error - - FindAllProvinces(page, limit int) ([]model.Province, int, error) - FindProvinceByID(id string, page, limit int) (*model.Province, int, error) - - FindAllRegencies(page, limit int) ([]model.Regency, int, error) - FindRegencyByID(id string, page, limit int) (*model.Regency, int, error) - - FindAllDistricts(page, limit int) ([]model.District, int, error) - FindDistrictByID(id string, page, limit int) (*model.District, int, error) - - FindAllVillages(page, limit int) ([]model.Village, int, error) - FindVillageByID(id string) (*model.Village, error) -} - -type wilayahIndonesiaRepository struct { - DB *gorm.DB -} - -func NewWilayahIndonesiaRepository(db *gorm.DB) WilayahIndonesiaRepository { - return &wilayahIndonesiaRepository{DB: db} -} - -func (r *wilayahIndonesiaRepository) ImportProvinces(provinces []model.Province) error { - for _, province := range provinces { - if err := r.DB.Create(&province).Error; err != nil { - return err - } - } - return nil -} - -func (r *wilayahIndonesiaRepository) ImportRegencies(regencies []model.Regency) error { - for _, regency := range regencies { - if err := r.DB.Create(®ency).Error; err != nil { - return err - } - } - return nil -} - -func (r *wilayahIndonesiaRepository) ImportDistricts(districts []model.District) error { - for _, district := range districts { - if err := r.DB.Create(&district).Error; err != nil { - return err - } - } - return nil -} - -func (r *wilayahIndonesiaRepository) ImportVillages(villages []model.Village) error { - for _, village := range villages { - if err := r.DB.Create(&village).Error; err != nil { - return err - } - } - return nil -} - -func (r *wilayahIndonesiaRepository) FindAllProvinces(page, limit int) ([]model.Province, int, error) { - var provinces []model.Province - var total int64 - - err := r.DB.Model(&model.Province{}).Count(&total).Error - if err != nil { - return nil, 0, err - } - - if page > 0 && limit > 0 { - err := r.DB.Offset((page - 1) * limit).Limit(limit).Find(&provinces).Error - if err != nil { - return nil, 0, err - } - } else { - - err := r.DB.Find(&provinces).Error - if err != nil { - return nil, 0, err - } - } - - return provinces, int(total), nil -} - -func (r *wilayahIndonesiaRepository) FindProvinceByID(id string, page, limit int) (*model.Province, int, error) { - var province model.Province - - err := r.DB.Preload("Regencies", func(db *gorm.DB) *gorm.DB { - if page > 0 && limit > 0 { - - return db.Offset((page - 1) * limit).Limit(limit) - } - - return db - }).Where("id = ?", id).First(&province).Error - if err != nil { - return nil, 0, err - } - - var totalRegencies int64 - r.DB.Model(&model.Regency{}).Where("province_id = ?", id).Count(&totalRegencies) - - return &province, int(totalRegencies), nil -} - -func (r *wilayahIndonesiaRepository) FindAllRegencies(page, limit int) ([]model.Regency, int, error) { - var regencies []model.Regency - var total int64 - - err := r.DB.Model(&model.Regency{}).Count(&total).Error - if err != nil { - return nil, 0, err - } - - if page > 0 && limit > 0 { - err := r.DB.Offset((page - 1) * limit).Limit(limit).Find(®encies).Error - if err != nil { - return nil, 0, err - } - } else { - - err := r.DB.Find(®encies).Error - if err != nil { - return nil, 0, err - } - } - - return regencies, int(total), nil -} - -func (r *wilayahIndonesiaRepository) FindRegencyByID(id string, page, limit int) (*model.Regency, int, error) { - var regency model.Regency - - err := r.DB.Preload("Districts", func(db *gorm.DB) *gorm.DB { - if page > 0 && limit > 0 { - return db.Offset((page - 1) * limit).Limit(limit) - } - return db - }).Where("id = ?", id).First(®ency).Error - - if err != nil { - return nil, 0, err - } - - var totalDistricts int64 - err = r.DB.Model(&model.District{}).Where("regency_id = ?", id).Count(&totalDistricts).Error - if err != nil { - return nil, 0, err - } - - return ®ency, int(totalDistricts), nil -} - -func (r *wilayahIndonesiaRepository) FindAllDistricts(page, limit int) ([]model.District, int, error) { - var district []model.District - var total int64 - - err := r.DB.Model(&model.District{}).Count(&total).Error - if err != nil { - return nil, 0, err - } - - if page > 0 && limit > 0 { - err := r.DB.Offset((page - 1) * limit).Limit(limit).Find(&district).Error - if err != nil { - return nil, 0, err - } - } else { - - err := r.DB.Find(&district).Error - if err != nil { - return nil, 0, err - } - } - - return district, int(total), nil -} - -func (r *wilayahIndonesiaRepository) FindDistrictByID(id string, page, limit int) (*model.District, int, error) { - var district model.District - - err := r.DB.Preload("Villages", func(db *gorm.DB) *gorm.DB { - if page > 0 && limit > 0 { - - return db.Offset((page - 1) * limit).Limit(limit) - } - - return db - }).Where("id = ?", id).First(&district).Error - if err != nil { - return nil, 0, err - } - - var totalVillage int64 - r.DB.Model(&model.Village{}).Where("district_id = ?", id).Count(&totalVillage) - - return &district, int(totalVillage), nil -} - -func (r *wilayahIndonesiaRepository) FindAllVillages(page, limit int) ([]model.Village, int, error) { - var villages []model.Village - var total int64 - - err := r.DB.Model(&model.Village{}).Count(&total).Error - if err != nil { - return nil, 0, err - } - - if page > 0 && limit > 0 { - err := r.DB.Offset((page - 1) * limit).Limit(limit).Find(&villages).Error - if err != nil { - return nil, 0, err - } - } else { - - err := r.DB.Find(&villages).Error - if err != nil { - return nil, 0, err - } - } - - return villages, int(total), nil -} - -func (r *wilayahIndonesiaRepository) FindVillageByID(id string) (*model.Village, error) { - var village model.Village - err := r.DB.Where("id = ?", id).First(&village).Error - if err != nil { - return nil, err - } - return &village, nil -} \ No newline at end of file diff --git a/internal/requestpickup/pickup_history_repository.go b/internal/requestpickup/pickup_history_repository.go new file mode 100644 index 0000000..d8af037 --- /dev/null +++ b/internal/requestpickup/pickup_history_repository.go @@ -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 +} diff --git a/internal/requestpickup/pickup_maching_service.go b/internal/requestpickup/pickup_maching_service.go new file mode 100644 index 0000000..4ecf57b --- /dev/null +++ b/internal/requestpickup/pickup_maching_service.go @@ -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 +} diff --git a/internal/requestpickup/pickup_matching_handler.go b/internal/requestpickup/pickup_matching_handler.go new file mode 100644 index 0000000..b3dc2f7 --- /dev/null +++ b/internal/requestpickup/pickup_matching_handler.go @@ -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) +} diff --git a/internal/requestpickup/requestpickup_dto.go b/internal/requestpickup/requestpickup_dto.go new file mode 100644 index 0000000..2ddf72e --- /dev/null +++ b/internal/requestpickup/requestpickup_dto.go @@ -0,0 +1,74 @@ +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 +} diff --git a/internal/requestpickup/requestpickup_handler.go b/internal/requestpickup/requestpickup_handler.go new file mode 100644 index 0000000..df31b39 --- /dev/null +++ b/internal/requestpickup/requestpickup_handler.go @@ -0,0 +1 @@ +package requestpickup \ No newline at end of file diff --git a/internal/requestpickup/requestpickup_repository.go b/internal/requestpickup/requestpickup_repository.go new file mode 100644 index 0000000..7d2861a --- /dev/null +++ b/internal/requestpickup/requestpickup_repository.go @@ -0,0 +1,142 @@ +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 +} diff --git a/internal/requestpickup/requestpickup_route.go b/internal/requestpickup/requestpickup_route.go new file mode 100644 index 0000000..df31b39 --- /dev/null +++ b/internal/requestpickup/requestpickup_route.go @@ -0,0 +1 @@ +package requestpickup \ No newline at end of file diff --git a/internal/requestpickup/requestpickup_service.go b/internal/requestpickup/requestpickup_service.go new file mode 100644 index 0000000..df31b39 --- /dev/null +++ b/internal/requestpickup/requestpickup_service.go @@ -0,0 +1 @@ +package requestpickup \ No newline at end of file diff --git a/dto/role_dto.go b/internal/role/role_dto.go similarity index 93% rename from dto/role_dto.go rename to internal/role/role_dto.go index 6002fc7..66665e0 100644 --- a/dto/role_dto.go +++ b/internal/role/role_dto.go @@ -1,4 +1,4 @@ -package dto +package role type RoleResponseDTO struct { ID string `json:"role_id"` diff --git a/internal/role/role_handler.go b/internal/role/role_handler.go new file mode 100644 index 0000000..bdb7b24 --- /dev/null +++ b/internal/role/role_handler.go @@ -0,0 +1,52 @@ +package role + +import ( + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +type RoleHandler struct { + roleService RoleService +} + +func NewRoleHandler(roleService RoleService) *RoleHandler { + return &RoleHandler{ + roleService: roleService, + } +} + +func (h *RoleHandler) GetRoles(c *fiber.Ctx) error { + + if _, err := middleware.GetUserFromContext(c); err != nil { + return utils.Unauthorized(c, "Unauthorized access") + } + + roles, err := h.roleService.GetRoles(c.Context()) + if err != nil { + return utils.InternalServerError(c, "Failed to fetch roles") + } + + return utils.SuccessWithData(c, "Roles fetched successfully", roles) +} + +func (h *RoleHandler) GetRoleByID(c *fiber.Ctx) error { + + if _, err := middleware.GetUserFromContext(c); err != nil { + return utils.Unauthorized(c, "Unauthorized access") + } + + roleID := c.Params("role_id") + if roleID == "" { + return utils.BadRequest(c, "Role ID is required") + } + + role, err := h.roleService.GetRoleByID(c.Context(), roleID) + if err != nil { + + return utils.NotFound(c, "Role not found") + } + + return utils.SuccessWithData(c, "Role fetched successfully", role) +} diff --git a/internal/role/role_repo.go b/internal/role/role_repo.go new file mode 100644 index 0000000..039e269 --- /dev/null +++ b/internal/role/role_repo.go @@ -0,0 +1,49 @@ +package role + +import ( + "context" + "rijig/model" + + "gorm.io/gorm" +) + +type RoleRepository interface { + FindByID(ctx context.Context, id string) (*model.Role, error) + FindRoleByName(ctx context.Context, roleName string) (*model.Role, error) + FindAll(ctx context.Context) ([]model.Role, error) +} + +type roleRepository struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) RoleRepository { + return &roleRepository{db} +} + +func (r *roleRepository) FindByID(ctx context.Context, id string) (*model.Role, error) { + var role model.Role + err := r.db.WithContext(ctx).Where("id = ?", id).First(&role).Error + if err != nil { + return nil, err + } + return &role, nil +} + +func (r *roleRepository) FindRoleByName(ctx context.Context, roleName string) (*model.Role, error) { + var role model.Role + err := r.db.WithContext(ctx).Where("role_name = ?", roleName).First(&role).Error + if err != nil { + return nil, err + } + return &role, nil +} + +func (r *roleRepository) FindAll(ctx context.Context) ([]model.Role, error) { + var roles []model.Role + err := r.db.WithContext(ctx).Find(&roles).Error + if err != nil { + return nil, err + } + return roles, nil +} diff --git a/internal/role/role_route.go b/internal/role/role_route.go new file mode 100644 index 0000000..5636785 --- /dev/null +++ b/internal/role/role_route.go @@ -0,0 +1,18 @@ +package role + +import ( + "rijig/config" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func UserRoleRouter(api fiber.Router) { + roleRepo := NewRoleRepository(config.DB) + roleService := NewRoleService(roleRepo) + roleHandler := NewRoleHandler(roleService) + roleRoute := api.Group("/role", middleware.AuthMiddleware()) + + roleRoute.Get("/", roleHandler.GetRoles) + roleRoute.Get("/:role_id", roleHandler.GetRoleByID) +} diff --git a/internal/role/role_service.go b/internal/role/role_service.go new file mode 100644 index 0000000..3a17e0c --- /dev/null +++ b/internal/role/role_service.go @@ -0,0 +1,89 @@ +package role + +import ( + "context" + "fmt" + "time" + + "rijig/utils" +) + +type RoleService interface { + GetRoles(ctx context.Context) ([]RoleResponseDTO, error) + GetRoleByID(ctx context.Context, roleID string) (*RoleResponseDTO, error) +} + +type roleService struct { + RoleRepo RoleRepository +} + +func NewRoleService(roleRepo RoleRepository) RoleService { + return &roleService{roleRepo} +} + +func (s *roleService) GetRoles(ctx context.Context) ([]RoleResponseDTO, error) { + cacheKey := "roles_list" + + var cachedRoles []RoleResponseDTO + err := utils.GetCache(cacheKey, &cachedRoles) + if err == nil { + return cachedRoles, nil + } + + roles, err := s.RoleRepo.FindAll(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch roles: %v", err) + } + + var roleDTOs []RoleResponseDTO + for _, role := range roles { + createdAt, _ := utils.FormatDateToIndonesianFormat(role.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(role.UpdatedAt) + + roleDTOs = append(roleDTOs, RoleResponseDTO{ + ID: role.ID, + RoleName: role.RoleName, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + err = utils.SetCache(cacheKey, roleDTOs, time.Hour*24) + if err != nil { + fmt.Printf("Error caching roles data to Redis: %v\n", err) + } + + return roleDTOs, nil +} + +func (s *roleService) GetRoleByID(ctx context.Context, roleID string) (*RoleResponseDTO, error) { + cacheKey := fmt.Sprintf("role:%s", roleID) + + var cachedRole RoleResponseDTO + err := utils.GetCache(cacheKey, &cachedRole) + if err == nil { + return &cachedRole, nil + } + + role, err := s.RoleRepo.FindByID(ctx, roleID) + if err != nil { + return nil, fmt.Errorf("role not found: %v", err) + } + + createdAt, _ := utils.FormatDateToIndonesianFormat(role.CreatedAt) + updatedAt, _ := utils.FormatDateToIndonesianFormat(role.UpdatedAt) + + roleDTO := &RoleResponseDTO{ + ID: role.ID, + RoleName: role.RoleName, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + + err = utils.SetCache(cacheKey, roleDTO, time.Hour*24) + if err != nil { + fmt.Printf("Error caching role data to Redis: %v\n", err) + } + + return roleDTO, nil +} diff --git a/internal/services/address_service.go b/internal/services/address_service.go deleted file mode 100644 index 7e3f22f..0000000 --- a/internal/services/address_service.go +++ /dev/null @@ -1,402 +0,0 @@ -package services - -import ( - "fmt" - "time" - - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type AddressService interface { - CreateAddress(userID string, request dto.CreateAddressDTO) (*dto.AddressResponseDTO, error) - GetAddressByUserID(userID string) ([]dto.AddressResponseDTO, error) - GetAddressByID(userID, id string) (*dto.AddressResponseDTO, error) - UpdateAddress(userID, id string, addressDTO dto.CreateAddressDTO) (*dto.AddressResponseDTO, error) - DeleteAddress(userID, id string) error -} - -type addressService struct { - AddressRepo repositories.AddressRepository - WilayahRepo repositories.WilayahIndonesiaRepository -} - -func NewAddressService(addressRepo repositories.AddressRepository, wilayahRepo repositories.WilayahIndonesiaRepository) AddressService { - return &addressService{ - AddressRepo: addressRepo, - WilayahRepo: wilayahRepo, - } -} - -func (s *addressService) CreateAddress(userID string, addressDTO dto.CreateAddressDTO) (*dto.AddressResponseDTO, error) { - - province, _, err := s.WilayahRepo.FindProvinceByID(addressDTO.Province, 0, 0) - if err != nil { - return nil, fmt.Errorf("invalid province_id") - } - - regency, _, err := s.WilayahRepo.FindRegencyByID(addressDTO.Regency, 0, 0) - if err != nil { - return nil, fmt.Errorf("invalid regency_id") - } - - district, _, err := s.WilayahRepo.FindDistrictByID(addressDTO.District, 0, 0) - if err != nil { - return nil, fmt.Errorf("invalid district_id") - } - - village, err := s.WilayahRepo.FindVillageByID(addressDTO.Village) - if err != nil { - return nil, fmt.Errorf("invalid village_id") - } - - address := model.Address{ - UserID: userID, - Province: province.Name, - Regency: regency.Name, - District: district.Name, - Village: village.Name, - PostalCode: addressDTO.PostalCode, - Detail: addressDTO.Detail, - Geography: addressDTO.Geography, - } - - err = s.AddressRepo.CreateAddress(&address) - if err != nil { - return nil, fmt.Errorf("failed to create address: %v", err) - } - - userCacheKey := fmt.Sprintf("user:%s:addresses", userID) - utils.DeleteData(userCacheKey) - - createdAt, _ := utils.FormatDateToIndonesianFormat(address.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(address.UpdatedAt) - - addressResponseDTO := &dto.AddressResponseDTO{ - UserID: address.UserID, - ID: address.ID, - Province: address.Province, - Regency: address.Regency, - District: address.District, - Village: address.Village, - PostalCode: address.PostalCode, - Detail: address.Detail, - Geography: address.Geography, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("address:%s", address.ID) - cacheData := map[string]interface{}{ - "data": addressResponseDTO, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching new address to Redis: %v\n", err) - } - - addresses, err := s.AddressRepo.FindAddressByUserID(userID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated addresses for user: %v", err) - } - - var addressDTOs []dto.AddressResponseDTO - for _, addr := range addresses { - createdAt, _ := utils.FormatDateToIndonesianFormat(addr.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(addr.UpdatedAt) - - addressDTOs = append(addressDTOs, dto.AddressResponseDTO{ - UserID: addr.UserID, - ID: addr.ID, - Province: addr.Province, - Regency: addr.Regency, - District: addr.District, - Village: addr.Village, - PostalCode: addr.PostalCode, - Detail: addr.Detail, - Geography: addr.Geography, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData = map[string]interface{}{ - "data": addressDTOs, - } - err = utils.SetJSONData(userCacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching updated user addresses to Redis: %v\n", err) - } - - return addressResponseDTO, nil -} - -func (s *addressService) GetAddressByUserID(userID string) ([]dto.AddressResponseDTO, error) { - - cacheKey := fmt.Sprintf("user:%s:addresses", userID) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - var addresses []dto.AddressResponseDTO - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - addressData, ok := item.(map[string]interface{}) - if ok { - addresses = append(addresses, dto.AddressResponseDTO{ - UserID: addressData["user_id"].(string), - ID: addressData["address_id"].(string), - Province: addressData["province"].(string), - Regency: addressData["regency"].(string), - District: addressData["district"].(string), - Village: addressData["village"].(string), - PostalCode: addressData["postalCode"].(string), - Detail: addressData["detail"].(string), - Geography: addressData["geography"].(string), - CreatedAt: addressData["createdAt"].(string), - UpdatedAt: addressData["updatedAt"].(string), - }) - } - } - return addresses, nil - } - } - - addresses, err := s.AddressRepo.FindAddressByUserID(userID) - if err != nil { - return nil, fmt.Errorf("failed to fetch addresses: %v", err) - } - - var addressDTOs []dto.AddressResponseDTO - for _, address := range addresses { - createdAt, _ := utils.FormatDateToIndonesianFormat(address.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(address.UpdatedAt) - - addressDTOs = append(addressDTOs, dto.AddressResponseDTO{ - UserID: address.UserID, - ID: address.ID, - Province: address.Province, - Regency: address.Regency, - District: address.District, - Village: address.Village, - PostalCode: address.PostalCode, - Detail: address.Detail, - Geography: address.Geography, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData := map[string]interface{}{ - "data": addressDTOs, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching addresses to Redis: %v\n", err) - } - - return addressDTOs, nil -} - -func (s *addressService) GetAddressByID(userID, id string) (*dto.AddressResponseDTO, error) { - address, err := s.AddressRepo.FindAddressByID(id) - if err != nil { - return nil, fmt.Errorf("address not found: %v", err) - } - - if address.UserID != userID { - return nil, fmt.Errorf("you are not authorized to update this address") - } - - cacheKey := fmt.Sprintf("address:%s", id) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - addressData, ok := cachedData["data"].(map[string]interface{}) - if ok { - address := dto.AddressResponseDTO{ - UserID: addressData["user_id"].(string), - ID: addressData["address_id"].(string), - Province: addressData["province"].(string), - Regency: addressData["regency"].(string), - District: addressData["district"].(string), - Village: addressData["village"].(string), - PostalCode: addressData["postalCode"].(string), - Detail: addressData["detail"].(string), - Geography: addressData["geography"].(string), - CreatedAt: addressData["createdAt"].(string), - UpdatedAt: addressData["updatedAt"].(string), - } - return &address, nil - } - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(address.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(address.UpdatedAt) - - addressDTO := &dto.AddressResponseDTO{ - UserID: address.UserID, - ID: address.ID, - Province: address.Province, - Regency: address.Regency, - District: address.District, - Village: address.Village, - PostalCode: address.PostalCode, - Detail: address.Detail, - Geography: address.Geography, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheData := map[string]interface{}{ - "data": addressDTO, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching address to Redis: %v\n", err) - } - - return addressDTO, nil -} - -func (s *addressService) UpdateAddress(userID, id string, addressDTO dto.CreateAddressDTO) (*dto.AddressResponseDTO, error) { - - address, err := s.AddressRepo.FindAddressByID(id) - if err != nil { - return nil, fmt.Errorf("address not found: %v", err) - } - - if address.UserID != userID { - return nil, fmt.Errorf("you are not authorized to update this address") - } - - province, _, err := s.WilayahRepo.FindProvinceByID(addressDTO.Province, 0, 0) - if err != nil { - return nil, fmt.Errorf("invalid province_id") - } - - regency, _, err := s.WilayahRepo.FindRegencyByID(addressDTO.Regency, 0, 0) - if err != nil { - return nil, fmt.Errorf("invalid regency_id") - } - - district, _, err := s.WilayahRepo.FindDistrictByID(addressDTO.District, 0, 0) - if err != nil { - return nil, fmt.Errorf("invalid district_id") - } - - village, err := s.WilayahRepo.FindVillageByID(addressDTO.Village) - if err != nil { - return nil, fmt.Errorf("invalid village_id") - } - - address.Province = province.Name - address.Regency = regency.Name - address.District = district.Name - address.Village = village.Name - address.PostalCode = addressDTO.PostalCode - address.Detail = addressDTO.Detail - address.Geography = addressDTO.Geography - address.UpdatedAt = time.Now() - - err = s.AddressRepo.UpdateAddress(address) - if err != nil { - return nil, fmt.Errorf("failed to update address: %v", err) - } - - addressCacheKey := fmt.Sprintf("address:%s", id) - utils.DeleteData(addressCacheKey) - - userAddressesCacheKey := fmt.Sprintf("user:%s:addresses", userID) - utils.DeleteData(userAddressesCacheKey) - - createdAt, _ := utils.FormatDateToIndonesianFormat(address.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(address.UpdatedAt) - - addressResponseDTO := &dto.AddressResponseDTO{ - UserID: address.UserID, - ID: address.ID, - Province: address.Province, - Regency: address.Regency, - District: address.District, - Village: address.Village, - PostalCode: address.PostalCode, - Detail: address.Detail, - Geography: address.Geography, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheData := map[string]interface{}{ - "data": addressResponseDTO, - } - err = utils.SetJSONData(addressCacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching updated address to Redis: %v\n", err) - } - - addresses, err := s.AddressRepo.FindAddressByUserID(address.UserID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated addresses for user: %v", err) - } - - var addressDTOs []dto.AddressResponseDTO - for _, addr := range addresses { - createdAt, _ := utils.FormatDateToIndonesianFormat(addr.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(addr.UpdatedAt) - - addressDTOs = append(addressDTOs, dto.AddressResponseDTO{ - UserID: addr.UserID, - ID: addr.ID, - Province: addr.Province, - Regency: addr.Regency, - District: addr.District, - Village: addr.Village, - PostalCode: addr.PostalCode, - Detail: addr.Detail, - Geography: addr.Geography, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData = map[string]interface{}{ - "data": addressDTOs, - } - err = utils.SetJSONData(userAddressesCacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching updated user addresses to Redis: %v\n", err) - } - - return addressResponseDTO, nil -} - -func (s *addressService) DeleteAddress(userID, addressID string) error { - - address, err := s.AddressRepo.FindAddressByID(addressID) - if err != nil { - return fmt.Errorf("address not found: %v", err) - } - - if address.UserID != userID { - return fmt.Errorf("you are not authorized to delete this address") - } - - err = s.AddressRepo.DeleteAddress(addressID) - if err != nil { - return fmt.Errorf("failed to delete address: %v", err) - } - - addressCacheKey := fmt.Sprintf("address:%s", addressID) - err = utils.DeleteData(addressCacheKey) - if err != nil { - fmt.Printf("Error deleting address cache: %v\n", err) - } - - userAddressesCacheKey := fmt.Sprintf("user:%s:addresses", address.UserID) - err = utils.DeleteData(userAddressesCacheKey) - if err != nil { - fmt.Printf("Error deleting user addresses cache: %v\n", err) - } - - return nil -} diff --git a/internal/services/article_service.go b/internal/services/article_service.go deleted file mode 100644 index b8467ab..0000000 --- a/internal/services/article_service.go +++ /dev/null @@ -1,418 +0,0 @@ -package services - -import ( - "encoding/json" - "fmt" - "mime/multipart" - "os" - "path/filepath" - "time" - - "github.com/google/uuid" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type ArticleService interface { - CreateArticle(request dto.RequestArticleDTO, coverImage *multipart.FileHeader) (*dto.ArticleResponseDTO, error) - GetAllArticles(page, limit int) ([]dto.ArticleResponseDTO, int, error) - GetArticleByID(id string) (*dto.ArticleResponseDTO, error) - UpdateArticle(id string, request dto.RequestArticleDTO, coverImage *multipart.FileHeader) (*dto.ArticleResponseDTO, error) - DeleteArticle(id string) error -} - -type articleService struct { - ArticleRepo repositories.ArticleRepository -} - -func NewArticleService(articleRepo repositories.ArticleRepository) ArticleService { - return &articleService{ArticleRepo: articleRepo} -} - -func (s *articleService) CreateArticle(request dto.RequestArticleDTO, coverImage *multipart.FileHeader) (*dto.ArticleResponseDTO, error) { - - coverImageDir := "./public/uploads/articles" - if err := os.MkdirAll(coverImageDir, os.ModePerm); err != nil { - return nil, fmt.Errorf("failed to create directory for cover image: %v", err) - } - - allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true} - extension := filepath.Ext(coverImage.Filename) - if !allowedExtensions[extension] { - return nil, fmt.Errorf("invalid file type, only .jpg, .jpeg, and .png are allowed") - } - - coverImageFileName := fmt.Sprintf("%s_cover%s", uuid.New().String(), extension) - coverImagePath := filepath.Join(coverImageDir, coverImageFileName) - - src, err := coverImage.Open() - if err != nil { - return nil, fmt.Errorf("failed to open uploaded file: %v", err) - } - defer src.Close() - - dst, err := os.Create(coverImagePath) - if err != nil { - return nil, fmt.Errorf("failed to create cover image file: %v", err) - } - defer dst.Close() - - if _, err := dst.ReadFrom(src); err != nil { - return nil, fmt.Errorf("failed to save cover image: %v", err) - } - - article := model.Article{ - Title: request.Title, - CoverImage: coverImagePath, - Author: request.Author, - Heading: request.Heading, - Content: request.Content, - } - - if err := s.ArticleRepo.CreateArticle(&article); err != nil { - return nil, fmt.Errorf("failed to create article: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(article.PublishedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(article.UpdatedAt) - - articleResponseDTO := &dto.ArticleResponseDTO{ - ID: article.ID, - Title: article.Title, - CoverImage: article.CoverImage, - Author: article.Author, - Heading: article.Heading, - Content: article.Content, - PublishedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("article:%s", article.ID) - cacheData := map[string]interface{}{ - "data": articleResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching article to Redis: %v\n", err) - } - - articles, total, err := s.ArticleRepo.FindAllArticles(0, 0) - if err != nil { - fmt.Printf("Error fetching all articles: %v\n", err) - } - - var articleDTOs []dto.ArticleResponseDTO - for _, a := range articles { - createdAt, _ := utils.FormatDateToIndonesianFormat(a.PublishedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(a.UpdatedAt) - - articleDTOs = append(articleDTOs, dto.ArticleResponseDTO{ - ID: a.ID, - Title: a.Title, - CoverImage: a.CoverImage, - Author: a.Author, - Heading: a.Heading, - Content: a.Content, - PublishedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - articlesCacheKey := "articles:all" - cacheData = map[string]interface{}{ - "data": articleDTOs, - "total": total, - } - if err := utils.SetJSONData(articlesCacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching all articles to Redis: %v\n", err) - } - - return articleResponseDTO, nil -} - -func (s *articleService) GetAllArticles(page, limit int) ([]dto.ArticleResponseDTO, int, error) { - var cacheKey string - - if page == 0 && limit == 0 { - cacheKey = "articles:all" - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - if data, ok := cachedData["data"].([]interface{}); ok { - var articles []dto.ArticleResponseDTO - for _, item := range data { - articleData, ok := item.(map[string]interface{}) - if ok { - articles = append(articles, dto.ArticleResponseDTO{ - ID: articleData["article_id"].(string), - Title: articleData["title"].(string), - CoverImage: articleData["coverImage"].(string), - Author: articleData["author"].(string), - Heading: articleData["heading"].(string), - Content: articleData["content"].(string), - PublishedAt: articleData["publishedAt"].(string), - UpdatedAt: articleData["updatedAt"].(string), - }) - } - } - - if total, ok := cachedData["total"].(float64); ok { - fmt.Printf("Cached Total Articles: %f\n", total) - return articles, int(total), nil - } else { - fmt.Println("Total articles not found in cache, using 0 as fallback.") - return articles, 0, nil - } - } - } - } - - articles, total, err := s.ArticleRepo.FindAllArticles(page, limit) - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch articles: %v", err) - } - - fmt.Printf("Total Articles from Database: %d\n", total) - - var articleDTOs []dto.ArticleResponseDTO - for _, article := range articles { - publishedAt, _ := utils.FormatDateToIndonesianFormat(article.PublishedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(article.UpdatedAt) - - articleDTOs = append(articleDTOs, dto.ArticleResponseDTO{ - ID: article.ID, - Title: article.Title, - CoverImage: article.CoverImage, - Author: article.Author, - Heading: article.Heading, - Content: article.Content, - PublishedAt: publishedAt, - UpdatedAt: updatedAt, - }) - } - - cacheKey = fmt.Sprintf("articles_page:%d_limit:%d", page, limit) - cacheData := map[string]interface{}{ - "data": articleDTOs, - "total": total, - } - - fmt.Printf("Setting cache with total: %d\n", total) - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching articles to Redis: %v\n", err) - } - - return articleDTOs, total, nil -} - -func (s *articleService) GetArticleByID(id string) (*dto.ArticleResponseDTO, error) { - - cacheKey := fmt.Sprintf("article:%s", id) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - articleResponse := &dto.ArticleResponseDTO{} - if data, ok := cachedData["data"].(string); ok { - if err := json.Unmarshal([]byte(data), articleResponse); err == nil { - return articleResponse, nil - } - } - } - - article, err := s.ArticleRepo.FindArticleByID(id) - if err != nil { - return nil, fmt.Errorf("failed to fetch article by ID: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(article.PublishedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(article.UpdatedAt) - - articleResponseDTO := &dto.ArticleResponseDTO{ - ID: article.ID, - Title: article.Title, - CoverImage: article.CoverImage, - Author: article.Author, - Heading: article.Heading, - Content: article.Content, - PublishedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheData := map[string]interface{}{ - "data": articleResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching article to Redis: %v\n", err) - } - - return articleResponseDTO, nil -} - -func (s *articleService) UpdateArticle(id string, request dto.RequestArticleDTO, coverImage *multipart.FileHeader) (*dto.ArticleResponseDTO, error) { - article, err := s.ArticleRepo.FindArticleByID(id) - if err != nil { - return nil, fmt.Errorf("article not found: %v", id) - } - - article.Title = request.Title - article.Heading = request.Heading - article.Content = request.Content - article.Author = request.Author - - var coverImagePath string - if coverImage != nil { - - coverImagePath, err = s.saveCoverImage(coverImage, article.CoverImage) - if err != nil { - return nil, fmt.Errorf("failed to save cover image: %v", err) - } - article.CoverImage = coverImagePath - } - - err = s.ArticleRepo.UpdateArticle(id, article) - if err != nil { - return nil, fmt.Errorf("failed to update article: %v", err) - } - - updatedArticle, err := s.ArticleRepo.FindArticleByID(id) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated article: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(updatedArticle.PublishedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(updatedArticle.UpdatedAt) - - articleResponseDTO := &dto.ArticleResponseDTO{ - ID: updatedArticle.ID, - Title: updatedArticle.Title, - CoverImage: updatedArticle.CoverImage, - Author: updatedArticle.Author, - Heading: updatedArticle.Heading, - Content: updatedArticle.Content, - PublishedAt: createdAt, - UpdatedAt: updatedAt, - } - - articleCacheKey := fmt.Sprintf("article:%s", updatedArticle.ID) - err = utils.SetJSONData(articleCacheKey, map[string]interface{}{"data": articleResponseDTO}, time.Hour*24) - if err != nil { - fmt.Printf("Error caching updated article to Redis: %v\n", err) - } - - articlesCacheKey := "articles:all" - err = utils.DeleteData(articlesCacheKey) - if err != nil { - fmt.Printf("Error deleting articles cache: %v\n", err) - } - - articles, _, err := s.ArticleRepo.FindAllArticles(0, 0) - if err != nil { - fmt.Printf("Error fetching all articles: %v\n", err) - } else { - var articleDTOs []dto.ArticleResponseDTO - for _, a := range articles { - createdAt, _ := utils.FormatDateToIndonesianFormat(a.PublishedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(a.UpdatedAt) - - articleDTOs = append(articleDTOs, dto.ArticleResponseDTO{ - ID: a.ID, - Title: a.Title, - CoverImage: a.CoverImage, - Author: a.Author, - Heading: a.Heading, - Content: a.Content, - PublishedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData := map[string]interface{}{ - "data": articleDTOs, - } - err = utils.SetJSONData(articlesCacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching updated articles to Redis: %v\n", err) - } - } - - return articleResponseDTO, nil -} - -func (s *articleService) saveCoverImage(coverImage *multipart.FileHeader, oldImagePath string) (string, error) { - coverImageDir := "./public/uploads/articles" - if _, err := os.Stat(coverImageDir); os.IsNotExist(err) { - if err := os.MkdirAll(coverImageDir, os.ModePerm); err != nil { - return "", fmt.Errorf("failed to create directory for cover image: %v", err) - } - } - - extension := filepath.Ext(coverImage.Filename) - if extension != ".jpg" && extension != ".jpeg" && extension != ".png" { - return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, and .png are allowed") - } - - coverImageFileName := fmt.Sprintf("%s_cover%s", uuid.New().String(), extension) - coverImagePath := filepath.Join(coverImageDir, coverImageFileName) - - if oldImagePath != "" { - err := os.Remove(oldImagePath) - if err != nil { - fmt.Printf("Failed to delete old cover image: %v\n", err) - } else { - fmt.Printf("Successfully deleted old cover image: %s\n", oldImagePath) - } - } - - src, err := coverImage.Open() - if err != nil { - return "", fmt.Errorf("failed to open uploaded file: %v", err) - } - defer src.Close() - - dst, err := os.Create(coverImagePath) - if err != nil { - return "", fmt.Errorf("failed to create cover image file: %v", err) - } - defer dst.Close() - - _, err = dst.ReadFrom(src) - if err != nil { - return "", fmt.Errorf("failed to save cover image: %v", err) - } - - return coverImagePath, nil -} - -func (s *articleService) DeleteArticle(id string) error { - article, err := s.ArticleRepo.FindArticleByID(id) - if err != nil { - return fmt.Errorf("failed to find article: %v", id) - } - - if article.CoverImage != "" { - err := os.Remove(article.CoverImage) - if err != nil { - fmt.Printf("Failed to delete cover image: %v\n", err) - } else { - fmt.Printf("Successfully deleted cover image: %s\n", article.CoverImage) - } - } - - err = s.ArticleRepo.DeleteArticle(id) - if err != nil { - return fmt.Errorf("failed to delete article: %v", err) - } - - articleCacheKey := fmt.Sprintf("article:%s", id) - err = utils.DeleteData(articleCacheKey) - if err != nil { - fmt.Printf("Error deleting cache for article: %v\n", err) - } - - articlesCacheKey := "articles:all" - err = utils.DeleteData(articlesCacheKey) - if err != nil { - fmt.Printf("Error deleting cache for all articles: %v\n", err) - } - - return nil -} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go deleted file mode 100644 index 6be1bba..0000000 --- a/internal/services/auth_service.go +++ /dev/null @@ -1,171 +0,0 @@ -package services - -import ( - "errors" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" - "golang.org/x/crypto/bcrypt" -) - -const ( - ErrUsernameTaken = "username is already taken" - ErrPhoneTaken = "phone number is already used for this role" - ErrEmailTaken = "email is already used for this role" - ErrInvalidRoleID = "invalid roleId" - ErrPasswordMismatch = "password and confirm password do not match" - ErrRoleIDRequired = "roleId is required" - ErrFailedToHashPassword = "failed to hash password" - ErrFailedToCreateUser = "failed to create user" - ErrIncorrectPassword = "incorrect password" - ErrAccountNotFound = "account not found" -) - -type UserService interface { - Login(credentials dto.LoginDTO) (*dto.UserResponseWithToken, error) - Register(user dto.RegisterDTO) (*dto.UserResponseDTO, error) -} - -type userService struct { - UserRepo repositories.UserRepository - RoleRepo repositories.RoleRepository - SecretKey string -} - -func NewUserService(userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, secretKey string) UserService { - return &userService{UserRepo: userRepo, RoleRepo: roleRepo, SecretKey: secretKey} -} - -func (s *userService) Login(credentials dto.LoginDTO) (*dto.UserResponseWithToken, error) { - if credentials.RoleID == "" { - return nil, errors.New(ErrRoleIDRequired) - } - - user, err := s.UserRepo.FindByIdentifierAndRole(credentials.Identifier, credentials.RoleID) - if err != nil { - return nil, errors.New(ErrAccountNotFound) - } - - if !CheckPasswordHash(credentials.Password, user.Password) { - return nil, errors.New(ErrIncorrectPassword) - } - - token, err := s.generateJWT(user) - if err != nil { - return nil, err - } - - sessionKey := fmt.Sprintf("session:%s", user.ID) - sessionData := map[string]interface{}{ - "userID": user.ID, - "roleID": user.RoleID, - "roleName": user.Role.RoleName, - } - - err = utils.SetJSONData(sessionKey, sessionData, time.Hour*24) - if err != nil { - return nil, err - } - - return &dto.UserResponseWithToken{ - RoleName: user.Role.RoleName, - UserID: user.ID, - Token: token, - }, nil -} - -func (s *userService) generateJWT(user *model.User) (string, error) { - claims := jwt.MapClaims{ - "sub": user.ID, - "iat": time.Now().Unix(), - "exp": time.Now().Add(time.Hour * 24).Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - tokenString, err := token.SignedString([]byte(s.SecretKey)) - if err != nil { - return "", err - } - - return tokenString, nil -} - -func CheckPasswordHash(password, hashedPassword string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) - return err == nil -} - -func (s *userService) Register(user dto.RegisterDTO) (*dto.UserResponseDTO, error) { - - if user.Password != user.ConfirmPassword { - return nil, fmt.Errorf("%s", ErrPasswordMismatch) - } - - if user.RoleID == "" { - return nil, fmt.Errorf("%s", ErrRoleIDRequired) - } - - role, err := s.RoleRepo.FindByID(user.RoleID) - if err != nil { - return nil, fmt.Errorf("%s: %v", ErrInvalidRoleID, err) - } - - if existingUser, _ := s.UserRepo.FindByUsername(user.Username); existingUser != nil { - return nil, fmt.Errorf("%s", ErrUsernameTaken) - } - - if existingPhone, _ := s.UserRepo.FindByPhoneAndRole(user.Phone, user.RoleID); existingPhone != nil { - return nil, fmt.Errorf("%s", ErrPhoneTaken) - } - - if existingEmail, _ := s.UserRepo.FindByEmailAndRole(user.Email, user.RoleID); existingEmail != nil { - return nil, fmt.Errorf("%s", ErrEmailTaken) - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("%s: %v", ErrFailedToHashPassword, err) - } - - newUser := model.User{ - Username: user.Username, - Name: user.Name, - Phone: user.Phone, - Email: user.Email, - Password: string(hashedPassword), - RoleID: user.RoleID, - } - - err = s.UserRepo.Create(&newUser) - if err != nil { - return nil, fmt.Errorf("%s: %v", ErrFailedToCreateUser, err) - } - - userResponse := s.prepareUserResponse(newUser, role) - - return userResponse, nil -} - -func (s *userService) prepareUserResponse(user model.User, role *model.Role) *dto.UserResponseDTO { - - createdAt, _ := utils.FormatDateToIndonesianFormat(user.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(user.UpdatedAt) - - return &dto.UserResponseDTO{ - ID: user.ID, - Username: user.Username, - Name: user.Name, - Phone: user.Phone, - Email: user.Email, - EmailVerified: user.EmailVerified, - RoleName: role.RoleName, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } -} diff --git a/internal/services/banner_service.go b/internal/services/banner_service.go deleted file mode 100644 index 9d5b6c1..0000000 --- a/internal/services/banner_service.go +++ /dev/null @@ -1,365 +0,0 @@ -package services - -import ( - "fmt" - "mime/multipart" - "os" - "path/filepath" - "time" - - "github.com/google/uuid" - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type BannerService interface { - CreateBanner(request dto.RequestBannerDTO, bannerImage *multipart.FileHeader) (*dto.ResponseBannerDTO, error) - GetAllBanners() ([]dto.ResponseBannerDTO, error) - GetBannerByID(id string) (*dto.ResponseBannerDTO, error) - UpdateBanner(id string, request dto.RequestBannerDTO, bannerImage *multipart.FileHeader) (*dto.ResponseBannerDTO, error) - DeleteBanner(id string) error -} - -type bannerService struct { - BannerRepo repositories.BannerRepository -} - -func NewBannerService(bannerRepo repositories.BannerRepository) BannerService { - return &bannerService{BannerRepo: bannerRepo} -} - -func (s *bannerService) saveBannerImage(bannerImage *multipart.FileHeader) (string, error) { - bannerImageDir := "./public/uploads/banners" - if _, err := os.Stat(bannerImageDir); os.IsNotExist(err) { - if err := os.MkdirAll(bannerImageDir, os.ModePerm); err != nil { - return "", fmt.Errorf("failed to create directory for banner image: %v", err) - } - } - - allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true} - extension := filepath.Ext(bannerImage.Filename) - if !allowedExtensions[extension] { - return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, and .png are allowed") - } - - bannerImageFileName := fmt.Sprintf("%s_banner%s", uuid.New().String(), extension) - bannerImagePath := filepath.Join(bannerImageDir, bannerImageFileName) - - src, err := bannerImage.Open() - if err != nil { - return "", fmt.Errorf("failed to open uploaded file: %v", err) - } - defer src.Close() - - dst, err := os.Create(bannerImagePath) - if err != nil { - return "", fmt.Errorf("failed to create banner image file: %v", err) - } - defer dst.Close() - - if _, err := dst.ReadFrom(src); err != nil { - return "", fmt.Errorf("failed to save banner image: %v", err) - } - - return bannerImagePath, nil -} - -func (s *bannerService) CreateBanner(request dto.RequestBannerDTO, bannerImage *multipart.FileHeader) (*dto.ResponseBannerDTO, error) { - - errors, valid := request.ValidateBannerInput() - if !valid { - return nil, fmt.Errorf("validation error: %v", errors) - } - - bannerImagePath, err := s.saveBannerImage(bannerImage) - if err != nil { - return nil, fmt.Errorf("failed to save banner image: %v", err) - } - - banner := model.Banner{ - BannerName: request.BannerName, - BannerImage: bannerImagePath, - } - - if err := s.BannerRepo.CreateBanner(&banner); err != nil { - return nil, fmt.Errorf("failed to create banner: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(banner.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(banner.UpdatedAt) - - bannerResponseDTO := &dto.ResponseBannerDTO{ - ID: banner.ID, - BannerName: banner.BannerName, - BannerImage: banner.BannerImage, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - articlesCacheKey := "banners:all" - err = utils.DeleteData(articlesCacheKey) - if err != nil { - fmt.Printf("Error deleting cache for all banners: %v\n", err) - } - - cacheKey := fmt.Sprintf("banner:%s", banner.ID) - cacheData := map[string]interface{}{ - "data": bannerResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching banner: %v\n", err) - } - - banners, err := s.BannerRepo.FindAllBanners() - if err == nil { - var bannersDTO []dto.ResponseBannerDTO - for _, b := range banners { - createdAt, _ := utils.FormatDateToIndonesianFormat(b.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(b.UpdatedAt) - - bannersDTO = append(bannersDTO, dto.ResponseBannerDTO{ - ID: b.ID, - BannerName: b.BannerName, - BannerImage: b.BannerImage, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData = map[string]interface{}{ - "data": bannersDTO, - } - if err := utils.SetJSONData(articlesCacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching updated banners to Redis: %v\n", err) - } - } else { - fmt.Printf("Error fetching all banners: %v\n", err) - } - - return bannerResponseDTO, nil -} - -func (s *bannerService) GetAllBanners() ([]dto.ResponseBannerDTO, error) { - var banners []dto.ResponseBannerDTO - - cacheKey := "banners:all" - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - if bannerData, ok := item.(map[string]interface{}); ok { - banners = append(banners, dto.ResponseBannerDTO{ - ID: bannerData["id"].(string), - BannerName: bannerData["bannername"].(string), - BannerImage: bannerData["bannerimage"].(string), - CreatedAt: bannerData["createdAt"].(string), - UpdatedAt: bannerData["updatedAt"].(string), - }) - } - } - return banners, nil - } - } - - records, err := s.BannerRepo.FindAllBanners() - if err != nil { - return nil, fmt.Errorf("failed to fetch banners: %v", err) - } - - for _, record := range records { - createdAt, _ := utils.FormatDateToIndonesianFormat(record.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(record.UpdatedAt) - - banners = append(banners, dto.ResponseBannerDTO{ - ID: record.ID, - BannerName: record.BannerName, - BannerImage: record.BannerImage, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData := map[string]interface{}{ - "data": banners, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching banners: %v\n", err) - } - - return banners, nil -} - -func (s *bannerService) GetBannerByID(id string) (*dto.ResponseBannerDTO, error) { - - cacheKey := fmt.Sprintf("banner:%s", id) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - if data, ok := cachedData["data"].(map[string]interface{}); ok { - return &dto.ResponseBannerDTO{ - ID: data["id"].(string), - BannerName: data["bannername"].(string), - BannerImage: data["bannerimage"].(string), - CreatedAt: data["createdAt"].(string), - UpdatedAt: data["updatedAt"].(string), - }, nil - } - } - - banner, err := s.BannerRepo.FindBannerByID(id) - if err != nil { - - return nil, fmt.Errorf("banner with ID %s not found", id) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(banner.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(banner.UpdatedAt) - - bannerResponseDTO := &dto.ResponseBannerDTO{ - ID: banner.ID, - BannerName: banner.BannerName, - BannerImage: banner.BannerImage, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheData := map[string]interface{}{ - "data": bannerResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching banner: %v\n", err) - } - - return bannerResponseDTO, nil -} - -func (s *bannerService) UpdateBanner(id string, request dto.RequestBannerDTO, bannerImage *multipart.FileHeader) (*dto.ResponseBannerDTO, error) { - - banner, err := s.BannerRepo.FindBannerByID(id) - if err != nil { - - return nil, fmt.Errorf("banner with ID %s not found", id) - } - - var oldImagePath string - if bannerImage != nil { - - bannerImagePath, err := s.saveBannerImage(bannerImage) - if err != nil { - return nil, fmt.Errorf("failed to save banner image: %v", err) - } - - oldImagePath = banner.BannerImage - banner.BannerImage = bannerImagePath - } - - banner.BannerName = request.BannerName - - if err := s.BannerRepo.UpdateBanner(id, banner); err != nil { - return nil, fmt.Errorf("failed to update banner: %v", err) - } - - if oldImagePath != "" { - err := os.Remove(oldImagePath) - if err != nil { - fmt.Printf("Failed to delete old banner image: %v\n", err) - } - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(banner.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(banner.UpdatedAt) - - bannerResponseDTO := &dto.ResponseBannerDTO{ - ID: banner.ID, - BannerName: banner.BannerName, - BannerImage: banner.BannerImage, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("banner:%s", id) - err = utils.DeleteData(cacheKey) - if err != nil { - fmt.Printf("Error deleting cache for banner: %v\n", err) - } - - cacheData := map[string]interface{}{ - "data": bannerResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching updated banner: %v\n", err) - } - - articlesCacheKey := "banners:all" - err = utils.DeleteData(articlesCacheKey) - if err != nil { - fmt.Printf("Error deleting cache for all banners: %v\n", err) - } - - banners, err := s.BannerRepo.FindAllBanners() - if err == nil { - var bannersDTO []dto.ResponseBannerDTO - for _, b := range banners { - createdAt, _ := utils.FormatDateToIndonesianFormat(b.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(b.UpdatedAt) - - bannersDTO = append(bannersDTO, dto.ResponseBannerDTO{ - ID: b.ID, - BannerName: b.BannerName, - BannerImage: b.BannerImage, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData = map[string]interface{}{ - "data": bannersDTO, - } - if err := utils.SetJSONData(articlesCacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching updated banners to Redis: %v\n", err) - } - } else { - fmt.Printf("Error fetching all banners: %v\n", err) - } - - return bannerResponseDTO, nil -} - -func (s *bannerService) DeleteBanner(id string) error { - - banner, err := s.BannerRepo.FindBannerByID(id) - if err != nil { - - return fmt.Errorf("banner with ID %s not found", id) - } - - if banner.BannerImage != "" { - err := os.Remove(banner.BannerImage) - if err != nil { - - fmt.Printf("Failed to delete banner image: %v\n", err) - } else { - fmt.Printf("Successfully deleted banner image: %s\n", banner.BannerImage) - } - } - - if err := s.BannerRepo.DeleteBanner(id); err != nil { - return fmt.Errorf("failed to delete banner from database: %v", err) - } - - cacheKey := fmt.Sprintf("banner:%s", banner.ID) - err = utils.DeleteData(cacheKey) - if err != nil { - fmt.Printf("Error deleting cache for banner: %v\n", err) - } - - articlesCacheKey := "banners:all" - err = utils.DeleteData(articlesCacheKey) - if err != nil { - fmt.Printf("Error deleting cache for all banners: %v\n", err) - } - - return nil -} diff --git a/internal/services/initialcoint_service.go b/internal/services/initialcoint_service.go deleted file mode 100644 index 73bb599..0000000 --- a/internal/services/initialcoint_service.go +++ /dev/null @@ -1,299 +0,0 @@ -package services - -import ( - "fmt" - "time" - - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type InitialCointService interface { - CreateInitialCoint(request dto.RequestInitialCointDTO) (*dto.ReponseInitialCointDTO, error) - GetAllInitialCoints() ([]dto.ReponseInitialCointDTO, error) - GetInitialCointByID(id string) (*dto.ReponseInitialCointDTO, error) - UpdateInitialCoint(id string, request dto.RequestInitialCointDTO) (*dto.ReponseInitialCointDTO, error) - DeleteInitialCoint(id string) error -} - -type initialCointService struct { - InitialCointRepo repositories.InitialCointRepository -} - -func NewInitialCointService(repo repositories.InitialCointRepository) InitialCointService { - return &initialCointService{InitialCointRepo: repo} -} - -func (s *initialCointService) CreateInitialCoint(request dto.RequestInitialCointDTO) (*dto.ReponseInitialCointDTO, error) { - - errors, valid := request.ValidateCointInput() - if !valid { - return nil, fmt.Errorf("validation error: %v", errors) - } - - coint := model.InitialCoint{ - CoinName: request.CoinName, - ValuePerUnit: request.ValuePerUnit, - } - if err := s.InitialCointRepo.CreateInitialCoint(&coint); err != nil { - return nil, fmt.Errorf("failed to create initial coint: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(coint.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(coint.UpdatedAt) - - responseDTO := &dto.ReponseInitialCointDTO{ - ID: coint.ID, - CoinName: coint.CoinName, - ValuePerUnit: coint.ValuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("initialcoint:%s", coint.ID) - cacheData := map[string]interface{}{ - "data": responseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching new initial coint: %v\n", err) - } - - err := s.updateAllCointCache() - if err != nil { - return nil, fmt.Errorf("error updating all initial coint cache: %v", err) - } - - return responseDTO, nil -} - -func (s *initialCointService) GetAllInitialCoints() ([]dto.ReponseInitialCointDTO, error) { - var cointsDTO []dto.ReponseInitialCointDTO - cacheKey := "initialcoints:all" - - cachedData, err := utils.GetJSONData(cacheKey) - if err != nil { - fmt.Printf("Error fetching cache for initialcoints: %v\n", err) - } - - if cachedData != nil { - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - if cointData, ok := item.(map[string]interface{}); ok { - - if coinID, ok := cointData["coin_id"].(string); ok { - if coinName, ok := cointData["coin_name"].(string); ok { - if valuePerUnit, ok := cointData["value_perunit"].(float64); ok { - if createdAt, ok := cointData["createdAt"].(string); ok { - if updatedAt, ok := cointData["updatedAt"].(string); ok { - - cointsDTO = append(cointsDTO, dto.ReponseInitialCointDTO{ - ID: coinID, - CoinName: coinName, - ValuePerUnit: valuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - } - } - } - } - } - } - return cointsDTO, nil - } - } - - records, err := s.InitialCointRepo.FindAllInitialCoints() - if err != nil { - return nil, fmt.Errorf("failed to fetch initial coints from database: %v", err) - } - - if len(records) == 0 { - return cointsDTO, nil - } - - for _, record := range records { - createdAt, _ := utils.FormatDateToIndonesianFormat(record.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(record.UpdatedAt) - - cointsDTO = append(cointsDTO, dto.ReponseInitialCointDTO{ - ID: record.ID, - CoinName: record.CoinName, - ValuePerUnit: record.ValuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData := map[string]interface{}{ - "data": cointsDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching all initial coints: %v\n", err) - } - - return cointsDTO, nil -} - -func (s *initialCointService) GetInitialCointByID(id string) (*dto.ReponseInitialCointDTO, error) { - cacheKey := fmt.Sprintf("initialcoint:%s", id) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - - if data, ok := cachedData["data"].(map[string]interface{}); ok { - - return &dto.ReponseInitialCointDTO{ - ID: data["coin_id"].(string), - CoinName: data["coin_name"].(string), - ValuePerUnit: data["value_perunit"].(float64), - CreatedAt: data["createdAt"].(string), - UpdatedAt: data["updatedAt"].(string), - }, nil - } else { - return nil, fmt.Errorf("error: cache data is not in the expected format for coin ID %s", id) - } - } - - coint, err := s.InitialCointRepo.FindInitialCointByID(id) - if err != nil { - return nil, fmt.Errorf("failed to fetch initial coint by ID %s: %v", id, err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(coint.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(coint.UpdatedAt) - - cointDTO := &dto.ReponseInitialCointDTO{ - ID: coint.ID, - CoinName: coint.CoinName, - ValuePerUnit: coint.ValuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheData := map[string]interface{}{ - "data": cointDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching initial coint by ID: %v\n", err) - } - - return cointDTO, nil -} - -func (s *initialCointService) UpdateInitialCoint(id string, request dto.RequestInitialCointDTO) (*dto.ReponseInitialCointDTO, error) { - - coint, err := s.InitialCointRepo.FindInitialCointByID(id) - if err != nil { - return nil, fmt.Errorf("initial coint with ID %s not found", id) - } - - coint.CoinName = request.CoinName - coint.ValuePerUnit = request.ValuePerUnit - - if err := s.InitialCointRepo.UpdateInitialCoint(id, coint); err != nil { - return nil, fmt.Errorf("failed to update initial coint: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(coint.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(coint.UpdatedAt) - - cointDTO := &dto.ReponseInitialCointDTO{ - ID: coint.ID, - CoinName: coint.CoinName, - ValuePerUnit: coint.ValuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("initialcoint:%s", id) - cacheData := map[string]interface{}{ - "data": cointDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - fmt.Printf("Error caching updated initial coint: %v\n", err) - } - - allCoints, err := s.InitialCointRepo.FindAllInitialCoints() - if err != nil { - return nil, fmt.Errorf("failed to fetch all initial coints from database: %v", err) - } - - var cointsDTO []dto.ReponseInitialCointDTO - for _, record := range allCoints { - createdAt, _ := utils.FormatDateToIndonesianFormat(record.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(record.UpdatedAt) - - cointsDTO = append(cointsDTO, dto.ReponseInitialCointDTO{ - ID: record.ID, - CoinName: record.CoinName, - ValuePerUnit: record.ValuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheAllKey := "initialcoints:all" - cacheAllData := map[string]interface{}{ - "data": cointsDTO, - } - if err := utils.SetJSONData(cacheAllKey, cacheAllData, time.Hour*24); err != nil { - fmt.Printf("Error caching all initial coints: %v\n", err) - } - - return cointDTO, nil -} - -func (s *initialCointService) DeleteInitialCoint(id string) error { - - coint, err := s.InitialCointRepo.FindInitialCointByID(id) - if err != nil { - return fmt.Errorf("initial coint with ID %s not found", id) - } - - if err := s.InitialCointRepo.DeleteInitialCoint(id); err != nil { - return fmt.Errorf("failed to delete initial coint: %v", err) - } - - cacheKey := fmt.Sprintf("initialcoint:%s", coint.ID) - if err := utils.DeleteData(cacheKey); err != nil { - fmt.Printf("Error deleting cache for initial coint: %v\n", err) - } - - return s.updateAllCointCache() -} - -func (s *initialCointService) updateAllCointCache() error { - - records, err := s.InitialCointRepo.FindAllInitialCoints() - if err != nil { - return fmt.Errorf("failed to fetch all initial coints from database: %v", err) - } - - var cointsDTO []dto.ReponseInitialCointDTO - for _, record := range records { - createdAt, _ := utils.FormatDateToIndonesianFormat(record.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(record.UpdatedAt) - - cointsDTO = append(cointsDTO, dto.ReponseInitialCointDTO{ - ID: record.ID, - CoinName: record.CoinName, - ValuePerUnit: record.ValuePerUnit, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheAllKey := "initialcoints:all" - cacheAllData := map[string]interface{}{ - "data": cointsDTO, - } - if err := utils.SetJSONData(cacheAllKey, cacheAllData, time.Hour*24); err != nil { - fmt.Printf("Error caching all initial coints: %v\n", err) - return err - } - - return nil -} diff --git a/internal/services/role_service.go b/internal/services/role_service.go deleted file mode 100644 index c8a8741..0000000 --- a/internal/services/role_service.go +++ /dev/null @@ -1,103 +0,0 @@ -package services - -import ( - "fmt" - "time" - - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type RoleService interface { - GetRoles() ([]dto.RoleResponseDTO, error) - GetRoleByID(roleID string) (*dto.RoleResponseDTO, error) -} - -type roleService struct { - RoleRepo repositories.RoleRepository -} - -func NewRoleService(roleRepo repositories.RoleRepository) RoleService { - return &roleService{RoleRepo: roleRepo} -} - -func (s *roleService) GetRoles() ([]dto.RoleResponseDTO, error) { - - cacheKey := "roles_list" - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - var roles []dto.RoleResponseDTO - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - role, ok := item.(map[string]interface{}) - if ok { - roles = append(roles, dto.RoleResponseDTO{ - ID: role["role_id"].(string), - RoleName: role["role_name"].(string), - CreatedAt: role["createdAt"].(string), - UpdatedAt: role["updatedAt"].(string), - }) - } - } - return roles, nil - } - } - - roles, err := s.RoleRepo.FindAll() - if err != nil { - return nil, fmt.Errorf("failed to fetch roles: %v", err) - } - - var roleDTOs []dto.RoleResponseDTO - for _, role := range roles { - createdAt, _ := utils.FormatDateToIndonesianFormat(role.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(role.UpdatedAt) - - roleDTOs = append(roleDTOs, dto.RoleResponseDTO{ - ID: role.ID, - RoleName: role.RoleName, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData := map[string]interface{}{ - "data": roleDTOs, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching roles data to Redis: %v\n", err) - } - - return roleDTOs, nil -} - -func (s *roleService) GetRoleByID(roleID string) (*dto.RoleResponseDTO, error) { - - role, err := s.RoleRepo.FindByID(roleID) - if err != nil { - return nil, fmt.Errorf("role not found: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(role.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(role.UpdatedAt) - - roleDTO := &dto.RoleResponseDTO{ - ID: role.ID, - RoleName: role.RoleName, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("role:%s", roleID) - cacheData := map[string]interface{}{ - "data": roleDTO, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching role data to Redis: %v\n", err) - } - - return roleDTO, nil -} diff --git a/internal/services/trash_service.go b/internal/services/trash_service.go deleted file mode 100644 index 58fa05d..0000000 --- a/internal/services/trash_service.go +++ /dev/null @@ -1,500 +0,0 @@ -package services - -import ( - "fmt" - "time" - - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type TrashService interface { - CreateCategory(request dto.RequestTrashCategoryDTO) (*dto.ResponseTrashCategoryDTO, error) - AddDetailToCategory(request dto.RequestTrashDetailDTO) (*dto.ResponseTrashDetailDTO, error) - - GetCategories() ([]dto.ResponseTrashCategoryDTO, error) - GetCategoryByID(id string) (*dto.ResponseTrashCategoryDTO, error) - GetTrashDetailByID(id string) (*dto.ResponseTrashDetailDTO, error) - - UpdateCategory(id string, request dto.RequestTrashCategoryDTO) (*dto.ResponseTrashCategoryDTO, error) - UpdateDetail(id string, request dto.RequestTrashDetailDTO) (*dto.ResponseTrashDetailDTO, error) - - DeleteCategory(id string) error - DeleteDetail(id string) error -} - -type trashService struct { - TrashRepo repositories.TrashRepository -} - -func NewTrashService(trashRepo repositories.TrashRepository) TrashService { - return &trashService{TrashRepo: trashRepo} -} - -func (s *trashService) CreateCategory(request dto.RequestTrashCategoryDTO) (*dto.ResponseTrashCategoryDTO, error) { - errors, valid := request.ValidateTrashCategoryInput() - if !valid { - return nil, fmt.Errorf("validation error: %v", errors) - } - - category := model.TrashCategory{ - Name: request.Name, - } - - if err := s.TrashRepo.CreateCategory(&category); err != nil { - return nil, fmt.Errorf("failed to create category: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(category.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(category.UpdatedAt) - - categoryResponseDTO := &dto.ResponseTrashCategoryDTO{ - ID: category.ID, - Name: category.Name, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - if err := s.CacheCategoryAndDetails(category.ID, categoryResponseDTO, nil, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching category: %v", err) - } - - categories, err := s.TrashRepo.GetCategories() - if err == nil { - var categoriesDTO []dto.ResponseTrashCategoryDTO - for _, c := range categories { - ccreatedAt, _ := utils.FormatDateToIndonesianFormat(c.CreatedAt) - cupdatedAt, _ := utils.FormatDateToIndonesianFormat(c.UpdatedAt) - categoriesDTO = append(categoriesDTO, dto.ResponseTrashCategoryDTO{ - ID: c.ID, - Name: c.Name, - CreatedAt: ccreatedAt, - UpdatedAt: cupdatedAt, - }) - } - - if err := s.CacheCategoryList(categoriesDTO, time.Hour*6); err != nil { - fmt.Printf("Error caching all categories: %v\n", err) - } - } - - return categoryResponseDTO, nil -} - -func (s *trashService) AddDetailToCategory(request dto.RequestTrashDetailDTO) (*dto.ResponseTrashDetailDTO, error) { - errors, valid := request.ValidateTrashDetailInput() - if !valid { - return nil, fmt.Errorf("validation error: %v", errors) - } - - detail := model.TrashDetail{ - CategoryID: request.CategoryID, - Description: request.Description, - Price: request.Price, - } - - if err := s.TrashRepo.AddDetailToCategory(&detail); err != nil { - return nil, fmt.Errorf("failed to add detail to category: %v", err) - } - - dcreatedAt, _ := utils.FormatDateToIndonesianFormat(detail.CreatedAt) - dupdatedAt, _ := utils.FormatDateToIndonesianFormat(detail.UpdatedAt) - - detailResponseDTO := &dto.ResponseTrashDetailDTO{ - ID: detail.ID, - CategoryID: detail.CategoryID, - Description: detail.Description, - Price: detail.Price, - CreatedAt: dcreatedAt, - UpdatedAt: dupdatedAt, - } - - cacheKey := fmt.Sprintf("detail:%s", detail.ID) - cacheData := map[string]interface{}{ - "data": detailResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching detail: %v", err) - } - - category, err := s.TrashRepo.GetCategoryByID(detail.CategoryID) - - if err == nil { - - ccreatedAt, _ := utils.FormatDateToIndonesianFormat(category.CreatedAt) - cupdatedAt, _ := utils.FormatDateToIndonesianFormat(category.UpdatedAt) - - categoryResponseDTO := &dto.ResponseTrashCategoryDTO{ - ID: category.ID, - Name: category.Name, - CreatedAt: ccreatedAt, - UpdatedAt: cupdatedAt, - } - - if err := s.CacheCategoryAndDetails(detail.CategoryID, categoryResponseDTO, category.Details, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching updated category: %v", err) - } - } else { - return nil, fmt.Errorf("error fetching category for cache update: %v", err) - } - - return detailResponseDTO, nil -} - -func (s *trashService) GetCategories() ([]dto.ResponseTrashCategoryDTO, error) { - cacheKey := "categories:all" - cachedCategories, err := utils.GetJSONData(cacheKey) - if err == nil && cachedCategories != nil { - var categoriesDTO []dto.ResponseTrashCategoryDTO - for _, category := range cachedCategories["data"].([]interface{}) { - categoryData := category.(map[string]interface{}) - categoriesDTO = append(categoriesDTO, dto.ResponseTrashCategoryDTO{ - ID: categoryData["id"].(string), - Name: categoryData["name"].(string), - CreatedAt: categoryData["createdAt"].(string), - UpdatedAt: categoryData["updatedAt"].(string), - }) - } - return categoriesDTO, nil - } - - categories, err := s.TrashRepo.GetCategories() - if err != nil { - return nil, fmt.Errorf("failed to fetch categories: %v", err) - } - - var categoriesDTO []dto.ResponseTrashCategoryDTO - for _, category := range categories { - createdAt, _ := utils.FormatDateToIndonesianFormat(category.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(category.UpdatedAt) - categoriesDTO = append(categoriesDTO, dto.ResponseTrashCategoryDTO{ - ID: category.ID, - Name: category.Name, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) - } - - cacheData := map[string]interface{}{ - "data": categoriesDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*6); err != nil { - fmt.Printf("Error caching categories: %v\n", err) - } - - return categoriesDTO, nil -} - -func (s *trashService) GetCategoryByID(id string) (*dto.ResponseTrashCategoryDTO, error) { - cacheKey := fmt.Sprintf("category:%s", id) - cachedCategory, err := utils.GetJSONData(cacheKey) - if err == nil && cachedCategory != nil { - categoryData := cachedCategory["data"].(map[string]interface{}) - details := mapDetails(cachedCategory["details"]) - return &dto.ResponseTrashCategoryDTO{ - ID: categoryData["id"].(string), - Name: categoryData["name"].(string), - CreatedAt: categoryData["createdAt"].(string), - UpdatedAt: categoryData["updatedAt"].(string), - Details: details, - }, nil - } - - category, err := s.TrashRepo.GetCategoryByID(id) - if err != nil { - return nil, fmt.Errorf("category not found: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(category.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(category.UpdatedAt) - - categoryDTO := &dto.ResponseTrashCategoryDTO{ - ID: category.ID, - Name: category.Name, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - if category.Details != nil { - var detailsDTO []dto.ResponseTrashDetailDTO - for _, detail := range category.Details { - detailsDTO = append(detailsDTO, dto.ResponseTrashDetailDTO{ - ID: detail.ID, - CategoryID: detail.CategoryID, - Description: detail.Description, - Price: detail.Price, - CreatedAt: detail.CreatedAt.Format("02-01-2006 15:04"), - UpdatedAt: detail.UpdatedAt.Format("02-01-2006 15:04"), - }) - } - categoryDTO.Details = detailsDTO - } - - if err := s.CacheCategoryAndDetails(category.ID, categoryDTO, categoryDTO.Details, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching category and details: %v", err) - } - - return categoryDTO, nil -} - -func (s *trashService) GetTrashDetailByID(id string) (*dto.ResponseTrashDetailDTO, error) { - cacheKey := fmt.Sprintf("detail:%s", id) - cachedDetail, err := utils.GetJSONData(cacheKey) - if err == nil && cachedDetail != nil { - detailData := cachedDetail["data"].(map[string]interface{}) - return &dto.ResponseTrashDetailDTO{ - ID: detailData["id"].(string), - CategoryID: detailData["category_id"].(string), - Description: detailData["description"].(string), - Price: detailData["price"].(float64), - CreatedAt: detailData["createdAt"].(string), - UpdatedAt: detailData["updatedAt"].(string), - }, nil - } - - detail, err := s.TrashRepo.GetTrashDetailByID(id) - if err != nil { - return nil, fmt.Errorf("trash detail not found: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(detail.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(detail.UpdatedAt) - - detailDTO := &dto.ResponseTrashDetailDTO{ - ID: detail.ID, - CategoryID: detail.CategoryID, - Description: detail.Description, - Price: detail.Price, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheData := map[string]interface{}{ - "data": detailDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*24); err != nil { - return nil, fmt.Errorf("error caching detail: %v", err) - } - - return detailDTO, nil -} - -func (s *trashService) UpdateCategory(id string, request dto.RequestTrashCategoryDTO) (*dto.ResponseTrashCategoryDTO, error) { - errors, valid := request.ValidateTrashCategoryInput() - if !valid { - return nil, fmt.Errorf("validation error: %v", errors) - } - - if err := s.TrashRepo.UpdateCategoryName(id, request.Name); err != nil { - return nil, fmt.Errorf("failed to update category: %v", err) - } - - category, err := s.TrashRepo.GetCategoryByID(id) - if err != nil { - return nil, fmt.Errorf("category not found: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(category.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(category.UpdatedAt) - - categoryResponseDTO := &dto.ResponseTrashCategoryDTO{ - ID: category.ID, - Name: category.Name, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - if err := s.CacheCategoryAndDetails(category.ID, categoryResponseDTO, category.Details, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching updated category: %v", err) - } - - allCategories, err := s.TrashRepo.GetCategories() - if err == nil { - var categoriesDTO []dto.ResponseTrashCategoryDTO - for _, c := range allCategories { - ccreatedAt, _ := utils.FormatDateToIndonesianFormat(c.CreatedAt) - cupdatedAt, _ := utils.FormatDateToIndonesianFormat(c.UpdatedAt) - categoriesDTO = append(categoriesDTO, dto.ResponseTrashCategoryDTO{ - ID: c.ID, - Name: c.Name, - CreatedAt: ccreatedAt, - UpdatedAt: cupdatedAt, - }) - } - - if err := s.CacheCategoryList(categoriesDTO, time.Hour*6); err != nil { - fmt.Printf("Error caching all categories: %v\n", err) - } - } - - return categoryResponseDTO, nil -} - -func (s *trashService) UpdateDetail(id string, request dto.RequestTrashDetailDTO) (*dto.ResponseTrashDetailDTO, error) { - errors, valid := request.ValidateTrashDetailInput() - if !valid { - return nil, fmt.Errorf("validation error: %v", errors) - } - - if err := s.TrashRepo.UpdateTrashDetail(id, request.Description, request.Price); err != nil { - return nil, fmt.Errorf("failed to update detail: %v", err) - } - - detail, err := s.TrashRepo.GetTrashDetailByID(id) - if err != nil { - return nil, fmt.Errorf("trash detail not found: %v", err) - } - - createdAt, _ := utils.FormatDateToIndonesianFormat(detail.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(detail.UpdatedAt) - - detailResponseDTO := &dto.ResponseTrashDetailDTO{ - ID: detail.ID, - CategoryID: detail.CategoryID, - Description: detail.Description, - Price: detail.Price, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - cacheKey := fmt.Sprintf("detail:%s", detail.ID) - cacheData := map[string]interface{}{ - "data": detailResponseDTO, - } - if err := utils.SetJSONData(cacheKey, cacheData, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching updated detail: %v", err) - } - - category, err := s.TrashRepo.GetCategoryByID(detail.CategoryID) - if err == nil { - - ccreatedAt, _ := utils.FormatDateToIndonesianFormat(category.CreatedAt) - cupdatedAt, _ := utils.FormatDateToIndonesianFormat(category.UpdatedAt) - - categoryResponseDTO := &dto.ResponseTrashCategoryDTO{ - ID: category.ID, - Name: category.Name, - CreatedAt: ccreatedAt, - UpdatedAt: cupdatedAt, - } - - if err := s.CacheCategoryAndDetails(detail.CategoryID, categoryResponseDTO, category.Details, time.Hour*6); err != nil { - return nil, fmt.Errorf("error caching updated category: %v", err) - } - } else { - fmt.Printf("Error fetching category for cache update: %v\n", err) - } - - return detailResponseDTO, nil -} - -func (s *trashService) DeleteCategory(id string) error { - detailsCacheKeyPrefix := "detail:" - details, err := s.TrashRepo.GetDetailsByCategoryID(id) - if err != nil { - return fmt.Errorf("failed to fetch details for category %s: %v", id, err) - } - - for _, detail := range details { - detailCacheKey := detailsCacheKeyPrefix + detail.ID - if err := s.deleteCache(detailCacheKey); err != nil { - return fmt.Errorf("error clearing cache for deleted detail: %v", err) - } - } - - if err := s.TrashRepo.DeleteCategory(id); err != nil { - return fmt.Errorf("failed to delete category: %v", err) - } - - if err := s.deleteCache("category:" + id); err != nil { - return fmt.Errorf("error clearing cache for deleted category: %v", err) - } - - if err := s.deleteCache("categories:all"); err != nil { - return fmt.Errorf("error clearing categories list cache: %v", err) - } - - return nil -} - -func (s *trashService) DeleteDetail(id string) error { - - detail, err := s.TrashRepo.GetTrashDetailByID(id) - if err != nil { - return fmt.Errorf("trash detail not found: %v", err) - } - - if err := s.TrashRepo.DeleteTrashDetail(id); err != nil { - return fmt.Errorf("failed to delete detail: %v", err) - } - - detailCacheKey := fmt.Sprintf("detail:%s", id) - if err := s.deleteCache(detailCacheKey); err != nil { - return fmt.Errorf("error clearing cache for deleted detail: %v", err) - } - - categoryCacheKey := fmt.Sprintf("category:%s", detail.CategoryID) - if err := s.deleteCache(categoryCacheKey); err != nil { - return fmt.Errorf("error clearing cache for category after detail deletion: %v", err) - } - - return nil -} - -func mapDetails(details interface{}) []dto.ResponseTrashDetailDTO { - var detailsDTO []dto.ResponseTrashDetailDTO - if details != nil { - for _, detail := range details.([]interface{}) { - detailData := detail.(map[string]interface{}) - detailsDTO = append(detailsDTO, dto.ResponseTrashDetailDTO{ - ID: detailData["id"].(string), - CategoryID: detailData["category_id"].(string), - Description: detailData["description"].(string), - Price: detailData["price"].(float64), - CreatedAt: detailData["createdAt"].(string), - UpdatedAt: detailData["updatedAt"].(string), - }) - } - } - return detailsDTO -} - -func (s *trashService) CacheCategoryAndDetails(categoryID string, categoryData interface{}, detailsData interface{}, expiration time.Duration) error { - cacheKey := fmt.Sprintf("category:%s", categoryID) - cacheData := map[string]interface{}{ - "data": categoryData, - "details": detailsData, - } - - err := utils.SetJSONData(cacheKey, cacheData, expiration) - if err != nil { - return fmt.Errorf("error caching category and details: %v", err) - } - - return nil -} - -func (s *trashService) CacheCategoryList(categoriesData interface{}, expiration time.Duration) error { - cacheKey := "categories:all" - cacheData := map[string]interface{}{ - "data": categoriesData, - } - - err := utils.SetJSONData(cacheKey, cacheData, expiration) - if err != nil { - return fmt.Errorf("error caching categories list: %v", err) - } - - return nil -} - -func (s *trashService) deleteCache(cacheKey string) error { - if err := utils.DeleteData(cacheKey); err != nil { - fmt.Printf("Error clearing cache for key: %v\n", cacheKey) - return fmt.Errorf("error clearing cache for key %s: %v", cacheKey, err) - } - fmt.Printf("Deleted cache for key: %s\n", cacheKey) - return nil -} diff --git a/internal/services/user_service.go b/internal/services/user_service.go deleted file mode 100644 index 827a099..0000000 --- a/internal/services/user_service.go +++ /dev/null @@ -1,262 +0,0 @@ -package services - -import ( - "encoding/json" - "errors" - "fmt" - "mime/multipart" - "os" - "path/filepath" - "time" - - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" - "golang.org/x/crypto/bcrypt" -) - -const avatarDir = "./public/uploads/avatars" - -var allowedExtensions = []string{".jpg", ".jpeg", ".png"} - -type UserProfileService interface { - GetUserProfile(userID string) (*dto.UserResponseDTO, error) - UpdateUserProfile(userID string, updateData dto.UpdateUserDTO) (*dto.UserResponseDTO, error) - UpdateUserPassword(userID string, passwordData dto.UpdatePasswordDTO) (string, error) - UpdateUserAvatar(userID string, file *multipart.FileHeader) (string, error) -} - -type userProfileService struct { - UserRepo repositories.UserRepository - RoleRepo repositories.RoleRepository - UserProfileRepo repositories.UserProfileRepository -} - -func NewUserProfileService(userProfileRepo repositories.UserProfileRepository) UserProfileService { - return &userProfileService{UserProfileRepo: userProfileRepo} -} - -func (s *userProfileService) prepareUserResponse(user *model.User) *dto.UserResponseDTO { - createdAt, _ := utils.FormatDateToIndonesianFormat(user.CreatedAt) - updatedAt, _ := utils.FormatDateToIndonesianFormat(user.UpdatedAt) - - return &dto.UserResponseDTO{ - ID: user.ID, - Username: user.Username, - Avatar: user.Avatar, - Name: user.Name, - Phone: user.Phone, - Email: user.Email, - EmailVerified: user.EmailVerified, - RoleName: user.Role.RoleName, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } -} - -func (s *userProfileService) GetUserProfile(userID string) (*dto.UserResponseDTO, error) { - - cacheKey := fmt.Sprintf("userProfile:%s", userID) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - - userResponse := &dto.UserResponseDTO{} - if data, ok := cachedData["data"].(string); ok { - if err := json.Unmarshal([]byte(data), userResponse); err != nil { - return nil, err - } - return userResponse, nil - } - } - - user, err := s.UserProfileRepo.FindByID(userID) - if err != nil { - return nil, errors.New("user not found") - } - - userResponse := s.prepareUserResponse(user) - - cacheData := map[string]interface{}{ - "data": userResponse, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching user profile to Redis: %v\n", err) - } - - return userResponse, nil -} - -func (s *userProfileService) UpdateUserProfile(userID string, updateData dto.UpdateUserDTO) (*dto.UserResponseDTO, error) { - user, err := s.UserProfileRepo.FindByID(userID) - if err != nil { - return nil, errors.New("user not found") - } - - validationErrors, valid := updateData.Validate() - if !valid { - return nil, fmt.Errorf("validation failed: %v", validationErrors) - } - - if updateData.Name != "" { - user.Name = updateData.Name - } - - if updateData.Phone != "" && updateData.Phone != user.Phone { - if err := s.updatePhoneIfNeeded(user, updateData.Phone); err != nil { - return nil, err - } - user.Phone = updateData.Phone - } - - if updateData.Email != "" && updateData.Email != user.Email { - if err := s.updateEmailIfNeeded(user, updateData.Email); err != nil { - return nil, err - } - user.Email = updateData.Email - } - - err = s.UserProfileRepo.Update(user) - if err != nil { - return nil, fmt.Errorf("failed to update user: %v", err) - } - - userResponse := s.prepareUserResponse(user) - - cacheKey := fmt.Sprintf("userProfile:%s", userID) - cacheData := map[string]interface{}{ - "data": userResponse, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error updating cached user profile in Redis: %v\n", err) - } - - return userResponse, nil -} - -func (s *userProfileService) updatePhoneIfNeeded(user *model.User, newPhone string) error { - existingPhone, _ := s.UserRepo.FindByPhoneAndRole(newPhone, user.RoleID) - if existingPhone != nil { - return fmt.Errorf("phone number is already used for this role") - } - return nil -} - -func (s *userProfileService) updateEmailIfNeeded(user *model.User, newEmail string) error { - existingEmail, _ := s.UserRepo.FindByEmailAndRole(newEmail, user.RoleID) - if existingEmail != nil { - return fmt.Errorf("email is already used for this role") - } - return nil -} - -func (s *userProfileService) UpdateUserPassword(userID string, passwordData dto.UpdatePasswordDTO) (string, error) { - - validationErrors, valid := passwordData.Validate() - if !valid { - return "", fmt.Errorf("validation failed: %v", validationErrors) - } - - user, err := s.UserProfileRepo.FindByID(userID) - if err != nil { - return "", errors.New("user not found") - } - - if !CheckPasswordHash(passwordData.OldPassword, user.Password) { - return "", errors.New("old password is incorrect") - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(passwordData.NewPassword), bcrypt.DefaultCost) - if err != nil { - return "", fmt.Errorf("failed to hash new password: %v", err) - } - - user.Password = string(hashedPassword) - err = s.UserProfileRepo.Update(user) - if err != nil { - return "", fmt.Errorf("failed to update password: %v", err) - } - - return "Password berhasil diupdate", nil -} - -func (s *userProfileService) UpdateUserAvatar(userID string, file *multipart.FileHeader) (string, error) { - - if err := ensureAvatarDirectoryExists(); err != nil { - return "", err - } - - if err := validateAvatarFile(file); err != nil { - return "", err - } - - updatedUser, err := s.UserProfileRepo.FindByID(userID) - if err != nil { - return "", fmt.Errorf("failed to retrieve user data: %v", err) - } - - if updatedUser.Avatar != nil && *updatedUser.Avatar != "" { - oldAvatarPath := "./public" + *updatedUser.Avatar - if err := os.Remove(oldAvatarPath); err != nil { - return "", fmt.Errorf("failed to remove old avatar: %v", err) - } - } - - avatarURL, err := saveAvatarFile(file, userID) - if err != nil { - return "", err - } - - err = s.UserProfileRepo.UpdateAvatar(userID, avatarURL) - if err != nil { - return "", fmt.Errorf("failed to update avatar in the database: %v", err) - } - - return "Foto profil berhasil diupdate", nil -} - -func ensureAvatarDirectoryExists() error { - if _, err := os.Stat(avatarDir); os.IsNotExist(err) { - if err := os.MkdirAll(avatarDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create avatar directory: %v", err) - } - } - return nil -} - -func validateAvatarFile(file *multipart.FileHeader) error { - extension := filepath.Ext(file.Filename) - for _, ext := range allowedExtensions { - if extension == ext { - return nil - } - } - return fmt.Errorf("invalid file type, only .jpg, .jpeg, and .png are allowed") -} - -func saveAvatarFile(file *multipart.FileHeader, userID string) (string, error) { - extension := filepath.Ext(file.Filename) - avatarFileName := fmt.Sprintf("%s_avatar%s", userID, extension) - avatarPath := filepath.Join(avatarDir, avatarFileName) - - src, err := file.Open() - if err != nil { - return "", fmt.Errorf("failed to open uploaded file: %v", err) - } - defer src.Close() - - dst, err := os.Create(avatarPath) - if err != nil { - return "", fmt.Errorf("failed to create file: %v", err) - } - defer dst.Close() - - _, err = dst.ReadFrom(src) - if err != nil { - return "", fmt.Errorf("failed to save avatar file: %v", err) - } - - return fmt.Sprintf("/uploads/avatars/%s", avatarFileName), nil -} diff --git a/internal/services/userpin_service.go b/internal/services/userpin_service.go deleted file mode 100644 index 4324c17..0000000 --- a/internal/services/userpin_service.go +++ /dev/null @@ -1,132 +0,0 @@ -package services - -import ( - "fmt" - "time" - - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" - "golang.org/x/crypto/bcrypt" -) - -type UserPinService interface { - CreateUserPin(userID, pin string) (string, error) - VerifyUserPin(userID, pin string) (string, error) - CheckPinStatus(userID string) (string, error) - UpdateUserPin(userID, oldPin, newPin string) (string, error) -} - -type userPinService struct { - UserPinRepo repositories.UserPinRepository -} - -func NewUserPinService(userPinRepo repositories.UserPinRepository) UserPinService { - return &userPinService{UserPinRepo: userPinRepo} -} - -func (s *userPinService) VerifyUserPin(userID, pin string) (string, error) { - if pin == "" { - return "", fmt.Errorf("pin tidak boleh kosong") - } - - userPin, err := s.UserPinRepo.FindByUserID(userID) - if err != nil { - return "", fmt.Errorf("error fetching user pin: %v", err) - } - if userPin == nil { - return "", fmt.Errorf("user pin not found") - } - - err = bcrypt.CompareHashAndPassword([]byte(userPin.Pin), []byte(pin)) - if err != nil { - return "", fmt.Errorf("incorrect pin") - } - - return "Pin yang anda masukkan benar", nil -} - -func (s *userPinService) CheckPinStatus(userID string) (string, error) { - userPin, err := s.UserPinRepo.FindByUserID(userID) - if err != nil { - return "", fmt.Errorf("error checking pin status: %v", err) - } - if userPin == nil { - return "Pin not created", nil - } - - return "Pin already created", nil -} - -func (s *userPinService) CreateUserPin(userID, pin string) (string, error) { - - existingPin, err := s.UserPinRepo.FindByUserID(userID) - if err != nil { - return "", fmt.Errorf("error checking existing pin: %v", err) - } - - if existingPin != nil { - return "", fmt.Errorf("you have already created a pin, you don't need to create another one") - } - - hashedPin, err := bcrypt.GenerateFromPassword([]byte(pin), bcrypt.DefaultCost) - if err != nil { - return "", fmt.Errorf("error hashing the pin: %v", err) - } - - newPin := model.UserPin{ - UserID: userID, - Pin: string(hashedPin), - } - - err = s.UserPinRepo.Create(&newPin) - if err != nil { - return "", fmt.Errorf("error creating user pin: %v", err) - } - - cacheKey := fmt.Sprintf("userpin:%s", userID) - cacheData := map[string]interface{}{"data": newPin} - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching new user pin to Redis: %v\n", err) - } - - return "Pin berhasil dibuat", nil -} - -func (s *userPinService) UpdateUserPin(userID, oldPin, newPin string) (string, error) { - - userPin, err := s.UserPinRepo.FindByUserID(userID) - if err != nil { - return "", fmt.Errorf("error fetching user pin: %v", err) - } - - if userPin == nil { - return "", fmt.Errorf("user pin not found") - } - - err = bcrypt.CompareHashAndPassword([]byte(userPin.Pin), []byte(oldPin)) - if err != nil { - return "", fmt.Errorf("incorrect old pin") - } - - hashedPin, err := bcrypt.GenerateFromPassword([]byte(newPin), bcrypt.DefaultCost) - if err != nil { - return "", fmt.Errorf("error hashing the new pin: %v", err) - } - - userPin.Pin = string(hashedPin) - err = s.UserPinRepo.Update(userPin) - if err != nil { - return "", fmt.Errorf("error updating user pin: %v", err) - } - - cacheKey := fmt.Sprintf("userpin:%s", userID) - cacheData := map[string]interface{}{"data": userPin} - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching updated user pin to Redis: %v\n", err) - } - - return "Pin berhasil diperbarui", nil -} diff --git a/internal/services/wilayah_indonesia_service.go b/internal/services/wilayah_indonesia_service.go deleted file mode 100644 index 0f78490..0000000 --- a/internal/services/wilayah_indonesia_service.go +++ /dev/null @@ -1,494 +0,0 @@ -package services - -import ( - "encoding/json" - "fmt" - "strconv" - "time" - - "github.com/pahmiudahgede/senggoldong/dto" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/model" - "github.com/pahmiudahgede/senggoldong/utils" -) - -type WilayahIndonesiaService interface { - ImportDataFromCSV() error - - GetAllProvinces(page, limit int) ([]dto.ProvinceResponseDTO, int, error) - GetProvinceByID(id string, page, limit int) (*dto.ProvinceResponseDTO, int, error) - - GetAllRegencies(page, limit int) ([]dto.RegencyResponseDTO, int, error) - GetRegencyByID(id string, page, limit int) (*dto.RegencyResponseDTO, int, error) - - GetAllDistricts(page, limit int) ([]dto.DistrictResponseDTO, int, error) - GetDistrictByID(id string, page, limit int) (*dto.DistrictResponseDTO, int, error) - - GetAllVillages(page, limit int) ([]dto.VillageResponseDTO, int, error) - GetVillageByID(id string) (*dto.VillageResponseDTO, error) -} - -type wilayahIndonesiaService struct { - WilayahRepo repositories.WilayahIndonesiaRepository -} - -func NewWilayahIndonesiaService(wilayahRepo repositories.WilayahIndonesiaRepository) WilayahIndonesiaService { - return &wilayahIndonesiaService{WilayahRepo: wilayahRepo} -} - -func (s *wilayahIndonesiaService) ImportDataFromCSV() error { - - provinces, err := utils.ReadCSV("public/document/provinces.csv") - if err != nil { - return fmt.Errorf("failed to read provinces CSV: %v", err) - } - - var provinceList []model.Province - for _, record := range provinces[1:] { - province := model.Province{ - ID: record[0], - Name: record[1], - } - provinceList = append(provinceList, province) - } - - if err := s.WilayahRepo.ImportProvinces(provinceList); err != nil { - return fmt.Errorf("failed to import provinces: %v", err) - } - - regencies, err := utils.ReadCSV("public/document/regencies.csv") - if err != nil { - return fmt.Errorf("failed to read regencies CSV: %v", err) - } - - var regencyList []model.Regency - for _, record := range regencies[1:] { - regency := model.Regency{ - ID: record[0], - ProvinceID: record[1], - Name: record[2], - } - regencyList = append(regencyList, regency) - } - - if err := s.WilayahRepo.ImportRegencies(regencyList); err != nil { - return fmt.Errorf("failed to import regencies: %v", err) - } - - districts, err := utils.ReadCSV("public/document/districts.csv") - if err != nil { - return fmt.Errorf("failed to read districts CSV: %v", err) - } - - var districtList []model.District - for _, record := range districts[1:] { - district := model.District{ - ID: record[0], - RegencyID: record[1], - Name: record[2], - } - districtList = append(districtList, district) - } - - if err := s.WilayahRepo.ImportDistricts(districtList); err != nil { - return fmt.Errorf("failed to import districts: %v", err) - } - - villages, err := utils.ReadCSV("public/document/villages.csv") - if err != nil { - return fmt.Errorf("failed to read villages CSV: %v", err) - } - - var villageList []model.Village - for _, record := range villages[1:] { - village := model.Village{ - ID: record[0], - DistrictID: record[1], - Name: record[2], - } - villageList = append(villageList, village) - } - - if err := s.WilayahRepo.ImportVillages(villageList); err != nil { - return fmt.Errorf("failed to import villages: %v", err) - } - - return nil -} - -func (s *wilayahIndonesiaService) GetAllProvinces(page, limit int) ([]dto.ProvinceResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("provinces_page:%d_limit:%d", page, limit) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - var provinces []dto.ProvinceResponseDTO - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - province, ok := item.(map[string]interface{}) - if ok { - provinces = append(provinces, dto.ProvinceResponseDTO{ - ID: province["id"].(string), - Name: province["name"].(string), - }) - } - } - total := int(cachedData["total"].(float64)) - return provinces, total, nil - } - } - - provinces, total, err := s.WilayahRepo.FindAllProvinces(page, limit) - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch provinces: %v", err) - } - - var provinceDTOs []dto.ProvinceResponseDTO - for _, province := range provinces { - provinceDTOs = append(provinceDTOs, dto.ProvinceResponseDTO{ - ID: province.ID, - Name: province.Name, - }) - } - - cacheData := map[string]interface{}{ - "data": provinceDTOs, - "total": total, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching provinces data: %v\n", err) - } - - return provinceDTOs, total, nil -} - -func (s *wilayahIndonesiaService) GetProvinceByID(id string, page, limit int) (*dto.ProvinceResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("province:%s_page:%d_limit:%d", id, page, limit) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - - var provinceDTO dto.ProvinceResponseDTO - if data, ok := cachedData["data"].(string); ok { - if err := json.Unmarshal([]byte(data), &provinceDTO); err == nil { - - totalRegencies, _ := strconv.Atoi(cachedData["total_regencies"].(string)) - return &provinceDTO, totalRegencies, nil - } - } - } - - province, totalRegencies, err := s.WilayahRepo.FindProvinceByID(id, page, limit) - if err != nil { - return nil, 0, err - } - - provinceDTO := dto.ProvinceResponseDTO{ - ID: province.ID, - Name: province.Name, - } - - var regencyDTOs []dto.RegencyResponseDTO - for _, regency := range province.Regencies { - regencyDTO := dto.RegencyResponseDTO{ - ID: regency.ID, - ProvinceID: regency.ProvinceID, - Name: regency.Name, - } - regencyDTOs = append(regencyDTOs, regencyDTO) - } - - provinceDTO.Regencies = regencyDTOs - - cacheData := map[string]interface{}{ - "data": provinceDTO, - "total_regencies": strconv.Itoa(totalRegencies), - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching province data: %v\n", err) - } - - return &provinceDTO, totalRegencies, nil -} - -func (s *wilayahIndonesiaService) GetAllRegencies(page, limit int) ([]dto.RegencyResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("regencies_page:%d_limit:%d", page, limit) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - var regencies []dto.RegencyResponseDTO - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - regency, ok := item.(map[string]interface{}) - if ok { - regencies = append(regencies, dto.RegencyResponseDTO{ - ID: regency["id"].(string), - ProvinceID: regency["province_id"].(string), - Name: regency["name"].(string), - }) - } - } - total := int(cachedData["total"].(float64)) - return regencies, total, nil - } - } - - regencies, total, err := s.WilayahRepo.FindAllRegencies(page, limit) - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch provinces: %v", err) - } - - var regencyDTOs []dto.RegencyResponseDTO - for _, regency := range regencies { - regencyDTOs = append(regencyDTOs, dto.RegencyResponseDTO{ - ID: regency.ID, - ProvinceID: regency.ProvinceID, - Name: regency.Name, - }) - } - - cacheData := map[string]interface{}{ - "data": regencyDTOs, - "total": total, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching regencies data: %v\n", err) - } - - return regencyDTOs, total, nil -} - -func (s *wilayahIndonesiaService) GetRegencyByID(id string, page, limit int) (*dto.RegencyResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("regency:%s_page:%d_limit:%d", id, page, limit) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - - var regencyDTO dto.RegencyResponseDTO - if data, ok := cachedData["data"].(string); ok { - if err := json.Unmarshal([]byte(data), ®encyDTO); err == nil { - - totalDistrict, _ := strconv.Atoi(cachedData["total_regencies"].(string)) - return ®encyDTO, totalDistrict, nil - } - } - } - - regency, totalDistrict, err := s.WilayahRepo.FindRegencyByID(id, page, limit) - if err != nil { - return nil, 0, err - } - - regencyDTO := dto.RegencyResponseDTO{ - ID: regency.ID, - ProvinceID: regency.ProvinceID, - Name: regency.Name, - } - - var districtDTOs []dto.DistrictResponseDTO - for _, regency := range regency.Districts { - districtDTO := dto.DistrictResponseDTO{ - ID: regency.ID, - RegencyID: regency.RegencyID, - Name: regency.Name, - } - districtDTOs = append(districtDTOs, districtDTO) - } - - regencyDTO.Districts = districtDTOs - - cacheData := map[string]interface{}{ - "data": regencyDTO, - "total_regencies": strconv.Itoa(totalDistrict), - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching province data: %v\n", err) - } - - return ®encyDTO, totalDistrict, nil -} - -func (s *wilayahIndonesiaService) GetAllDistricts(page, limit int) ([]dto.DistrictResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("district_page:%d_limit:%d", page, limit) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - var districts []dto.DistrictResponseDTO - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - district, ok := item.(map[string]interface{}) - if ok { - districts = append(districts, dto.DistrictResponseDTO{ - ID: district["id"].(string), - RegencyID: district["regency_id"].(string), - Name: district["name"].(string), - }) - } - } - total := int(cachedData["total"].(float64)) - return districts, total, nil - } - } - - districts, total, err := s.WilayahRepo.FindAllDistricts(page, limit) - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch districts: %v", err) - } - - var districtsDTOs []dto.DistrictResponseDTO - for _, district := range districts { - districtsDTOs = append(districtsDTOs, dto.DistrictResponseDTO{ - ID: district.ID, - RegencyID: district.RegencyID, - Name: district.Name, - }) - } - - cacheData := map[string]interface{}{ - "data": districtsDTOs, - "total": total, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching districts data: %v\n", err) - } - - return districtsDTOs, total, nil -} - -func (s *wilayahIndonesiaService) GetDistrictByID(id string, page, limit int) (*dto.DistrictResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("district:%s_page:%d_limit:%d", id, page, limit) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - - var districtDTO dto.DistrictResponseDTO - if data, ok := cachedData["data"].(string); ok { - if err := json.Unmarshal([]byte(data), &districtDTO); err == nil { - - totalVillage, _ := strconv.Atoi(cachedData["total_village"].(string)) - return &districtDTO, totalVillage, nil - } - } - } - - district, totalVillages, err := s.WilayahRepo.FindDistrictByID(id, page, limit) - if err != nil { - return nil, 0, err - } - - districtDTO := dto.DistrictResponseDTO{ - ID: district.ID, - RegencyID: district.RegencyID, - Name: district.Name, - } - - var villageDTOs []dto.VillageResponseDTO - for _, village := range district.Villages { - regencyDTO := dto.VillageResponseDTO{ - ID: village.ID, - DistrictID: village.DistrictID, - Name: village.Name, - } - villageDTOs = append(villageDTOs, regencyDTO) - } - - districtDTO.Villages = villageDTOs - - cacheData := map[string]interface{}{ - "data": districtDTO, - "total_villages": strconv.Itoa(totalVillages), - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching province data: %v\n", err) - } - - return &districtDTO, totalVillages, nil -} - -func (s *wilayahIndonesiaService) GetAllVillages(page, limit int) ([]dto.VillageResponseDTO, int, error) { - - cacheKey := fmt.Sprintf("villages_page:%d_limit:%d", page, limit) - - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - var villages []dto.VillageResponseDTO - if data, ok := cachedData["data"].([]interface{}); ok { - for _, item := range data { - villageData, ok := item.(map[string]interface{}) - if ok { - villages = append(villages, dto.VillageResponseDTO{ - ID: villageData["id"].(string), - DistrictID: villageData["district_id"].(string), - Name: villageData["name"].(string), - }) - } - } - return villages, int(cachedData["total"].(float64)), nil - } - } - - villages, total, err := s.WilayahRepo.FindAllVillages(page, limit) - if err != nil { - return nil, 0, fmt.Errorf("failed to fetch villages: %v", err) - } - - var villageDTOs []dto.VillageResponseDTO - for _, village := range villages { - villageDTOs = append(villageDTOs, dto.VillageResponseDTO{ - ID: village.ID, - DistrictID: village.DistrictID, - Name: village.Name, - }) - } - - cacheData := map[string]interface{}{ - "data": villageDTOs, - "total": total, - } - err = utils.SetJSONData(cacheKey, cacheData, time.Hour*24) - if err != nil { - fmt.Printf("Error caching villages data to Redis: %v\n", err) - } - - return villageDTOs, total, nil -} - -func (s *wilayahIndonesiaService) GetVillageByID(id string) (*dto.VillageResponseDTO, error) { - - cacheKey := fmt.Sprintf("village:%s", id) - cachedData, err := utils.GetJSONData(cacheKey) - if err == nil && cachedData != nil { - villageResponse := &dto.VillageResponseDTO{} - if data, ok := cachedData["data"].(string); ok { - if err := json.Unmarshal([]byte(data), villageResponse); err == nil { - return villageResponse, nil - } - } - } - - village, err := s.WilayahRepo.FindVillageByID(id) - if err != nil { - return nil, fmt.Errorf("village not found: %v", err) - } - - villageResponse := &dto.VillageResponseDTO{ - ID: village.ID, - DistrictID: village.DistrictID, - Name: village.Name, - } - - cacheData := map[string]interface{}{ - "data": villageResponse, - } - err = utils.SetJSONData(cacheKey, cacheData, 24*time.Hour) - if err != nil { - fmt.Printf("Error caching village data to Redis: %v\n", err) - } - - return villageResponse, nil -} diff --git a/internal/trash/trash_dto.go b/internal/trash/trash_dto.go new file mode 100644 index 0000000..a900a5d --- /dev/null +++ b/internal/trash/trash_dto.go @@ -0,0 +1,75 @@ +package trash + +import ( + "strings" +) + +type RequestTrashCategoryDTO struct { + Name string `json:"name"` + EstimatedPrice float64 `json:"estimated_price"` + IconTrash string `json:"icon_trash,omitempty"` + Variety string `json:"variety"` +} + +type RequestTrashDetailDTO struct { + CategoryID string `json:"category_id"` + StepOrder int `json:"step"` + IconTrashDetail string `json:"icon_trash_detail,omitempty"` + Description string `json:"description"` +} + +type ResponseTrashCategoryDTO struct { + ID string `json:"id,omitempty"` + TrashName string `json:"trash_name,omitempty"` + TrashIcon string `json:"trash_icon,omitempty"` + EstimatedPrice float64 `json:"estimated_price"` + Variety string `json:"variety,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + TrashDetail []ResponseTrashDetailDTO `json:"trash_detail,omitempty"` +} + +type ResponseTrashDetailDTO struct { + ID string `json:"trashdetail_id"` + CategoryID string `json:"category_id"` + IconTrashDetail string `json:"trashdetail_icon,omitempty"` + Description string `json:"description"` + StepOrder int `json:"step_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (r *RequestTrashCategoryDTO) ValidateRequestTrashCategoryDTO() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.Name) == "" { + errors["name"] = append(errors["name"], "name is required") + } + if r.EstimatedPrice <= 0 { + errors["estimated_price"] = append(errors["estimated_price"], "estimated price must be greater than 0") + } + if strings.TrimSpace(r.Variety) == "" { + errors["variety"] = append(errors["variety"], "variety is required") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} + +func (r *RequestTrashDetailDTO) ValidateRequestTrashDetailDTO() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.Description) == "" { + errors["description"] = append(errors["description"], "description is required") + } + if strings.TrimSpace(r.CategoryID) == "" { + errors["category_id"] = append(errors["category_id"], "category_id is required") + } + + if len(errors) > 0 { + return errors, false + } + return nil, true +} diff --git a/internal/trash/trash_handler.go b/internal/trash/trash_handler.go new file mode 100644 index 0000000..7d6553c --- /dev/null +++ b/internal/trash/trash_handler.go @@ -0,0 +1,521 @@ +package trash + +import ( + "rijig/utils" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type TrashHandler struct { + trashService TrashServiceInterface +} + +func NewTrashHandler(trashService TrashServiceInterface) *TrashHandler { + return &TrashHandler{ + trashService: trashService, + } +} + +func (h *TrashHandler) CreateTrashCategory(c *fiber.Ctx) error { + var req RequestTrashCategoryDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + response, err := h.trashService.CreateTrashCategory(c.Context(), req) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + return utils.InternalServerError(c, "Failed to create trash category") + } + + return utils.CreateSuccessWithData(c, "Trash category created successfully", response) +} + +func (h *TrashHandler) CreateTrashCategoryWithIcon(c *fiber.Ctx) error { + var req RequestTrashCategoryDTO + + req.Name = c.FormValue("name") + req.Variety = c.FormValue("variety") + + if estimatedPriceStr := c.FormValue("estimated_price"); estimatedPriceStr != "" { + if price, err := strconv.ParseFloat(estimatedPriceStr, 64); err == nil { + req.EstimatedPrice = price + } + } + + iconFile, err := c.FormFile("icon") + if err != nil && err.Error() != "there is no uploaded file associated with the given key" { + return utils.BadRequest(c, "Invalid icon file") + } + + response, err := h.trashService.CreateTrashCategoryWithIcon(c.Context(), req, iconFile) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "invalid file type") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to create trash category") + } + + return utils.CreateSuccessWithData(c, "Trash category created successfully", response) +} + +func (h *TrashHandler) CreateTrashCategoryWithDetails(c *fiber.Ctx) error { + var req struct { + Category RequestTrashCategoryDTO `json:"category"` + Details []RequestTrashDetailDTO `json:"details"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + response, err := h.trashService.CreateTrashCategoryWithDetails(c.Context(), req.Category, req.Details) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + return utils.InternalServerError(c, "Failed to create trash category with details") + } + + return utils.CreateSuccessWithData(c, "Trash category with details created successfully", response) +} + +func (h *TrashHandler) UpdateTrashCategory(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Category ID is required") + } + + var req RequestTrashCategoryDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + response, err := h.trashService.UpdateTrashCategory(c.Context(), id, req) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to update trash category") + } + + return utils.SuccessWithData(c, "Trash category updated successfully", response) +} + +func (h *TrashHandler) UpdateTrashCategoryWithIcon(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Category ID is required") + } + + var req RequestTrashCategoryDTO + + req.Name = c.FormValue("name") + req.Variety = c.FormValue("variety") + + if estimatedPriceStr := c.FormValue("estimated_price"); estimatedPriceStr != "" { + if price, err := strconv.ParseFloat(estimatedPriceStr, 64); err == nil { + req.EstimatedPrice = price + } + } + + iconFile, _ := c.FormFile("icon") + + response, err := h.trashService.UpdateTrashCategoryWithIcon(c.Context(), id, req, iconFile) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + if strings.Contains(err.Error(), "invalid file type") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to update trash category") + } + + return utils.SuccessWithData(c, "Trash category updated successfully", response) +} + +func (h *TrashHandler) GetAllTrashCategories(c *fiber.Ctx) error { + withDetails := c.Query("with_details", "false") + + if withDetails == "true" { + response, err := h.trashService.GetAllTrashCategoriesWithDetails(c.Context()) + if err != nil { + return utils.InternalServerError(c, "Failed to get trash categories") + } + return utils.SuccessWithData(c, "Trash categories retrieved successfully", response) + } + + response, err := h.trashService.GetAllTrashCategories(c.Context()) + if err != nil { + return utils.InternalServerError(c, "Failed to get trash categories") + } + + return utils.SuccessWithData(c, "Trash categories retrieved successfully", response) +} + +func (h *TrashHandler) GetTrashCategoryByID(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Category ID is required") + } + + withDetails := c.Query("with_details", "false") + + if withDetails == "true" { + response, err := h.trashService.GetTrashCategoryByIDWithDetails(c.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to get trash category") + } + return utils.SuccessWithData(c, "Trash category retrieved successfully", response) + } + + response, err := h.trashService.GetTrashCategoryByID(c.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to get trash category") + } + + return utils.SuccessWithData(c, "Trash category retrieved successfully", response) +} + +func (h *TrashHandler) DeleteTrashCategory(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Category ID is required") + } + + err := h.trashService.DeleteTrashCategory(c.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to delete trash category") + } + + return utils.Success(c, "Trash category deleted successfully") +} + +func (h *TrashHandler) CreateTrashDetail(c *fiber.Ctx) error { + var req RequestTrashDetailDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + response, err := h.trashService.CreateTrashDetail(c.Context(), req) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + return utils.InternalServerError(c, "Failed to create trash detail") + } + + return utils.CreateSuccessWithData(c, "Trash detail created successfully", response) +} + +func (h *TrashHandler) CreateTrashDetailWithIcon(c *fiber.Ctx) error { + var req RequestTrashDetailDTO + + req.CategoryID = c.FormValue("category_id") + req.Description = c.FormValue("description") + + if stepOrderStr := c.FormValue("step_order"); stepOrderStr != "" { + if stepOrder, err := strconv.Atoi(stepOrderStr); err == nil { + req.StepOrder = stepOrder + } + } + + iconFile, err := c.FormFile("icon") + if err != nil && err.Error() != "there is no uploaded file associated with the given key" { + return utils.BadRequest(c, "Invalid icon file") + } + + response, err := h.trashService.CreateTrashDetailWithIcon(c.Context(), req, iconFile) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "invalid file type") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to create trash detail") + } + + return utils.CreateSuccessWithData(c, "Trash detail created successfully", response) +} + +func (h *TrashHandler) AddTrashDetailToCategory(c *fiber.Ctx) error { + categoryID := c.Params("categoryId") + if categoryID == "" { + return utils.BadRequest(c, "Category ID is required") + } + + var req RequestTrashDetailDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + response, err := h.trashService.AddTrashDetailToCategory(c.Context(), categoryID, req) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + return utils.InternalServerError(c, "Failed to add trash detail to category") + } + + return utils.CreateSuccessWithData(c, "Trash detail added to category successfully", response) +} + +func (h *TrashHandler) AddTrashDetailToCategoryWithIcon(c *fiber.Ctx) error { + categoryID := c.Params("categoryId") + if categoryID == "" { + return utils.BadRequest(c, "Category ID is required") + } + + var req RequestTrashDetailDTO + + req.Description = c.FormValue("description") + + if stepOrderStr := c.FormValue("step_order"); stepOrderStr != "" { + if stepOrder, err := strconv.Atoi(stepOrderStr); err == nil { + req.StepOrder = stepOrder + } + } + + iconFile, err := c.FormFile("icon") + if err != nil && err.Error() != "there is no uploaded file associated with the given key" { + return utils.BadRequest(c, "Invalid icon file") + } + + response, err := h.trashService.AddTrashDetailToCategoryWithIcon(c.Context(), categoryID, req, iconFile) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "invalid file type") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to add trash detail to category") + } + + return utils.CreateSuccessWithData(c, "Trash detail added to category successfully", response) +} + +func (h *TrashHandler) UpdateTrashDetail(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Detail ID is required") + } + + var req RequestTrashDetailDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + response, err := h.trashService.UpdateTrashDetail(c.Context(), id, req) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash detail not found") + } + return utils.InternalServerError(c, "Failed to update trash detail") + } + + return utils.SuccessWithData(c, "Trash detail updated successfully", response) +} + +func (h *TrashHandler) UpdateTrashDetailWithIcon(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Detail ID is required") + } + + var req RequestTrashDetailDTO + + req.Description = c.FormValue("description") + + if stepOrderStr := c.FormValue("step_order"); stepOrderStr != "" { + if stepOrder, err := strconv.Atoi(stepOrderStr); err == nil { + req.StepOrder = stepOrder + } + } + + iconFile, _ := c.FormFile("icon") + + response, err := h.trashService.UpdateTrashDetailWithIcon(c.Context(), id, req, iconFile) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash detail not found") + } + if strings.Contains(err.Error(), "invalid file type") { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, "Failed to update trash detail") + } + + return utils.SuccessWithData(c, "Trash detail updated successfully", response) +} + +func (h *TrashHandler) GetTrashDetailsByCategory(c *fiber.Ctx) error { + categoryID := c.Params("categoryId") + if categoryID == "" { + return utils.BadRequest(c, "Category ID is required") + } + + response, err := h.trashService.GetTrashDetailsByCategory(c.Context(), categoryID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to get trash details") + } + + return utils.SuccessWithData(c, "Trash details retrieved successfully", response) +} + +func (h *TrashHandler) GetTrashDetailByID(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Detail ID is required") + } + + response, err := h.trashService.GetTrashDetailByID(c.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash detail not found") + } + return utils.InternalServerError(c, "Failed to get trash detail") + } + + return utils.SuccessWithData(c, "Trash detail retrieved successfully", response) +} + +func (h *TrashHandler) DeleteTrashDetail(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Detail ID is required") + } + + err := h.trashService.DeleteTrashDetail(c.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash detail not found") + } + return utils.InternalServerError(c, "Failed to delete trash detail") + } + + return utils.Success(c, "Trash detail deleted successfully") +} + +func (h *TrashHandler) BulkCreateTrashDetails(c *fiber.Ctx) error { + categoryID := c.Params("categoryId") + if categoryID == "" { + return utils.BadRequest(c, "Category ID is required") + } + + var req struct { + Details []RequestTrashDetailDTO `json:"details"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + if len(req.Details) == 0 { + return utils.BadRequest(c, "At least one detail is required") + } + + response, err := h.trashService.BulkCreateTrashDetails(c.Context(), categoryID, req.Details) + if err != nil { + if strings.Contains(err.Error(), "validation failed") { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", extractValidationErrors(err.Error())) + } + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to bulk create trash details") + } + + return utils.CreateSuccessWithData(c, "Trash details created successfully", response) +} + +func (h *TrashHandler) BulkDeleteTrashDetails(c *fiber.Ctx) error { + var req struct { + DetailIDs []string `json:"detail_ids"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + if len(req.DetailIDs) == 0 { + return utils.BadRequest(c, "At least one detail ID is required") + } + + err := h.trashService.BulkDeleteTrashDetails(c.Context(), req.DetailIDs) + if err != nil { + return utils.InternalServerError(c, "Failed to bulk delete trash details") + } + + return utils.Success(c, "Trash details deleted successfully") +} + +func (h *TrashHandler) ReorderTrashDetails(c *fiber.Ctx) error { + categoryID := c.Params("categoryId") + if categoryID == "" { + return utils.BadRequest(c, "Category ID is required") + } + + var req struct { + OrderedDetailIDs []string `json:"ordered_detail_ids"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + if len(req.OrderedDetailIDs) == 0 { + return utils.BadRequest(c, "At least one detail ID is required") + } + + err := h.trashService.ReorderTrashDetails(c.Context(), categoryID, req.OrderedDetailIDs) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Trash category not found") + } + return utils.InternalServerError(c, "Failed to reorder trash details") + } + + return utils.Success(c, "Trash details reordered successfully") +} + +func extractValidationErrors(errMsg string) interface{} { + + if strings.Contains(errMsg, "validation failed:") { + return strings.TrimSpace(strings.Split(errMsg, "validation failed:")[1]) + } + return errMsg +} diff --git a/internal/trash/trash_repository.go b/internal/trash/trash_repository.go new file mode 100644 index 0000000..638cc2d --- /dev/null +++ b/internal/trash/trash_repository.go @@ -0,0 +1,326 @@ +package trash + +import ( + "context" + "errors" + "fmt" + "rijig/model" + "time" + + "gorm.io/gorm" +) + +type TrashRepositoryInterface interface { + CreateTrashCategory(ctx context.Context, category *model.TrashCategory) error + CreateTrashCategoryWithDetails(ctx context.Context, category *model.TrashCategory, details []model.TrashDetail) error + UpdateTrashCategory(ctx context.Context, id string, updates map[string]interface{}) error + GetAllTrashCategories(ctx context.Context) ([]model.TrashCategory, error) + GetAllTrashCategoriesWithDetails(ctx context.Context) ([]model.TrashCategory, error) + GetTrashCategoryByID(ctx context.Context, id string) (*model.TrashCategory, error) + GetTrashCategoryByIDWithDetails(ctx context.Context, id string) (*model.TrashCategory, error) + DeleteTrashCategory(ctx context.Context, id string) error + + CreateTrashDetail(ctx context.Context, detail *model.TrashDetail) error + AddTrashDetailToCategory(ctx context.Context, categoryID string, detail *model.TrashDetail) error + UpdateTrashDetail(ctx context.Context, id string, updates map[string]interface{}) error + GetTrashDetailsByCategory(ctx context.Context, categoryID string) ([]model.TrashDetail, error) + GetTrashDetailByID(ctx context.Context, id string) (*model.TrashDetail, error) + DeleteTrashDetail(ctx context.Context, id string) error + + CheckTrashCategoryExists(ctx context.Context, id string) (bool, error) + CheckTrashDetailExists(ctx context.Context, id string) (bool, error) + GetMaxStepOrderByCategory(ctx context.Context, categoryID string) (int, error) +} + +type trashRepository struct { + db *gorm.DB +} + +func NewTrashRepository(db *gorm.DB) TrashRepositoryInterface { + return &trashRepository{ + db, + } +} + +func (r *trashRepository) CreateTrashCategory(ctx context.Context, category *model.TrashCategory) error { + if err := r.db.WithContext(ctx).Create(category).Error; err != nil { + return fmt.Errorf("failed to create trash category: %w", err) + } + return nil +} + +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 { + + if err := tx.Create(category).Error; err != nil { + return fmt.Errorf("failed to create trash category: %w", err) + } + + if len(details) > 0 { + + for i := range details { + details[i].TrashCategoryID = category.ID + + if details[i].StepOrder == 0 { + details[i].StepOrder = i + 1 + } + } + + if err := tx.Create(&details).Error; err != nil { + return fmt.Errorf("failed to create trash details: %w", err) + } + } + + return nil + }) +} + +func (r *trashRepository) UpdateTrashCategory(ctx context.Context, id string, updates map[string]interface{}) error { + + exists, err := r.CheckTrashCategoryExists(ctx, id) + if err != nil { + return err + } + if !exists { + return errors.New("trash category not found") + } + + updates["updated_at"] = time.Now() + + result := r.db.WithContext(ctx).Model(&model.TrashCategory{}).Where("id = ?", id).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update trash category: %w", result.Error) + } + + if result.RowsAffected == 0 { + return errors.New("no rows affected during update") + } + + return nil +} + +func (r *trashRepository) GetAllTrashCategories(ctx context.Context) ([]model.TrashCategory, error) { + var categories []model.TrashCategory + + if err := r.db.WithContext(ctx).Find(&categories).Error; err != nil { + return nil, fmt.Errorf("failed to get trash categories: %w", err) + } + + return categories, nil +} + +func (r *trashRepository) GetAllTrashCategoriesWithDetails(ctx context.Context) ([]model.TrashCategory, error) { + var categories []model.TrashCategory + + if err := r.db.WithContext(ctx).Preload("Details", func(db *gorm.DB) *gorm.DB { + return db.Order("step_order ASC") + }).Find(&categories).Error; err != nil { + return nil, fmt.Errorf("failed to get trash categories with details: %w", err) + } + + return categories, nil +} + +func (r *trashRepository) GetTrashCategoryByID(ctx context.Context, id string) (*model.TrashCategory, error) { + var category model.TrashCategory + + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&category).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("trash category not found") + } + return nil, fmt.Errorf("failed to get trash category: %w", err) + } + + return &category, nil +} + +func (r *trashRepository) GetTrashCategoryByIDWithDetails(ctx context.Context, id string) (*model.TrashCategory, error) { + var category model.TrashCategory + + if err := r.db.WithContext(ctx).Preload("Details", func(db *gorm.DB) *gorm.DB { + return db.Order("step_order ASC") + }).Where("id = ?", id).First(&category).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("trash category not found") + } + return nil, fmt.Errorf("failed to get trash category with details: %w", err) + } + + return &category, nil +} + +func (r *trashRepository) DeleteTrashCategory(ctx context.Context, id string) error { + + exists, err := r.CheckTrashCategoryExists(ctx, id) + if err != nil { + return err + } + if !exists { + return errors.New("trash category not found") + } + + result := r.db.WithContext(ctx).Delete(&model.TrashCategory{ID: id}) + if result.Error != nil { + return fmt.Errorf("failed to delete trash category: %w", result.Error) + } + + if result.RowsAffected == 0 { + return errors.New("no rows affected during deletion") + } + + return nil +} + +func (r *trashRepository) CreateTrashDetail(ctx context.Context, detail *model.TrashDetail) error { + + exists, err := r.CheckTrashCategoryExists(ctx, detail.TrashCategoryID) + if err != nil { + return err + } + if !exists { + return errors.New("trash category not found") + } + + if detail.StepOrder == 0 { + maxOrder, err := r.GetMaxStepOrderByCategory(ctx, detail.TrashCategoryID) + if err != nil { + return err + } + detail.StepOrder = maxOrder + 1 + } + + if err := r.db.WithContext(ctx).Create(detail).Error; err != nil { + return fmt.Errorf("failed to create trash detail: %w", err) + } + + return nil +} + +func (r *trashRepository) AddTrashDetailToCategory(ctx context.Context, categoryID string, detail *model.TrashDetail) error { + + exists, err := r.CheckTrashCategoryExists(ctx, categoryID) + if err != nil { + return err + } + if !exists { + return errors.New("trash category not found") + } + + detail.TrashCategoryID = categoryID + + if detail.StepOrder == 0 { + maxOrder, err := r.GetMaxStepOrderByCategory(ctx, categoryID) + if err != nil { + return err + } + detail.StepOrder = maxOrder + 1 + } + + if err := r.db.WithContext(ctx).Create(detail).Error; err != nil { + return fmt.Errorf("failed to add trash detail to category: %w", err) + } + + return nil +} + +func (r *trashRepository) UpdateTrashDetail(ctx context.Context, id string, updates map[string]interface{}) error { + + exists, err := r.CheckTrashDetailExists(ctx, id) + if err != nil { + return err + } + if !exists { + return errors.New("trash detail not found") + } + + updates["updated_at"] = time.Now() + + result := r.db.WithContext(ctx).Model(&model.TrashDetail{}).Where("id = ?", id).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update trash detail: %w", result.Error) + } + + if result.RowsAffected == 0 { + return errors.New("no rows affected during update") + } + + return nil +} + +func (r *trashRepository) GetTrashDetailsByCategory(ctx context.Context, categoryID string) ([]model.TrashDetail, error) { + var details []model.TrashDetail + + if err := r.db.WithContext(ctx).Where("trash_category_id = ?", categoryID).Order("step_order ASC").Find(&details).Error; err != nil { + return nil, fmt.Errorf("failed to get trash details: %w", err) + } + + return details, nil +} + +func (r *trashRepository) GetTrashDetailByID(ctx context.Context, id string) (*model.TrashDetail, error) { + var detail model.TrashDetail + + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&detail).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("trash detail not found") + } + return nil, fmt.Errorf("failed to get trash detail: %w", err) + } + + return &detail, nil +} + +func (r *trashRepository) DeleteTrashDetail(ctx context.Context, id string) error { + + exists, err := r.CheckTrashDetailExists(ctx, id) + if err != nil { + return err + } + if !exists { + return errors.New("trash detail not found") + } + + result := r.db.WithContext(ctx).Delete(&model.TrashDetail{ID: id}) + if result.Error != nil { + return fmt.Errorf("failed to delete trash detail: %w", result.Error) + } + + if result.RowsAffected == 0 { + return errors.New("no rows affected during deletion") + } + + return nil +} + +func (r *trashRepository) CheckTrashCategoryExists(ctx context.Context, id string) (bool, error) { + var count int64 + + if err := r.db.WithContext(ctx).Model(&model.TrashCategory{}).Where("id = ?", id).Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check trash category existence: %w", err) + } + + return count > 0, nil +} + +func (r *trashRepository) CheckTrashDetailExists(ctx context.Context, id string) (bool, error) { + var count int64 + + if err := r.db.WithContext(ctx).Model(&model.TrashDetail{}).Where("id = ?", id).Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check trash detail existence: %w", err) + } + + return count > 0, nil +} + +func (r *trashRepository) GetMaxStepOrderByCategory(ctx context.Context, categoryID string) (int, error) { + var maxOrder int + + if err := r.db.WithContext(ctx).Model(&model.TrashDetail{}). + Where("trash_category_id = ?", categoryID). + Select("COALESCE(MAX(step_order), 0)"). + Scan(&maxOrder).Error; err != nil { + return 0, fmt.Errorf("failed to get max step order: %w", err) + } + + return maxOrder, nil +} diff --git a/internal/trash/trash_route.go b/internal/trash/trash_route.go new file mode 100644 index 0000000..f2c201c --- /dev/null +++ b/internal/trash/trash_route.go @@ -0,0 +1,84 @@ +// ===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) +} diff --git a/internal/trash/trash_service.go b/internal/trash/trash_service.go new file mode 100644 index 0000000..16f7b72 --- /dev/null +++ b/internal/trash/trash_service.go @@ -0,0 +1,750 @@ +package trash + +import ( + "context" + "errors" + "fmt" + "log" + "mime/multipart" + "os" + "path/filepath" + "rijig/model" + "strings" + "time" + + "github.com/google/uuid" +) + +type TrashServiceInterface interface { + CreateTrashCategory(ctx context.Context, req RequestTrashCategoryDTO) (*ResponseTrashCategoryDTO, error) + CreateTrashCategoryWithDetails(ctx context.Context, categoryReq RequestTrashCategoryDTO, detailsReq []RequestTrashDetailDTO) (*ResponseTrashCategoryDTO, error) + CreateTrashCategoryWithIcon(ctx context.Context, req RequestTrashCategoryDTO, iconFile *multipart.FileHeader) (*ResponseTrashCategoryDTO, error) + UpdateTrashCategory(ctx context.Context, id string, req RequestTrashCategoryDTO) (*ResponseTrashCategoryDTO, error) + UpdateTrashCategoryWithIcon(ctx context.Context, id string, req RequestTrashCategoryDTO, iconFile *multipart.FileHeader) (*ResponseTrashCategoryDTO, error) + GetAllTrashCategories(ctx context.Context) ([]ResponseTrashCategoryDTO, error) + GetAllTrashCategoriesWithDetails(ctx context.Context) ([]ResponseTrashCategoryDTO, error) + GetTrashCategoryByID(ctx context.Context, id string) (*ResponseTrashCategoryDTO, error) + GetTrashCategoryByIDWithDetails(ctx context.Context, id string) (*ResponseTrashCategoryDTO, error) + DeleteTrashCategory(ctx context.Context, id string) error + + CreateTrashDetail(ctx context.Context, req RequestTrashDetailDTO) (*ResponseTrashDetailDTO, error) + CreateTrashDetailWithIcon(ctx context.Context, req RequestTrashDetailDTO, iconFile *multipart.FileHeader) (*ResponseTrashDetailDTO, error) + AddTrashDetailToCategory(ctx context.Context, categoryID string, req RequestTrashDetailDTO) (*ResponseTrashDetailDTO, error) + AddTrashDetailToCategoryWithIcon(ctx context.Context, categoryID string, req RequestTrashDetailDTO, iconFile *multipart.FileHeader) (*ResponseTrashDetailDTO, error) + UpdateTrashDetail(ctx context.Context, id string, req RequestTrashDetailDTO) (*ResponseTrashDetailDTO, error) + UpdateTrashDetailWithIcon(ctx context.Context, id string, req RequestTrashDetailDTO, iconFile *multipart.FileHeader) (*ResponseTrashDetailDTO, error) + GetTrashDetailsByCategory(ctx context.Context, categoryID string) ([]ResponseTrashDetailDTO, error) + GetTrashDetailByID(ctx context.Context, id string) (*ResponseTrashDetailDTO, error) + DeleteTrashDetail(ctx context.Context, id string) error + + BulkCreateTrashDetails(ctx context.Context, categoryID string, detailsReq []RequestTrashDetailDTO) ([]ResponseTrashDetailDTO, error) + BulkDeleteTrashDetails(ctx context.Context, detailIDs []string) error + ReorderTrashDetails(ctx context.Context, categoryID string, orderedDetailIDs []string) error +} + +type TrashService struct { + trashRepo TrashRepositoryInterface +} + +func NewTrashService(trashRepo TrashRepositoryInterface) TrashServiceInterface { + return &TrashService{ + trashRepo: trashRepo, + } +} + +func (s *TrashService) saveIconOfTrash(iconTrash *multipart.FileHeader) (string, error) { + pathImage := "/uploads/icontrash/" + iconTrashDir := "./public" + os.Getenv("BASE_URL") + pathImage + + if _, err := os.Stat(iconTrashDir); os.IsNotExist(err) { + if err := os.MkdirAll(iconTrashDir, os.ModePerm); err != nil { + return "", fmt.Errorf("failed to create directory for icon trash: %v", err) + } + } + + allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".svg": true} + extension := strings.ToLower(filepath.Ext(iconTrash.Filename)) + if !allowedExtensions[extension] { + return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, .png, and .svg are allowed") + } + + iconTrashFileName := fmt.Sprintf("%s_icontrash%s", uuid.New().String(), extension) + iconTrashPath := filepath.Join(iconTrashDir, iconTrashFileName) + + src, err := iconTrash.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %v", err) + } + defer src.Close() + + dst, err := os.Create(iconTrashPath) + if err != nil { + return "", fmt.Errorf("failed to create icon trash file: %v", err) + } + defer dst.Close() + + if _, err := dst.ReadFrom(src); err != nil { + return "", fmt.Errorf("failed to save icon trash: %v", err) + } + + iconTrashUrl := fmt.Sprintf("%s%s", pathImage, iconTrashFileName) + return iconTrashUrl, nil +} + +func (s *TrashService) saveIconOfTrashDetail(iconTrashDetail *multipart.FileHeader) (string, error) { + pathImage := "/uploads/icontrashdetail/" + iconTrashDetailDir := "./public" + os.Getenv("BASE_URL") + pathImage + + if _, err := os.Stat(iconTrashDetailDir); os.IsNotExist(err) { + if err := os.MkdirAll(iconTrashDetailDir, os.ModePerm); err != nil { + return "", fmt.Errorf("failed to create directory for icon trash detail: %v", err) + } + } + + allowedExtensions := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".svg": true} + extension := strings.ToLower(filepath.Ext(iconTrashDetail.Filename)) + if !allowedExtensions[extension] { + return "", fmt.Errorf("invalid file type, only .jpg, .jpeg, .png, and .svg are allowed") + } + + iconTrashDetailFileName := fmt.Sprintf("%s_icontrashdetail%s", uuid.New().String(), extension) + iconTrashDetailPath := filepath.Join(iconTrashDetailDir, iconTrashDetailFileName) + + src, err := iconTrashDetail.Open() + if err != nil { + return "", fmt.Errorf("failed to open uploaded file: %v", err) + } + defer src.Close() + + dst, err := os.Create(iconTrashDetailPath) + if err != nil { + return "", fmt.Errorf("failed to create icon trash detail file: %v", err) + } + defer dst.Close() + + if _, err := dst.ReadFrom(src); err != nil { + return "", fmt.Errorf("failed to save icon trash detail: %v", err) + } + + iconTrashDetailUrl := fmt.Sprintf("%s%s", pathImage, iconTrashDetailFileName) + return iconTrashDetailUrl, nil +} + +func (s *TrashService) deleteIconTrashFile(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 *TrashService) deleteIconTrashDetailFile(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("Trash detail image deleted successfully: %s", absolutePath) + return nil +} + +func (s *TrashService) CreateTrashCategoryWithIcon(ctx context.Context, req RequestTrashCategoryDTO, iconFile *multipart.FileHeader) (*ResponseTrashCategoryDTO, error) { + if errors, valid := req.ValidateRequestTrashCategoryDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + var iconUrl string + var err error + + if iconFile != nil { + iconUrl, err = s.saveIconOfTrash(iconFile) + if err != nil { + return nil, fmt.Errorf("failed to save icon: %w", err) + } + } + + category := &model.TrashCategory{ + Name: req.Name, + IconTrash: iconUrl, + EstimatedPrice: req.EstimatedPrice, + Variety: req.Variety, + } + + if err := s.trashRepo.CreateTrashCategory(ctx, category); err != nil { + + if iconUrl != "" { + s.deleteIconTrashFile(iconUrl) + } + return nil, fmt.Errorf("failed to create trash category: %w", err) + } + + response := s.convertTrashCategoryToResponseDTO(category) + return response, nil +} + +func (s *TrashService) UpdateTrashCategoryWithIcon(ctx context.Context, id string, req RequestTrashCategoryDTO, iconFile *multipart.FileHeader) (*ResponseTrashCategoryDTO, error) { + if errors, valid := req.ValidateRequestTrashCategoryDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + existingCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get existing category: %w", err) + } + + var iconUrl string = existingCategory.IconTrash + + if iconFile != nil { + newIconUrl, err := s.saveIconOfTrash(iconFile) + if err != nil { + return nil, fmt.Errorf("failed to save new icon: %w", err) + } + iconUrl = newIconUrl + } + + updates := map[string]interface{}{ + "name": req.Name, + "icon_trash": iconUrl, + "estimated_price": req.EstimatedPrice, + "variety": req.Variety, + } + + if err := s.trashRepo.UpdateTrashCategory(ctx, id, updates); err != nil { + + if iconFile != nil && iconUrl != existingCategory.IconTrash { + s.deleteIconTrashFile(iconUrl) + } + return nil, fmt.Errorf("failed to update trash category: %w", err) + } + + if iconFile != nil && existingCategory.IconTrash != "" && iconUrl != existingCategory.IconTrash { + if err := s.deleteIconTrashFile(existingCategory.IconTrash); err != nil { + log.Printf("Warning: failed to delete old icon: %v", err) + } + } + + updatedCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to retrieve updated category: %w", err) + } + + response := s.convertTrashCategoryToResponseDTO(updatedCategory) + return response, nil +} + +func (s *TrashService) CreateTrashDetailWithIcon(ctx context.Context, req RequestTrashDetailDTO, iconFile *multipart.FileHeader) (*ResponseTrashDetailDTO, error) { + if errors, valid := req.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + var iconUrl string + var err error + + if iconFile != nil { + iconUrl, err = s.saveIconOfTrashDetail(iconFile) + if err != nil { + return nil, fmt.Errorf("failed to save icon: %w", err) + } + } + + detail := &model.TrashDetail{ + TrashCategoryID: req.CategoryID, + IconTrashDetail: iconUrl, + Description: req.Description, + StepOrder: req.StepOrder, + } + + if err := s.trashRepo.CreateTrashDetail(ctx, detail); err != nil { + + if iconUrl != "" { + s.deleteIconTrashDetailFile(iconUrl) + } + return nil, fmt.Errorf("failed to create trash detail: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(detail) + return response, nil +} + +func (s *TrashService) AddTrashDetailToCategoryWithIcon(ctx context.Context, categoryID string, req RequestTrashDetailDTO, iconFile *multipart.FileHeader) (*ResponseTrashDetailDTO, error) { + if errors, valid := req.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + var iconUrl string + var err error + + if iconFile != nil { + iconUrl, err = s.saveIconOfTrashDetail(iconFile) + if err != nil { + return nil, fmt.Errorf("failed to save icon: %w", err) + } + } + + detail := &model.TrashDetail{ + IconTrashDetail: iconUrl, + Description: req.Description, + StepOrder: req.StepOrder, + } + + if err := s.trashRepo.AddTrashDetailToCategory(ctx, categoryID, detail); err != nil { + + if iconUrl != "" { + s.deleteIconTrashDetailFile(iconUrl) + } + return nil, fmt.Errorf("failed to add trash detail to category: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(detail) + return response, nil +} + +func (s *TrashService) UpdateTrashDetailWithIcon(ctx context.Context, id string, req RequestTrashDetailDTO, iconFile *multipart.FileHeader) (*ResponseTrashDetailDTO, error) { + if errors, valid := req.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + existingDetail, err := s.trashRepo.GetTrashDetailByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get existing detail: %w", err) + } + + var iconUrl string = existingDetail.IconTrashDetail + + if iconFile != nil { + newIconUrl, err := s.saveIconOfTrashDetail(iconFile) + if err != nil { + return nil, fmt.Errorf("failed to save new icon: %w", err) + } + iconUrl = newIconUrl + } + + updates := map[string]interface{}{ + "icon_trash_detail": iconUrl, + "description": req.Description, + "step_order": req.StepOrder, + } + + if err := s.trashRepo.UpdateTrashDetail(ctx, id, updates); err != nil { + + if iconFile != nil && iconUrl != existingDetail.IconTrashDetail { + s.deleteIconTrashDetailFile(iconUrl) + } + return nil, fmt.Errorf("failed to update trash detail: %w", err) + } + + if iconFile != nil && existingDetail.IconTrashDetail != "" && iconUrl != existingDetail.IconTrashDetail { + if err := s.deleteIconTrashDetailFile(existingDetail.IconTrashDetail); err != nil { + log.Printf("Warning: failed to delete old icon: %v", err) + } + } + + updatedDetail, err := s.trashRepo.GetTrashDetailByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to retrieve updated detail: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(updatedDetail) + return response, nil +} + +func (s *TrashService) CreateTrashCategory(ctx context.Context, req RequestTrashCategoryDTO) (*ResponseTrashCategoryDTO, error) { + if errors, valid := req.ValidateRequestTrashCategoryDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + category := &model.TrashCategory{ + Name: req.Name, + IconTrash: req.IconTrash, + EstimatedPrice: req.EstimatedPrice, + Variety: req.Variety, + } + + if err := s.trashRepo.CreateTrashCategory(ctx, category); err != nil { + return nil, fmt.Errorf("failed to create trash category: %w", err) + } + + response := s.convertTrashCategoryToResponseDTO(category) + return response, nil +} + +func (s *TrashService) CreateTrashCategoryWithDetails(ctx context.Context, categoryReq RequestTrashCategoryDTO, detailsReq []RequestTrashDetailDTO) (*ResponseTrashCategoryDTO, error) { + if errors, valid := categoryReq.ValidateRequestTrashCategoryDTO(); !valid { + return nil, fmt.Errorf("category validation failed: %v", errors) + } + + for i, detailReq := range detailsReq { + if errors, valid := detailReq.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("detail %d validation failed: %v", i+1, errors) + } + } + + category := &model.TrashCategory{ + Name: categoryReq.Name, + IconTrash: categoryReq.IconTrash, + EstimatedPrice: categoryReq.EstimatedPrice, + Variety: categoryReq.Variety, + } + + details := make([]model.TrashDetail, len(detailsReq)) + for i, detailReq := range detailsReq { + details[i] = model.TrashDetail{ + IconTrashDetail: detailReq.IconTrashDetail, + Description: detailReq.Description, + StepOrder: detailReq.StepOrder, + } + } + + if err := s.trashRepo.CreateTrashCategoryWithDetails(ctx, category, details); err != nil { + return nil, fmt.Errorf("failed to create trash category with details: %w", err) + } + + createdCategory, err := s.trashRepo.GetTrashCategoryByIDWithDetails(ctx, category.ID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve created category: %w", err) + } + + response := s.convertTrashCategoryToResponseDTOWithDetails(createdCategory) + return response, nil +} + +func (s *TrashService) UpdateTrashCategory(ctx context.Context, id string, req RequestTrashCategoryDTO) (*ResponseTrashCategoryDTO, error) { + if errors, valid := req.ValidateRequestTrashCategoryDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + exists, err := s.trashRepo.CheckTrashCategoryExists(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to check category existence: %w", err) + } + if !exists { + return nil, errors.New("trash category not found") + } + + updates := map[string]interface{}{ + "name": req.Name, + "icon_trash": req.IconTrash, + "estimated_price": req.EstimatedPrice, + "variety": req.Variety, + } + + if err := s.trashRepo.UpdateTrashCategory(ctx, id, updates); err != nil { + return nil, fmt.Errorf("failed to update trash category: %w", err) + } + + updatedCategory, err := s.trashRepo.GetTrashCategoryByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to retrieve updated category: %w", err) + } + + response := s.convertTrashCategoryToResponseDTO(updatedCategory) + return response, nil +} + +func (s *TrashService) GetAllTrashCategories(ctx context.Context) ([]ResponseTrashCategoryDTO, error) { + categories, err := s.trashRepo.GetAllTrashCategories(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get trash categories: %w", err) + } + + responses := make([]ResponseTrashCategoryDTO, len(categories)) + for i, category := range categories { + responses[i] = *s.convertTrashCategoryToResponseDTO(&category) + } + + return responses, nil +} + +func (s *TrashService) GetAllTrashCategoriesWithDetails(ctx context.Context) ([]ResponseTrashCategoryDTO, error) { + categories, err := s.trashRepo.GetAllTrashCategoriesWithDetails(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get trash categories with details: %w", err) + } + + responses := make([]ResponseTrashCategoryDTO, len(categories)) + for i, category := range categories { + responses[i] = *s.convertTrashCategoryToResponseDTOWithDetails(&category) + } + + return responses, nil +} + +func (s *TrashService) GetTrashCategoryByID(ctx context.Context, id string) (*ResponseTrashCategoryDTO, error) { + category, err := s.trashRepo.GetTrashCategoryByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get trash category: %w", err) + } + + response := s.convertTrashCategoryToResponseDTO(category) + return response, nil +} + +func (s *TrashService) GetTrashCategoryByIDWithDetails(ctx context.Context, id string) (*ResponseTrashCategoryDTO, error) { + category, err := s.trashRepo.GetTrashCategoryByIDWithDetails(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get trash category with details: %w", err) + } + + response := s.convertTrashCategoryToResponseDTOWithDetails(category) + return response, nil +} + +func (s *TrashService) DeleteTrashCategory(ctx context.Context, id string) error { + + category, err := s.trashRepo.GetTrashCategoryByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to get category: %w", err) + } + + if err := s.trashRepo.DeleteTrashCategory(ctx, id); err != nil { + return fmt.Errorf("failed to delete trash category: %w", err) + } + + if category.IconTrash != "" { + if err := s.deleteIconTrashFile(category.IconTrash); err != nil { + log.Printf("Warning: failed to delete category icon: %v", err) + } + } + + return nil +} + +func (s *TrashService) CreateTrashDetail(ctx context.Context, req RequestTrashDetailDTO) (*ResponseTrashDetailDTO, error) { + if errors, valid := req.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + detail := &model.TrashDetail{ + TrashCategoryID: req.CategoryID, + IconTrashDetail: req.IconTrashDetail, + Description: req.Description, + StepOrder: req.StepOrder, + } + + if err := s.trashRepo.CreateTrashDetail(ctx, detail); err != nil { + return nil, fmt.Errorf("failed to create trash detail: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(detail) + return response, nil +} + +func (s *TrashService) AddTrashDetailToCategory(ctx context.Context, categoryID string, req RequestTrashDetailDTO) (*ResponseTrashDetailDTO, error) { + if errors, valid := req.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + detail := &model.TrashDetail{ + IconTrashDetail: req.IconTrashDetail, + Description: req.Description, + StepOrder: req.StepOrder, + } + + if err := s.trashRepo.AddTrashDetailToCategory(ctx, categoryID, detail); err != nil { + return nil, fmt.Errorf("failed to add trash detail to category: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(detail) + return response, nil +} + +func (s *TrashService) UpdateTrashDetail(ctx context.Context, id string, req RequestTrashDetailDTO) (*ResponseTrashDetailDTO, error) { + if errors, valid := req.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("validation failed: %v", errors) + } + + exists, err := s.trashRepo.CheckTrashDetailExists(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to check detail existence: %w", err) + } + if !exists { + return nil, errors.New("trash detail not found") + } + + updates := map[string]interface{}{ + "icon_trash_detail": req.IconTrashDetail, + "description": req.Description, + "step_order": req.StepOrder, + } + + if err := s.trashRepo.UpdateTrashDetail(ctx, id, updates); err != nil { + return nil, fmt.Errorf("failed to update trash detail: %w", err) + } + + updatedDetail, err := s.trashRepo.GetTrashDetailByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to retrieve updated detail: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(updatedDetail) + return response, nil +} + +func (s *TrashService) GetTrashDetailsByCategory(ctx context.Context, categoryID string) ([]ResponseTrashDetailDTO, error) { + exists, err := s.trashRepo.CheckTrashCategoryExists(ctx, categoryID) + if err != nil { + return nil, fmt.Errorf("failed to check category existence: %w", err) + } + if !exists { + return nil, errors.New("trash category not found") + } + + details, err := s.trashRepo.GetTrashDetailsByCategory(ctx, categoryID) + if err != nil { + return nil, fmt.Errorf("failed to get trash details: %w", err) + } + + responses := make([]ResponseTrashDetailDTO, len(details)) + for i, detail := range details { + responses[i] = *s.convertTrashDetailToResponseDTO(&detail) + } + + return responses, nil +} + +func (s *TrashService) GetTrashDetailByID(ctx context.Context, id string) (*ResponseTrashDetailDTO, error) { + detail, err := s.trashRepo.GetTrashDetailByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get trash detail: %w", err) + } + + response := s.convertTrashDetailToResponseDTO(detail) + return response, nil +} + +func (s *TrashService) DeleteTrashDetail(ctx context.Context, id string) error { + + detail, err := s.trashRepo.GetTrashDetailByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to get detail: %w", err) + } + + if err := s.trashRepo.DeleteTrashDetail(ctx, id); err != nil { + return fmt.Errorf("failed to delete trash detail: %w", err) + } + + if detail.IconTrashDetail != "" { + if err := s.deleteIconTrashDetailFile(detail.IconTrashDetail); err != nil { + log.Printf("Warning: failed to delete detail icon: %v", err) + } + } + + return nil +} + +func (s *TrashService) BulkCreateTrashDetails(ctx context.Context, categoryID string, detailsReq []RequestTrashDetailDTO) ([]ResponseTrashDetailDTO, error) { + exists, err := s.trashRepo.CheckTrashCategoryExists(ctx, categoryID) + if err != nil { + return nil, fmt.Errorf("failed to check category existence: %w", err) + } + if !exists { + return nil, errors.New("trash category not found") + } + + for i, detailReq := range detailsReq { + if errors, valid := detailReq.ValidateRequestTrashDetailDTO(); !valid { + return nil, fmt.Errorf("detail %d validation failed: %v", i+1, errors) + } + } + + responses := make([]ResponseTrashDetailDTO, len(detailsReq)) + for i, detailReq := range detailsReq { + response, err := s.AddTrashDetailToCategory(ctx, categoryID, detailReq) + if err != nil { + return nil, fmt.Errorf("failed to create detail %d: %w", i+1, err) + } + responses[i] = *response + } + + return responses, nil +} + +func (s *TrashService) BulkDeleteTrashDetails(ctx context.Context, detailIDs []string) error { + for _, id := range detailIDs { + if err := s.DeleteTrashDetail(ctx, id); err != nil { + return fmt.Errorf("failed to delete detail %s: %w", id, err) + } + } + return nil +} + +func (s *TrashService) ReorderTrashDetails(ctx context.Context, categoryID string, orderedDetailIDs []string) error { + exists, err := s.trashRepo.CheckTrashCategoryExists(ctx, categoryID) + if err != nil { + return fmt.Errorf("failed to check category existence: %w", err) + } + if !exists { + return errors.New("trash category not found") + } + + for i, detailID := range orderedDetailIDs { + updates := map[string]interface{}{ + "step_order": i + 1, + } + if err := s.trashRepo.UpdateTrashDetail(ctx, detailID, updates); err != nil { + return fmt.Errorf("failed to reorder detail %s: %w", detailID, err) + } + } + + return nil +} + +func (s *TrashService) convertTrashCategoryToResponseDTO(category *model.TrashCategory) *ResponseTrashCategoryDTO { + return &ResponseTrashCategoryDTO{ + ID: category.ID, + TrashName: category.Name, + TrashIcon: category.IconTrash, + EstimatedPrice: category.EstimatedPrice, + Variety: category.Variety, + CreatedAt: category.CreatedAt.Format(time.RFC3339), + UpdatedAt: category.UpdatedAt.Format(time.RFC3339), + } +} + +func (s *TrashService) convertTrashCategoryToResponseDTOWithDetails(category *model.TrashCategory) *ResponseTrashCategoryDTO { + response := s.convertTrashCategoryToResponseDTO(category) + + details := make([]ResponseTrashDetailDTO, len(category.Details)) + for i, detail := range category.Details { + details[i] = *s.convertTrashDetailToResponseDTO(&detail) + } + response.TrashDetail = details + + return response +} + +func (s *TrashService) convertTrashDetailToResponseDTO(detail *model.TrashDetail) *ResponseTrashDetailDTO { + return &ResponseTrashDetailDTO{ + ID: detail.ID, + CategoryID: detail.TrashCategoryID, + IconTrashDetail: detail.IconTrashDetail, + Description: detail.Description, + StepOrder: detail.StepOrder, + CreatedAt: detail.CreatedAt.Format(time.RFC3339), + UpdatedAt: detail.UpdatedAt.Format(time.RFC3339), + } +} diff --git a/internal/userpin/userpin_dto.go b/internal/userpin/userpin_dto.go new file mode 100644 index 0000000..c3a3302 --- /dev/null +++ b/internal/userpin/userpin_dto.go @@ -0,0 +1,48 @@ +package userpin + +import ( + "rijig/utils" + "strings" +) + +type RequestPinDTO struct { + // DeviceId string `json:"device_id"` + Pin string `json:"userpin"` +} + +func (r *RequestPinDTO) ValidateRequestPinDTO() (map[string][]string, bool) { + errors := make(map[string][]string) + + if err := utils.ValidatePin(r.Pin); err != nil { + errors["pin"] = append(errors["pin"], err.Error()) + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} + +type UpdatePinDTO struct { + OldPin string `json:"old_pin"` + NewPin string `json:"new_pin"` +} + +func (u *UpdatePinDTO) ValidateUpdatePinDTO() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(u.OldPin) == "" { + errors["old_pin"] = append(errors["old_pin"], "Old pin is required") + } + + if err := utils.ValidatePin(u.NewPin); err != nil { + errors["new_pin"] = append(errors["new_pin"], err.Error()) + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} diff --git a/internal/userpin/userpin_handler.go b/internal/userpin/userpin_handler.go new file mode 100644 index 0000000..eced2fd --- /dev/null +++ b/internal/userpin/userpin_handler.go @@ -0,0 +1,67 @@ +package userpin + +import ( + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +type UserPinHandler struct { + service UserPinService +} + +func NewUserPinHandler(service UserPinService) *UserPinHandler { + return &UserPinHandler{service} +} + +func (h *UserPinHandler) CreateUserPinHandler(c *fiber.Ctx) error { + + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return utils.Unauthorized(c, "Authentication required") + } + + if claims.UserID == "" || claims.DeviceID == "" { + return utils.BadRequest(c, "Invalid user claims") + } + + var req RequestPinDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + if errs, ok := req.ValidateRequestPinDTO(); !ok { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation error", errs) + } + + pintokenresponse, err := h.service.CreateUserPin(c.Context(), claims.UserID, claims.DeviceID, &req) + if err != nil { + if err.Error() == Pinhasbeencreated { + return utils.BadRequest(c, err.Error()) + } + return utils.InternalServerError(c, err.Error()) + } + + return utils.SuccessWithData(c, "PIN created successfully", pintokenresponse) +} + +func (h *UserPinHandler) VerifyPinHandler(c *fiber.Ctx) error { + + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + var req RequestPinDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request body") + } + + token, err := h.service.VerifyUserPin(c.Context(), claims.UserID, claims.DeviceID, &req) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + return utils.SuccessWithData(c, "PIN verified successfully", token) +} diff --git a/internal/userpin/userpin_repo.go b/internal/userpin/userpin_repo.go new file mode 100644 index 0000000..4cd6c7e --- /dev/null +++ b/internal/userpin/userpin_repo.go @@ -0,0 +1,50 @@ +package userpin + +import ( + "context" + "rijig/model" + + "gorm.io/gorm" +) + +type UserPinRepository interface { + FindByUserID(ctx context.Context, userID string) (*model.UserPin, error) + Create(ctx context.Context, userPin *model.UserPin) error + Update(ctx context.Context, userPin *model.UserPin) error +} + +type userPinRepository struct { + db *gorm.DB +} + +func NewUserPinRepository(db *gorm.DB) UserPinRepository { + return &userPinRepository{db} +} + +func (r *userPinRepository) FindByUserID(ctx context.Context, userID string) (*model.UserPin, error) { + var userPin model.UserPin + err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&userPin).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &userPin, nil +} + +func (r *userPinRepository) Create(ctx context.Context, userPin *model.UserPin) error { + err := r.db.WithContext(ctx).Create(userPin).Error + if err != nil { + return err + } + return nil +} + +func (r *userPinRepository) Update(ctx context.Context, userPin *model.UserPin) error { + err := r.db.WithContext(ctx).Save(userPin).Error + if err != nil { + return err + } + return nil +} diff --git a/internal/userpin/userpin_route.go b/internal/userpin/userpin_route.go new file mode 100644 index 0000000..3c34411 --- /dev/null +++ b/internal/userpin/userpin_route.go @@ -0,0 +1,25 @@ +package userpin + +import ( + "rijig/config" + "rijig/internal/authentication" + "rijig/internal/userprofile" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func UsersPinRoute(api fiber.Router) { + userPinRepo := NewUserPinRepository(config.DB) + authRepo := authentication.NewAuthenticationRepository(config.DB) + userprofileRepo := userprofile.NewUserProfileRepository(config.DB) + + userPinService := NewUserPinService(userPinRepo, authRepo, userprofileRepo) + + userPinHandler := NewUserPinHandler(userPinService) + + pin := api.Group("/pin", middleware.AuthMiddleware()) + + pin.Post("/create", userPinHandler.CreateUserPinHandler) + pin.Post("/verif", userPinHandler.VerifyPinHandler) +} diff --git a/internal/userpin/userpin_service.go b/internal/userpin/userpin_service.go new file mode 100644 index 0000000..56da709 --- /dev/null +++ b/internal/userpin/userpin_service.go @@ -0,0 +1,166 @@ +package userpin + +import ( + "context" + "errors" + "fmt" + "rijig/internal/authentication" + "rijig/internal/userprofile" + "rijig/model" + "rijig/utils" + + "gorm.io/gorm" +) + +type UserPinService interface { + CreateUserPin(ctx context.Context, userID, deviceId string, dto *RequestPinDTO) (*authentication.AuthResponse, error) + VerifyUserPin(ctx context.Context, userID, deviceID string, pin *RequestPinDTO) (*authentication.AuthResponse, error) +} + +type userPinService struct { + UserPinRepo UserPinRepository + authRepo authentication.AuthenticationRepository + userProfileRepo userprofile.UserProfileRepository +} + +func NewUserPinService(UserPinRepo UserPinRepository, + authRepo authentication.AuthenticationRepository, + userProfileRepo userprofile.UserProfileRepository) UserPinService { + return &userPinService{UserPinRepo, authRepo, userProfileRepo} +} + +var ( + Pinhasbeencreated = "PIN already created" +) + +func (s *userPinService) CreateUserPin(ctx context.Context, userID, deviceId string, dto *RequestPinDTO) (*authentication.AuthResponse, error) { + + _, err := s.UserPinRepo.FindByUserID(ctx, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("%v", Pinhasbeencreated) + } + + hashed, err := utils.HashingPlainText(dto.Pin) + if err != nil { + return nil, fmt.Errorf("failed to hash PIN: %w", err) + } + + userPin := &model.UserPin{ + UserID: userID, + Pin: hashed, + } + + if err := s.UserPinRepo.Create(ctx, userPin); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to create pin: %w", err) + } + + updates := map[string]interface{}{ + "registration_progress": utils.ProgressComplete, + "registration_status": utils.RegStatusComplete, + } + + if err = s.authRepo.PatchUser(ctx, userID, updates); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to update user profile: %w", err) + } + + 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( + updated.ID, + updated.Role.RoleName, + deviceId, + updated.RegistrationStatus, + int(updated.RegistrationProgress), + ) + + if err != nil { + return nil, fmt.Errorf("gagal generate token: %v", err) + } + + nextStep := utils.GetNextRegistrationStep( + updated.Role.RoleName, + int(updated.RegistrationProgress), + updated.RegistrationStatus, + ) + + return &authentication.AuthResponse{ + Message: "mantap semuanya completed", + AccessToken: tokenResponse.AccessToken, + RefreshToken: tokenResponse.RefreshToken, + TokenType: string(tokenResponse.TokenType), + ExpiresIn: tokenResponse.ExpiresIn, + RegistrationStatus: updated.RegistrationStatus, + NextStep: nextStep, + SessionID: tokenResponse.SessionID, + }, nil +} + +func (s *userPinService) VerifyUserPin(ctx context.Context, userID, deviceID string, pin *RequestPinDTO) (*authentication.AuthResponse, error) { + user, err := s.authRepo.FindUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("user not found") + } + + userPin, err := s.UserPinRepo.FindByUserID(ctx, userID) + if err != nil || userPin == nil { + return nil, fmt.Errorf("PIN not found") + } + + if !utils.CompareHashAndPlainText(userPin.Pin, pin.Pin) { + return nil, fmt.Errorf("PIN does not match, %s , %s", userPin.Pin, pin.Pin) + } + + // roleName := strings.ToLower(user.Role.RoleName) + + // 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 +} diff --git a/internal/userprofile/userprofile_dto.go b/internal/userprofile/userprofile_dto.go new file mode 100644 index 0000000..9f7cd99 --- /dev/null +++ b/internal/userprofile/userprofile_dto.go @@ -0,0 +1,67 @@ +package userprofile + +import ( + "rijig/internal/role" + "rijig/utils" + "strings" +) + +type UserProfileResponseDTO struct { + ID string `json:"id,omitempty"` + Avatar string `json:"avatar,omitempty"` + Name string `json:"name,omitempty"` + Gender string `json:"gender,omitempty"` + Dateofbirth string `json:"dateofbirth,omitempty"` + Placeofbirth string `json:"placeofbirth,omitempty"` + Phone string `json:"phone,omitempty"` + Email string `json:"email,omitempty"` + PhoneVerified bool `json:"phone_verified,omitempty"` + Password string `json:"password,omitempty"` + Role role.RoleResponseDTO `json:"role"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type RequestUserProfileDTO struct { + Name string `json:"name"` + Gender string `json:"gender"` + Dateofbirth string `json:"dateofbirth"` + Placeofbirth string `json:"placeofbirth"` + Phone string `json:"phone"` +} + +func (r *RequestUserProfileDTO) ValidateRequestUserProfileDTO() (map[string][]string, bool) { + errors := make(map[string][]string) + + if strings.TrimSpace(r.Name) == "" { + errors["name"] = append(errors["name"], "Name is required") + } + + if strings.TrimSpace(r.Gender) == "" { + errors["gender"] = append(errors["gender"], "jenis kelamin tidak boleh kosong") + } else if r.Gender != "perempuan" && r.Gender != "laki-laki" { + errors["gender"] = append(errors["gender"], "jenis kelamin harus 'perempuan' atau 'laki-laki'") + } + + if strings.TrimSpace(r.Dateofbirth) == "" { + errors["dateofbirth"] = append(errors["dateofbirth"], "tanggal lahir dibutuhkan") + } else if !utils.IsValidDate(r.Dateofbirth) { + errors["dateofbirth"] = append(errors["dateofbirth"], "tanggal lahir harus berformat DD-MM-YYYY") + } + + if strings.TrimSpace(r.Placeofbirth) == "" { + errors["placeofbirth"] = append(errors["placeofbirth"], "Name is required") + } + + if strings.TrimSpace(r.Phone) == "" { + errors["phone"] = append(errors["phone"], "Phone number is required") + } else if !utils.IsValidPhoneNumber(r.Phone) { + errors["phone"] = append(errors["phone"], "Invalid phone number format. Use 62 followed by 9-13 digits") + } + + if len(errors) > 0 { + return errors, false + } + + return nil, true +} diff --git a/internal/userprofile/userprofile_handler.go b/internal/userprofile/userprofile_handler.go new file mode 100644 index 0000000..05cc96d --- /dev/null +++ b/internal/userprofile/userprofile_handler.go @@ -0,0 +1,76 @@ +package userprofile + +import ( + "context" + "log" + "rijig/middleware" + "rijig/utils" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +type UserProfileHandler struct { + service UserProfileService +} + +func NewUserProfileHandler(service UserProfileService) *UserProfileHandler { + return &UserProfileHandler{ + service: service, + } +} + +func (h *UserProfileHandler) GetUserProfile(c *fiber.Ctx) error { + + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + userProfile, err := h.service.GetUserProfile(ctx, claims.UserID) + if err != nil { + if strings.Contains(err.Error(), ErrUserNotFound.Error()) { + return utils.NotFound(c, "User profile not found") + } + + log.Printf("Error getting user profile: %v", err) + return utils.InternalServerError(c, "Failed to retrieve user profile") + } + + return utils.SuccessWithData(c, "User profile retrieved successfully", userProfile) +} + +func (h *UserProfileHandler) UpdateUserProfile(c *fiber.Ctx) error { + claims, err := middleware.GetUserFromContext(c) + if err != nil { + return err + } + + var req RequestUserProfileDTO + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "Invalid request format") + } + + if validationErrors, isValid := req.ValidateRequestUserProfileDTO(); !isValid { + return utils.ResponseErrorData(c, fiber.StatusBadRequest, "Validation failed", validationErrors) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + updatedProfile, err := h.service.UpdateRegistUserProfile(ctx, claims.UserID, claims.DeviceID, &req) + if err != nil { + + if strings.Contains(err.Error(), "user not found") { + return utils.NotFound(c, "User not found") + } + + log.Printf("Error updating user profile: %v", err) + return utils.InternalServerError(c, "Failed to update user profile") + } + + return utils.SuccessWithData(c, "User profile updated successfully", updatedProfile) +} diff --git a/internal/userprofile/userprofile_repo.go b/internal/userprofile/userprofile_repo.go new file mode 100644 index 0000000..926950f --- /dev/null +++ b/internal/userprofile/userprofile_repo.go @@ -0,0 +1,107 @@ +package userprofile + +import ( + "context" + "errors" + "rijig/model" + + "gorm.io/gorm" +) + +type UserProfileRepository interface { + GetByID(ctx context.Context, userID string) (*model.User, error) + GetByRoleName(ctx context.Context, roleName string) ([]*model.User, error) + // GetIdentityCardsByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.IdentityCard, error) + // GetCompanyProfileByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.IdentityCard, error) + Update(ctx context.Context, userID string, user *model.User) error +} + +type userProfileRepository struct { + db *gorm.DB +} + +func NewUserProfileRepository(db *gorm.DB) UserProfileRepository { + return &userProfileRepository{ + db: db, + } +} + +func (r *userProfileRepository) GetByID(ctx context.Context, userID string) (*model.User, error) { + var user model.User + + err := r.db.WithContext(ctx). + Preload("Role"). + Where("id = ?", userID). + First(&user).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *userProfileRepository) GetByRoleName(ctx context.Context, roleName string) ([]*model.User, error) { + var users []*model.User + + err := r.db.WithContext(ctx). + Preload("Role"). + Joins("JOIN roles ON users.role_id = roles.id"). + Where("roles.role_name = ?", roleName). + Find(&users).Error + + if err != nil { + return nil, err + } + + return users, nil +} + +/* func (r *userProfileRepository) GetIdentityCardsByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.IdentityCard, error) { + var identityCards []model.IdentityCard + + if err := r.db.WithContext(ctx). + Joins("JOIN users ON identity_cards.user_id = users.id"). + Where("users.registration_status = ?", userRegStatus). + Preload("User"). + Find(&identityCards).Error; err != nil { + log.Printf("Error fetching identity cards by user registration status: %v", err) + return nil, fmt.Errorf("error fetching identity cards by user registration status: %w", err) + } + + return identityCards, nil +} + +func (r *userProfileRepository) GetCompanyProfileByUserRegStatus(ctx context.Context, userRegStatus string) ([]model.IdentityCard, error) { + var identityCards []model.IdentityCard + + if err := r.db.WithContext(ctx). + Joins("JOIN users ON company_profiles.user_id = users.id"). + Where("users.registration_status = ?", userRegStatus). + Preload("User"). + Find(&identityCards).Error; err != nil { + log.Printf("Error fetching identity cards by user registration status: %v", err) + return nil, fmt.Errorf("error fetching identity cards by user registration status: %w", err) + } + return identityCards, nil +} */ + +func (r *userProfileRepository) Update(ctx context.Context, userID string, user *model.User) error { + result := r.db.WithContext(ctx). + Model(&model.User{}). + Where("id = ?", userID). + Updates(user) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return ErrUserNotFound + } + + return nil +} diff --git a/internal/userprofile/userprofile_route.go b/internal/userprofile/userprofile_route.go new file mode 100644 index 0000000..e011fa4 --- /dev/null +++ b/internal/userprofile/userprofile_route.go @@ -0,0 +1,20 @@ +package userprofile + +import ( + "rijig/config" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func UserProfileRouter(api fiber.Router) { + userProfileRepo := NewUserProfileRepository(config.DB) + userProfileService := NewUserProfileService(userProfileRepo) + userProfileHandler := NewUserProfileHandler(userProfileService) + + userRoute := api.Group("/userprofile") + userRoute.Use(middleware.AuthMiddleware()) + + userRoute.Get("/", userProfileHandler.GetUserProfile) + userRoute.Put("/update", userProfileHandler.UpdateUserProfile) +} \ No newline at end of file diff --git a/internal/userprofile/userprofile_service.go b/internal/userprofile/userprofile_service.go new file mode 100644 index 0000000..dd405d1 --- /dev/null +++ b/internal/userprofile/userprofile_service.go @@ -0,0 +1,160 @@ +package userprofile + +import ( + "context" + "errors" + "fmt" + "rijig/internal/authentication" + "rijig/internal/role" + "rijig/model" + "rijig/utils" + "time" +) + +var ( + ErrUserNotFound = errors.New("user tidak ditemukan") +) + +type UserProfileService interface { + GetUserProfile(ctx context.Context, userID string) (*UserProfileResponseDTO, error) + UpdateRegistUserProfile(ctx context.Context, userID, deviceId string, req *RequestUserProfileDTO) (*authentication.AuthResponse, error) +} + +type userProfileService struct { + repo UserProfileRepository +} + +func NewUserProfileService(repo UserProfileRepository) UserProfileService { + return &userProfileService{ + repo: repo, + } +} + +func (s *userProfileService) GetUserProfile(ctx context.Context, userID string) (*UserProfileResponseDTO, error) { + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get user profile: %w", err) + } + + return s.mapToResponseDTO(user), nil +} + +func (s *userProfileService) UpdateRegistUserProfile(ctx context.Context, userID, deviceId string, req *RequestUserProfileDTO) (*authentication.AuthResponse, error) { + + _, err := s.repo.GetByID(ctx, userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + updateUser := &model.User{ + Name: req.Name, + Gender: req.Gender, + Dateofbirth: req.Dateofbirth, + Placeofbirth: req.Placeofbirth, + Phone: req.Phone, + RegistrationProgress: utils.ProgressDataSubmitted, + } + + if err := s.repo.Update(ctx, userID, updateUser); err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to update user profile: %w", err) + } + + updatedUser, err := s.repo.GetByID(ctx, userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get updated user: %w", err) + } + + tokenResponse, err := utils.GenerateTokenPair( + updatedUser.ID, + updatedUser.Role.RoleName, + // req.DeviceID, + deviceId, + updatedUser.RegistrationStatus, + int(updatedUser.RegistrationProgress), + ) + + if err != nil { + return nil, fmt.Errorf("gagal generate token: %v", err) + } + + nextStep := utils.GetNextRegistrationStep( + updatedUser.Role.RoleName, + int(updatedUser.RegistrationProgress), + updateUser.RegistrationStatus, + ) + + return &authentication.AuthResponse{ + Message: "Isi data diri berhasil", + AccessToken: tokenResponse.AccessToken, + RefreshToken: tokenResponse.RefreshToken, + TokenType: string(tokenResponse.TokenType), + ExpiresIn: tokenResponse.ExpiresIn, + RegistrationStatus: updateUser.RegistrationStatus, + NextStep: nextStep, + SessionID: tokenResponse.SessionID, + }, nil + + // return s.mapToResponseDTO(updatedUser), nil +} + +func (s *userProfileService) mapToResponseDTO(user *model.User) *UserProfileResponseDTO { + + createdAt, err := utils.FormatDateToIndonesianFormat(user.CreatedAt) + if err != nil { + createdAt = user.CreatedAt.Format(time.RFC3339) + } + + updatedAt, err := utils.FormatDateToIndonesianFormat(user.UpdatedAt) + if err != nil { + updatedAt = user.UpdatedAt.Format(time.RFC3339) + } + + response := &UserProfileResponseDTO{ + ID: user.ID, + Name: user.Name, + Gender: user.Gender, + Dateofbirth: user.Dateofbirth, + Placeofbirth: user.Placeofbirth, + Phone: user.Phone, + PhoneVerified: user.PhoneVerified, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + + if user.Avatar != nil { + response.Avatar = *user.Avatar + } + + if user.Role != nil { + roleCreatedAt, err := utils.FormatDateToIndonesianFormat(user.Role.CreatedAt) + if err != nil { + roleCreatedAt = user.Role.CreatedAt.Format(time.RFC3339) + } + + roleUpdatedAt, err := utils.FormatDateToIndonesianFormat(user.Role.UpdatedAt) + if err != nil { + roleUpdatedAt = user.Role.UpdatedAt.Format(time.RFC3339) + } + + response.Role = role.RoleResponseDTO{ + ID: user.Role.ID, + RoleName: user.Role.RoleName, + CreatedAt: roleCreatedAt, + UpdatedAt: roleUpdatedAt, + } + } + + return response +} diff --git a/internal/whatsapp/scanner.html b/internal/whatsapp/scanner.html new file mode 100644 index 0000000..160c4e2 --- /dev/null +++ b/internal/whatsapp/scanner.html @@ -0,0 +1,184 @@ + + + + + + WhatsApp QR Scanner + + + +
+ + +

Scan QR code untuk menghubungkan WhatsApp Anda

+ +
+ WhatsApp QR Code +
+ +
+
+

Cara menggunakan:

+
    +
  1. Buka WhatsApp di ponsel Anda
  2. +
  3. Tap Menu atau Settings dan pilih WhatsApp Web
  4. +
  5. Arahkan ponsel Anda ke QR code ini untuk memindainya
  6. +
  7. Tunggu hingga terhubung
  8. +
+
+ +
+ + Menunggu pemindaian QR code... +
+
+
+ + + + \ No newline at end of file diff --git a/internal/whatsapp/success_scan.html b/internal/whatsapp/success_scan.html new file mode 100644 index 0000000..2063fcc --- /dev/null +++ b/internal/whatsapp/success_scan.html @@ -0,0 +1,411 @@ + + + + + + WhatsApp - Berhasil Terhubung + + + +
+ + +
βœ…
+ +
+ WhatsApp berhasil terhubung dan siap digunakan! +
+ +
+
+ Status Koneksi: +
+ + Checking... +
+
+
+ Status Login: +
+ + Checking... +
+
+
+ +
+ +
+ + + + + +
+
+ + + + \ No newline at end of file diff --git a/internal/whatsapp/whatsapp_handler.go b/internal/whatsapp/whatsapp_handler.go new file mode 100644 index 0000000..fe71abb --- /dev/null +++ b/internal/whatsapp/whatsapp_handler.go @@ -0,0 +1,311 @@ +package whatsapp + +import ( + "regexp" + "rijig/config" + "rijig/utils" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +type QRResponse struct { + QRCode string `json:"qr_code,omitempty"` + Status string `json:"status"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +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() + if wa == nil { + return utils.InternalServerError(c, "WhatsApp service not initialized") + } + + if wa.IsLoggedIn() { + data := QRResponse{ + Status: "logged_in", + Message: "WhatsApp is already connected and logged in", + Timestamp: time.Now().Unix(), + } + return utils.SuccessWithData(c, "Already logged in", data) + } + + qrDataURI, err := wa.GenerateQR() + if err != nil { + return utils.InternalServerError(c, "Failed to generate QR code: "+err.Error()) + } + + switch qrDataURI { + case "success": + data := QRResponse{ + Status: "login_success", + Message: "WhatsApp login successful", + Timestamp: time.Now().Unix(), + } + return utils.SuccessWithData(c, "Successfully logged in", data) + + case "already_connected": + data := QRResponse{ + Status: "already_connected", + Message: "WhatsApp is already connected", + Timestamp: time.Now().Unix(), + } + return utils.SuccessWithData(c, "Already connected", data) + + default: + + 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") + } + + // if !wa.IsLoggedIn() { + // return utils.Unauthorized(c, "WhatsApp not logged in") + // } + + isConnected := wa.IsConnected() + isLoggedIn := wa.IsLoggedIn() + + 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" + } + + data := StatusResponse{ + 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 { + wa := config.GetWhatsAppService() + if wa == nil { + return utils.InternalServerError(c, "WhatsApp service not initialized") + } + + if !wa.IsLoggedIn() { + return utils.BadRequest(c, "No active session to logout") + } + + err := wa.Logout() + if err != nil { + return utils.InternalServerError(c, "Failed to logout: "+err.Error()) + } + + data := map[string]interface{}{ + "timestamp": time.Now().Unix(), + } + + return utils.SuccessWithData(c, "Successfully logged out and session deleted", data) +} + +func SendMessageHandler(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") + } + + req := GetValidatedSendMessageRequest(c) + if req == nil { + return utils.BadRequest(c, "Invalid request data") + } + + 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() + } +} diff --git a/internal/whatsapp/whatsapp_route.go b/internal/whatsapp/whatsapp_route.go new file mode 100644 index 0000000..b478d9e --- /dev/null +++ b/internal/whatsapp/whatsapp_route.go @@ -0,0 +1,32 @@ +package whatsapp + +import ( + "rijig/middleware" + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +func WhatsAppRouter(api fiber.Router) { + + whatsapp := api.Group("/whatsapp") + + 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") + }) +} diff --git a/dto/wiayah_indonesia_dto.go b/internal/wilayahindo/wilayahindo_dto.go similarity index 97% rename from dto/wiayah_indonesia_dto.go rename to internal/wilayahindo/wilayahindo_dto.go index b247b59..ff4015a 100644 --- a/dto/wiayah_indonesia_dto.go +++ b/internal/wilayahindo/wilayahindo_dto.go @@ -1,4 +1,4 @@ -package dto +package wilayahindo type ProvinceResponseDTO struct { ID string `json:"id"` diff --git a/internal/wilayahindo/wilayahindo_handler.go b/internal/wilayahindo/wilayahindo_handler.go new file mode 100644 index 0000000..cf23a6e --- /dev/null +++ b/internal/wilayahindo/wilayahindo_handler.go @@ -0,0 +1,272 @@ +package wilayahindo + +import ( + "strconv" + "strings" + + "rijig/utils" + + "github.com/gofiber/fiber/v2" +) + +type WilayahIndonesiaHandler struct { + WilayahService WilayahIndonesiaService +} + +func NewWilayahIndonesiaHandler(wilayahService WilayahIndonesiaService) *WilayahIndonesiaHandler { + return &WilayahIndonesiaHandler{ + WilayahService: wilayahService, + } +} + +func (h *WilayahIndonesiaHandler) ImportDataFromCSV(c *fiber.Ctx) error { + ctx := c.Context() + + if err := h.WilayahService.ImportDataFromCSV(ctx); err != nil { + return utils.InternalServerError(c, "Failed to import data from CSV: "+err.Error()) + } + + return utils.Success(c, "Data imported successfully from CSV") +} + +func (h *WilayahIndonesiaHandler) GetAllProvinces(c *fiber.Ctx) error { + ctx := c.Context() + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + provinces, total, err := h.WilayahService.GetAllProvinces(ctx, page, limit) + if err != nil { + return utils.InternalServerError(c, "Failed to fetch provinces: "+err.Error()) + } + + response := map[string]interface{}{ + "provinces": provinces, + "total": total, + } + + return utils.SuccessWithPagination(c, "Provinces retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetProvinceByID(c *fiber.Ctx) error { + ctx := c.Context() + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Province ID is required") + } + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + province, totalRegencies, err := h.WilayahService.GetProvinceByID(ctx, id, page, limit) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Province not found") + } + return utils.InternalServerError(c, "Failed to fetch province: "+err.Error()) + } + + response := map[string]interface{}{ + "province": province, + "total_regencies": totalRegencies, + } + + return utils.SuccessWithPagination(c, "Province retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetAllRegencies(c *fiber.Ctx) error { + ctx := c.Context() + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + regencies, total, err := h.WilayahService.GetAllRegencies(ctx, page, limit) + if err != nil { + return utils.InternalServerError(c, "Failed to fetch regencies: "+err.Error()) + } + + response := map[string]interface{}{ + "regencies": regencies, + "total": total, + } + + return utils.SuccessWithPagination(c, "Regencies retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetRegencyByID(c *fiber.Ctx) error { + ctx := c.Context() + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Regency ID is required") + } + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + regency, totalDistricts, err := h.WilayahService.GetRegencyByID(ctx, id, page, limit) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Regency not found") + } + return utils.InternalServerError(c, "Failed to fetch regency: "+err.Error()) + } + + response := map[string]interface{}{ + "regency": regency, + "total_districts": totalDistricts, + } + + return utils.SuccessWithPagination(c, "Regency retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetAllDistricts(c *fiber.Ctx) error { + ctx := c.Context() + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + districts, total, err := h.WilayahService.GetAllDistricts(ctx, page, limit) + if err != nil { + return utils.InternalServerError(c, "Failed to fetch districts: "+err.Error()) + } + + response := map[string]interface{}{ + "districts": districts, + "total": total, + } + + return utils.SuccessWithPagination(c, "Districts retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetDistrictByID(c *fiber.Ctx) error { + ctx := c.Context() + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "District ID is required") + } + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + district, totalVillages, err := h.WilayahService.GetDistrictByID(ctx, id, page, limit) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "District not found") + } + return utils.InternalServerError(c, "Failed to fetch district: "+err.Error()) + } + + response := map[string]interface{}{ + "district": district, + "total_villages": totalVillages, + } + + return utils.SuccessWithPagination(c, "District retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetAllVillages(c *fiber.Ctx) error { + ctx := c.Context() + + page, limit, err := h.parsePaginationParams(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + villages, total, err := h.WilayahService.GetAllVillages(ctx, page, limit) + if err != nil { + return utils.InternalServerError(c, "Failed to fetch villages: "+err.Error()) + } + + response := map[string]interface{}{ + "villages": villages, + "total": total, + } + + return utils.SuccessWithPagination(c, "Villages retrieved successfully", response, page, limit) +} + +func (h *WilayahIndonesiaHandler) GetVillageByID(c *fiber.Ctx) error { + ctx := c.Context() + + id := c.Params("id") + if id == "" { + return utils.BadRequest(c, "Village ID is required") + } + + village, err := h.WilayahService.GetVillageByID(ctx, id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return utils.NotFound(c, "Village not found") + } + return utils.InternalServerError(c, "Failed to fetch village: "+err.Error()) + } + + return utils.SuccessWithData(c, "Village retrieved successfully", village) +} + +func (h *WilayahIndonesiaHandler) parsePaginationParams(c *fiber.Ctx) (int, int, error) { + + page := 1 + limit := 10 + + if pageStr := c.Query("page"); pageStr != "" { + parsedPage, err := strconv.Atoi(pageStr) + if err != nil { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid page parameter") + } + if parsedPage < 1 { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "Page must be greater than 0") + } + page = parsedPage + } + + if limitStr := c.Query("limit"); limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid limit parameter") + } + if parsedLimit < 1 { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "Limit must be greater than 0") + } + if parsedLimit > 100 { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "Limit cannot exceed 100") + } + limit = parsedLimit + } + + return page, limit, nil +} + +func (h *WilayahIndonesiaHandler) SetupRoutes(app *fiber.App) { + + api := app.Group("/api/v1/wilayah") + + api.Post("/import", h.ImportDataFromCSV) + + api.Get("/provinces", h.GetAllProvinces) + api.Get("/provinces/:id", h.GetProvinceByID) + + api.Get("/regencies", h.GetAllRegencies) + api.Get("/regencies/:id", h.GetRegencyByID) + + api.Get("/districts", h.GetAllDistricts) + api.Get("/districts/:id", h.GetDistrictByID) + + api.Get("/villages", h.GetAllVillages) + api.Get("/villages/:id", h.GetVillageByID) +} diff --git a/internal/wilayahindo/wilayahindo_repository.go b/internal/wilayahindo/wilayahindo_repository.go new file mode 100644 index 0000000..0ed548f --- /dev/null +++ b/internal/wilayahindo/wilayahindo_repository.go @@ -0,0 +1,310 @@ +package wilayahindo + +import ( + "context" + "errors" + "fmt" + "rijig/model" + + "gorm.io/gorm" +) + +type WilayahIndonesiaRepository interface { + ImportProvinces(ctx context.Context, provinces []model.Province) error + ImportRegencies(ctx context.Context, regencies []model.Regency) error + ImportDistricts(ctx context.Context, districts []model.District) error + ImportVillages(ctx context.Context, villages []model.Village) error + + FindAllProvinces(ctx context.Context, page, limit int) ([]model.Province, int, error) + FindProvinceByID(ctx context.Context, id string, page, limit int) (*model.Province, int, error) + + FindAllRegencies(ctx context.Context, page, limit int) ([]model.Regency, int, error) + FindRegencyByID(ctx context.Context, id string, page, limit int) (*model.Regency, int, error) + + FindAllDistricts(ctx context.Context, page, limit int) ([]model.District, int, error) + FindDistrictByID(ctx context.Context, id string, page, limit int) (*model.District, int, error) + + FindAllVillages(ctx context.Context, page, limit int) ([]model.Village, int, error) + FindVillageByID(ctx context.Context, id string) (*model.Village, error) +} + +type wilayahIndonesiaRepository struct { + DB *gorm.DB +} + +func NewWilayahIndonesiaRepository(db *gorm.DB) WilayahIndonesiaRepository { + return &wilayahIndonesiaRepository{DB: db} +} + +func (r *wilayahIndonesiaRepository) ImportProvinces(ctx context.Context, provinces []model.Province) error { + if len(provinces) == 0 { + return errors.New("no provinces to import") + } + + if err := r.DB.WithContext(ctx).CreateInBatches(provinces, 100).Error; err != nil { + return fmt.Errorf("failed to import provinces: %w", err) + } + return nil +} + +func (r *wilayahIndonesiaRepository) ImportRegencies(ctx context.Context, regencies []model.Regency) error { + if len(regencies) == 0 { + return errors.New("no regencies to import") + } + + if err := r.DB.WithContext(ctx).CreateInBatches(regencies, 100).Error; err != nil { + return fmt.Errorf("failed to import regencies: %w", err) + } + return nil +} + +func (r *wilayahIndonesiaRepository) ImportDistricts(ctx context.Context, districts []model.District) error { + if len(districts) == 0 { + return errors.New("no districts to import") + } + + if err := r.DB.WithContext(ctx).CreateInBatches(districts, 100).Error; err != nil { + return fmt.Errorf("failed to import districts: %w", err) + } + return nil +} + +func (r *wilayahIndonesiaRepository) ImportVillages(ctx context.Context, villages []model.Village) error { + if len(villages) == 0 { + return errors.New("no villages to import") + } + + if err := r.DB.WithContext(ctx).CreateInBatches(villages, 100).Error; err != nil { + return fmt.Errorf("failed to import villages: %w", err) + } + return nil +} + +func (r *wilayahIndonesiaRepository) FindAllProvinces(ctx context.Context, page, limit int) ([]model.Province, int, error) { + var provinces []model.Province + var total int64 + + if err := r.DB.WithContext(ctx).Model(&model.Province{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count provinces: %w", err) + } + + query := r.DB.WithContext(ctx) + + if page > 0 && limit > 0 { + if page < 1 { + return nil, 0, errors.New("page must be greater than 0") + } + if limit < 1 || limit > 1000 { + return nil, 0, errors.New("limit must be between 1 and 1000") + } + query = query.Offset((page - 1) * limit).Limit(limit) + } + + if err := query.Find(&provinces).Error; err != nil { + return nil, 0, fmt.Errorf("failed to find provinces: %w", err) + } + + return provinces, int(total), nil +} + +func (r *wilayahIndonesiaRepository) FindProvinceByID(ctx context.Context, id string, page, limit int) (*model.Province, int, error) { + if id == "" { + return nil, 0, errors.New("province ID cannot be empty") + } + + var province model.Province + + preloadQuery := func(db *gorm.DB) *gorm.DB { + if page > 0 && limit > 0 { + if page < 1 { + return db + } + if limit < 1 || limit > 1000 { + return db + } + return db.Offset((page - 1) * limit).Limit(limit) + } + return db + } + + if err := r.DB.WithContext(ctx).Preload("Regencies", preloadQuery).Where("id = ?", id).First(&province).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, fmt.Errorf("province with ID %s not found", id) + } + return nil, 0, fmt.Errorf("failed to find province: %w", err) + } + + var totalRegencies int64 + if err := r.DB.WithContext(ctx).Model(&model.Regency{}).Where("province_id = ?", id).Count(&totalRegencies).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count regencies: %w", err) + } + + return &province, int(totalRegencies), nil +} + +func (r *wilayahIndonesiaRepository) FindAllRegencies(ctx context.Context, page, limit int) ([]model.Regency, int, error) { + var regencies []model.Regency + var total int64 + + if err := r.DB.WithContext(ctx).Model(&model.Regency{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count regencies: %w", err) + } + + query := r.DB.WithContext(ctx) + + if page > 0 && limit > 0 { + if page < 1 { + return nil, 0, errors.New("page must be greater than 0") + } + if limit < 1 || limit > 1000 { + return nil, 0, errors.New("limit must be between 1 and 1000") + } + query = query.Offset((page - 1) * limit).Limit(limit) + } + + if err := query.Find(®encies).Error; err != nil { + return nil, 0, fmt.Errorf("failed to find regencies: %w", err) + } + + return regencies, int(total), nil +} + +func (r *wilayahIndonesiaRepository) FindRegencyByID(ctx context.Context, id string, page, limit int) (*model.Regency, int, error) { + if id == "" { + return nil, 0, errors.New("regency ID cannot be empty") + } + + var regency model.Regency + + preloadQuery := func(db *gorm.DB) *gorm.DB { + if page > 0 && limit > 0 { + if page < 1 { + return db + } + if limit < 1 || limit > 1000 { + return db + } + return db.Offset((page - 1) * limit).Limit(limit) + } + return db + } + + if err := r.DB.WithContext(ctx).Preload("Districts", preloadQuery).Where("id = ?", id).First(®ency).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, fmt.Errorf("regency with ID %s not found", id) + } + return nil, 0, fmt.Errorf("failed to find regency: %w", err) + } + + var totalDistricts int64 + if err := r.DB.WithContext(ctx).Model(&model.District{}).Where("regency_id = ?", id).Count(&totalDistricts).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count districts: %w", err) + } + + return ®ency, int(totalDistricts), nil +} + +func (r *wilayahIndonesiaRepository) FindAllDistricts(ctx context.Context, page, limit int) ([]model.District, int, error) { + var districts []model.District + var total int64 + + if err := r.DB.WithContext(ctx).Model(&model.District{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count districts: %w", err) + } + + query := r.DB.WithContext(ctx) + + if page > 0 && limit > 0 { + if page < 1 { + return nil, 0, errors.New("page must be greater than 0") + } + if limit < 1 || limit > 1000 { + return nil, 0, errors.New("limit must be between 1 and 1000") + } + query = query.Offset((page - 1) * limit).Limit(limit) + } + + if err := query.Find(&districts).Error; err != nil { + return nil, 0, fmt.Errorf("failed to find districts: %w", err) + } + + return districts, int(total), nil +} + +func (r *wilayahIndonesiaRepository) FindDistrictByID(ctx context.Context, id string, page, limit int) (*model.District, int, error) { + if id == "" { + return nil, 0, errors.New("district ID cannot be empty") + } + + var district model.District + + preloadQuery := func(db *gorm.DB) *gorm.DB { + if page > 0 && limit > 0 { + if page < 1 { + return db + } + if limit < 1 || limit > 1000 { + return db + } + return db.Offset((page - 1) * limit).Limit(limit) + } + return db + } + + if err := r.DB.WithContext(ctx).Preload("Villages", preloadQuery).Where("id = ?", id).First(&district).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, fmt.Errorf("district with ID %s not found", id) + } + return nil, 0, fmt.Errorf("failed to find district: %w", err) + } + + var totalVillages int64 + if err := r.DB.WithContext(ctx).Model(&model.Village{}).Where("district_id = ?", id).Count(&totalVillages).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count villages: %w", err) + } + + return &district, int(totalVillages), nil +} + +func (r *wilayahIndonesiaRepository) FindAllVillages(ctx context.Context, page, limit int) ([]model.Village, int, error) { + var villages []model.Village + var total int64 + + if err := r.DB.WithContext(ctx).Model(&model.Village{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count villages: %w", err) + } + + query := r.DB.WithContext(ctx) + + if page > 0 && limit > 0 { + if page < 1 { + return nil, 0, errors.New("page must be greater than 0") + } + if limit < 1 || limit > 1000 { + return nil, 0, errors.New("limit must be between 1 and 1000") + } + query = query.Offset((page - 1) * limit).Limit(limit) + } + + if err := query.Find(&villages).Error; err != nil { + return nil, 0, fmt.Errorf("failed to find villages: %w", err) + } + + return villages, int(total), nil +} + +func (r *wilayahIndonesiaRepository) FindVillageByID(ctx context.Context, id string) (*model.Village, error) { + if id == "" { + return nil, errors.New("village ID cannot be empty") + } + + var village model.Village + if err := r.DB.WithContext(ctx).Where("id = ?", id).First(&village).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("village with ID %s not found", id) + } + return nil, fmt.Errorf("failed to find village: %w", err) + } + + return &village, nil +} diff --git a/internal/wilayahindo/wilayahindo_route.go b/internal/wilayahindo/wilayahindo_route.go new file mode 100644 index 0000000..7d11403 --- /dev/null +++ b/internal/wilayahindo/wilayahindo_route.go @@ -0,0 +1,32 @@ +package wilayahindo + +import ( + "rijig/config" + "rijig/middleware" + + "github.com/gofiber/fiber/v2" +) + +func WilayahRouter(api fiber.Router) { + + wilayahRepo := NewWilayahIndonesiaRepository(config.DB) + wilayahService := NewWilayahIndonesiaService(wilayahRepo) + wilayahHandler := NewWilayahIndonesiaHandler(wilayahService) + + api.Post("/import/data-wilayah-indonesia", middleware.RequireAdminRole(), wilayahHandler.ImportDataFromCSV) + + wilayahAPI := api.Group("/wilayah-indonesia") + + wilayahAPI.Get("/provinces", wilayahHandler.GetAllProvinces) + wilayahAPI.Get("/provinces/:provinceid", wilayahHandler.GetProvinceByID) + + wilayahAPI.Get("/regencies", wilayahHandler.GetAllRegencies) + wilayahAPI.Get("/regencies/:regencyid", wilayahHandler.GetRegencyByID) + + wilayahAPI.Get("/districts", wilayahHandler.GetAllDistricts) + wilayahAPI.Get("/districts/:districtid", wilayahHandler.GetDistrictByID) + + wilayahAPI.Get("/villages", wilayahHandler.GetAllVillages) + wilayahAPI.Get("/villages/:villageid", wilayahHandler.GetVillageByID) + +} diff --git a/internal/wilayahindo/wilayahindo_service.go b/internal/wilayahindo/wilayahindo_service.go new file mode 100644 index 0000000..b1a0407 --- /dev/null +++ b/internal/wilayahindo/wilayahindo_service.go @@ -0,0 +1,454 @@ +package wilayahindo + +import ( + "context" + "fmt" + "time" + + "rijig/model" + "rijig/utils" +) + +type WilayahIndonesiaService interface { + ImportDataFromCSV(ctx context.Context) error + + GetAllProvinces(ctx context.Context, page, limit int) ([]ProvinceResponseDTO, int, error) + GetProvinceByID(ctx context.Context, id string, page, limit int) (*ProvinceResponseDTO, int, error) + + GetAllRegencies(ctx context.Context, page, limit int) ([]RegencyResponseDTO, int, error) + GetRegencyByID(ctx context.Context, id string, page, limit int) (*RegencyResponseDTO, int, error) + + GetAllDistricts(ctx context.Context, page, limit int) ([]DistrictResponseDTO, int, error) + GetDistrictByID(ctx context.Context, id string, page, limit int) (*DistrictResponseDTO, int, error) + + GetAllVillages(ctx context.Context, page, limit int) ([]VillageResponseDTO, int, error) + GetVillageByID(ctx context.Context, id string) (*VillageResponseDTO, error) +} + +type wilayahIndonesiaService struct { + WilayahRepo WilayahIndonesiaRepository +} + +func NewWilayahIndonesiaService(wilayahRepo WilayahIndonesiaRepository) WilayahIndonesiaService { + return &wilayahIndonesiaService{WilayahRepo: wilayahRepo} +} + +func (s *wilayahIndonesiaService) ImportDataFromCSV(ctx context.Context) error { + + provinces, err := utils.ReadCSV("public/document/provinces.csv") + if err != nil { + return fmt.Errorf("failed to read provinces CSV: %w", err) + } + + var provinceList []model.Province + for _, record := range provinces[1:] { + if len(record) >= 2 { + province := model.Province{ + ID: record[0], + Name: record[1], + } + provinceList = append(provinceList, province) + } + } + + if err := s.WilayahRepo.ImportProvinces(ctx, provinceList); err != nil { + return fmt.Errorf("failed to import provinces: %w", err) + } + + regencies, err := utils.ReadCSV("public/document/regencies.csv") + if err != nil { + return fmt.Errorf("failed to read regencies CSV: %w", err) + } + + var regencyList []model.Regency + for _, record := range regencies[1:] { + if len(record) >= 3 { + regency := model.Regency{ + ID: record[0], + ProvinceID: record[1], + Name: record[2], + } + regencyList = append(regencyList, regency) + } + } + + if err := s.WilayahRepo.ImportRegencies(ctx, regencyList); err != nil { + return fmt.Errorf("failed to import regencies: %w", err) + } + + districts, err := utils.ReadCSV("public/document/districts.csv") + if err != nil { + return fmt.Errorf("failed to read districts CSV: %w", err) + } + + var districtList []model.District + for _, record := range districts[1:] { + if len(record) >= 3 { + district := model.District{ + ID: record[0], + RegencyID: record[1], + Name: record[2], + } + districtList = append(districtList, district) + } + } + + if err := s.WilayahRepo.ImportDistricts(ctx, districtList); err != nil { + return fmt.Errorf("failed to import districts: %w", err) + } + + villages, err := utils.ReadCSV("public/document/villages.csv") + if err != nil { + return fmt.Errorf("failed to read villages CSV: %w", err) + } + + var villageList []model.Village + for _, record := range villages[1:] { + if len(record) >= 3 { + village := model.Village{ + ID: record[0], + DistrictID: record[1], + Name: record[2], + } + villageList = append(villageList, village) + } + } + + if err := s.WilayahRepo.ImportVillages(ctx, villageList); err != nil { + return fmt.Errorf("failed to import villages: %w", err) + } + + return nil +} + +func (s *wilayahIndonesiaService) GetAllProvinces(ctx context.Context, page, limit int) ([]ProvinceResponseDTO, int, error) { + cacheKey := fmt.Sprintf("provinces_page:%d_limit:%d", page, limit) + + var cachedResponse struct { + Data []ProvinceResponseDTO `json:"data"` + Total int `json:"total"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return cachedResponse.Data, cachedResponse.Total, nil + } + + provinces, total, err := s.WilayahRepo.FindAllProvinces(ctx, page, limit) + if err != nil { + return nil, 0, fmt.Errorf("failed to fetch provinces: %w", err) + } + + provinceDTOs := make([]ProvinceResponseDTO, len(provinces)) + for i, province := range provinces { + provinceDTOs[i] = ProvinceResponseDTO{ + ID: province.ID, + Name: province.Name, + } + } + + cacheData := struct { + Data []ProvinceResponseDTO `json:"data"` + Total int `json:"total"` + }{ + Data: provinceDTOs, + Total: total, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching provinces data: %v\n", err) + } + + return provinceDTOs, total, nil +} + +func (s *wilayahIndonesiaService) GetProvinceByID(ctx context.Context, id string, page, limit int) (*ProvinceResponseDTO, int, error) { + cacheKey := fmt.Sprintf("province:%s_page:%d_limit:%d", id, page, limit) + + var cachedResponse struct { + Data ProvinceResponseDTO `json:"data"` + TotalRegencies int `json:"total_regencies"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return &cachedResponse.Data, cachedResponse.TotalRegencies, nil + } + + province, totalRegencies, err := s.WilayahRepo.FindProvinceByID(ctx, id, page, limit) + if err != nil { + return nil, 0, err + } + + provinceDTO := ProvinceResponseDTO{ + ID: province.ID, + Name: province.Name, + } + + regencyDTOs := make([]RegencyResponseDTO, len(province.Regencies)) + for i, regency := range province.Regencies { + regencyDTOs[i] = RegencyResponseDTO{ + ID: regency.ID, + ProvinceID: regency.ProvinceID, + Name: regency.Name, + } + } + provinceDTO.Regencies = regencyDTOs + + cacheData := struct { + Data ProvinceResponseDTO `json:"data"` + TotalRegencies int `json:"total_regencies"` + }{ + Data: provinceDTO, + TotalRegencies: totalRegencies, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching province data: %v\n", err) + } + + return &provinceDTO, totalRegencies, nil +} + +func (s *wilayahIndonesiaService) GetAllRegencies(ctx context.Context, page, limit int) ([]RegencyResponseDTO, int, error) { + cacheKey := fmt.Sprintf("regencies_page:%d_limit:%d", page, limit) + + var cachedResponse struct { + Data []RegencyResponseDTO `json:"data"` + Total int `json:"total"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return cachedResponse.Data, cachedResponse.Total, nil + } + + regencies, total, err := s.WilayahRepo.FindAllRegencies(ctx, page, limit) + if err != nil { + return nil, 0, fmt.Errorf("failed to fetch regencies: %w", err) + } + + regencyDTOs := make([]RegencyResponseDTO, len(regencies)) + for i, regency := range regencies { + regencyDTOs[i] = RegencyResponseDTO{ + ID: regency.ID, + ProvinceID: regency.ProvinceID, + Name: regency.Name, + } + } + + cacheData := struct { + Data []RegencyResponseDTO `json:"data"` + Total int `json:"total"` + }{ + Data: regencyDTOs, + Total: total, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching regencies data: %v\n", err) + } + + return regencyDTOs, total, nil +} + +func (s *wilayahIndonesiaService) GetRegencyByID(ctx context.Context, id string, page, limit int) (*RegencyResponseDTO, int, error) { + cacheKey := fmt.Sprintf("regency:%s_page:%d_limit:%d", id, page, limit) + + var cachedResponse struct { + Data RegencyResponseDTO `json:"data"` + TotalDistricts int `json:"total_districts"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return &cachedResponse.Data, cachedResponse.TotalDistricts, nil + } + + regency, totalDistricts, err := s.WilayahRepo.FindRegencyByID(ctx, id, page, limit) + if err != nil { + return nil, 0, err + } + + regencyDTO := RegencyResponseDTO{ + ID: regency.ID, + ProvinceID: regency.ProvinceID, + Name: regency.Name, + } + + districtDTOs := make([]DistrictResponseDTO, len(regency.Districts)) + for i, district := range regency.Districts { + districtDTOs[i] = DistrictResponseDTO{ + ID: district.ID, + RegencyID: district.RegencyID, + Name: district.Name, + } + } + regencyDTO.Districts = districtDTOs + + cacheData := struct { + Data RegencyResponseDTO `json:"data"` + TotalDistricts int `json:"total_districts"` + }{ + Data: regencyDTO, + TotalDistricts: totalDistricts, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching regency data: %v\n", err) + } + + return ®encyDTO, totalDistricts, nil +} + +func (s *wilayahIndonesiaService) GetAllDistricts(ctx context.Context, page, limit int) ([]DistrictResponseDTO, int, error) { + cacheKey := fmt.Sprintf("districts_page:%d_limit:%d", page, limit) + + var cachedResponse struct { + Data []DistrictResponseDTO `json:"data"` + Total int `json:"total"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return cachedResponse.Data, cachedResponse.Total, nil + } + + districts, total, err := s.WilayahRepo.FindAllDistricts(ctx, page, limit) + if err != nil { + return nil, 0, fmt.Errorf("failed to fetch districts: %w", err) + } + + districtDTOs := make([]DistrictResponseDTO, len(districts)) + for i, district := range districts { + districtDTOs[i] = DistrictResponseDTO{ + ID: district.ID, + RegencyID: district.RegencyID, + Name: district.Name, + } + } + + cacheData := struct { + Data []DistrictResponseDTO `json:"data"` + Total int `json:"total"` + }{ + Data: districtDTOs, + Total: total, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching districts data: %v\n", err) + } + + return districtDTOs, total, nil +} + +func (s *wilayahIndonesiaService) GetDistrictByID(ctx context.Context, id string, page, limit int) (*DistrictResponseDTO, int, error) { + cacheKey := fmt.Sprintf("district:%s_page:%d_limit:%d", id, page, limit) + + var cachedResponse struct { + Data DistrictResponseDTO `json:"data"` + TotalVillages int `json:"total_villages"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return &cachedResponse.Data, cachedResponse.TotalVillages, nil + } + + district, totalVillages, err := s.WilayahRepo.FindDistrictByID(ctx, id, page, limit) + if err != nil { + return nil, 0, err + } + + districtDTO := DistrictResponseDTO{ + ID: district.ID, + RegencyID: district.RegencyID, + Name: district.Name, + } + + villageDTOs := make([]VillageResponseDTO, len(district.Villages)) + for i, village := range district.Villages { + villageDTOs[i] = VillageResponseDTO{ + ID: village.ID, + DistrictID: village.DistrictID, + Name: village.Name, + } + } + districtDTO.Villages = villageDTOs + + cacheData := struct { + Data DistrictResponseDTO `json:"data"` + TotalVillages int `json:"total_villages"` + }{ + Data: districtDTO, + TotalVillages: totalVillages, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching district data: %v\n", err) + } + + return &districtDTO, totalVillages, nil +} + +func (s *wilayahIndonesiaService) GetAllVillages(ctx context.Context, page, limit int) ([]VillageResponseDTO, int, error) { + cacheKey := fmt.Sprintf("villages_page:%d_limit:%d", page, limit) + + var cachedResponse struct { + Data []VillageResponseDTO `json:"data"` + Total int `json:"total"` + } + + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return cachedResponse.Data, cachedResponse.Total, nil + } + + villages, total, err := s.WilayahRepo.FindAllVillages(ctx, page, limit) + if err != nil { + return nil, 0, fmt.Errorf("failed to fetch villages: %w", err) + } + + villageDTOs := make([]VillageResponseDTO, len(villages)) + for i, village := range villages { + villageDTOs[i] = VillageResponseDTO{ + ID: village.ID, + DistrictID: village.DistrictID, + Name: village.Name, + } + } + + cacheData := struct { + Data []VillageResponseDTO `json:"data"` + Total int `json:"total"` + }{ + Data: villageDTOs, + Total: total, + } + + if err := utils.SetCache(cacheKey, cacheData, 24*time.Hour); err != nil { + fmt.Printf("Error caching villages data: %v\n", err) + } + + return villageDTOs, total, nil +} + +func (s *wilayahIndonesiaService) GetVillageByID(ctx context.Context, id string) (*VillageResponseDTO, error) { + cacheKey := fmt.Sprintf("village:%s", id) + + var cachedResponse VillageResponseDTO + if err := utils.GetCache(cacheKey, &cachedResponse); err == nil { + return &cachedResponse, nil + } + + village, err := s.WilayahRepo.FindVillageByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("village not found: %w", err) + } + + villageResponse := &VillageResponseDTO{ + ID: village.ID, + DistrictID: village.DistrictID, + Name: village.Name, + } + + if err := utils.SetCache(cacheKey, villageResponse, 24*time.Hour); err != nil { + fmt.Printf("Error caching village data: %v\n", err) + } + + return villageResponse, nil +} diff --git a/internal/worker/cart_worker.go b/internal/worker/cart_worker.go new file mode 100644 index 0000000..3e82f7a --- /dev/null +++ b/internal/worker/cart_worker.go @@ -0,0 +1,157 @@ +package worker + +import ( + "context" + "encoding/json" + "log" + "strings" + "time" + + "rijig/config" + "rijig/internal/cart" + "rijig/internal/trash" + "rijig/model" +) + +type CartWorker struct { + cartService cart.CartService + cartRepo cart.CartRepository + trashRepo trash.TrashRepositoryInterface +} + +func NewCartWorker(cartService cart.CartService, cartRepo cart.CartRepository, trashRepo trash.TrashRepositoryInterface) *CartWorker { + return &CartWorker{ + cartService: cartService, + cartRepo: cartRepo, + trashRepo: trashRepo, + } +} + +func (w *CartWorker) AutoCommitExpiringCarts() error { + ctx := context.Background() + threshold := 1 * time.Minute + + keys, err := cart.GetExpiringCartKeys(ctx, threshold) + if err != nil { + return err + } + + if len(keys) == 0 { + return nil + } + + log.Printf("[CART-WORKER] Found %d carts expiring within 1 minute", len(keys)) + + successCount := 0 + for _, key := range keys { + userID := w.extractUserIDFromKey(key) + if userID == "" { + log.Printf("[CART-WORKER] Invalid key format: %s", key) + continue + } + + hasCart, err := w.cartRepo.HasExistingCart(ctx, userID) + if err != nil { + log.Printf("[CART-WORKER] Error checking existing cart for user %s: %v", userID, err) + continue + } + + if hasCart { + + if err := cart.DeleteCartFromRedis(ctx, userID); err != nil { + log.Printf("[CART-WORKER] Failed to delete Redis cache for user %s: %v", userID, err) + } else { + log.Printf("[CART-WORKER] Deleted Redis cache for user %s (already has DB cart)", userID) + } + continue + } + + cartData, err := w.getCartFromRedis(ctx, key) + if err != nil { + log.Printf("[CART-WORKER] Failed to get cart data for key %s: %v", key, err) + continue + } + + if err := w.commitCartToDB(ctx, userID, cartData); err != nil { + log.Printf("[CART-WORKER] Failed to commit cart for user %s: %v", userID, err) + continue + } + + 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) + } + + successCount++ + log.Printf("[CART-WORKER] Successfully auto-committed cart for user %s", userID) + } + + log.Printf("[CART-WORKER] Auto-commit completed: %d successful commits", successCount) + return nil +} + +func (w *CartWorker) extractUserIDFromKey(key string) string { + parts := strings.Split(key, ":") + if len(parts) >= 2 { + return parts[len(parts)-1] + } + return "" +} + +func (w *CartWorker) getCartFromRedis(ctx context.Context, key string) (*cart.RequestCartDTO, error) { + val, err := config.RedisClient.Get(ctx, key).Result() + if err != nil { + return nil, err + } + + var cart cart.RequestCartDTO + if err := json.Unmarshal([]byte(val), &cart); err != nil { + return nil, err + } + + return &cart, nil +} + +func (w *CartWorker) commitCartToDB(ctx context.Context, userID string, cartData *cart.RequestCartDTO) error { + if len(cartData.CartItems) == 0 { + return nil + } + + totalAmount := 0.0 + totalPrice := 0.0 + var cartItems []model.CartItem + + for _, item := range cartData.CartItems { + if item.Amount <= 0 { + continue + } + + trash, err := w.trashRepo.GetTrashCategoryByID(ctx, item.TrashID) + if err != nil { + log.Printf("[CART-WORKER] Warning: Skipping invalid trash category %s", item.TrashID) + continue + } + + subtotal := item.Amount * trash.EstimatedPrice + totalAmount += item.Amount + totalPrice += subtotal + + cartItems = append(cartItems, model.CartItem{ + TrashCategoryID: item.TrashID, + Amount: item.Amount, + SubTotalEstimatedPrice: subtotal, + }) + } + + if len(cartItems) == 0 { + return nil + } + + newCart := &model.Cart{ + UserID: userID, + TotalAmount: totalAmount, + EstimatedTotalPrice: totalPrice, + CartItems: cartItems, + } + + return w.cartRepo.CreateCartWithItems(ctx, newCart) +} diff --git a/middleware/api_key.go b/middleware/api_key.go deleted file mode 100644 index 0693eb4..0000000 --- a/middleware/api_key.go +++ /dev/null @@ -1,22 +0,0 @@ -package middleware - -import ( - "os" - - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func APIKeyMiddleware(c *fiber.Ctx) error { - apiKey := c.Get("x-api-key") - if apiKey == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: API key is required") - } - - validAPIKey := os.Getenv("API_KEY") - if apiKey != validAPIKey { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: Invalid API key") - } - - return c.Next() -} diff --git a/middleware/auth_middleware.go b/middleware/auth_middleware.go deleted file mode 100644 index 427c5f6..0000000 --- a/middleware/auth_middleware.go +++ /dev/null @@ -1,55 +0,0 @@ -package middleware - -import ( - "os" - - "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt/v5" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func AuthMiddleware(c *fiber.Ctx) error { - tokenString := c.Get("Authorization") - if tokenString == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: No token provided") - } - - if len(tokenString) > 7 && tokenString[:7] == "Bearer " { - tokenString = tokenString[7:] - } - - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - return []byte(os.Getenv("SECRET_KEY")), nil - }) - if err != nil || !token.Valid { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: Invalid token") - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok || claims["sub"] == nil { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: Invalid token claims") - } - - userID := claims["sub"].(string) - if userID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: Invalid user session") - } - - sessionKey := "session:" + userID - sessionData, err := utils.GetJSONData(sessionKey) - if err != nil || sessionData == nil { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Session expired or invalid") - } - - roleID, roleOK := sessionData["roleID"].(string) - roleName, roleNameOK := sessionData["roleName"].(string) - if !roleOK || !roleNameOK { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: Invalid session data") - } - - c.Locals("userID", userID) - c.Locals("roleID", roleID) - c.Locals("roleName", roleName) - - return c.Next() -} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..6da7a10 --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,579 @@ +package middleware + +import ( + "crypto/subtle" + "fmt" + "os" + "rijig/utils" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +type AuthConfig struct { + RequiredTokenType utils.TokenType + RequiredRoles []string + RequiredStatuses []string + RequiredStep int + RequireComplete bool + SkipAuth bool + AllowPartialToken bool + CustomErrorHandler ErrorHandler +} + +type ErrorHandler func(c *fiber.Ctx, err error) error + +type AuthContext struct { + Claims *utils.JWTClaims + StepInfo *utils.RegistrationStepInfo + IsAdmin bool + CanAccess bool +} + +type AuthError struct { + Code string `json:"error"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` +} + +func (e *AuthError) Error() string { + return e.Message +} + +var ( + ErrMissingToken = &AuthError{ + Code: "MISSING_TOKEN", + Message: "Token akses diperlukan", + } + + ErrInvalidTokenFormat = &AuthError{ + Code: "INVALID_TOKEN_FORMAT", + Message: "Format token tidak valid", + } + + ErrInvalidToken = &AuthError{ + Code: "INVALID_TOKEN", + Message: "Token tidak valid atau telah kadaluarsa", + } + + ErrUserContextNotFound = &AuthError{ + Code: "USER_CONTEXT_NOT_FOUND", + Message: "Silakan login terlebih dahulu", + } + + ErrInsufficientPermissions = &AuthError{ + Code: "INSUFFICIENT_PERMISSIONS", + Message: "Akses ditolak untuk role ini", + } + + ErrRegistrationIncomplete = &AuthError{ + Code: "REGISTRATION_INCOMPLETE", + Message: "Registrasi belum lengkap", + } + + ErrRegistrationNotApproved = &AuthError{ + Code: "REGISTRATION_NOT_APPROVED", + Message: "Registrasi belum disetujui", + } + + ErrInvalidTokenType = &AuthError{ + Code: "INVALID_TOKEN_TYPE", + Message: "Tipe token tidak sesuai", + } + + ErrStepNotAccessible = &AuthError{ + Code: "STEP_NOT_ACCESSIBLE", + Message: "Step registrasi belum dapat diakses", + } + + ErrAwaitingApproval = &AuthError{ + Code: "AWAITING_ADMIN_APPROVAL", + Message: "Menunggu persetujuan admin", + } + + ErrInvalidRegistrationStatus = &AuthError{ + Code: "INVALID_REGISTRATION_STATUS", + Message: "Status registrasi tidak sesuai", + } +) + +func defaultErrorHandler(c *fiber.Ctx, err error) error { + if authErr, ok := err.(*AuthError); ok { + statusCode := getStatusCodeForError(authErr.Code) + return c.Status(statusCode).JSON(authErr) + } + + return c.Status(fiber.StatusInternalServerError).JSON(&AuthError{ + Code: "INTERNAL_ERROR", + Message: "Terjadi kesalahan internal", + }) +} + +func getStatusCodeForError(errorCode string) int { + switch errorCode { + case "MISSING_TOKEN", "INVALID_TOKEN_FORMAT", "INVALID_TOKEN", "USER_CONTEXT_NOT_FOUND": + return fiber.StatusUnauthorized + case "INSUFFICIENT_PERMISSIONS", "REGISTRATION_INCOMPLETE", "REGISTRATION_NOT_APPROVED", + "INVALID_TOKEN_TYPE", "STEP_NOT_ACCESSIBLE", "AWAITING_ADMIN_APPROVAL", + "INVALID_REGISTRATION_STATUS": + return fiber.StatusForbidden + default: + return fiber.StatusInternalServerError + } +} + +func APIKeyMiddleware(c *fiber.Ctx) error { + apiKey := c.Get("x-api-key") + if apiKey == "" { + return utils.Unauthorized(c, "Unauthorized: API key is required") + } + + validAPIKey := os.Getenv("API_KEY") + if apiKey != validAPIKey { + return utils.Unauthorized(c, "Unauthorized: Invalid API key") + } + + return c.Next() +} + +func AuthMiddleware(config ...AuthConfig) fiber.Handler { + cfg := AuthConfig{} + if len(config) > 0 { + cfg = config[0] + } + + if cfg.CustomErrorHandler == nil { + cfg.CustomErrorHandler = defaultErrorHandler + } + + return func(c *fiber.Ctx) error { + + if cfg.SkipAuth { + return c.Next() + } + + claims, err := extractAndValidateToken(c) + if err != nil { + return cfg.CustomErrorHandler(c, err) + } + + authCtx := createAuthContext(claims) + + if err := validateAuthConfig(authCtx, cfg); err != nil { + return cfg.CustomErrorHandler(c, err) + } + + c.Locals("user", claims) + c.Locals("auth_context", authCtx) + + return c.Next() + } +} + +func extractAndValidateToken(c *fiber.Ctx) (*utils.JWTClaims, error) { + authHeader := c.Get("Authorization") + if authHeader == "" { + return nil, ErrMissingToken + } + + token, err := utils.ExtractTokenFromHeader(authHeader) + if err != nil { + return nil, ErrInvalidTokenFormat + } + + claims, err := utils.ValidateAccessToken(token) + if err != nil { + return nil, ErrInvalidToken + } + + return claims, nil +} + +func createAuthContext(claims *utils.JWTClaims) *AuthContext { + stepInfo := utils.GetRegistrationStepInfo( + claims.Role, + claims.RegistrationProgress, + claims.RegistrationStatus, + ) + + return &AuthContext{ + Claims: claims, + StepInfo: stepInfo, + IsAdmin: claims.Role == utils.RoleAdministrator, + CanAccess: stepInfo.IsAccessible, + } +} + +func validateAuthConfig(authCtx *AuthContext, cfg AuthConfig) error { + claims := authCtx.Claims + + if cfg.RequiredTokenType != "" { + if claims.TokenType != cfg.RequiredTokenType { + return &AuthError{ + Code: "INVALID_TOKEN_TYPE", + Message: fmt.Sprintf("Endpoint memerlukan token type: %s", cfg.RequiredTokenType), + Details: fiber.Map{ + "current_token_type": claims.TokenType, + "required_token_type": cfg.RequiredTokenType, + }, + } + } + } + + if len(cfg.RequiredRoles) > 0 { + if !contains(cfg.RequiredRoles, claims.Role) { + return &AuthError{ + Code: "INSUFFICIENT_PERMISSIONS", + Message: "Akses ditolak untuk role ini", + Details: fiber.Map{ + "user_role": claims.Role, + "allowed_roles": cfg.RequiredRoles, + }, + } + } + } + + if len(cfg.RequiredStatuses) > 0 { + if !contains(cfg.RequiredStatuses, claims.RegistrationStatus) { + return &AuthError{ + Code: "INVALID_REGISTRATION_STATUS", + Message: "Status registrasi tidak sesuai", + Details: fiber.Map{ + "current_status": claims.RegistrationStatus, + "allowed_statuses": cfg.RequiredStatuses, + "next_step": authCtx.StepInfo.Description, + }, + } + } + } + + if cfg.RequiredStep > 0 { + if claims.RegistrationProgress < cfg.RequiredStep { + return &AuthError{ + Code: "STEP_NOT_ACCESSIBLE", + Message: "Step registrasi belum dapat diakses", + Details: fiber.Map{ + "current_step": claims.RegistrationProgress, + "required_step": cfg.RequiredStep, + "current_step_info": authCtx.StepInfo.Description, + }, + } + } + + if authCtx.StepInfo.RequiresAdminApproval && !authCtx.CanAccess { + return &AuthError{ + Code: "AWAITING_ADMIN_APPROVAL", + Message: "Menunggu persetujuan admin", + Details: fiber.Map{ + "status": claims.RegistrationStatus, + }, + } + } + } + + if cfg.RequireComplete { + if claims.TokenType != utils.TokenTypeFull { + return &AuthError{ + Code: "REGISTRATION_INCOMPLETE", + Message: "Registrasi belum lengkap", + Details: fiber.Map{ + "registration_status": claims.RegistrationStatus, + "registration_progress": claims.RegistrationProgress, + "next_step": authCtx.StepInfo.Description, + "requires_admin_approval": authCtx.StepInfo.RequiresAdminApproval, + "can_proceed": authCtx.CanAccess, + }, + } + } + + if !utils.IsRegistrationComplete(claims.RegistrationStatus) { + return ErrRegistrationNotApproved + } + } + + return nil +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func RequireAuth() fiber.Handler { + return AuthMiddleware() +} + +func RequireFullToken() fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredTokenType: utils.TokenTypeFull, + RequireComplete: true, + }) +} + +func RequirePartialToken() fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredTokenType: utils.TokenTypePartial, + }) +} + +func RequireRoles(roles ...string) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredRoles: roles, + }) +} + +func RequireAdminRole() fiber.Handler { + return RequireRoles(utils.RoleAdministrator) +} + +func RequireRegistrationStep(step int) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredStep: step, + }) +} + +func RequireRegistrationStatus(statuses ...string) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredStatuses: statuses, + }) +} + +func RequireTokenType(tokenType utils.TokenType) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredTokenType: tokenType, + }) +} + +func RequireCompleteRegistrationForRole(roles ...string) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, err := GetUserFromContext(c) + if err != nil { + return err + } + + if contains(roles, claims.Role) { + return RequireFullToken()(c) + } + + return c.Next() + } +} + +func RequireRoleAndComplete(roles ...string) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredRoles: roles, + RequireComplete: true, + }) +} + +func RequireRoleAndStep(step int, roles ...string) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredRoles: roles, + RequiredStep: step, + }) +} + +func RequireRoleAndStatus(statuses []string, roles ...string) fiber.Handler { + return AuthMiddleware(AuthConfig{ + RequiredRoles: roles, + RequiredStatuses: statuses, + }) +} + +func GetUserFromContext(c *fiber.Ctx) (*utils.JWTClaims, error) { + claims, ok := c.Locals("user").(*utils.JWTClaims) + if !ok { + return nil, ErrUserContextNotFound + } + return claims, nil +} + +func GetAuthContextFromContext(c *fiber.Ctx) (*AuthContext, error) { + authCtx, ok := c.Locals("auth_context").(*AuthContext) + if !ok { + + claims, err := GetUserFromContext(c) + if err != nil { + return nil, err + } + return createAuthContext(claims), nil + } + return authCtx, nil +} + +func MustGetUserFromContext(c *fiber.Ctx) *utils.JWTClaims { + claims, err := GetUserFromContext(c) + if err != nil { + panic("user context not found") + } + return claims +} + +func GetUserID(c *fiber.Ctx) (string, error) { + claims, err := GetUserFromContext(c) + if err != nil { + return "", err + } + return claims.UserID, nil +} + +func GetUserRole(c *fiber.Ctx) (string, error) { + claims, err := GetUserFromContext(c) + if err != nil { + return "", err + } + return claims.Role, nil +} + +func IsAdmin(c *fiber.Ctx) bool { + claims, err := GetUserFromContext(c) + if err != nil { + return false + } + return claims.Role == utils.RoleAdministrator +} + +func IsRegistrationComplete(c *fiber.Ctx) bool { + claims, err := GetUserFromContext(c) + if err != nil { + return false + } + return utils.IsRegistrationComplete(claims.RegistrationStatus) +} + +func HasRole(c *fiber.Ctx, role string) bool { + claims, err := GetUserFromContext(c) + if err != nil { + return false + } + return claims.Role == role +} + +func HasAnyRole(c *fiber.Ctx, roles ...string) bool { + claims, err := GetUserFromContext(c) + if err != nil { + return false + } + return contains(roles, claims.Role) +} + +type RateLimitConfig struct { + MaxRequests int + Window time.Duration + KeyFunc func(*fiber.Ctx) string + SkipFunc func(*fiber.Ctx) bool +} + +func AuthRateLimit(config RateLimitConfig) fiber.Handler { + if config.KeyFunc == nil { + config.KeyFunc = func(c *fiber.Ctx) string { + claims, err := GetUserFromContext(c) + if err != nil { + return c.IP() + } + return fmt.Sprintf("user:%s", claims.UserID) + } + } + + return func(c *fiber.Ctx) error { + if config.SkipFunc != nil && config.SkipFunc(c) { + return c.Next() + } + + key := fmt.Sprintf("rate_limit:%s", config.KeyFunc(c)) + + var count int + err := utils.GetCache(key, &count) + if err != nil { + count = 0 + } + + if count >= config.MaxRequests { + return c.Status(fiber.StatusTooManyRequests).JSON(&AuthError{ + Code: "RATE_LIMIT_EXCEEDED", + Message: "Terlalu banyak permintaan, coba lagi nanti", + Details: fiber.Map{ + "max_requests": config.MaxRequests, + "window": config.Window.String(), + }, + }) + } + + count++ + utils.SetCache(key, count, config.Window) + + return c.Next() + } +} + +func DeviceValidation() fiber.Handler { + return func(c *fiber.Ctx) error { + claims, err := GetUserFromContext(c) + if err != nil { + return err + } + + deviceID := claims.DeviceID + if deviceID == "" { + return c.Status(fiber.StatusBadRequest).JSON(&AuthError{ + Code: "MISSING_DEVICE_ID", + Message: "Device ID diperlukan", + }) + } + + if subtle.ConstantTimeCompare([]byte(claims.DeviceID), []byte(deviceID)) != 1 { + return c.Status(fiber.StatusForbidden).JSON(&AuthError{ + Code: "DEVICE_MISMATCH", + Message: "Device tidak cocok dengan token", + }) + } + + return c.Next() + } +} + +func SessionValidation() fiber.Handler { + return func(c *fiber.Ctx) error { + claims, err := GetUserFromContext(c) + if err != nil { + return err + } + + sessionKey := fmt.Sprintf("session:%s", claims.SessionID) + var sessionData interface{} + err = utils.GetCache(sessionKey, &sessionData) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(&AuthError{ + Code: "SESSION_EXPIRED", + Message: "Sesi telah berakhir, silakan login kembali", + }) + } + + return c.Next() + } +} + +func AuthLogger() fiber.Handler { + return logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} - User: ${locals:user_id} Role: ${locals:user_role} IP: ${ip}\n", + CustomTags: map[string]logger.LogFunc{ + "user_id": func(output logger.Buffer, c *fiber.Ctx, data *logger.Data, extraParam string) (int, error) { + if claims, err := GetUserFromContext(c); err == nil { + return output.WriteString(claims.UserID) + } + return output.WriteString("anonymous") + }, + "user_role": func(output logger.Buffer, c *fiber.Ctx, data *logger.Data, extraParam string) (int, error) { + if claims, err := GetUserFromContext(c); err == nil { + return output.WriteString(claims.Role) + } + return output.WriteString("none") + }, + }, + }) +} diff --git a/middleware/role_middleware.go b/middleware/role_middleware.go deleted file mode 100644 index dcb6687..0000000 --- a/middleware/role_middleware.go +++ /dev/null @@ -1,28 +0,0 @@ -package middleware - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func RoleMiddleware(allowedRoles ...string) fiber.Handler { - return func(c *fiber.Ctx) error { - - if len(allowedRoles) == 0 { - return utils.GenericResponse(c, fiber.StatusForbidden, "Forbidden: No roles specified") - } - - roleID, ok := c.Locals("roleID").(string) - if !ok || roleID == "" { - return utils.GenericResponse(c, fiber.StatusUnauthorized, "Unauthorized: Role not found") - } - - for _, role := range allowedRoles { - if role == roleID { - return c.Next() - } - } - - return utils.GenericResponse(c, fiber.StatusForbidden, "Access Denied: You don't have permission to access this resource") - } -} diff --git a/model/about_model.go b/model/about_model.go new file mode 100644 index 0000000..746d2fa --- /dev/null +++ b/model/about_model.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +type About struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + Title string `gorm:"not null" json:"title"` + CoverImage string `json:"cover_image"` + AboutDetail []AboutDetail `gorm:"foreignKey:AboutID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"about_detail"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updated_at"` +} + +type AboutDetail struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + AboutID string `gorm:"not null" json:"about_id"` + ImageDetail string `json:"image_detail"` + Description string `json:"description"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updated_at"` +} diff --git a/model/address_model.go b/model/address_model.go index 1da69ec..d5674d0 100644 --- a/model/address_model.go +++ b/model/address_model.go @@ -12,7 +12,8 @@ type Address struct { Village string `gorm:"not null" json:"village"` PostalCode string `gorm:"not null" json:"postalCode"` Detail string `gorm:"not null" json:"detail"` - Geography string `gorm:"not null" json:"geography"` + Latitude float64 `gorm:"not null" json:"latitude"` + Longitude float64 `gorm:"not null" json:"longitude"` CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` } diff --git a/model/collector_model.go b/model/collector_model.go new file mode 100644 index 0000000..4000cad --- /dev/null +++ b/model/collector_model.go @@ -0,0 +1,25 @@ +package model + +import "time" + +type Collector struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + UserID string `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` + JobStatus string `gorm:"default:inactive" json:"jobstatus"` + Rating float32 `gorm:"default:5" json:"rating"` + AddressID string `gorm:"not null" json:"address_id"` + Address Address `gorm:"foreignKey:AddressID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"address"` + AvaibleTrashByCollector []AvaibleTrashByCollector `gorm:"foreignKey:CollectorID;constraint:OnDelete:CASCADE;" json:"avaible_trash"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updated_at"` +} + +type AvaibleTrashByCollector struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + CollectorID string `gorm:"not null" json:"collector_id"` + Collector *Collector `gorm:"foreignKey:CollectorID;constraint:OnDelete:CASCADE;" json:"-"` + TrashCategoryID string `gorm:"not null" json:"trash_id"` + TrashCategory TrashCategory `gorm:"foreignKey:TrashCategoryID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"trash_category"` + Price float32 `json:"price"` +} diff --git a/model/company_profile_model.go b/model/company_profile_model.go new file mode 100644 index 0000000..8e39ae7 --- /dev/null +++ b/model/company_profile_model.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +type CompanyProfile struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + UserID string `gorm:"not null" json:"userId"` + User User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"user"` + CompanyName string `gorm:"not null" json:"company_name"` + CompanyAddress string `gorm:"not null" json:"company_address"` + CompanyPhone string `gorm:"not null" json:"company_phone"` + CompanyEmail string `json:"company_email,omitempty"` + CompanyLogo string `json:"company_logo,omitempty"` + CompanyWebsite string `json:"company_website,omitempty"` + TaxID string `json:"tax_id,omitempty"` + FoundedDate string `json:"founded_date,omitempty"` + CompanyType string `json:"company_type,omitempty"` + CompanyDescription string `gorm:"type:text" json:"company_description"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updated_at"` +} diff --git a/model/coveragearea_model.go b/model/coveragearea_model.go new file mode 100644 index 0000000..4cff160 --- /dev/null +++ b/model/coveragearea_model.go @@ -0,0 +1,11 @@ +package model + +import "time" + +type CoverageArea struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + Province string `gorm:"not null" json:"province"` + Regency string `gorm:"not null" json:"regency"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` +} diff --git a/model/identitycard_model.go b/model/identitycard_model.go new file mode 100644 index 0000000..088e28f --- /dev/null +++ b/model/identitycard_model.go @@ -0,0 +1,30 @@ +package model + +import "time" + +type IdentityCard struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + UserID string `gorm:"not null" json:"userId"` + User User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"user"` + Identificationumber string `gorm:"not null" json:"identificationumber"` + Fullname string `gorm:"not null" json:"fullname"` + Placeofbirth string `gorm:"not null" json:"placeofbirth"` + Dateofbirth string `gorm:"not null" json:"dateofbirth"` + Gender string `gorm:"not null" json:"gender"` + BloodType string `gorm:"not null" json:"bloodtype"` + Province string `gorm:"not null" json:"province"` + District string `gorm:"not null" json:"district"` + SubDistrict string `gorm:"not null" json:"subdistrict"` + Hamlet string `gorm:"not null" json:"hamlet"` + Village string `gorm:"not null" json:"village"` + Neighbourhood string `gorm:"not null" json:"neighbourhood"` + PostalCode string `gorm:"not null" json:"postalcode"` + Religion string `gorm:"not null" json:"religion"` + Maritalstatus string `gorm:"not null" json:"maritalstatus"` + Job string `gorm:"not null" json:"job"` + Citizenship string `gorm:"not null" json:"citizenship"` + Validuntil string `gorm:"not null" json:"validuntil"` + Cardphoto string `gorm:"not null" json:"cardphoto"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` +} diff --git a/model/pickup_history_model.go b/model/pickup_history_model.go new file mode 100644 index 0000000..71c2504 --- /dev/null +++ b/model/pickup_history_model.go @@ -0,0 +1,12 @@ +package model + +import "time" + +type PickupStatusHistory struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()" json:"id"` + RequestID string `gorm:"not null" json:"request_id"` + Status string `gorm:"not null" json:"status"` + ChangedAt time.Time `gorm:"not null" json:"changed_at"` + ChangedByID string `gorm:"not null" json:"changed_by_id"` + ChangedByRole string `gorm:"not null" json:"changed_by_role"` +} diff --git a/model/product_model.go b/model/product_model.go new file mode 100644 index 0000000..13fe765 --- /dev/null +++ b/model/product_model.go @@ -0,0 +1,22 @@ +package model + +import "time" + +type Product struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null;column:id" json:"id"` + StoreID string `gorm:"type:uuid;not null;column:store_id" json:"storeId"` + Store Store `gorm:"foreignKey:StoreID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` + ProductName string `gorm:"not null;column:product_name;index" json:"productName"` + ProductImages []ProductImage `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE;" json:"productImages,omitempty"` + Quantity int `gorm:"not null;column:quantity" json:"quantity"` + Saled int `gorm:"default:0;column:saled" json:"saled"` + CreatedAt time.Time `gorm:"default:current_timestamp;column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"default:current_timestamp;column:updated_at" json:"updatedAt"` +} + +type ProductImage struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null;column:id" json:"id"` + ProductID string `gorm:"type:uuid;not null;column:product_id" json:"productId"` + Product Product `gorm:"foreignKey:ProductID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` + ImageURL string `gorm:"not null;column:image_url" json:"imageURL"` +} diff --git a/model/rating_model.go b/model/rating_model.go new file mode 100644 index 0000000..8cceafb --- /dev/null +++ b/model/rating_model.go @@ -0,0 +1,13 @@ +package model + +import "time" + +type PickupRating struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()" json:"id"` + RequestID string `gorm:"not null;unique" json:"request_id"` + UserID string `gorm:"not null" json:"user_id"` + CollectorID string `gorm:"not null" json:"collector_id"` + Rating float32 `gorm:"not null" json:"rating"` + Feedback string `json:"feedback,omitempty"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"created_at"` +} diff --git a/model/requestpickup_model.go b/model/requestpickup_model.go new file mode 100644 index 0000000..9128768 --- /dev/null +++ b/model/requestpickup_model.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" +) + +type RequestPickup struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + UserId string `gorm:"not null" json:"user_id"` + User *User `gorm:"foreignKey:UserId" json:"user"` + AddressId string `gorm:"not null" json:"address_id"` + Address *Address `gorm:"foreignKey:AddressId" json:"address"` + RequestItems []RequestPickupItem `gorm:"foreignKey:RequestPickupId;constraint:OnDelete:CASCADE;" json:"request_items"` + Notes string `json:"notes"` + StatusPickup string `gorm:"default:'waiting_collector'" json:"status_pickup"` + CollectorID *string `gorm:"type:uuid" json:"collector_id,omitempty"` + Collector *Collector `gorm:"foreignKey:CollectorID;references:ID" json:"collector,omitempty"` + ConfirmedByCollectorAt *time.Time `json:"confirmed_by_collector_at,omitempty"` + RequestMethod string `gorm:"not null" json:"request_method"` + FinalPrice float64 `json:"final_price"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +type RequestPickupItem struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + RequestPickupId string `gorm:"not null" json:"request_pickup_id"` + RequestPickup *RequestPickup `gorm:"foreignKey:RequestPickupId" json:"-"` + TrashCategoryId string `gorm:"not null" json:"trash_category_id"` + TrashCategory *TrashCategory `gorm:"foreignKey:TrashCategoryId" json:"trash_category"` + EstimatedAmount float64 `gorm:"not null" json:"estimated_amount"` + EstimatedPricePerKg float64 `gorm:"not null" json:"estimated_price_per_kg"` + EstimatedSubtotalPrice float64 `gorm:"not null" json:"estimated_subtotal_price"` +} diff --git a/model/role_model.go b/model/role_model.go index 5f595e0..14c6ca0 100644 --- a/model/role_model.go +++ b/model/role_model.go @@ -3,9 +3,8 @@ package model import "time" type Role struct { - ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` - RoleName string `gorm:"unique;not null" json:"roleName"` - Users []User `gorm:"foreignKey:RoleID" json:"users"` + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` + RoleName string `gorm:"unique;not null" json:"roleName"` CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` } diff --git a/model/store_model.go b/model/store_model.go new file mode 100644 index 0000000..b4489eb --- /dev/null +++ b/model/store_model.go @@ -0,0 +1,20 @@ +package model + +import "time" + +type Store struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null;column:id" json:"id"` + UserID string `gorm:"type:uuid;not null;column:user_id" json:"userId"` + User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` + StoreName string `gorm:"not null;column:store_name;index" json:"storeName"` + StoreLogo string `gorm:"not null;column:store_logo" json:"storeLogo"` + StoreBanner string `gorm:"not null;column:store_banner" json:"storeBanner"` + StoreInfo string `gorm:"not null;column:store_info" json:"storeInfo"` + StoreAddressID string `gorm:"type:uuid;not null;column:store_address_id" json:"storeAddressId"` + StoreAddress Address `gorm:"foreignKey:StoreAddressID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` + Followers int `gorm:"default:0;column:followers" json:"followers,omitempty"` + Products []Product `gorm:"foreignKey:StoreID;constraint:OnDelete:CASCADE;" json:"products,omitempty"` + TotalProduct int `gorm:"default:0;column:total_product" json:"totalProduct,omitempty"` + CreatedAt time.Time `gorm:"default:current_timestamp;column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"default:current_timestamp;column:updated_at" json:"updatedAt"` +} diff --git a/model/trash_model.go b/model/trash_model.go index 5bd05fb..63884a6 100644 --- a/model/trash_model.go +++ b/model/trash_model.go @@ -3,18 +3,22 @@ package model import "time" type TrashCategory struct { - ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()" json:"id"` - Name string `gorm:"not null" json:"name"` - Details []TrashDetail `gorm:"foreignKey:CategoryID;constraint:OnDelete:CASCADE;" json:"details"` - 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()" json:"id"` + Name string `gorm:"not null" json:"trash_name"` + IconTrash string `json:"trash_icon,omitempty"` + EstimatedPrice float64 `gorm:"not null" json:"estimated_price"` + Variety string `gorm:"not null" json:"variety"` + Details []TrashDetail `gorm:"foreignKey:TrashCategoryID;constraint:OnDelete:CASCADE;" json:"trash_detail"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` } type TrashDetail struct { - ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()" json:"id"` - CategoryID string `gorm:"type:uuid;not null" json:"category_id"` - Description string `gorm:"not null" json:"description"` - Price float64 `gorm:"not null" json:"price"` - 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()" json:"trashdetail_id"` + TrashCategoryID string `gorm:"type:uuid;not null" json:"category_id"` + IconTrashDetail string `json:"trashdetail_icon,omitempty"` + Description string `gorm:"not null" json:"description"` + StepOrder int `gorm:"not null" json:"step_order"` + CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` + UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` } diff --git a/model/trashcart_model.go b/model/trashcart_model.go new file mode 100644 index 0000000..0b4d05f --- /dev/null +++ b/model/trashcart_model.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" +) + +type Cart struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()" json:"id"` + UserID string `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE;" json:"-"` + CartItems []CartItem `gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE;" json:"cart_items"` + TotalAmount float64 `json:"total_amount"` + EstimatedTotalPrice float64 `json:"estimated_total_price"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +type CartItem struct { + ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()" json:"id"` + CartID string `gorm:"not null" json:"-"` + Cart *Cart `gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE;" json:"-"` + TrashCategoryID string `gorm:"not null" json:"trash_id"` + TrashCategory TrashCategory `gorm:"foreignKey:TrashCategoryID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"trash_category"` + Amount float64 `json:"amount"` + SubTotalEstimatedPrice float64 `json:"subtotal_estimated_price"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} diff --git a/model/user_model.go b/model/user_model.go index 8f26826..323c6ac 100644 --- a/model/user_model.go +++ b/model/user_model.go @@ -3,16 +3,21 @@ 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"` - Username string `gorm:"not null" json:"username"` - Name string `gorm:"not null" json:"name"` - Phone string `gorm:"not null" json:"phone"` - Email string `gorm:"not null" json:"email"` - EmailVerified bool `gorm:"default:false" json:"emailVerified"` - Password string `gorm:"not null" json:"password"` - RoleID string `gorm:"not null" json:"roleId"` - Role *Role `gorm:"foreignKey:RoleID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"role"` - 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"` + EmailVerified bool `gorm:"default:false" json:"emailVerified"` + 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"` } diff --git a/model/userpin_model.go b/model/userpin_model.go index 0ad17be..8d4f876 100644 --- a/model/userpin_model.go +++ b/model/userpin_model.go @@ -5,6 +5,7 @@ import "time" type UserPin struct { ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"` UserID string `gorm:"not null" json:"userId"` + User User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"` Pin string `gorm:"not null" json:"pin"` CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"` UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"` diff --git a/presentation/address_route.go b/presentation/address_route.go deleted file mode 100644 index 84e549d..0000000 --- a/presentation/address_route.go +++ /dev/null @@ -1,25 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" -) - -func AddressRouter(api fiber.Router) { - addressRepo := repositories.NewAddressRepository(config.DB) - wilayahRepo := repositories.NewWilayahIndonesiaRepository(config.DB) - addressService := services.NewAddressService(addressRepo, wilayahRepo) - addressHandler := handler.NewAddressHandler(addressService) - - adddressAPI := api.Group("/user/address") - - adddressAPI.Post("/create-address", middleware.AuthMiddleware, addressHandler.CreateAddress) - adddressAPI.Get("/get-address", middleware.AuthMiddleware, addressHandler.GetAddressByUserID) - adddressAPI.Get("/get-address/:address_id", middleware.AuthMiddleware, addressHandler.GetAddressByID) - adddressAPI.Put("/update-address/:address_id", middleware.AuthMiddleware, addressHandler.UpdateAddress) - adddressAPI.Delete("/delete-address/:address_id", middleware.AuthMiddleware, addressHandler.DeleteAddress) -} diff --git a/presentation/article_route.go b/presentation/article_route.go deleted file mode 100644 index 3d51dd0..0000000 --- a/presentation/article_route.go +++ /dev/null @@ -1,25 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func ArticleRouter(api fiber.Router) { - articleRepo := repositories.NewArticleRepository(config.DB) - articleService := services.NewArticleService(articleRepo) - articleHandler := handler.NewArticleHandler(articleService) - - articleAPI := api.Group("/article-rijik") - - articleAPI.Post("/create-article", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), articleHandler.CreateArticle) - articleAPI.Get("/view-article", articleHandler.GetAllArticles) - articleAPI.Get("/view-article/:article_id", articleHandler.GetArticleByID) - articleAPI.Put("/update-article/:article_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), articleHandler.UpdateArticle) - articleAPI.Delete("/delete-article/:article_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), articleHandler.DeleteArticle) -} diff --git a/presentation/auth_route.go b/presentation/auth_route.go deleted file mode 100644 index 138992e..0000000 --- a/presentation/auth_route.go +++ /dev/null @@ -1,31 +0,0 @@ -package presentation - -import ( - "log" - "os" - - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" -) - -func AuthRouter(api fiber.Router) { - secretKey := os.Getenv("SECRET_KEY") - if secretKey == "" { - log.Fatal("SECRET_KEY is not set in the environment variables") - os.Exit(1) - } - - userRepo := repositories.NewUserRepository(config.DB) - roleRepo := repositories.NewRoleRepository(config.DB) - userService := services.NewUserService(userRepo, roleRepo, secretKey) - userHandler := handler.NewUserHandler(userService) - - api.Post("/login", userHandler.Login) - api.Post("/register", userHandler.Register) - api.Post("/logout", middleware.AuthMiddleware, userHandler.Logout) - -} diff --git a/presentation/banner_route.go b/presentation/banner_route.go deleted file mode 100644 index 7139c4f..0000000 --- a/presentation/banner_route.go +++ /dev/null @@ -1,25 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func BannerRouter(api fiber.Router) { - bannerRepo := repositories.NewBannerRepository(config.DB) - bannerService := services.NewBannerService(bannerRepo) - BannerHandler := handler.NewBannerHandler(bannerService) - - bannerAPI := api.Group("/banner-rijik") - - bannerAPI.Post("/create-banner", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), BannerHandler.CreateBanner) - bannerAPI.Get("/getall-banner", BannerHandler.GetAllBanners) - bannerAPI.Get("/get-banner/:banner_id", BannerHandler.GetBannerByID) - bannerAPI.Put("/update-banner/:banner_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), BannerHandler.UpdateBanner) - bannerAPI.Delete("/delete-banner/:banner_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), BannerHandler.DeleteBanner) -} diff --git a/presentation/initialcoint_route.go b/presentation/initialcoint_route.go deleted file mode 100644 index 9ef0dd5..0000000 --- a/presentation/initialcoint_route.go +++ /dev/null @@ -1,27 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func InitialCointRoute(api fiber.Router) { - - initialCointRepo := repositories.NewInitialCointRepository(config.DB) - initialCointService := services.NewInitialCointService(initialCointRepo) - initialCointHandler := handler.NewInitialCointHandler(initialCointService) - - initialCoint := api.Group("/initialcoint") - initialCoint.Use(middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator)) - - initialCoint.Post("/create", initialCointHandler.CreateInitialCoint) - initialCoint.Get("/getall", initialCointHandler.GetAllInitialCoints) - initialCoint.Get("/get/:coin_id", initialCointHandler.GetInitialCointByID) - initialCoint.Put("/update/:coin_id", initialCointHandler.UpdateInitialCoint) - initialCoint.Delete("/delete/:coin_id", initialCointHandler.DeleteInitialCoint) -} diff --git a/presentation/role_route.go b/presentation/role_route.go deleted file mode 100644 index d4652a1..0000000 --- a/presentation/role_route.go +++ /dev/null @@ -1,20 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func RoleRouter(api fiber.Router) { - roleRepo := repositories.NewRoleRepository(config.DB) - roleService := services.NewRoleService(roleRepo) - roleHandler := handler.NewRoleHandler(roleService) - - api.Get("/roles", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), roleHandler.GetRoles) - api.Get("/role/:role_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), roleHandler.GetRoleByID) -} diff --git a/presentation/trash_route.go b/presentation/trash_route.go deleted file mode 100644 index 5e5eb59..0000000 --- a/presentation/trash_route.go +++ /dev/null @@ -1,31 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func TrashRouter(api fiber.Router) { - trashRepo := repositories.NewTrashRepository(config.DB) - trashService := services.NewTrashService(trashRepo) - trashHandler := handler.NewTrashHandler(trashService) - - trashAPI := api.Group("/trash") - - trashAPI.Post("/category", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), trashHandler.CreateCategory) - trashAPI.Post("/category/detail", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), trashHandler.AddDetailToCategory) - trashAPI.Get("/categories", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator, utils.RolePengelola, utils.RolePengepul), trashHandler.GetCategories) - trashAPI.Get("/category/:category_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator, utils.RolePengelola, utils.RolePengepul), trashHandler.GetCategoryByID) - trashAPI.Get("/detail/:detail_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator, utils.RolePengelola, utils.RolePengepul), trashHandler.GetTrashDetailByID) - - trashAPI.Patch("/category/:category_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), trashHandler.UpdateCategory) - trashAPI.Put("/detail/:detail_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), trashHandler.UpdateDetail) - - trashAPI.Delete("/category/:category_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), trashHandler.DeleteCategory) - trashAPI.Delete("/detail/:detail_id", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), trashHandler.DeleteDetail) -} diff --git a/presentation/user_route.go b/presentation/user_route.go deleted file mode 100644 index d847432..0000000 --- a/presentation/user_route.go +++ /dev/null @@ -1,21 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" -) - -func UserProfileRouter(api fiber.Router) { - userProfileRepo := repositories.NewUserProfileRepository(config.DB) - userProfileService := services.NewUserProfileService(userProfileRepo) - userProfileHandler := handler.NewUserProfileHandler(userProfileService) - - api.Get("/user", middleware.AuthMiddleware, userProfileHandler.GetUserProfile) - api.Put("/user/update-user", middleware.AuthMiddleware, userProfileHandler.UpdateUserProfile) - api.Patch("/user/update-user-password", middleware.AuthMiddleware, userProfileHandler.UpdateUserPassword) - api.Patch("/user/upload-photoprofile", middleware.AuthMiddleware, userProfileHandler.UpdateUserAvatar) -} diff --git a/presentation/userpin_route.go b/presentation/userpin_route.go deleted file mode 100644 index 3f1a8ee..0000000 --- a/presentation/userpin_route.go +++ /dev/null @@ -1,24 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" -) - -func UserPinRouter(api fiber.Router) { - - userPinRepo := repositories.NewUserPinRepository(config.DB) - - userPinService := services.NewUserPinService(userPinRepo) - - userPinHandler := handler.NewUserPinHandler(userPinService) - - api.Post("/user/set-pin", middleware.AuthMiddleware, userPinHandler.CreateUserPin) - api.Post("/user/verif-pin", middleware.AuthMiddleware, userPinHandler.VerifyUserPin) - api.Get("/user/cek-pin-status", middleware.AuthMiddleware, userPinHandler.CheckPinStatus) - api.Patch("/user/update-pin", middleware.AuthMiddleware, userPinHandler.UpdateUserPin) -} diff --git a/presentation/wilayahindonesia_route.go b/presentation/wilayahindonesia_route.go deleted file mode 100644 index 12aa1d4..0000000 --- a/presentation/wilayahindonesia_route.go +++ /dev/null @@ -1,35 +0,0 @@ -package presentation - -import ( - "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/config" - "github.com/pahmiudahgede/senggoldong/internal/handler" - "github.com/pahmiudahgede/senggoldong/internal/repositories" - "github.com/pahmiudahgede/senggoldong/internal/services" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/utils" -) - -func WilayahRouter(api fiber.Router) { - - wilayahRepo := repositories.NewWilayahIndonesiaRepository(config.DB) - wilayahService := services.NewWilayahIndonesiaService(wilayahRepo) - wilayahHandler := handler.NewWilayahImportHandler(wilayahService) - - api.Post("/import/data-wilayah-indonesia", middleware.AuthMiddleware, middleware.RoleMiddleware(utils.RoleAdministrator), wilayahHandler.ImportWilayahData) - - wilayahAPI := api.Group("/wilayah-indonesia") - - wilayahAPI.Get("/provinces", middleware.AuthMiddleware, wilayahHandler.GetProvinces) - wilayahAPI.Get("/provinces/:provinceid", middleware.AuthMiddleware, wilayahHandler.GetProvinceByID) - - wilayahAPI.Get("/regencies", middleware.AuthMiddleware, wilayahHandler.GetAllRegencies) - wilayahAPI.Get("/regencies/:regencyid", middleware.AuthMiddleware, wilayahHandler.GetRegencyByID) - - wilayahAPI.Get("/districts", middleware.AuthMiddleware, wilayahHandler.GetAllDistricts) - wilayahAPI.Get("/districts/:districtid", middleware.AuthMiddleware, wilayahHandler.GetDistrictByID) - - wilayahAPI.Get("/villages", middleware.AuthMiddleware, wilayahHandler.GetAllVillages) - wilayahAPI.Get("/villages/:villageid", middleware.AuthMiddleware, wilayahHandler.GetVillageByID) - -} diff --git a/public/document/districts.csv b/public/document/districts.csv old mode 100644 new mode 100755 diff --git a/public/document/provinces.csv b/public/document/provinces.csv old mode 100644 new mode 100755 diff --git a/public/document/regencies.csv b/public/document/regencies.csv old mode 100644 new mode 100755 diff --git a/public/document/villages.csv b/public/document/villages.csv old mode 100644 new mode 100755 diff --git a/router/setup_routes.go.go b/router/setup_routes.go.go index 374dbac..ea43573 100644 --- a/router/setup_routes.go.go +++ b/router/setup_routes.go.go @@ -1,23 +1,71 @@ package router import ( + "os" + + "rijig/internal/about" + "rijig/internal/article" + "rijig/internal/authentication" + "rijig/internal/company" + "rijig/internal/identitycart" + "rijig/internal/role" + "rijig/internal/trash" + "rijig/internal/userpin" + "rijig/internal/userprofile" + "rijig/internal/whatsapp" + "rijig/internal/wilayahindo" + "rijig/middleware" + + // "rijig/presentation" + "github.com/gofiber/fiber/v2" - "github.com/pahmiudahgede/senggoldong/middleware" - "github.com/pahmiudahgede/senggoldong/presentation" ) func SetupRoutes(app *fiber.App) { - api := app.Group("/apirijikid/v2") + apa := app.Group(os.Getenv("BASE_URL")) + apa.Static("/uploads", "./public"+os.Getenv("BASE_URL")+"/uploads") + // a := app.Group(os.Getenv("BASE_URL")) + // whatsapp.WhatsAppRouter(a) + + api := app.Group(os.Getenv("BASE_URL")) api.Use(middleware.APIKeyMiddleware) - presentation.AuthRouter(api) - presentation.UserProfileRouter(api) - presentation.UserPinRouter(api) - presentation.RoleRouter(api) - presentation.WilayahRouter(api) - presentation.AddressRouter(api) - presentation.ArticleRouter(api) - presentation.BannerRouter(api) - presentation.InitialCointRoute(api) - presentation.TrashRouter(api) + authentication.AuthenticationRouter(api) + identitycart.UserIdentityCardRoute(api) + company.CompanyRouter(api) + userpin.UsersPinRoute(api) + role.UserRoleRouter(api) + + article.ArticleRouter(api) + userprofile.UserProfileRouter(api) + wilayahindo.WilayahRouter(api) + trash.TrashRouter(api) + about.AboutRouter(api) + whatsapp.WhatsAppRouter(api) + + // || auth router || // + // presentation.AuthRouter(api) + // presentationn.AuthAdminRouter(api) + // presentationn.AuthPengelolaRouter(api) + // presentationn.AuthPengepulRouter(api) + // presentationn.AuthMasyarakatRouter(api) + // || auth router || // + // presentation.IdentityCardRouter(api) + // presentation.CompanyProfileRouter(api) + // presentation.RequestPickupRouter(api) + // presentation.PickupMatchingRouter(api) + // presentation.PickupRatingRouter(api) + + // presentation.CollectorRouter(api) + // presentation.TrashCartRouter(api) + + // presentation.UserProfileRouter(api) + // presentation.UserPinRouter(api) + // // presentation.RoleRouter(api) + // presentation.WilayahRouter(api) + // presentation.AddressRouter(api) + // // presentation.ArticleRouter(api) + // // presentation.AboutRouter(api) + // presentation.TrashRouter(api) + // presentation.CoverageAreaRouter(api) } diff --git a/utils/api_response.go b/utils/api_response.go new file mode 100644 index 0000000..ea94afd --- /dev/null +++ b/utils/api_response.go @@ -0,0 +1,102 @@ +package utils + +import ( + "github.com/gofiber/fiber/v2" +) + +type Meta struct { + Status int `json:"status"` + Message string `json:"message"` + Page *int `json:"page,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +type Response struct { + Meta Meta `json:"meta"` + Data interface{} `json:"data,omitempty"` +} + +func ResponseMeta(c *fiber.Ctx, status int, message string) error { + response := Response{ + Meta: Meta{ + Status: status, + Message: message, + }, + } + return c.Status(status).JSON(response) +} + +func ResponseData(c *fiber.Ctx, status int, message string, data interface{}) error { + response := Response{ + Meta: Meta{ + Status: status, + Message: message, + }, + Data: data, + } + return c.Status(status).JSON(response) +} + +func ResponsePagination(c *fiber.Ctx, status int, message string, data interface{}, page, limit int) error { + response := Response{ + Meta: Meta{ + Status: status, + Message: message, + Page: &page, + Limit: &limit, + }, + Data: data, + } + return c.Status(status).JSON(response) +} + +func ResponseErrorData(c *fiber.Ctx, status int, message string, errors interface{}) error { + type ResponseWithErrors struct { + Meta Meta `json:"meta"` + Errors interface{} `json:"errors"` + } + response := ResponseWithErrors{ + Meta: Meta{ + Status: status, + Message: message, + }, + Errors: errors, + } + return c.Status(status).JSON(response) +} + +func Success(c *fiber.Ctx, message string) error { + return ResponseMeta(c, fiber.StatusOK, message) +} + +func SuccessWithData(c *fiber.Ctx, message string, data interface{}) error { + return ResponseData(c, fiber.StatusOK, message, data) +} + +func CreateSuccessWithData(c *fiber.Ctx, message string, data interface{}) error { + return ResponseData(c, fiber.StatusCreated, message, data) +} + +func SuccessWithPagination(c *fiber.Ctx, message string, data interface{}, page, limit int) error { + return ResponsePagination(c, fiber.StatusOK, message, data, page, limit) +} + +func BadRequest(c *fiber.Ctx, message string) error { + return ResponseMeta(c, fiber.StatusBadRequest, message) +} + +func NotFound(c *fiber.Ctx, message string) error { + return ResponseMeta(c, fiber.StatusNotFound, message) +} + +func InternalServerError(c *fiber.Ctx, message string) error { + return ResponseMeta(c, fiber.StatusInternalServerError, message) +} + +func Unauthorized(c *fiber.Ctx, message string) error { + return ResponseMeta(c, fiber.StatusUnauthorized, message) +} + +func Forbidden(c *fiber.Ctx, message string) error { + return ResponseMeta(c, fiber.StatusForbidden, message) +} diff --git a/utils/email_utils.go b/utils/email_utils.go new file mode 100644 index 0000000..53f0926 --- /dev/null +++ b/utils/email_utils.go @@ -0,0 +1,179 @@ +package utils + +import ( + "fmt" + "os" + "strconv" + "time" + + "gopkg.in/gomail.v2" +) + +type EmailService struct { + host string + port int + username string + password string + from string + fromName string +} + +type OTPData struct { + Code string `json:"code"` + Email string `json:"email"` + ExpiresAt int64 `json:"expires_at"` + Attempts int `json:"attempts"` + CreatedAt int64 `json:"created_at"` +} + +const ( + OTP_LENGTH = 6 + OTP_EXPIRY = 5 * time.Minute + MAX_OTP_ATTEMPTS = 3 +) + +func NewEmailService() *EmailService { + port, _ := strconv.Atoi("587") + + return &EmailService{ + host: "smtp.gmail.com", + port: port, + username: os.Getenv("SMTP_FROM_EMAIL"), + password: os.Getenv("GMAIL_APP_PASSWORD"), + from: os.Getenv("SMTP_FROM_EMAIL"), + fromName: os.Getenv("SMTP_FROM_NAME"), + } +} + +func StoreOTP(email, otp string) error { + key := fmt.Sprintf("otp:admin:%s", email) + + data := OTPData{ + Code: otp, + Email: email, + ExpiresAt: time.Now().Add(OTP_EXPIRY).Unix(), + Attempts: 0, + CreatedAt: time.Now().Unix(), + } + + return SetCache(key, data, OTP_EXPIRY) +} + +func ValidateOTP(email, inputOTP string) error { + key := fmt.Sprintf("otp:admin:%s", email) + + var data OTPData + err := GetCache(key, &data) + if err != nil { + return fmt.Errorf("OTP tidak ditemukan atau sudah kadaluarsa") + } + + if time.Now().Unix() > data.ExpiresAt { + DeleteCache(key) + return fmt.Errorf("OTP sudah kadaluarsa") + } + + if data.Attempts >= MAX_OTP_ATTEMPTS { + DeleteCache(key) + return fmt.Errorf("OTP diblokir karena terlalu banyak percobaan salah") + } + + if data.Code != inputOTP { + + data.Attempts++ + SetCache(key, data, time.Until(time.Unix(data.ExpiresAt, 0))) + return fmt.Errorf("OTP tidak valid. Sisa percobaan: %d", MAX_OTP_ATTEMPTS-data.Attempts) + } + + DeleteCache(key) + return nil +} + +func (e *EmailService) SendOTPEmail(email, name, otp string) error { + m := gomail.NewMessage() + m.SetHeader("From", m.FormatAddress(e.from, e.fromName)) + m.SetHeader("To", email) + m.SetHeader("Subject", "Kode Verifikasi Login Administrator - Rijig") + + body := fmt.Sprintf(` + + + + + + + +
+
+

πŸ” Kode Verifikasi Login

+
+
+

Halo %s,

+

Anda telah meminta untuk login sebagai Administrator. Gunakan kode verifikasi berikut:

+ +
%s
+ +

Penting:

+ + +

⚠️ Jika Anda tidak melakukan permintaan login ini, abaikan email ini.

+
+ +
+ + + `, name, otp) + + m.SetBody("text/html", body) + + d := gomail.NewDialer(e.host, e.port, e.username, e.password) + + if err := d.DialAndSend(m); err != nil { + return fmt.Errorf("failed to send email: %v", err) + } + + return nil +} + +func IsOTPValid(email string) bool { + key := fmt.Sprintf("otp:admin:%s", email) + + var data OTPData + err := GetCache(key, &data) + if err != nil { + return false + } + + return time.Now().Unix() <= data.ExpiresAt && data.Attempts < MAX_OTP_ATTEMPTS +} + +func GetOTPRemainingTime(email string) (time.Duration, error) { + key := fmt.Sprintf("otp:admin:%s", email) + + var data OTPData + err := GetCache(key, &data) + if err != nil { + return 0, err + } + + remaining := time.Until(time.Unix(data.ExpiresAt, 0)) + if remaining < 0 { + return 0, fmt.Errorf("OTP expired") + } + + return remaining, nil +} diff --git a/utils/email_verification.go b/utils/email_verification.go new file mode 100644 index 0000000..7f6dc02 --- /dev/null +++ b/utils/email_verification.go @@ -0,0 +1,208 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "gopkg.in/gomail.v2" +) + +type EmailVerificationData struct { + Token string `json:"token"` + Email string `json:"email"` + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` + Used bool `json:"used"` + CreatedAt int64 `json:"created_at"` +} + +const ( + EMAIL_VERIFICATION_TOKEN_EXPIRY = 24 * time.Hour + EMAIL_VERIFICATION_TOKEN_LENGTH = 32 +) + +func GenerateEmailVerificationToken() (string, error) { + bytes := make([]byte, EMAIL_VERIFICATION_TOKEN_LENGTH) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate email verification token: %v", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func StoreEmailVerificationToken(email, userID, token string) error { + key := fmt.Sprintf("email_verification:%s", email) + + DeleteCache(key) + + data := EmailVerificationData{ + Token: token, + Email: email, + UserID: userID, + ExpiresAt: time.Now().Add(EMAIL_VERIFICATION_TOKEN_EXPIRY).Unix(), + Used: false, + CreatedAt: time.Now().Unix(), + } + + return SetCache(key, data, EMAIL_VERIFICATION_TOKEN_EXPIRY) +} + +func ValidateEmailVerificationToken(email, inputToken string) (*EmailVerificationData, error) { + key := fmt.Sprintf("email_verification:%s", email) + + var data EmailVerificationData + err := GetCache(key, &data) + if err != nil { + return nil, fmt.Errorf("token verifikasi tidak ditemukan atau sudah kadaluarsa") + } + + if time.Now().Unix() > data.ExpiresAt { + DeleteCache(key) + return nil, fmt.Errorf("token verifikasi sudah kadaluarsa") + } + + if data.Used { + return nil, fmt.Errorf("token verifikasi sudah digunakan") + } + + // Validate token + if !ConstantTimeCompare(data.Token, inputToken) { + return nil, fmt.Errorf("token verifikasi tidak valid") + } + + return &data, nil +} + +// Mark email verification token as used +func MarkEmailVerificationTokenAsUsed(email string) error { + key := fmt.Sprintf("email_verification:%s", email) + + var data EmailVerificationData + err := GetCache(key, &data) + if err != nil { + return err + } + + data.Used = true + remaining := time.Until(time.Unix(data.ExpiresAt, 0)) + + return SetCache(key, data, remaining) +} + +// Check if email verification token exists and still valid +func IsEmailVerificationTokenValid(email string) bool { + key := fmt.Sprintf("email_verification:%s", email) + + var data EmailVerificationData + err := GetCache(key, &data) + if err != nil { + return false + } + + return time.Now().Unix() <= data.ExpiresAt && !data.Used +} + +// Get remaining email verification token time +func GetEmailVerificationTokenRemainingTime(email string) (time.Duration, error) { + key := fmt.Sprintf("email_verification:%s", email) + + var data EmailVerificationData + err := GetCache(key, &data) + if err != nil { + return 0, err + } + + remaining := time.Until(time.Unix(data.ExpiresAt, 0)) + if remaining < 0 { + return 0, fmt.Errorf("token expired") + } + + return remaining, nil +} + +// Send email verification email +func (e *EmailService) SendEmailVerificationEmail(email, name, token string) error { + // Create verification URL - in real app this would be frontend URL + verificationURL := fmt.Sprintf("http://localhost:3000/verify-email?token=%s&email=%s", token, email) + + m := gomail.NewMessage() + m.SetHeader("From", m.FormatAddress(e.from, e.fromName)) + m.SetHeader("To", email) + m.SetHeader("Subject", "Verifikasi Email Administrator - Rijig") + + // Email template + body := fmt.Sprintf(` + + + + + + + +
+
+

βœ… Verifikasi Email

+
+
+
πŸŽ‰
+ +

Selamat %s!

+

Akun Administrator Anda telah berhasil dibuat. Untuk mengaktifkan akun dan mulai menggunakan sistem Rijig, silakan verifikasi email Anda dengan mengklik tombol di bawah ini:

+ +
+ Verifikasi Email Saya +
+ +

Atau copy paste link berikut ke browser Anda:

+
%s
+ +
+

Informasi Penting:

+
    +
  • Link verifikasi berlaku selama 24 jam
  • +
  • Setelah verifikasi, Anda dapat login ke sistem
  • +
  • Link hanya dapat digunakan sekali
  • +
  • Jangan bagikan link ini kepada siapapun
  • +
+
+ +

Langkah selanjutnya setelah verifikasi:

+
    +
  1. Login menggunakan email dan password
  2. +
  3. Masukkan kode OTP yang dikirim ke email
  4. +
  5. Mulai menggunakan sistem Rijig
  6. +
+ +

Jika Anda tidak membuat akun ini, abaikan email ini.

+
+ +
+ + + `, name, verificationURL, verificationURL) + + m.SetBody("text/html", body) + + d := gomail.NewDialer(e.host, e.port, e.username, e.password) + + if err := d.DialAndSend(m); err != nil { + return fmt.Errorf("failed to send email verification email: %v", err) + } + + return nil +} diff --git a/utils/havershine.go b/utils/havershine.go new file mode 100644 index 0000000..9de461f --- /dev/null +++ b/utils/havershine.go @@ -0,0 +1,39 @@ +package utils + +import ( + "math" +) + +const ( + earthRadiusMi = 3958 + earthRaidusKm = 6371 +) + +type Coord struct { + Lat float64 + Lon float64 +} + +func degreesToRadians(d float64) float64 { + return d * math.Pi / 180 +} + +func Distance(p, q Coord) (mi, km float64) { + lat1 := degreesToRadians(p.Lat) + lon1 := degreesToRadians(p.Lon) + lat2 := degreesToRadians(q.Lat) + lon2 := degreesToRadians(q.Lon) + + diffLat := lat2 - lat1 + diffLon := lon2 - lon1 + + a := math.Pow(math.Sin(diffLat/2), 2) + math.Cos(lat1)*math.Cos(lat2)* + math.Pow(math.Sin(diffLon/2), 2) + + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + mi = c * earthRadiusMi + km = c * earthRaidusKm + + return mi, km +} diff --git a/utils/identity_number_validator.go b/utils/identity_number_validator.go new file mode 100644 index 0000000..b5ecf53 --- /dev/null +++ b/utils/identity_number_validator.go @@ -0,0 +1,95 @@ +package utils + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +var Wilayah = `{"provinsi":{"11":"ACEH","12":"SUMATERA UTARA","13":"SUMATERA BARAT","14":"RIAU","15":"JAMBI","16":"SUMATERA SELATAN","17":"BENGKULU","18":"LAMPUNG","19":"KEPULAUAN BANGKA BELITUNG","21":"KEPULAUAN RIAU","31":"DKI JAKARTA","32":"JAWA BARAT","33":"JAWA TENGAH","34":"DAERAH ISTIMEWA YOGYAKARTA","35":"JAWA TIMUR","36":"BANTEN","51":"BALI","52":"NUSA TENGGARA BARAT","53":"NUSA TENGGARA TIMUR","61":"KALIMANTAN BARAT","62":"KALIMANTAN TENGAH","63":"KALIMANTAN SELATAN","64":"KALIMANTAN TIMUR","65":"KALIMANTAN UTARA","71":"SULAWESI UTARA","72":"SULAWESI TENGAH","73":"SULAWESI SELATAN","74":"SULAWESI TENGGARA","75":"GORONTALO","76":"SULAWESI BARAT","81":"MALUKU","82":"MALUKU UTARA","91":"P A P U A","92":"PAPUA BARAT"},"kabkot":{"1101":"KAB. ACEH SELATAN","1102":"KAB. ACEH TENGGARA","1103":"KAB. ACEH TIMUR","1104":"KAB. ACEH TENGAH","1105":"KAB. ACEH BARAT","1106":"KAB. ACEH BESAR","1107":"KAB. PIDIE","1108":"KAB. ACEH UTARA","1109":"KAB. SIMEULUE","1110":"KAB. ACEH SINGKIL","1111":"KAB. BIREUEN","1112":"KAB. ACEH BARAT DAYA","1113":"KAB. GAYO LUES","1114":"KAB. ACEH JAYA","1115":"KAB. NAGAN RAYA","1116":"KAB. ACEH TAMIANG","1117":"KAB. BENER MERIAH","1118":"KAB. PIDIE JAYA","1171":"KOTA BANDA ACEH","1172":"KOTA SABANG","1173":"KOTA LHOKSEUMAWE","1174":"KOTA LANGSA","1175":"KOTA SUBULUSSALAM","1201":"KAB. TAPANULI TENGAH","1202":"KAB. TAPANULI UTARA","1203":"KAB. TAPANULI SELATAN","1204":"KAB. NIAS","1205":"KAB. LANGKAT","1206":"KAB. KARO","1207":"KAB. DELI SERDANG","1208":"KAB. SIMALUNGUN","1209":"KAB. ASAHAN","1210":"KAB. LABUHANBATU","1211":"KAB. DAIRI","1212":"KAB. TOBA SAMOSIR","1213":"KAB. MANDAILING NATAL","1214":"KAB. NIAS SELATAN","1215":"KAB. PAKPAK BHARAT","1216":"KAB. HUMBANG HASUNDUTAN","1217":"KAB. SAMOSIR","1218":"KAB. SERDANG BEDAGAI","1219":"KAB. BATU BARA","1220":"KAB. PADANG LAWAS UTARA","1221":"KAB. PADANG LAWAS","1222":"KAB. LABUHANBATU SELATAN","1223":"KAB. LABUHANBATU UTARA","1224":"KAB. NIAS UTARA","1225":"KAB. NIAS BARAT","1271":"KOTA MEDAN","1272":"KOTA PEMATANG SIANTAR","1273":"KOTA SIBOLGA","1274":"KOTA TANJUNG BALAI","1275":"KOTA BINJAI","1276":"KOTA TEBING TINGGI","1277":"KOTA PADANGSIDIMPUAN","1278":"KOTA GUNUNGSITOLI","1301":"KAB. PESISIR SELATAN","1302":"KAB. SOLOK","1303":"KAB. SIJUNJUNG","1304":"KAB. TANAH DATAR","1305":"KAB. PADANG PARIAMAN","1306":"KAB. AGAM","1307":"KAB. LIMA PULUH KOTA","1308":"KAB. PASAMAN","1309":"KAB. KEPULAUAN MENTAWAI","1310":"KAB. DHARMASRAYA","1311":"KAB. SOLOK SELATAN","1312":"KAB. PASAMAN BARAT","1371":"KOTA PADANG","1372":"KOTA SOLOK","1373":"KOTA SAWAHLUNTO","1374":"KOTA PADANG PANJANG","1375":"KOTA BUKITTINGGI","1376":"KOTA PAYAKUMBUH","1377":"KOTA PARIAMAN","1401":"KAB. KAMPAR","1402":"KAB. INDRAGIRI HULU","1403":"KAB. BENGKALIS","1404":"KAB. INDRAGIRI HILIR","1405":"KAB. PELALAWAN","1406":"KAB. ROKAN HULU","1407":"KAB. ROKAN HILIR","1408":"KAB. SIAK","1409":"KAB. KUANTAN SINGINGI","1410":"KAB. KEPULAUAN MERANTI","1471":"KOTA PEKANBARU","1472":"KOTA DUMAI","1501":"KAB. KERINCI","1502":"KAB. MERANGIN","1503":"KAB. SAROLANGUN","1504":"KAB. BATANGHARI","1505":"KAB. MUARO JAMBI","1506":"KAB. TANJUNG JABUNG BARAT","1507":"KAB. TANJUNG JABUNG TIMUR","1508":"KAB. BUNGO","1509":"KAB. TEBO","1571":"KOTA JAMBI","1572":"KOTA SUNGAI PENUH","1601":"KAB. OGAN KOMERING ULU","1602":"KAB. OGAN KOMERING ILIR","1603":"KAB. MUARA ENIM","1604":"KAB. LAHAT","1605":"KAB. MUSI RAWAS","1606":"KAB. MUSI BANYUASIN","1607":"KAB. BANYUASIN","1608":"KAB. OGAN KOMERING ULU TIMU","1609":"KAB. OGAN KOMERING ULU SELAT","1610":"KAB. OGAN ILIR","1611":"KAB. EMPAT LAWANG","1612":"KAB. PENUKAL ABAB LEMATANG I","1613":"KAB. MUSI RAWAS UTARA","1671":"KOTA PALEMBANG","1672":"KOTA PAGAR ALAM","1673":"KOTA LUBUK LINGGAU","1674":"KOTA PRABUMULIH","1701":"KAB. BENGKULU SELATAN","1702":"KAB. REJANG LEBONG","1703":"KAB. BENGKULU UTARA","1704":"KAB. KAUR","1705":"KAB. SELUMA","1706":"KAB. MUKO MUKO","1707":"KAB. LEBONG","1708":"KAB. KEPAHIANG","1709":"KAB. BENGKULU TENGAH","1771":"KOTA BENGKULU","1801":"KAB. LAMPUNG SELATAN","1802":"KAB. LAMPUNG TENGAH","1803":"KAB. LAMPUNG UTARA","1804":"KAB. LAMPUNG BARAT","1805":"KAB. TULANG BAWANG","1806":"KAB. TANGGAMUS","1807":"KAB. LAMPUNG TIMUR","1808":"KAB. WAY KANAN","1809":"KAB. PESAWARAN","1810":"KAB. PRINGSEWU","1811":"KAB. MESUJI","1812":"KAB. TULANG BAWANG BARAT","1813":"KAB. PESISIR BARAT","1871":"KOTA BANDAR LAMPUNG","1872":"KOTA METRO","1901":"KAB. BANGKA","1902":"KAB. BELITUNG","1903":"KAB. BANGKA SELATAN","1904":"KAB. BANGKA TENGAH","1905":"KAB. BANGKA BARAT","1906":"KAB. BELITUNG TIMUR","1971":"KOTA PANGKAL PINANG","2101":"KAB. BINTAN","2102":"KAB. KARIMUN","2103":"KAB. NATUNA","2104":"KAB. LINGGA","2105":"KAB. KEPULAUAN ANAMBAS","2171":"KOTA BATAM","2172":"KOTA TANJUNG PINANG","3101":"KAB. ADM. KEP. SERIBU","3171":"KOTA ADM. JAKARTA PUSAT","3172":"KOTA ADM. JAKARTA UTARA","3173":"KOTA ADM. JAKARTA BARAT","3174":"KOTA ADM. JAKARTA SELATAN","3175":"KOTA ADM. JAKARTA TIMUR","3201":"KAB. BOGOR","3202":"KAB. SUKABUMI","3203":"KAB. CIANJUR","3204":"KAB. BANDUNG","3205":"KAB. GARUT","3206":"KAB. TASIKMALAYA","3207":"KAB. CIAMIS","3208":"KAB. KUNINGAN","3209":"KAB. CIREBON","3210":"KAB. MAJALENGKA","3211":"KAB. SUMEDANG","3212":"KAB. INDRAMAYU","3213":"KAB. SUBANG","3214":"KAB. PURWAKARTA","3215":"KAB. KARAWANG","3216":"KAB. BEKASI","3217":"KAB. BANDUNG BARAT","3218":"KAB. PANGANDARAN","3271":"KOTA BOGOR","3272":"KOTA SUKABUMI","3273":"KOTA BANDUNG","3274":"KOTA CIREBON","3275":"KOTA BEKASI","3276":"KOTA DEPOK","3277":"KOTA CIMAHI","3278":"KOTA TASIKMALAYA","3279":"KOTA BANJAR","3301":"KAB. CILACAP","3302":"KAB. BANYUMAS","3303":"KAB. PURBALINGGA","3304":"KAB. BANJARNEGARA","3305":"KAB. KEBUMEN","3306":"KAB. PURWOREJO","3307":"KAB. WONOSOBO","3308":"KAB. MAGELANG","3309":"KAB. BOYOLALI","3310":"KAB. KLATEN","3311":"KAB. SUKOHARJO","3312":"KAB. WONOGIRI","3313":"KAB. KARANGANYAR","3314":"KAB. SRAGEN","3315":"KAB. GROBOGAN","3316":"KAB. BLORA","3317":"KAB. REMBANG","3318":"KAB. PATI","3319":"KAB. KUDUS","3320":"KAB. JEPARA","3321":"KAB. DEMAK","3322":"KAB. SEMARANG","3323":"KAB. TEMANGGUNG","3324":"KAB. KENDAL","3325":"KAB. BATANG","3326":"KAB. PEKALONGAN","3327":"KAB. PEMALANG","3328":"KAB. TEGAL","3329":"KAB. BREBES","3371":"KOTA MAGELANG","3372":"KOTA SURAKARTA","3373":"KOTA SALATIGA","3374":"KOTA SEMARANG","3375":"KOTA PEKALONGAN","3376":"KOTA TEGAL","3401":"KAB. KULON PROGO","3402":"KAB. BANTUL","3403":"KAB. GUNUNG KIDUL","3404":"KAB. SLEMAN","3471":"KOTA YOGYAKARTA","3501":"KAB. PACITAN","3502":"KAB. PONOROGO","3503":"KAB. TRENGGALEK","3504":"KAB. TULUNGAGUNG","3505":"KAB. BLITAR","3506":"KAB. KEDIRI","3507":"KAB. MALANG","3508":"KAB. LUMAJANG","3509":"KAB. JEMBER","3510":"KAB. BANYUWANGI","3511":"KAB. BONDOWOSO","3512":"KAB. SITUBONDO","3513":"KAB. PROBOLINGGO","3514":"KAB. PASURUAN","3515":"KAB. SIDOARJO","3516":"KAB. MOJOKERTO","3517":"KAB. JOMBANG","3518":"KAB. NGANJUK","3519":"KAB. MADIUN","3520":"KAB. MAGETAN","3521":"KAB. NGAWI","3522":"KAB. BOJONEGORO","3523":"KAB. TUBAN","3524":"KAB. LAMONGAN","3525":"KAB. GRESIK","3526":"KAB. BANGKALAN","3527":"KAB. SAMPANG","3528":"KAB. PAMEKASAN","3529":"KAB. SUMENEP","3571":"KOTA KEDIRI","3572":"KOTA BLITAR","3573":"KOTA MALANG","3574":"KOTA PROBOLINGGO","3575":"KOTA PASURUAN","3576":"KOTA MOJOKERTO","3577":"KOTA MADIUN","3578":"KOTA SURABAYA","3579":"KOTA BATU","3601":"KAB. PANDEGLANG","3602":"KAB. LEBAK","3603":"KAB. TANGERANG","3604":"KAB. SERANG","3671":"KOTA TANGERANG","3672":"KOTA CILEGON","3673":"KOTA SERANG","3674":"KOTA TANGERANG SELATAN","5101":"KAB. JEMBRANA","5102":"KAB. TABANAN","5103":"KAB. BADUNG","5104":"KAB. GIANYAR","5105":"KAB. KLUNGKUNG","5106":"KAB. BANGLI","5107":"KAB. KARANGASEM","5108":"KAB. BULELENG","5171":"KOTA DENPASAR","5201":"KAB. LOMBOK BARAT","5202":"KAB. LOMBOK TENGAH","5203":"KAB. LOMBOK TIMUR","5204":"KAB. SUMBAWA","5205":"KAB. DOMPU","5206":"KAB. BIMA","5207":"KAB. SUMBAWA BARAT","5208":"KAB. LOMBOK UTARA","5271":"KOTA MATARAM","5272":"KOTA BIMA","5301":"KAB. KUPANG","5302":"KAB TIMOR TENGAH SELATAN","5303":"KAB. TIMOR TENGAH UTARA","5304":"KAB. BELU","5305":"KAB. ALOR","5306":"KAB. FLORES TIMUR","5307":"KAB. SIKKA","5308":"KAB. ENDE","5309":"KAB. NGADA","5310":"KAB. MANGGARAI","5311":"KAB. SUMBA TIMUR","5312":"KAB. SUMBA BARAT","5313":"KAB. LEMBATA","5314":"KAB. ROTE NDAO","5315":"KAB. MANGGARAI BARAT","5316":"KAB. NAGEKEO","5317":"KAB. SUMBA TENGAH","5318":"KAB. SUMBA BARAT DAYA","5319":"KAB. MANGGARAI TIMUR","5320":"KAB. SABU RAIJUA","5321":"KAB. MALAKA","5371":"KOTA KUPANG","6101":"KAB. SAMBAS","6102":"KAB. MEMPAWAH","6103":"KAB. SANGGAU","6104":"KAB. KETAPANG","6105":"KAB. SINTANG","6106":"KAB. KAPUAS HULU","6107":"KAB. BENGKAYANG","6108":"KAB. LANDAK","6109":"KAB. SEKADAU","6110":"KAB. MELAWI","6111":"KAB. KAYONG UTARA","6112":"KAB. KUBU RAYA","6171":"KOTA PONTIANAK","6172":"KOTA SINGKAWANG","6201":"KAB. KOTAWARINGIN BARAT","6202":"KAB. KOTAWARINGIN TIMUR","6203":"KAB. KAPUAS","6204":"KAB. BARITO SELATAN","6205":"KAB. BARITO UTARA","6206":"KAB. KATINGAN","6207":"KAB. SERUYAN","6208":"KAB. SUKAMARA","6209":"KAB. LAMANDAU","6210":"KAB. GUNUNG MAS","6211":"KAB. PULANG PISAU","6212":"KAB. MURUNG RAYA","6213":"KAB. BARITO TIMUR","6271":"KOTA PALANGKARAYA","6301":"KAB. TANAH LAUT","6302":"KAB. KOTABARU","6303":"KAB. BANJAR","6304":"KAB. BARITO KUALA","6305":"KAB. TAPIN","6306":"KAB. HULU SUNGAI SELATAN","6307":"KAB. HULU SUNGAI TENGAH","6308":"KAB. HULU SUNGAI UTARA","6309":"KAB. TABALONG","6310":"KAB. TANAH BUMBU","6311":"KAB. BALANGAN","6371":"KOTA BANJARMASIN","6372":"KOTA BANJARBARU","6401":"KAB. PASER","6402":"KAB. KUTAI KARTANEGARA","6403":"KAB. BERAU","6407":"KAB. KUTAI BARAT","6408":"KAB. KUTAI TIMUR","6409":"KAB. PENAJAM PASER UTARA","6411":"KAB. MAHAKAM ULU","6471":"KOTA BALIKPAPAN","6472":"KOTA SAMARINDA","6474":"KOTA BONTANG","6501":"KAB. BULUNGAN","6502":"KAB. MALINAU","6503":"KAB. NUNUKAN","6504":"KAB. TANA TIDUNG","6571":"KOT. TARAKAN","7101":"KAB. BOLAANG MONGONDOW","7102":"KAB. MINAHASA","7103":"KAB. KEPULAUAN SANGIHE","7104":"KAB. KEPULAUAN TALAUD","7105":"KAB. MINAHASA SELATAN","7106":"KAB. MINAHASA UTARA","7107":"KAB. MINAHASA TENGGARA","7108":"KAB. BOLAANG MONGONDOW UT","7109":"KAB. KEP. SIAU TAGULANDANG B","7110":"KAB. BOLAANG MONGONDOW TI","7111":"KAB. BOLAANG MONGONDOW SE","7171":"KOTA MANADO","7172":"KOTA BITUNG","7173":"KOTA TOMOHON","7174":"KOTA KOTAMOBAGU","7201":"KAB. BANGGAI","7202":"KAB. POSO","7203":"KAB. DONGGALA","7204":"KAB. TOLI TOLI","7205":"KAB. BUOL","7206":"KAB. MOROWALI","7207":"KAB. BANGGAI KEPULAUAN","7208":"KAB. PARIGI MOUTONG","7209":"KAB. TOJO UNA UNA","7210":"KAB. SIGI","7211":"KAB. BANGGAI LAUT","7212":"KAB. MOROWALI UTARA","7271":"KOTA PALU","7301":"KAB. KEPULAUAN SELAYAR","7302":"KAB. BULUKUMBA","7303":"KAB. BANTAENG","7304":"KAB. JENEPONTO","7305":"KAB. TAKALAR","7306":"KAB. GOWA","7307":"KAB. SINJAI","7308":"KAB. BONE","7309":"KAB. MAROS","7310":"KAB. PANGKAJENE KEPULAUAN","7311":"KAB. BARRU","7312":"KAB. SOPPENG","7313":"KAB. WAJO","7314":"KAB. SIDENRENG RAPPANG","7315":"KAB. PINRANG","7316":"KAB. ENREKANG","7317":"KAB. LUWU","7318":"KAB. TANA TORAJA","7322":"KAB. LUWU UTARA","7324":"KAB. LUWU TIMUR","7326":"KAB. TORAJA UTARA","7371":"KOTA MAKASSAR","7372":"KOTA PARE PARE","7373":"KOTA PALOPO","7401":"KAB. KOLAKA","7402":"KAB. KONAWE","7403":"KAB. MUNA","7404":"KAB. BUTON","7405":"KAB. KONAWE SELATAN","7406":"KAB. BOMBANA","7407":"KAB. WAKATOBI","7408":"KAB. KOLAKA UTARA","7409":"KAB. KONAWE UTARA","7410":"KAB. BUTON UTARA","7411":"KAB. KOLAKA TIMUR","7412":"KAB. KONAWE KEPULAUAN","7413":"KAB. MUNA BARAT","7414":"KAB. BUTON TENGAH","7415":"KAB. BUTON SELATAN","7471":"KOTA KENDARI","7472":"KOTA BAU BAU","7501":"KAB. GORONTALO","7502":"KAB. BOALEMO","7503":"KAB. BONE BOLANGO","7504":"KAB. PAHUWATO","7505":"KAB. GORONTALO UTARA","7571":"KOTA GORONTALO","7601":"KAB. MAMUJU UTARA","7602":"KAB. MAMUJU","7603":"KAB. MAMASA","7604":"KAB. POLEWALI MANDAR","7605":"KAB. MAJENE","7606":"KAB. MAMUJU TENGAH","8101":"KAB. MALUKU TENGAH","8102":"KAB. MALUKU TENGGARA","8103":"KAB MALUKU TENGGARA BARAT","8104":"KAB. BURU","8105":"KAB. SERAM BAGIAN TIMUR","8106":"KAB. SERAM BAGIAN BARAT","8107":"KAB. KEPULAUAN ARU","8108":"KAB. MALUKU BARAT DAYA","8109":"KAB. BURU SELATAN","8171":"KOTA AMBON","8172":"KOTA TUAL","8201":"KAB. HALMAHERA BARAT","8202":"KAB. HALMAHERA TENGAH","8203":"KAB. HALMAHERA UTARA","8204":"KAB. HALMAHERA SELATAN","8205":"KAB. KEPULAUAN SULA","8206":"KAB. HALMAHERA TIMUR","8207":"KAB. PULAU MOROTAI","8208":"KAB. PULAU TALIABU","8271":"KOTA TERNATE","8272":"KOTA TIDORE KEPULAUAN","9101":"KAB. MERAUKE","9102":"KAB. JAYAWIJAYA","9103":"KAB. JAYAPURA","9104":"KAB. NABIRE","9105":"KAB. KEPULAUAN YAPEN","9106":"KAB. BIAK NUMFOR","9107":"KAB. PUNCAK JAYA","9108":"KAB. PANIAI","9109":"KAB. MIMIKA","9110":"KAB. SARMI","9111":"KAB. KEEROM","9112":"KAB PEGUNUNGAN BINTANG","9113":"KAB. YAHUKIMO","9114":"KAB. TOLIKARA","9115":"KAB. WAROPEN","9116":"KAB. BOVEN DIGOEL","9117":"KAB. MAPPI","9118":"KAB. ASMAT","9119":"KAB. SUPIORI","9120":"KAB. MAMBERAMO RAYA","9121":"KAB. MAMBERAMO TENGAH","9122":"KAB. YALIMO","9123":"KAB. LANNY JAYA","9124":"KAB. NDUGA","9125":"KAB. PUNCAK","9126":"KAB. DOGIYAI","9127":"KAB. INTAN JAYA","9128":"KAB. DEIYAI","9171":"KOTA JAYAPURA","9201":"KAB. SORONG","9202":"KOT. MANOKWARI","9203":"KAB. FAK FAK","9204":"KAB. SORONG SELATAN","9205":"KAB. RAJA AMPAT","9206":"KAB. TELUK BINTUNI","9207":"KAB. TELUK WONDAMA","9208":"KAB. KAIMANA","9209":"KAB. TAMBRAUW","9210":"KAB. MAYBRAT","9211":"KAB. MANOKWARI SELATAN","9212":"KAB. PEGUNUNGAN ARFAK","9271":"KOTA SORONG"},"kecamatan":{"110101":"BAKONGAN -- 23773","110102":"KLUET UTARA -- 23771","110103":"KLUET SELATAN -- 23772","110104":"LABUHAN HAJI -- 23761","110105":"MEUKEK -- 23754","110106":"SAMADUA -- 23752","110107":"SAWANG -- 24377","110108":"TAPAKTUAN -- 23711","110109":"TRUMON -- 23774","110110":"PASI RAJA -- 23755","110111":"LABUHAN HAJI TIMUR -- 23758","110112":"LABUHAN HAJI BARAT -- 23757","110113":"KLUET TENGAH -- 23772","110114":"KLUET TIMUR -- 23772","110115":"BAKONGAN TIMUR -- 23773","110116":"TRUMON TIMUR -- 23774","110117":"KOTA BAHAGIA -- 23773","110118":"TRUMON TENGAH -- 23774","110201":"LAWE ALAS -- 24661","110202":"LAWE SIGALA-GALA -- 24673","110203":"BAMBEL -- 24671","110204":"BABUSSALAM -- 24651","110205":"BADAR -- 24652","110206":"BABUL MAKMUR -- 24673","110207":"DARUL HASANAH -- 24653","110208":"LAWE BULAN -- 24651","110209":"BUKIT TUSAM -- 24671","110210":"SEMADAM -- 24678","110211":"BABUL RAHMAH -- 24673","110212":"KETAMBE -- 24652","110213":"DELENG POKHKISEN -- 24660","110214":"LAWE SUMUR -- 24671","110215":"TANOH ALAS -- 24673","110216":"LEUSER -- 24673","110301":"DARUL AMAN -- 24455","110302":"JULOK -- 24457","110303":"IDI RAYEUK -- 24454","110304":"BIREM BAYEUN -- 24452","110305":"SERBAJADI -- 24461","110306":"NURUSSALAM -- 24456","110307":"PEUREULAK -- 24453","110308":"RANTAU SELAMAT -- 24452","110309":"SIMPANG ULIM -- 24458","110310":"RANTAU PEUREULAK -- 24441","110311":"PANTE BIDARI -- 24458","110312":"MADAT -- 24458","110313":"INDRA MAKMU -- 24457","110314":"IDI TUNONG -- 24454","110315":"BANDA ALAM -- 24458","110316":"PEUDAWA -- 24454","110317":"PEUREULAK TIMUR -- 24453","110318":"PEUREULAK BARAT -- 24453","110319":"SUNGAI RAYA -- 24458","110320":"SIMPANG JERNIH -- 24458","110321":"DARUL IHSAN -- 24468","110322":"DARUL FALAH -- 24454","110323":"IDI TIMUR -- 24456","110324":"PEUNARON -- 24461","110401":"LINGE -- 24563","110402":"SILIH NARA -- 24562","110403":"BEBESEN -- 24552","110407":"PEGASING -- 24561","110408":"BINTANG -- 24571","110410":"KETOL -- 24562","110411":"KEBAYAKAN -- 24519","110412":"KUTE PANANG -- 24568","110413":"CELALA -- 24562","110417":"LAUT TAWAR -- 24511 - 24516","110418":"ATU LINTANG -- 24563","110419":"JAGONG JEGET -- 24563","110420":"BIES -- 24561","110421":"RUSIP ANTARA -- 24562","110501":"JOHAN PAHWALAN -- 23617 - 23618","110502":"KAWAY XVI -- 23681","110503":"SUNGAI MAS -- 23681","110504":"WOYLA -- 23682","110505":"SAMATIGA -- 23652","110506":"BUBON -- 23652","110507":"ARONGAN LAMBALEK -- 23652","110508":"PANTE CEUREUMEN -- 23681","110509":"MEUREUBO -- 23615","110510":"WOYLA BARAT -- 23682","110511":"WOYLA TIMUR -- 23682","110512":"PANTON REU -- 23681","110601":"LHOONG -- 23354","110602":"LHOKNGA -- 23353","110603":"INDRAPURI -- 23363","110604":"SEULIMEUM -- 23951","110605":"MONTASIK -- 23362","110606":"SUKAMAKMUR -- 23361","110607":"DARUL IMARAH -- 23352","110608":"PEUKAN BADA -- 23351","110609":"MESJID RAYA -- 23381","110610":"INGIN JAYA -- 23371","110611":"KUTA BARO -- 23372","110612":"DARUSSALAM -- 23373","110613":"PULO ACEH -- 23391","110614":"LEMBAH SEULAWAH -- 23952","110615":"KOTA JANTHO -- 23917","110616":"KOTA COT GLIE -- 23363","110617":"KUTA MALAKA -- 23363","110618":"SIMPANG TIGA -- 23371","110619":"DARUL KAMAL -- 23352","110620":"BAITUSSALAM -- 23373","110621":"KRUENG BARONA JAYA -- 23371","110622":"LEUPUNG -- 23353","110623":"BLANG BINTANG -- 23360","110703":"BATEE -- 24152","110704":"DELIMA -- 24162","110705":"GEUMPANG -- 24167","110706":"GEULUMPANG TIGA -- 24183","110707":"INDRA JAYA -- 23657","110708":"KEMBANG TANJONG -- 24182","110709":"KOTA SIGLI -- 24112","110711":"MILA -- 24163","110712":"MUARA TIGA -- 24153","110713":"MUTIARA -- 24173","110714":"PADANG TIJI -- 24161","110715":"PEUKAN BARO -- 24172","110716":"PIDIE -- 24151","110717":"SAKTI -- 24164","110718":"SIMPANG TIGA -- 23371","110719":"TANGSE -- 24166","110721":"TIRO/TRUSEB -- 24174","110722":"KEUMALA -- 24165","110724":"MUTIARA TIMUR -- 24173","110725":"GRONG-GRONG -- 24150","110727":"MANE -- 24186","110729":"GLUMPANG BARO -- 24183","110731":"TITEUE -- 24165","110801":"BAKTIYA -- 24392","110802":"DEWANTARA -- 24354","110803":"KUTA MAKMUR -- 24371","110804":"LHOKSUKON -- 24382","110805":"MATANGKULI -- 24386","110806":"MUARA BATU -- 24355","110807":"MEURAH MULIA -- 24372","110808":"SAMUDERA -- 24374","110809":"SEUNUDDON -- 24393","110810":"SYAMTALIRA ARON -- 24381","110811":"SYAMTALIRA BAYU -- 24373","110812":"TANAH LUAS -- 24385","110813":"TANAH PASIR -- 24391","110814":"T. JAMBO AYE -- 24395","110815":"SAWANG -- 24377","110816":"NISAM -- 24376","110817":"COT GIREK -- 24352","110818":"LANGKAHAN -- 24394","110819":"BAKTIYA BARAT -- 24392","110820":"PAYA BAKONG -- 24386","110821":"NIBONG -- 24385","110822":"SIMPANG KRAMAT -- 24313","110823":"LAPANG -- 24391","110824":"PIRAK TIMUR -- 24386","110825":"GEUREDONG PASE -- 24373","110826":"BANDA BARO -- 24376","110827":"NISAM ANTARA -- 24376","110901":"SIMEULUE TENGAH -- 23894","110902":"SALANG -- 23893","110903":"TEUPAH BARAT -- 23892","110904":"SIMEULUE TIMUR -- 23891","110905":"TELUK DALAM -- 23891","110906":"SIMEULUE BARAT -- 23892","110907":"TEUPAH SELATAN -- 23891","110908":"ALAPAN -- 23893","110909":"TEUPAH TENGAH -- 23891","110910":"SIMEULUE CUT -- 23894","111001":"PULAU BANYAK -- 24791","111002":"SIMPANG KANAN -- 24783","111004":"SINGKIL -- 24785","111006":"GUNUNG MERIAH -- 24784","111009":"KOTA BAHARU -- 24784","111010":"SINGKIL UTARA -- 24785","111011":"DANAU PARIS -- 24784","111012":"SURO MAKMUR -- 24784","111013":"SINGKOHOR -- 24784","111014":"KUALA BARU -- 24784","111016":"PULAU BANYAK BARAT -- 24791","111101":"SAMALANGA -- 24264","111102":"JEUNIEB -- 24263","111103":"PEUDADA -- 24262","111104":"JEUMPA -- 24251","111105":"PEUSANGAN -- 24261","111106":"MAKMUR -- 23662","111107":"GANDAPURA -- 24356","111108":"PANDRAH -- 24263","111109":"JULI -- 24251","111110":"JANGKA -- 24261","111111":"SIMPANG MAMPLAM -- 24251","111112":"PEULIMBANG -- 24263","111113":"KOTA JUANG -- 24251","111114":"KUALA -- 23661","111115":"PEUSANGAN SIBLAH KRUENG -- 24261","111116":"PEUSANGAN SELATAN -- 24261","111117":"KUTA BLANG -- 24356","111201":"BLANG PIDIE -- 23764","111202":"TANGAN-TANGAN -- 23763","111203":"MANGGENG -- 23762","111204":"SUSOH -- 23765","111205":"KUALA BATEE -- 23766","111206":"BABAH ROT -- 23767","111207":"SETIA -- 23763","111208":"JEUMPA -- 24251","111209":"LEMBAH SABIL -- 23762","111301":"BLANGKEJEREN -- 24655","111302":"KUTAPANJANG -- 24655","111303":"RIKIT GAIB -- 24654","111304":"TERANGUN -- 24656","111305":"PINING -- 24655","111306":"BLANGPEGAYON -- 24653","111307":"PUTERI BETUNG -- 24658","111308":"DABUN GELANG -- 24653","111309":"BLANGJERANGO -- 24655","111310":"TERIPE JAYA -- 24657","111311":"PANTAN CUACA -- 24654","111401":"TEUNOM -- 23653","111402":"KRUENG SABEE -- 23654","111403":"SETIA BHAKTI -- 23655","111404":"SAMPOINIET -- 23656","111405":"JAYA -- 23371","111406":"PANGA -- 23653","111407":"INDRA JAYA -- 23657","111408":"DARUL HIKMAH -- 23656","111409":"PASIE RAYA -- 23653","111501":"KUALA -- 23661","111502":"SEUNAGAN -- 23671","111503":"SEUNAGAN TIMUR -- 23671","111504":"BEUTONG -- 23672","111505":"DARUL MAKMUR -- 23662","111506":"SUKA MAKMUE -- 23671","111507":"KUALA PESISIR -- 23661","111508":"TADU RAYA -- 23661","111509":"TRIPA MAKMUR -- 23662","111510":"BEUTONG ATEUH BANGGALANG -- 23672","111601":"MANYAK PAYED -- 24471","111602":"BENDAHARA -- 24472","111603":"KARANG BARU -- 24476","111604":"SERUWAY -- 24473","111605":"KOTA KUALASINPANG -- 24475","111606":"KEJURUAN MUDA -- 24477","111607":"TAMIANG HULU -- 24478","111608":"RANTAU -- 24452","111609":"BANDA MULIA -- 24472","111610":"BANDAR PUSAKA -- 24478","111611":"TENGGULUN -- 24477","111612":"SEKERAK -- 24476","111701":"PINTU RIME GAYO -- 24553","111702":"PERMATA -- 24582","111703":"SYIAH UTAMA -- 24582","111704":"BANDAR -- 24184","111705":"BUKIT -- 24671","111706":"WIH PESAM -- 24581","111707":"TIMANG GAJAH -- 24553","111708":"BENER KELIPAH -- 24582","111709":"MESIDAH -- 24582","111710":"GAJAH PUTIH -- 24553","111801":"MEUREUDU -- 24186","111802":"ULIM -- 24458","111803":"JANGKA BUAYA -- 24186","111804":"BANDAR DUA -- 24188","111805":"MEURAH DUA -- 24186","111806":"BANDAR BARU -- 24184","111807":"PANTERAJA -- 24185","111808":"TRIENGGADENG -- 24185","117101":"BAITURRAHMAN -- 23244","117102":"KUTA ALAM -- 23126","117103":"MEURAXA -- 23232","117104":"SYIAH KUALA -- 23116","117105":"LUENG BATA -- 23245","117106":"KUTA RAJA -- 23128","117107":"BANDA RAYA -- 23239","117108":"JAYA BARU -- 23235","117109":"ULEE KARENG -- 23117","117201":"SUKAKARYA -- 23514","117202":"SUKAJAYA -- 23524","117301":"MUARA DUA -- 24352","117302":"BANDA SAKTI -- 24351","117303":"BLANG MANGAT -- 24375","117304":"MUARA SATU -- 24352","117401":"LANGSA TIMUR -- 24411","117402":"LANGSA BARAT -- 24410","117403":"LANGSA KOTA -- 24410","117404":"LANGSA LAMA -- 24416","117405":"LANGSA BARO -- 24415","117501":"SIMPANG KIRI -- 24782","117502":"PENANGGALAN -- 24782","117503":"RUNDENG -- 24786","117504":"SULTAN DAULAT -- 24782","117505":"LONGKIB -- 24782","120101":"BARUS -- 22564","120102":"SORKAM -- 22563","120103":"PANDAN -- 22613","120104":"PINANGSORI -- 22654","120105":"MANDUAMAS -- 22565","120106":"KOLANG -- 22562","120107":"TAPIAN NAULI -- 22618","120108":"SIBABANGUN -- 22654","120109":"SOSOR GADONG -- 22564","120110":"SORKAM BARAT -- 22563","120111":"SIRANDORUNG -- 22565","120112":"ANDAM DEWI -- 22651","120113":"SITAHUIS -- 22611","120114":"TUKKA -- 22617","120115":"BADIRI -- 22654","120116":"PASARIBU TOBING -- 22563","120117":"BARUS UTARA -- 22564","120118":"SUKA BANGUN -- 22654","120119":"LUMUT -- 22654","120120":"SARUDIK -- 22611","120201":"TARUTUNG -- 22413","120202":"SIATAS BARITA -- 22417","120203":"ADIAN KOTING -- 22461","120204":"SIPOHOLON -- 22452","120205":"PAHAE JULU -- 22463","120206":"PAHAE JAE -- 22465","120207":"SIMANGUMBAN -- 22466","120208":"PURBA TUA -- 22465","120209":"SIBORONG-BORONG -- 22474","120210":"PAGARAN -- 22458","120211":"PARMONANGAN -- 22453","120212":"SIPAHUTAR -- 22471","120213":"PANGARIBUAN -- 22472","120214":"GAROGA -- 22473","120215":"MUARA -- 22476","120301":"ANGKOLA BARAT -- 22735","120302":"BATANG TORU -- 22738","120303":"ANGKOLA TIMUR -- 22733","120304":"SIPIROK -- 22742","120305":"SAIPAR DOLOK HOLE -- 22758","120306":"ANGKOLA SELATAN -- 22732","120307":"BATANG ANGKOLA -- 22773","120314":"ARSE -- 21126","120320":"MARANCAR -- 22738","120321":"SAYUR MATINGGI -- 22774","120322":"AEK BILAH -- 22758","120329":"MUARA BATANG TORU -- 22738","120330":"TANO TOMBANGAN ANGKOLA -- 22774","120331":"ANGKOLA SANGKUNUR -- 22735","120405":"HILIDUHO -- 22854","120406":"GIDO -- 22871","120410":"IDANOGAWO -- 22872","120411":"BAWOLATO -- 22876","120420":"HILISERANGKAI -- 22851","120421":"BOTOMUZOI -- 22815","120427":"ULUGAWO -- 22861","120428":"MA'U -- -","120429":"SOMOLO-MOLO -- 22871","120435":"SOGAE'ADU -- -","120501":"BAHOROK -- 20774","120502":"SALAPIAN -- 20773","120503":"KUALA -- 21475","120504":"SEI BINGEI -- -","120505":"BINJAI -- 20719","120506":"SELESAI -- 20762","120507":"STABAT -- 20811","120508":"WAMPU -- 20851","120509":"SECANGGANG -- 20855","120510":"HINAI -- 20854","120511":"TANJUNG PURA -- 20853","120512":"PADANG TUALANG -- 20852","120513":"GEBANG -- 20856","120514":"BABALAN -- 20857","120515":"PANGKALAN SUSU -- 20858","120516":"BESITANG -- 20859","120517":"SEI LEPAN -- 20773","120518":"BRANDAN BARAT -- 20881","120519":"BATANG SERANGAN -- 20852","120520":"SAWIT SEBERANG -- 20811","120521":"SIRAPIT -- 20772","120522":"KUTAMBARU -- 20773","120523":"PEMATANG JAYA -- 20858","120601":"KABANJAHE -- 22111","120602":"BERASTAGI -- 22152","120603":"BARUSJAHE -- 22172","120604":"TIGAPANAH -- 22171","120605":"MEREK -- 22173","120606":"MUNTE -- 22755","120607":"JUHAR -- 22163","120608":"TIGABINANGA -- 22162","120609":"LAUBALENG -- 22164","120610":"MARDINGDING -- -","120611":"PAYUNG -- 22154","120612":"SIMPANG EMPAT -- 21271","120613":"KUTABULUH -- 22155","120614":"DOLAT RAYAT -- 22171","120615":"MERDEKA -- 22153","120616":"NAMAN TERAN -- -","120617":"TIGANDERKET -- 22154","120701":"GUNUNG MERIAH -- 20583","120702":"TANJUNG MORAWA -- 20362","120703":"SIBOLANGIT -- 20357","120704":"KUTALIMBARU -- 20354","120705":"PANCUR BATU -- 20353","120706":"NAMORAMBE -- 20356","120707":"SIBIRU-BIRU -- -","120708":"STM HILIR -- -","120709":"BANGUN PURBA -- 20581","120719":"GALANG -- 20585","120720":"STM HULU -- -","120721":"PATUMBAK -- 20361","120722":"DELI TUA -- 20355","120723":"SUNGGAL -- 20121","120724":"HAMPARAN PERAK -- 20374","120725":"LABUHAN DELI -- 20373","120726":"PERCUT SEI TUAN -- 20371","120727":"BATANG KUIS -- 20372","120728":"LUBUK PAKAM -- 20511","120731":"PAGAR MERBAU -- 20551","120732":"PANTAI LABU -- 20553","120733":"BERINGIN -- 20552","120801":"SIANTAR -- 21126","120802":"GUNUNG MALELA -- 21174","120803":"GUNUNG MALIGAS -- 21174","120804":"PANEI -- 21161","120805":"PANOMBEIAN PANE -- 21165","120806":"JORLANG HATARAN -- 21172","120807":"RAYA KAHEAN -- 21156","120808":"BOSAR MALIGAS -- 21183","120809":"SIDAMANIK -- 21171","120810":"PEMATANG SIDAMANIK -- 21186","120811":"TANAH JAWA -- 21181","120812":"HATONDUHAN -- 21174","120813":"DOLOK PANRIBUAN -- 21173","120814":"PURBA -- 20581","120815":"HARANGGAOL HORISON -- 21174","120816":"GIRSANG SIPANGAN BOLON -- 21174","120817":"DOLOK BATU NANGGAR -- 21155","120818":"HUTA BAYU RAJA -- 21182","120819":"JAWA MARAJA BAH JAMBI -- 21153","120820":"DOLOK PARDAMEAN -- 21163","120821":"PEMATANG BANDAR -- 21186","120822":"BANDAR HULUAN -- 21184","120823":"BANDAR -- 21274","120824":"BANDAR MASILAM -- 21184","120825":"SILIMAKUTA -- 21167","120826":"DOLOK SILAU -- 21168","120827":"SILOU KAHEAN -- 21157","120828":"TAPIAN DOLOK -- 21154","120829":"RAYA -- 22866","120830":"UJUNG PADANG -- 21187","120831":"PAMATANG SILIMA HUTA -- -","120908":"MERANTI -- 21264","120909":"AIR JOMAN -- 21263","120910":"TANJUNG BALAI -- 21352","120911":"SEI KEPAYANG -- 21381","120912":"SIMPANG EMPAT -- 21271","120913":"AIR BATU -- 21272","120914":"PULAU RAKYAT -- 21273","120915":"BANDAR PULAU -- 21274","120916":"BUNTU PANE -- 21261","120917":"BANDAR PASIR MANDOGE -- 21262","120918":"AEK KUASAN -- 21273","120919":"KOTA KISARAN BARAT -- -","120920":"KOTA KISARAN TIMUR -- -","120921":"AEK SONGSONGAN -- 21274","120922":"RAHUNIG -- -","120923":"SEI DADAP -- 21272","120924":"SEI KEPAYANG BARAT -- 21381","120925":"SEI KEPAYANG TIMUR -- 21381","120926":"TINGGI RAJA -- 21261","120927":"SETIA JANJI -- 21261","120928":"SILAU LAUT -- 21263","120929":"RAWANG PANCA ARGA -- 21264","120930":"PULO BANDRING -- 21264","120931":"TELUK DALAM -- 21271","120932":"AEK LEDONG -- 21273","121001":"RANTAU UTARA -- 21419","121002":"RANTAU SELATAN -- 21421","121007":"BILAH BARAT -- 21411","121008":"BILAH HILIR -- 21471","121009":"BILAH HULU -- 21451","121014":"PANGKATAN -- 21462","121018":"PANAI TENGAH -- 21472","121019":"PANAI HILIR -- 21473","121020":"PANAI HULU -- 21471","121101":"SIDIKALANG -- 22212","121102":"SUMBUL -- 22281","121103":"TIGALINGGA -- 22252","121104":"SIEMPAT NEMPU -- 22261","121105":"SILIMA PUNGGA PUNGA -- -","121106":"TANAH PINEM -- 22253","121107":"SIEMPAT NEMPU HULU -- 22254","121108":"SIEMPAT NEMPU HILIR -- 22263","121109":"PEGAGAN HILIR -- 22283","121110":"PARBULUAN -- 22282","121111":"LAE PARIRA -- 22281","121112":"GUNUNG SITEMBER -- 22251","121113":"BRAMPU -- 22251","121114":"SILAHISABUNGAN -- 22281","121115":"SITINJO -- 22219","121201":"BALIGE -- 22312","121202":"LAGUBOTI -- 22381","121203":"SILAEN -- 22382","121204":"HABINSARAN -- 22383","121205":"PINTU POHAN MERANTI -- 22384","121206":"BORBOR -- -","121207":"PORSEA -- 22384","121208":"AJIBATA -- 22386","121209":"LUMBAN JULU -- 22386","121210":"ULUAN -- 21184","121219":"SIGUMPAR -- 22381","121220":"SIANTAR NARUMONDA -- 22384","121221":"NASSAU -- 22383","121222":"TAMPAHAN -- 22312","121223":"BONATUA LUNASI -- 22386","121224":"PARMAKSIAN -- 22384","121301":"PANYABUNGAN -- 22912","121302":"PANYABUNGAN UTARA -- 22978","121303":"PANYABUNGAN TIMUR -- 22912","121304":"PANYABUNGAN SELATAN -- 22952","121305":"PANYABUNGAN BARAT -- 22911","121306":"SIABU -- 22976","121307":"BUKIT MALINTANG -- 22977","121308":"KOTANOPAN -- 22994","121309":"LEMBAH SORIK MARAPI -- -","121310":"TAMBANGAN -- 22994","121311":"ULU PUNGKUT -- 22998","121312":"MUARA SIPONGI -- 22998","121313":"BATANG NATAL -- 22983","121314":"LINGGA BAYU -- 22983","121315":"BATAHAN -- 22988","121316":"NATAL -- 22983","121317":"MUARA BATANG GADIS -- 22989","121318":"RANTO BAEK -- 22983","121319":"HUTA BARGOT -- 22978","121320":"PUNCAK SORIK MARAPI -- 22994","121321":"PAKANTAN -- 22998","121322":"SINUNUKAN -- 22988","121323":"NAGA JUANG -- 22977","121401":"LOLOMATUA -- 22867","121402":"GOMO -- 22873","121403":"LAHUSA -- 22874","121404":"HIBALA -- 22881","121405":"PULAU-PULAU BATU -- 22881","121406":"TELUK DALAM -- 21271","121407":"AMANDRAYA -- 22866","121408":"LALOWA'U -- -","121409":"SUSUA -- 22866","121410":"MANIAMOLO -- 22865","121411":"HILIMEGAI -- 22864","121412":"TOMA -- 22865","121413":"MAZINO -- 22865","121414":"UMBUNASI -- 22873","121415":"ARAMO -- 22866","121416":"PULAU-PULAU BATU TIMUR -- 22881","121417":"MAZO -- 22873","121418":"FANAYAMA -- 22865","121419":"ULUNOYO -- 22867","121420":"HURUNA -- 22867","121421":"O'O'U -- -","121422":"ONOHAZUMBA -- 22864","121423":"HILISALAWA'AHE -- -","121425":"SIDUA'ORI -- -","121426":"SOMAMBAWA -- 22874","121427":"BORONADU -- 22873","121428":"SIMUK -- 22881","121429":"PULAU-PULAU BATU BARAT -- 22881","121430":"PULAU-PULAU BATU UTARA -- 22881","121501":"SITELU TALI URANG JEHE -- -","121502":"KERAJAAN -- 22271","121503":"SALAK -- 22272","121504":"SITELU TALI URANG JULU -- -","121505":"PERGETTENG GETTENG SENGKUT -- 22271","121506":"PAGINDAR -- 22271","121507":"TINADA -- 22272","121508":"SIEMPAT RUBE -- 22272","121601":"PARLILITAN -- 22456","121602":"POLLUNG -- 22457","121603":"BAKTIRAJA -- 22457","121604":"PARANGINAN -- 22475","121605":"LINTONG NIHUTA -- 22475","121606":"DOLOK SANGGUL -- 22457","121607":"SIJAMAPOLANG -- 22457","121608":"ONAN GANJANG -- 22454","121609":"PAKKAT -- 22455","121610":"TARABINTANG -- 22456","121701":"SIMANINDO -- 22395","121702":"ONAN RUNGGU -- 22391","121703":"NAINGGOLAN -- 22394","121704":"PALIPI -- 22393","121705":"HARIAN -- 22391","121706":"SIANJAR MULA MULA -- -","121707":"RONGGUR NIHUTA -- 22392","121708":"PANGURURAN -- 22392","121709":"SITIO-TIO -- 22395","121801":"PANTAI CERMIN -- 20987","121802":"PERBAUNGAN -- 20986","121803":"TELUK MENGKUDU -- 20997","121804":"SEI. RAMPAH -- -","121805":"TANJUNG BERINGIN -- 20996","121806":"BANDAR KHALIFAH -- 20994","121807":"DOLOK MERAWAN -- 20993","121808":"SIPISPIS -- 20992","121809":"DOLOK MASIHUL -- 20991","121810":"KOTARIH -- 20984","121811":"SILINDA -- 20984","121812":"SERBA JADI -- 20991","121813":"TEBING TINGGI -- 20615","121814":"PEGAJAHAN -- 20986","121815":"SEI BAMBAN -- 20995","121816":"TEBING SYAHBANDAR -- 20998","121817":"BINTANG BAYU -- 20984","121901":"MEDANG DERAS -- 21258","121902":"SEI SUKA -- 21257","121903":"AIR PUTIH -- 21256","121904":"LIMA PULUH -- 21255","121905":"TALAWI -- 21254","121906":"TANJUNG TIRAM -- 21253","121907":"SEI BALAI -- 21252","122001":"DOLOK SIGOMPULON -- 22756","122002":"DOLOK -- 22756","122003":"HALONGONAN -- 22753","122004":"PADANG BOLAK -- 22753","122005":"PADANG BOLAK JULU -- 22753","122006":"PORTIBI -- 22741","122007":"BATANG ONANG -- 22762","122008":"SIMANGAMBAT -- 22747","122009":"HULU SIHAPAS -- 22733","122101":"SOSOPAN -- 22762","122102":"BARUMUN TENGAH -- 22755","122103":"HURISTAK -- 22742","122104":"LUBUK BARUMUN -- 22763","122105":"HUTA RAJA TINGGI -- 22774","122106":"ULU BARUMUN -- 22763","122107":"BARUMUN -- 22755","122108":"SOSA -- 22765","122109":"BATANG LUBU SUTAM -- 22742","122110":"BARUMUN SELATAN -- 22763","122111":"AEK NABARA BARUMUN -- 22755","122112":"SIHAPAS BARUMUN -- 22755","122201":"KOTAPINANG -- 21464","122202":"KAMPUNG RAKYAT -- 21463","122203":"TORGAMBA -- 21464","122204":"SUNGAI KANAN -- 21465","122205":"SILANGKITANG -- 21461","122301":"KUALUH HULU -- 21457","122302":"KUALUH LEIDONG -- 21475","122303":"KUALUH HILIR -- 21474","122304":"AEK KUO -- 21455","122305":"MARBAU -- 21452","122306":"NA IX - X -- 21454","122307":"AEK NATAS -- 21455","122308":"KUALUH SELATAN -- 21457","122401":"LOTU -- 22851","122402":"SAWO -- 22852","122403":"TUHEMBERUA -- 22852","122404":"SITOLU ORI -- 22852","122405":"NAMOHALU ESIWA -- 22816","122406":"ALASA TALUMUZOI -- 22814","122407":"ALASA -- 22861","122408":"TUGALA OYO -- 22861","122409":"AFULU -- 22857","122410":"LAHEWA -- 22853","122411":"LAHEWA TIMUR -- 22851","122501":"LAHOMI -- 22864","122502":"SIROMBU -- 22863","122503":"MANDREHE BARAT -- 22812","122504":"MORO'O -- -","122505":"MANDREHE -- 22814","122506":"MANDREHE UTARA -- 22814","122507":"LOLOFITU MOI -- 22875","122508":"ULU MORO'O -- -","127101":"MEDAN KOTA -- 20215","127102":"MEDAN SUNGGAL -- 20121","127103":"MEDAN HELVETIA -- 20126","127104":"MEDAN DENAI -- 20228","127105":"MEDAN BARAT -- 20115","127106":"MEDAN DELI -- 20243","127107":"MEDAN TUNTUNGAN -- 20136","127108":"MEDAN BELAWAN -- 20414","127109":"MEDAN AMPLAS -- 20229","127110":"MEDAN AREA -- 20215","127111":"MEDAN JOHOR -- 20144","127112":"MEDAN MARELAN -- 20254","127113":"MEDAN LABUHAN -- 20251","127114":"MEDAN TEMBUNG -- 20223","127115":"MEDAN MAIMUN -- 20151","127116":"MEDAN POLONIA -- 20152","127117":"MEDAN BARU -- 20154","127118":"MEDAN PERJUANGAN -- 20233","127119":"MEDAN PETISAH -- 20112","127120":"MEDAN TIMUR -- 20235","127121":"MEDAN SELAYANG -- 20133","127201":"SIANTAR TIMUR -- 21136","127202":"SIANTAR BARAT -- 21112","127203":"SIANTAR UTARA -- 21142","127204":"SIANTAR SELATAN -- 21126","127205":"SIANTAR MARIHAT -- 21129","127206":"SIANTAR MARTOBA -- 21137","127207":"SIANTAR SITALASARI -- 21139","127208":"SIANTAR MARIMBUN -- 21128","127301":"SIBOLGA UTARA -- 22511","127302":"SIBOLGA KOTA -- 22521","127303":"SIBOLGA SELATAN -- 22533","127304":"SIBOLGA SAMBAS -- 22535","127401":"TANJUNG BALAI SELATAN -- 21315","127402":"TANJUNG BALAI UTARA -- 21324","127403":"SEI TUALANG RASO -- 21344","127404":"TELUK NIBUNG -- 21335","127405":"DATUK BANDAR -- 21367","127406":"DATUK BANDAR TIMUR -- 21367","127501":"BINJAI UTARA -- 20747","127502":"BINJAI KOTA -- 20715","127503":"BINJAI BARAT -- 20719","127504":"BINJAI TIMUR -- 20736","127505":"BINJAI SELATAN -- 20728","127601":"PADANG HULU -- 20625","127602":"RAMBUTAN -- 20611","127603":"PADANG HILIR -- 20634","127604":"BAJENIS -- 20613","127605":"TEBING TINGGI KOTA -- 20615","127701":"PADANGSIDIMPUAN UTARA -- -","127702":"PADANGSIDIMPUAN SELATAN -- -","127703":"PADANGSIDIMPUAN BATUNADUA -- -","127704":"PADANGSIDIMPUAN HUTAIMBARU -- -","127705":"PADANGSIDIMPUAN TENGGARA -- -","127706":"PADANGSIDIMPUAN ANGKOLA JULU -- -","130101":"PANCUNG SOAL -- 25673","130102":"RANAH PESISIR -- 25666","130103":"LENGAYANG -- 25663","130104":"BATANG KAPAS -- 25661","130105":"IV JURAI -- 25651","130106":"BAYANG -- 25652","130107":"KOTO XI TARUSAN -- 25654","130108":"SUTERA -- 25662","130109":"LINGGO SARI BAGANTI -- 25668","130111":"BASA AMPEK BALAI TAPAN -- 25673","130112":"IV NAGARI BAYANG UTARA -- 25652","130113":"AIRPURA -- 25673","130114":"RANAH AMPEK HULU TAPAN -- 25673","130115":"SILAUT -- 25674","130203":"PANTAI CERMIN -- 27373","130204":"LEMBAH GUMANTI -- 27371","130205":"PAYUNG SEKAKI -- 27387","130206":"LEMBANG JAYA -- 27383","130207":"GUNUNG TALANG -- 27365","130208":"BUKIT SUNDI -- 27381","130209":"IX KOTO SUNGAI LASI -- -","130210":"KUBUNG -- 27361","130211":"X KOTO SINGKARAK -- 27356","130212":"X KOTO DIATAS -- 27355","130213":"JUNJUNG SIRIH -- 27388","130217":"HILIRAN GUMANTI -- 27372","130218":"TIGO LURAH -- 27372","130219":"DANAU KEMBAR -- 27383","130303":"TANJUNG GADANG -- 27571","130304":"SIJUNJUNG -- 27553","130305":"IV NAGARI -- 26161","130306":"KAMANG BARU -- 27572","130307":"LUBUAK TAROK -- 27553","130308":"KOTO VII -- 27562","130309":"SUMPUR KUDUS -- 27563","130310":"KUPITAN -- 27564","130401":"X KOTO -- 27151","130402":"BATIPUH -- 27265","130403":"RAMBATAN -- 27271","130404":"LIMA KAUM -- 27213","130405":"TANJUNG EMAS -- 27281","130406":"LINTAU BUO -- 27292","130407":"SUNGAYANG -- 27294","130408":"SUNGAI TARAB -- 27261","130409":"PARIANGAN -- 27264","130410":"SALIMPAUANG -- -","130411":"PADANG GANTING -- 27282","130412":"TANJUANG BARU -- -","130413":"LINTAU BUO UTARA -- 27292","130414":"BATIPUAH SELATAN -- -","130501":"LUBUK ALUNG -- 25581","130502":"BATANG ANAI -- 25586","130503":"NAN SABARIS -- 25571","130505":"VII KOTO SUNGAI SARIK -- 25573","130506":"V KOTO KAMPUNG DALAM -- 25552","130507":"SUNGAI GARINGGING -- -","130508":"SUNGAI LIMAU -- 25561","130509":"IV KOTO AUR MALINTANG -- 25564","130510":"ULAKAN TAPAKIH -- 25572","130511":"SINTUAK TOBOH GADANG -- 25582","130512":"PADANG SAGO -- 25573","130513":"BATANG GASAN -- 25563","130514":"V KOTO TIMUR -- 25573","130516":"PATAMUAN -- 25573","130517":"ENAM LINGKUNG -- 25584","130601":"TANJUNG MUTIARA -- 26473","130602":"LUBUK BASUNG -- 26451","130603":"TANJUNG RAYA -- 26471","130604":"MATUR -- 26162","130605":"IV KOTO -- 25564","130606":"BANUHAMPU -- 26181","130607":"AMPEK ANGKEK -- 26191","130608":"BASO -- 26192","130609":"TILATANG KAMANG -- 26152","130610":"PALUPUH -- 26151","130611":"PELEMBAYAN -- -","130612":"SUNGAI PUA -- 26181","130613":"AMPEK NAGARI -- 26161","130614":"CANDUNG -- 26191","130615":"KAMANG MAGEK -- 26152","130616":"MALALAK -- -","130701":"SULIKI -- 26255","130702":"GUGUAK -- 26111","130703":"PAYAKUMBUH -- 26228","130704":"LUAK -- 26261","130705":"HARAU -- 26271","130706":"PANGKALAN KOTO BARU -- 26272","130707":"KAPUR IX -- 26273","130708":"GUNUANG OMEH -- 26256","130709":"LAREH SAGO HALABAN -- 26262","130710":"SITUJUAH LIMO NAGARI -- -","130711":"MUNGKA -- 26254","130712":"BUKIK BARISAN -- 26257","130713":"AKABILURU -- 26252","130804":"BONJOL -- 26381","130805":"LUBUK SIKAPING -- 26318","130807":"PANTI -- 26352","130808":"MAPAT TUNGGUL -- 26353","130812":"DUO KOTO -- 26311","130813":"TIGO NAGARI -- 26353","130814":"RAO -- 26353","130815":"MAPAT TUNGGUL SELATAN -- 26353","130816":"SIMPANG ALAHAN MATI -- 26381","130817":"PADANG GELUGUR -- 26352","130818":"RAO UTARA -- 26353","130819":"RAO SELATAN -- 26353","130901":"PAGAI UTARA -- 25391","130902":"SIPORA SELATAN -- 25392","130903":"SIBERUT SELATAN -- 25393","130904":"SIBERUT UTARA -- 25394","130905":"SIBERUT BARAT -- 25393","130906":"SIBERUT BARAT DAYA -- 25393","130907":"SIBERUT TENGAH -- 25394","130908":"SIPORA UTARA -- 25392","130909":"SIKAKAP -- 25391","130910":"PAGAI SELATAN -- 25391","131001":"KOTO BARU -- 27681","131002":"PULAU PUNJUNG -- 27573","131003":"SUNGAI RUMBAI -- 27684","131004":"SITIUNG -- 27678","131005":"SEMBILAN KOTO -- 27681","131006":"TIMPEH -- 27678","131007":"KOTO SALAK -- 27681","131008":"TIUMANG -- 27681","131009":"PADANG LAWEH -- 27681","131010":"ASAM JUJUHAN -- 27684","131011":"KOTO BESAR -- 27684","131101":"SANGIR -- 27779","131102":"SUNGAI PAGU -- 27776","131103":"KOTO PARIK GADANG DIATEH -- 27775","131104":"SANGIR JUJUAN -- 27777","131105":"SANGIR BATANG HARI -- 27779","131106":"PAUH DUO -- 27776","131107":"SANGIR BALAI JANGGO -- 27777","131201":"SUNGAIBEREMAS -- -","131202":"LEMBAH MELINTANG -- 26572","131203":"PASAMAN -- 26566","131204":"TALAMAU -- 26561","131205":"KINALI -- 26567","131206":"GUNUNGTULEH -- 26571","131207":"RANAH BATAHAN -- 26366","131208":"KOTO BALINGKA -- 26572","131209":"SUNGAIAUR -- 26573","131210":"LUHAK NAN DUO -- 26567","131211":"SASAK RANAH PESISIR -- -","137101":"PADANG SELATAN -- 25217","137102":"PADANG TIMUR -- 25126","137103":"PADANG BARAT -- 25118","137104":"PADANG UTARA -- 25132","137105":"BUNGUS TELUK KABUNG -- 25237","137106":"LUBUK BEGALUNG -- 25222","137107":"LUBUK KILANGAN -- 25231","137108":"PAUH -- 27776","137109":"KURANJI -- 25154","137110":"NANGGALO -- 25145","137111":"KOTO TANGAH -- 25176","137201":"LUBUK SIKARAH -- 27317","137202":"TANJUNG HARAPAN -- 27321","137301":"LEMBAH SEGAR -- 27418","137302":"BARANGIN -- 27422","137303":"SILUNGKANG -- 27435","137304":"TALAWI -- 27443","137401":"PADANG PANJANG TIMUR -- 27125","137402":"PADANG PANJANG BARAT -- 27114","137501":"GUGUAK PANJANG -- 26111","137502":"MANDIANGIN K. SELAYAN -- -","137503":"AUR BIRUGO TIGO BALEH -- 26131","137601":"PAYAKUMBUH BARAT -- 26224","137602":"PAYAKUMBUH UTARA -- 26212","137603":"PAYAKUMBUH TIMUR -- 26231","137604":"LAMPOSI TIGO NAGORI -- -","137605":"PAYAKUMBUH SELATAN -- 26228","137701":"PARIAMAN TENGAH -- 25519","137702":"PARIAMAN UTARA -- 25522","137703":"PARIAMAN SELATAN -- 25531","137704":"PARIAMAN TIMUR -- 25531","140101":"BANGKINANG KOTA -- -","140102":"KAMPAR -- 28461","140103":"TAMBANG -- 28462","140104":"XIII KOTO KAMPAR -- 28453","140106":"SIAK HULU -- 28452","140107":"KAMPAR KIRI -- 28471","140108":"KAMPAR KIRI HILIR -- 28471","140109":"KAMPAR KIRI HULU -- 28471","140110":"TAPUNG -- 28464","140111":"TAPUNG HILIR -- 28464","140112":"TAPUNG HULU -- 28464","140113":"SALO -- 28451","140114":"RUMBIO JAYA -- 28458","140116":"PERHENTIAN RAJA -- 28462","140117":"KAMPAR TIMUR -- 28461","140118":"KAMPAR UTARA -- 28461","140119":"KAMPAR KIRI TENGAH -- 28471","140120":"GUNUNG SAHILAN -- 28471","140121":"KOTO KAMPAR HULU -- 28453","140201":"RENGAT -- 29351","140202":"RENGAT BARAT -- 29351","140203":"KELAYANG -- 29352","140204":"PASIR PENYU -- 29352","140205":"PERANAP -- 29354","140206":"SIBERIDA -- -","140207":"BATANG CENAKU -- 29355","140208":"BATANG GANGSAL -- -","140209":"LIRIK -- 29353","140210":"KUALA CENAKU -- 29335","140211":"SUNGAI LALA -- 29363","140212":"LUBUK BATU JAYA -- 29352","140213":"RAKIT KULIM -- 29352","140214":"BATANG PERANAP -- 29354","140301":"BENGKALIS -- 28711","140302":"BANTAN -- 28754","140303":"BUKIT BATU -- 28761","140309":"MANDAU -- 28784","140310":"RUPAT -- 28781","140311":"RUPAT UTARA -- 28781","140312":"SIAK KECIL -- 28771","140313":"PINGGIR -- 28784","140401":"RETEH -- 29273","140402":"ENOK -- 29272","140403":"KUALA INDRAGIRI -- 29281","140404":"TEMBILAHAN -- 29212","140405":"TEMPULING -- 29261","140406":"GAUNG ANAK SERKA -- 29253","140407":"MANDAH -- 29254","140408":"KATEMAN -- 29255","140409":"KERITANG -- 29274","140410":"TANAH MERAH -- 29271","140411":"BATANG TUAKA -- 29252","140412":"GAUNG -- 29282","140413":"TEMBILAHAN HULU -- 29213","140414":"KEMUNING -- 29274","140415":"PELANGIRAN -- 29255","140416":"TELUK BELENGKONG -- 29255","140417":"PULAU BURUNG -- 29256","140418":"CONCONG -- 29281","140419":"KEMPAS -- 29261","140420":"SUNGAI BATANG -- 29273","140501":"UKUI -- 28382","140502":"PANGKALAN KERINCI -- 28381","140503":"PANGKALAN KURAS -- 28382","140504":"PANGKALAN LESUNG -- 28382","140505":"LANGGAM -- 28381","140506":"PELALAWAN -- 28353","140507":"KERUMUTAN -- 28353","140508":"BUNUT -- 28383","140509":"TELUK MERANTI -- 28353","140510":"KUALA KAMPAR -- 28384","140511":"BANDAR SEI KIJANG -- 28383","140512":"BANDAR PETALANGAN -- 28384","140601":"UJUNG BATU -- 28554","140602":"ROKAN IV KOTO -- 28555","140603":"RAMBAH -- 28557","140604":"TAMBUSAI -- 28558","140605":"KEPENUHAN -- 28559","140606":"KUNTO DARUSSALAM -- 28556","140607":"RAMBAH SAMO -- 28565","140608":"RAMBAH HILIR -- 28557","140609":"TAMBUSAI UTARA -- 28558","140610":"BANGUN PURBA -- 28557","140611":"TANDUN -- 28554","140612":"KABUN -- 28554","140613":"BONAI DARUSSALAM -- 28559","140614":"PAGARAN TAPAH DARUSSALAM -- 28556","140615":"KEPENUHAN HULU -- 28559","140616":"PENDALIAN IV KOTO -- 28555","140701":"KUBU -- 28991","140702":"BANGKO -- 28912","140703":"TANAH PUTIH -- 28983","140704":"RIMBA MELINTANG -- 28953","140705":"BAGAN SINEMBAH -- 28992","140706":"PASIR LIMAU KAPAS -- 28991","140707":"SINABOI -- 28912","140708":"PUJUD -- 28983","140709":"TANAH PUTIH TANJUNG MELAWAN -- 28983","140710":"BANGKO PUSAKO -- -","140711":"SIMPANG KANAN -- 28992","140712":"BATU HAMPAR -- 28912","140713":"RANTAU KOPAR -- 28983","140714":"PEKAITAN -- 28912","140715":"KUBU BABUSSALAM -- 28991","140801":"SIAK -- 28771","140802":"SUNGAI APIT -- 28662","140803":"MINAS -- 28685","140804":"TUALANG -- 28772","140805":"SUNGAI MANDAU -- 28671","140806":"DAYUN -- 28671","140807":"KERINCI KANAN -- 28654","140808":"BUNGA RAYA -- 28763","140809":"KOTO GASIB -- 28671","140810":"KANDIS -- 28686","140811":"LUBUK DALAM -- 28654","140812":"SABAK AUH -- 28685","140813":"MEMPURA -- 28773","140814":"PUSAKO -- 28992","140901":"KUANTAN MUDIK -- 29564","140902":"KUANTAN TENGAH -- 29511","140903":"SINGINGI -- 29563","140904":"KUANTAN HILIR -- 29561","140905":"CERENTI -- 29555","140906":"BENAI -- 29566","140907":"GUNUNGTOAR -- 29565","140908":"SINGINGI HILIR -- 29563","140909":"PANGEAN -- 29553","140910":"LOGAS TANAH DARAT -- 29556","140911":"INUMAN -- 29565","140912":"HULU KUANTAN -- 29565","140913":"KUANTAN HILIR SEBERANG -- 29561","140914":"SENTAJO RAYA -- 29566","140915":"PUCUK RANTAU -- 29564","141001":"TEBING TINGGI -- 28753","141002":"RANGSANG BARAT -- 28755","141003":"RANGSANG -- 28755","141004":"TEBING TINGGI BARAT -- 28753","141005":"MERBAU -- 28752","141006":"PULAUMERBAU -- 28752","141007":"TEBING TINGGI TIMUR -- 28753","141008":"TASIK PUTRI PUYU -- 28752","147101":"SUKAJADI -- 28122","147102":"PEKANBARU KOTA -- 28114","147103":"SAIL -- 28131","147104":"LIMA PULUH -- 28144","147105":"SENAPELAN -- 28153","147106":"RUMBAI -- 28263","147107":"BUKIT RAYA -- 28284","147108":"TAMPAN -- 28291","147109":"MARPOYAN DAMAI -- 28125","147110":"TENAYAN RAYA -- 28286","147111":"PAYUNG SEKAKI -- 28292","147112":"RUMBAI PESISIR -- 28263","147201":"DUMAI BARAT -- 28821","147202":"DUMAI TIMUR -- 28811","147203":"BUKIT KAPUR -- 28882","147204":"SUNGAI SEMBILAN -- 28826","147205":"MEDANG KAMPAI -- 28825","147206":"DUMAI KOTA -- 28812","147207":"DUMAI SELATAN -- 28825","150101":"GUNUNG RAYA -- 37174","150102":"DANAU KERINCI -- 37172","150104":"SITINJAU LAUT -- 37171","150105":"AIR HANGAT -- 37161","150106":"GUNUNG KERINCI -- 37162","150107":"BATANG MERANGIN -- 37175","150108":"KELILING DANAU -- 37173","150109":"KAYU ARO -- 37163","150111":"AIR HANGAT TIMUR -- 37161","150115":"GUNUNG TUJUH -- 37163","150116":"SIULAK -- 37162","150117":"DEPATI TUJUH -- 37161","150118":"SIULAK MUKAI -- 37162","150119":"KAYU ARO BARAT -- 37163","150121":"AIR HANGAT BARAT -- 37161","150201":"JANGKAT -- 37372","150202":"BANGKO -- 37311","150203":"MUARA SIAU -- 37371","150204":"SUNGAI MANAU -- 37361","150205":"TABIR -- 37353","150206":"PAMENANG -- 37352","150207":"TABIR ULU -- 37356","150208":"TABIR SELATAN -- 37354","150209":"LEMBAH MASURAI -- 37372","150210":"BANGKO BARAT -- 37311","150211":"NALO TATAN -- -","150212":"BATANG MASUMAI -- 37311","150213":"PAMENANG BARAT -- 37352","150214":"TABIR ILIR -- 37353","150215":"TABIR TIMUR -- 37353","150216":"RENAH PEMBARAP -- 37361","150217":"PANGKALAN JAMBU -- 37361","150218":"SUNGAI TENANG -- 37372","150301":"BATANG ASAI -- 37485","150302":"LIMUN -- 37382","150303":"SAROLANGUN -- 37481","150304":"PAUH -- 37491","150305":"PELAWAN -- 37482","150306":"MANDIANGIN -- 37492","150307":"AIR HITAM -- 37491","150308":"BATHIN VIII -- 37481","150309":"SINGKUT -- 37482","150310":"CERMIN NAN GEDANG -- -","150401":"MERSAM -- 36654","150402":"MUARA TEMBESI -- 36653","150403":"MUARA BULIAN -- 36611","150404":"BATIN XXIV -- 36656","150405":"PEMAYUNG -- 36657","150406":"MARO SEBO ULU -- 36655","150407":"BAJUBANG -- 36611","150408":"MARO SEBO ILIR -- 36655","150501":"JAMBI LUAR KOTA -- 36361","150502":"SEKERNAN -- 36381","150503":"KUMPEH -- 36373","150504":"MARO SEBO -- 36382","150505":"MESTONG -- 36364","150506":"KUMPEH ULU -- 36373","150507":"SUNGAI BAHAR -- 36365","150508":"SUNGAI GELAM -- 36364","150510":"BAHAR SELATAN -- 36365","150601":"TUNGKAL ULU -- 36552","150602":"TUNGKAL ILIR -- 36555","150603":"PENGABUAN -- 36553","150604":"BETARA -- 36555","150605":"MERLUNG -- 36554","150606":"TEBING TINGGI -- 36552","150607":"BATANG ASAM -- 36552","150608":"RENAH MENDALUH -- 36554","150609":"MUARA PAPALIK -- 36554","150610":"SEBERANG KOTA -- 36511","150611":"BRAM ITAM -- 36514","150612":"KUALA BETARA -- 36555","150613":"SENYERANG -- 36553","150701":"MUARA SABAK TIMUR -- 36761","150702":"NIPAH PANJANG -- 36771","150703":"MENDAHARA -- 36764","150704":"RANTAU RASAU -- 36772","150705":"S A D U -- 36773","150706":"DENDANG -- 36763","150707":"MUARA SABAK BARAT -- 36761","150708":"KUALA JAMBI -- 36761","150709":"MENDAHARA ULU -- 36764","150710":"GERAGAI -- 36764","150711":"BERBAK -- 36572","150801":"TANAH TUMBUH -- 37255","150802":"RANTAU PANDAN -- 37261","150803":"PASAR MUARO BUNGO -- -","150804":"JUJUHAN -- 37257","150805":"TANAH SEPENGGAL -- 37263","150806":"PELEPAT -- 37262","150807":"LIMBUR LUBUK MENGKUANG -- 37211","150808":"MUKO-MUKO BATHIN VII -- -","150809":"PELEPAT ILIR -- 37252","150810":"BATIN II BABEKO -- -","150811":"BATHIN III -- 37211","150812":"BUNGO DANI -- 37211","150813":"RIMBO TENGAH -- 37211","150814":"BATHIN III ULU -- 37261","150815":"BATHIN II PELAYANG -- 37255","150816":"JUJUHAN ILIR -- 37257","150817":"TANAH SEPENGGAL LINTAS -- 37263","150901":"TEBO TENGAH -- 37571","150902":"TEBO ILIR -- 37572","150903":"TEBO ULU -- 37554","150904":"RIMBO BUJANG -- 37553","150905":"SUMAY -- 37573","150906":"VII KOTO -- 37259","150907":"RIMBO ULU -- 37553","150908":"RIMBO ILIR -- 37553","150909":"TENGAH ILIR -- 37572","150910":"SERAI SERUMPUN -- 37554","150911":"VII KOTO ILIR -- 37259","150912":"MUARA TABIR -- 37572","157101":"TELANAIPURA -- 36123","157102":"JAMBI SELATAN -- 36139","157103":"JAMBI TIMUR -- 36145","157104":"PASAR JAMBI -- 36112","157105":"PELAYANGAN -- 36251","157106":"DANAU TELUK -- 36262","157107":"KOTA BARU -- 36129","157108":"JELUTUNG -- 36134","157201":"SUNGAI PENUH -- 37111","157202":"PESISIR BUKIT -- 37111","157203":"HAMPARAN RAWANG -- 37152","157204":"TANAH KAMPUNG -- 37171","157205":"KUMUN DEBAI -- 37111","157206":"PONDOK TINGGI -- 37111","157207":"KOTO BARU -- 37152","157208":"SUNGAI BUNGKAL -- 37112","160107":"SOSOH BUAY RAYAP -- 32151","160108":"PENGANDONAN -- 32155","160109":"PENINJAUAN -- 32191","160113":"BATURAJA BARAT -- 32121","160114":"BATURAJA TIMUR -- 32111","160120":"ULU OGAN -- 32157","160121":"SEMIDANG AJI -- 32156","160122":"LUBUK BATANG -- 32192","160128":"LENGKITI -- 32158","160129":"SINAR PENINJAUAN -- 32159","160130":"LUBUK RAJA -- 32152","160131":"MUARA JAYA -- 32155","160202":"TANJUNG LUBUK -- 30671","160203":"PEDAMARAN -- 30672","160204":"MESUJI -- 30681","160205":"KAYU AGUNG -- 30618","160208":"SIRAH PULAU PADANG -- 30652","160211":"TULUNG SELAPAN -- 30655","160212":"PAMPANGAN -- 30654","160213":"LEMPUING -- 30657","160214":"AIR SUGIHAN -- 30656","160215":"SUNGAI MENANG -- 30681","160217":"JEJAWI -- 30652","160218":"CENGAL -- 30658","160219":"PANGKALAN LAMPAM -- 30659","160220":"MESUJI MAKMUR -- 30681","160221":"MESUJI RAYA -- 30681","160222":"LEMPUING JAYA -- 30657","160223":"TELUK GELAM -- 30673","160224":"PEDAMARAN TIMUR -- 30672","160301":"TANJUNG AGUNG -- 31355","160302":"MUARA ENIM -- 31311","160303":"RAMBANG DANGKU -- 31172","160304":"GUNUNG MEGANG -- 31352","160306":"GELUMBANG -- 31171","160307":"LAWANG KIDUL -- 31711","160308":"SEMENDE DARAT LAUT -- -","160309":"SEMENDE DARAT TENGAH -- -","160310":"SEMENDE DARAT ULU -- -","160311":"UJAN MAS -- 31351","160314":"LUBAI -- 31173","160315":"RAMBANG -- 31172","160316":"SUNGAI ROTAN -- 31357","160317":"LEMBAK -- 31171","160319":"BENAKAT -- 31626","160321":"KELEKAR -- 31171","160322":"MUARA BELIDA -- 31171","160323":"BELIMBING -- -","160324":"BELIDA DARAT -- -","160325":"LUBAI ULU -- -","160401":"TANJUNGSAKTI PUMU -- 31581","160406":"JARAI -- 31591","160407":"KOTA AGUNG -- 31462","160408":"PULAUPINANG -- 31461","160409":"MERAPI BARAT -- 31471","160410":"LAHAT -- 31414","160412":"PAJAR BULAN -- 31356","160415":"MULAK ULU -- 31453","160416":"KIKIM SELATAN -- 31452","160417":"KIKIM TIMUR -- 31452","160418":"KIKIM TENGAH -- 31452","160419":"KIKIM BARAT -- 31452","160420":"PSEKSU -- 31419","160421":"GUMAY TALANG -- 31419","160422":"PAGAR GUNUNG -- 31461","160423":"MERAPI TIMUR -- 31471","160424":"TANJUNG SAKTI PUMI -- 31581","160425":"GUMAY ULU -- 31461","160426":"MERAPI SELATAN -- 31471","160427":"TANJUNGTEBAT -- 31462","160428":"MUARAPAYANG -- 31591","160429":"SUKAMERINDU -- 31356","160501":"TUGUMULYO -- 31662","160502":"MUARA LAKITAN -- 31666","160503":"MUARA KELINGI -- 31663","160508":"JAYALOKA -- 31665","160509":"MUARA BELITI -- 31661","160510":"STL ULU TERAWAS -- 30771","160511":"SELANGIT -- 31625","160512":"MEGANG SAKTI -- 31657","160513":"PURWODADI -- 31668","160514":"BTS. ULU -- -","160518":"TIANG PUMPUNG KEPUNGUT -- 31661","160519":"SUMBER HARTA -- 30771","160520":"TUAH NEGERI -- 31663","160521":"SUKA KARYA -- 31665","160601":"SEKAYU -- 30711","160602":"LAIS -- 30757","160603":"SUNGAI KERUH -- 30757","160604":"BATANG HARI LEKO -- 30755","160605":"SANGA DESA -- 30759","160606":"BABAT TOMAN -- 30752","160607":"SUNGAI LILIN -- 30755","160608":"KELUANG -- 30754","160609":"BAYUNG LENCIR -- 30756","160610":"PLAKAT TINGGI -- 30758","160611":"LALAN -- 30758","160612":"TUNGKAL JAYA -- 30756","160613":"LAWANG WETAN -- 30752","160614":"BABAT SUPAT -- 30755","160701":"BANYUASIN I -- 30962","160702":"BANYUASIN II -- 30953","160703":"BANYUASIN III -- 30953","160704":"PULAU RIMAU -- 30959","160705":"BETUNG -- 30958","160706":"RAMBUTAN -- 30967","160707":"MUARA PADANG -- 30975","160708":"MUARA TELANG -- 30974","160709":"MAKARTI JAYA -- 30972","160710":"TALANG KELAPA -- 30961","160711":"RANTAU BAYUR -- 30968","160712":"TANJUNG LAGO -- 30961","160713":"MUARA SUGIHAN -- 30975","160714":"AIR SALEK -- 30975","160715":"TUNGKAL ILIR -- 30959","160716":"SUAK TAPEH -- 30958","160717":"SEMBAWA -- 30953","160718":"SUMBER MARGA TELANG -- 30974","160719":"AIR KUMBANG -- 30962","160801":"MARTAPURA -- 32315","160802":"BUAY MADANG -- 32361","160803":"BELITANG -- 32385","160804":"CEMPAKA -- 32384","160805":"BUAY PEMUKA PELIUNG -- -","160806":"MADANG SUKU II -- 32366","160807":"MADANG SUKU I -- 32362","160808":"SEMENDAWAI SUKU III -- 32386","160809":"BELITANG II -- 32383","160810":"BELITANG III -- 32385","160811":"BUNGA MAYANG -- 32381","160812":"BUAY MADANG TIMUR -- 32361","160813":"MADANG SUKU III -- 32366","160814":"SEMENDAWAI BARAT -- 32184","160815":"SEMENDAWAI TIMUR -- 32185","160816":"JAYAPURA -- 32381","160817":"BELITANG JAYA -- 32385","160818":"BELITANG MADANG RAYA -- 32362","160819":"BELITANG MULYA -- 32383","160820":"BUAY PEMUKA BANGSA RAJA -- 32361","160901":"MUARA DUA -- 32272","160902":"PULAU BERINGIN -- 32273","160903":"BANDING AGUNG -- 32274","160904":"MUARA DUA KISAM -- 32272","160905":"SIMPANG -- 32264","160906":"BUAY SANDANG AJI -- 32277","160907":"BUAY RUNJUNG -- 32278","160908":"MEKAKAU ILIR -- 32276","160909":"BUAY PEMACA -- 32265","160910":"KISAM TINGGI -- 32279","160911":"KISAM ILIR -- 32272","160912":"BUAY PEMATANG RIBU RANAU TENGAH -- 32274","160913":"WARKUK RANAU SELATAN -- 32274","160914":"RUNJUNG AGUNG -- 32278","160915":"SUNGAI ARE -- 32273","160916":"SINDANG DANAU -- 32273","160917":"BUANA PEMACA -- 32264","160918":"TIGA DIHAJI -- 32277","160919":"BUAY RAWAN -- 32211","161001":"MUARA KUANG -- 30865","161002":"TANJUNG BATU -- 30664","161003":"TANJUNG RAJA -- 30661","161004":"INDRALAYA -- 30862","161005":"PEMULUTAN -- 30653","161006":"RANTAU ALAI -- 30866","161007":"INDRALAYA UTARA -- 30862","161008":"INDRALAYA SELATAN -- 30862","161009":"PEMULUTAN SELATAN -- 30653","161010":"PEMULUTAN BARAT -- 30653","161011":"RANTAU PANJANG -- 30661","161012":"SUNGAI PINANG -- 30661","161013":"KANDIS -- 30867","161014":"RAMBANG KUANG -- 30869","161015":"LUBUK KELIAT -- 30868","161016":"PAYARAMAN -- 30664","161101":"MUARA PINANG -- 31592","161102":"PENDOPO -- 31593","161103":"ULU MUSI -- 31594","161104":"TEBING TINGGI -- 31453","161105":"LINTANG KANAN -- 31593","161106":"TALANG PADANG -- 31596","161107":"PASEMAH AIR KERUH -- 31595","161108":"SIKAP DALAM -- 31594","161109":"SALING -- 31453","161110":"PENDOPO BARAT -- 31593","161201":"TALANG UBI -- 31214","161202":"PENUKAL UTARA -- 31315","161203":"PENUKAL -- 31315","161204":"ABAB -- 31315","161205":"TANAH ABANG -- 31314","161301":"RUPIT -- 31654","161302":"RAWAS ULU -- 31656","161303":"NIBUNG -- 31667","161304":"RAWAS ILIR -- 31655","161305":"KARANG DAPO -- 31658","161306":"KARANG JAYA -- 31654","161307":"ULU RAWAS -- 31669","167101":"ILIR BARAT II -- 30141","167102":"SEBERANG ULU I -- 30257","167103":"SEBERANG ULU II -- 30267","167104":"ILIR BARAT I -- 30136","167105":"ILIR TIMUR I -- 30117","167106":"ILIR TIMUR II -- 30117","167107":"SUKARAMI -- 30151","167108":"SAKO -- 30163","167109":"KEMUNING -- 30127","167110":"KALIDONI -- 30114","167111":"BUKIT KECIL -- 30132","167112":"GANDUS -- 30147","167113":"KERTAPATI -- 30259","167114":"PLAJU -- 30268","167115":"ALANG-ALANG LEBAR -- 30154","167116":"SEMATANG BORANG -- 30161","167201":"PAGAR ALAM UTARA -- 31513","167202":"PAGAR ALAM SELATAN -- 31526","167203":"DEMPO UTARA -- 31521","167204":"DEMPO SELATAN -- 31521","167205":"DEMPO TENGAH -- 31521","167305":"LUBUK LINGGAU TIMUR II -- -","167306":"LUBUK LINGGAU BARAT II -- -","167307":"LUBUK LINGGAU SELATAN II -- -","167308":"LUBUK LINGGAU UTARA II -- -","167401":"PRABUMULIH BARAT -- 31122","167402":"PRABUMULIH TIMUR -- 31117","167403":"CAMBAI -- 31141","167404":"RAMBANG KPK TENGAH -- -","167405":"PRABUMULIH UTARA -- 31121","167406":"PRABUMULIH SELATAN -- 31124","170101":"KEDURANG -- 38553","170102":"SEGINIM -- 38552","170103":"PINO -- 38571","170104":"MANNA -- 38571","170105":"KOTA MANNA -- 38511","170106":"PINO RAYA -- 38571","170107":"KEDURANG ILIR -- 38553","170108":"AIR NIPIS -- 38571","170109":"ULU MANNA -- 38571","170110":"BUNGA MAS -- 38511","170111":"PASAR MANNA -- 38518","170206":"KOTA PADANG -- 39183","170207":"PADANG ULAK TANDING -- 39182","170208":"SINDANG KELINGI -- 39153","170209":"CURUP -- 39125","170210":"BERMANI ULU -- 39152","170211":"SELUPU REJANG -- 39153","170216":"CURUP UTARA -- 39125","170217":"CURUP TIMUR -- 39115","170218":"CURUP SELATAN -- 39125","170219":"CURUP TENGAH -- 39125","170220":"BINDURIANG -- 39182","170221":"SINDANG BELITI ULU -- 39182","170222":"SINDANG DATARAN -- -","170223":"SINDANG BELITI ILIR -- 39183","170224":"BERMANI ULU RAYA -- 39152","170301":"ENGGANO -- 38387","170306":"KERKAP -- 38374","170307":"KOTA ARGA MAKMUR -- -","170308":"GIRI MULYA -- -","170309":"PADANG JAYA -- 38657","170310":"LAIS -- 38653","170311":"BATIK NAU -- 38656","170312":"KETAHUN -- 38361","170313":"NAPAL PUTIH -- 38363","170314":"PUTRI HIJAU -- 38326","170315":"AIR BESI -- 38575","170316":"AIR NAPAL -- 38373","170319":"HULU PALIK -- 38374","170320":"AIR PADANG -- 38653","170321":"ARMA JAYA -- 38611","170323":"ULOK KUPAI -- 38363","170401":"KINAL -- 38962","170402":"TANJUNG KEMUNING -- 38955","170403":"KAUR UTARA -- 38956","170404":"KAUR TENGAH -- 38961","170405":"KAUR SELATAN -- 38963","170406":"MAJE -- 38965","170407":"NASAL -- 38964","170408":"SEMIDANG GUMAY -- -","170409":"KELAM TENGAH -- 38955","170410":"LUAS -- 38961","170411":"MUARA SAHUNG -- 38961","170412":"TETAP -- 38963","170413":"LUNGKANG KULE -- 38956","170414":"PADANG GUCI HILIR -- 38956","170415":"PADANG GUCI HULU -- 38956","170501":"SUKARAJA -- 38877","170502":"SELUMA -- 38883","170503":"TALO -- 38886","170504":"SEMIDANG ALAS -- 38873","170505":"SEMIDANG ALAS MARAS -- 38875","170506":"AIR PERIUKAN -- 38881","170507":"LUBUK SANDI -- 38882","170508":"SELUMA BARAT -- 38883","170509":"SELUMA TIMUR -- 38885","170510":"SELUMA UTARA -- 38884","170511":"SELUMA SELATAN -- 38878","170512":"TALO KECIL -- 38888","170513":"ULU TALO -- 38886","170514":"ILIR TALO -- 38887","170601":"LUBUK PINANG -- 38767","170602":"KOTA MUKOMUKO -- 38765","170603":"TERAS TERUNJAM -- 38768","170604":"PONDOK SUGUH -- 38766","170605":"IPUH -- 38764","170606":"MALIN DEMAN -- 38764","170607":"AIR RAMI -- 38764","170608":"TERAMANG JAYA -- 38766","170609":"SELAGAN RAYA -- 38768","170610":"PENARIK -- 38768","170611":"XIV KOTO -- 38765","170612":"V KOTO -- 38765","170613":"AIR MAJUNTO -- 38767","170614":"AIR DIKIT -- 38765","170615":"SUNGAI RUMBAI -- 38766","170701":"LEBONG UTARA -- 39264","170702":"LEBONG ATAS -- 39265","170703":"LEBONG TENGAH -- 39263","170704":"LEBONG SELATAN -- 39262","170705":"RIMBO PENGADANG -- 39261","170706":"TOPOS -- 39262","170707":"BINGIN KUNING -- 39262","170708":"LEBONG SAKTI -- 39267","170709":"PELABAI -- 39265","170710":"AMEN -- 39264","170711":"URAM JAYA -- 39268","170712":"PINANG BELAPIS -- 39269","170801":"BERMANI ILIR -- 39374","170802":"UJAN MAS -- 39371","170803":"TEBAT KARAI -- 39373","170804":"KEPAHIANG -- 39372","170805":"MERIGI -- 38383","170806":"KEBAWETAN -- 39372","170807":"SEBERANG MUSI -- 39373","170808":"MUARA KEMUMU -- 39374","170901":"KARANG TINGGI -- 38382","170902":"TALANG EMPAT -- 38385","170903":"PONDOK KELAPA -- 38371","170904":"PEMATANG TIGA -- 38372","170905":"PAGAR JATI -- 38383","170906":"TABA PENANJUNG -- 38386","170907":"MERIGI KELINDANG -- 38386","170908":"MERIGI SAKTI -- 38383","170909":"PONDOK KUBANG -- 38375","170910":"BANG HAJI -- 38372","177101":"SELEBAR -- 38214","177102":"GADING CEMPAKA -- 38221","177103":"TELUK SEGARA -- 38118","177104":"MUARA BANGKA HULU -- 38121","177105":"KAMPUNG MELAYU -- 38215","177106":"RATU AGUNG -- 38223","177107":"RATU SAMBAN -- 38222","177108":"SUNGAI SERUT -- 38119","177109":"SINGARAN PATI -- 38229","180104":"NATAR -- 35362","180105":"TANJUNG BINTANG -- 35361","180106":"KALIANDA -- 35551","180107":"SIDOMULYO -- 35353","180108":"KATIBUNG -- 35452","180109":"PENENGAHAN -- 35592","180110":"PALAS -- 35594","180113":"JATI AGUNG -- 35365","180114":"KETAPANG -- 35596","180115":"SRAGI -- 35597","180116":"RAJA BASA -- 35552","180117":"CANDIPURO -- 35356","180118":"MERBAU MATARAM -- 35357","180121":"BAKAUHENI -- 35592","180122":"TANJUNG SARI -- 35361","180123":"WAY SULAN -- 35452","180124":"WAY PANJI -- 35353","180201":"KALIREJO -- 34174","180202":"BANGUN REJO -- 34173","180203":"PADANG RATU -- 34175","180204":"GUNUNG SUGIH -- 34161","180205":"TRIMURJO -- 34172","180206":"PUNGGUR -- 34152","180207":"TERBANGGI BESAR -- 34163","180208":"SEPUTIH RAMAN -- 34155","180209":"RUMBIA -- 34157","180210":"SEPUTIH BANYAK -- 34156","180211":"SEPUTIH MATARAM -- 34164","180212":"SEPUTIH SURABAYA -- 34158","180213":"TERUSAN NUNYAI -- 34167","180214":"BUMI RATU NUBAN -- 34161","180215":"BEKRI -- 34162","180216":"SEPUTIH AGUNG -- 34166","180217":"WAY PANGUBUAN -- 35213","180218":"BANDAR MATARAM -- 34169","180219":"PUBIAN -- 34176","180220":"SELAGAI LINGGA -- 34176","180221":"ANAK TUHA -- 34161","180222":"SENDANG AGUNG -- 34174","180223":"KOTA GAJAH -- 34153","180224":"BUMI NABUNG -- 34168","180225":"WAY SEPUTIH -- 34179","180226":"BANDAR SURABAYA -- 34159","180227":"ANAK RATU AJI -- 35513","180228":"PUTRA RUMBIA -- 34157","180301":"BUKIT KEMUNING -- 34556","180302":"KOTABUMI -- 34511","180303":"SUNGKAI SELATAN -- 34554","180304":"TANJUNG RAJA -- 34557","180305":"ABUNG TIMUR -- 34583","180306":"ABUNG BARAT -- 34558","180307":"ABUNG SELATAN -- 34581","180308":"SUNGKAI UTARA -- 34555","180309":"KOTABUMI UTARA -- 34511","180310":"KOTABUMI SELATAN -- 34511","180311":"ABUNG TENGAH -- 34582","180312":"ABUNG TINGGI -- 34556","180313":"ABUNG SEMULI -- 34581","180314":"ABUNG SURAKARTA -- 34581","180315":"MUARA SUNGKAI -- 34559","180316":"BUNGA MAYANG -- 34555","180317":"HULU SUNGKAI -- 34555","180318":"SUNGKAI TENGAH -- 34555","180319":"ABUNG PEKURUN -- 34582","180320":"SUNGKAI JAYA -- 34554","180321":"SUNGKAI BARAT -- 34558","180322":"ABUNG KUNANG -- 34558","180323":"BLAMBANGAN PAGAR -- 34581","180404":"BALIK BUKIT -- 34811","180405":"SUMBER JAYA -- 34871","180406":"BELALAU -- 34872","180407":"WAY TENONG -- 34884","180408":"SEKINCAU -- 34885","180409":"SUOH -- 34882","180410":"BATU BRAK -- 34881","180411":"SUKAU -- 34879","180415":"GEDUNG SURIAN -- 34871","180418":"KEBUN TEBU -- 34871","180419":"AIR HITAM -- 34876","180420":"PAGAR DEWA -- 34885","180421":"BATU KETULIS -- 34872","180422":"LUMBOK SEMINUNG -- 34879","180423":"BANDAR NEGERI SUOH -- 34882","180502":"MENGGALA -- 34613","180506":"GEDUNG AJI -- 34681","180508":"BANJAR AGUNG -- 34682","180511":"GEDUNG MENENG -- 34596","180512":"RAWA JITU SELATAN -- 34596","180513":"PENAWAR TAMA -- 34595","180518":"RAWA JITU TIMUR -- 34596","180520":"BANJAR MARGO -- 34682","180522":"RAWA PITU -- 34595","180523":"PENAWAR AJI -- 34595","180525":"DENTE TELADAS -- 34596","180526":"MERAKSA AJI -- 34681","180527":"GEDUNG AJI BARU -- 34595","180601":"KOTA AGUNG -- 35384","180602":"TALANG PADANG -- 35377","180603":"WONOSOBO -- 35686","180604":"PULAU PANGGUNG -- 35679","180609":"CUKUH BALAK -- 35683","180611":"PUGUNG -- 35675","180612":"SEMAKA -- 35386","180613":"SUMBER REJO -- -","180615":"ULU BELU -- 35377","180616":"PEMATANG SAWA -- 35382","180617":"KLUMBAYAN -- -","180618":"KOTA AGUNG BARAT -- 35384","180619":"KOTA AGUNG TIMUR -- 35384","180620":"GISTING -- 35378","180621":"GUNUNG ALIP -- 35379","180624":"LIMAU -- 35613","180625":"BANDAR NEGERI SEMUONG -- 35686","180626":"AIR NANINGAN -- 35679","180627":"BULOK -- 35682","180628":"KLUMBAYAN BARAT -- -","180701":"SUKADANA -- 34194","180702":"LABUHAN MARINGGAI -- 34198","180703":"JABUNG -- 34384","180704":"PEKALONGAN -- 34391","180705":"SEKAMPUNG -- 34385","180706":"BATANGHARI -- 34381","180707":"WAY JEPARA -- 34396","180708":"PURBOLINGGO -- 34373","180709":"RAMAN UTARA -- 34371","180710":"METRO KIBANG -- 34331","180711":"MARGA TIGA -- 34386","180712":"SEKAMPUNG UDIK -- 34385","180713":"BATANGHARI NUBAN -- 34372","180714":"BUMI AGUNG -- 34763","180715":"BANDAR SRIBHAWONO -- -","180716":"MATARAM BARU -- 34199","180717":"MELINTING -- 34377","180718":"GUNUNG PELINDUNG -- 34388","180719":"PASIR SAKTI -- 34387","180720":"WAWAY KARYA -- 34376","180721":"LABUHAN RATU -- 35149","180722":"BRAJA SELEBAH -- -","180723":"WAY BUNGUR -- 34373","180724":"MARGA SEKAMPUNG -- 34384","180801":"BLAMBANGAN UMPU -- 34764","180802":"KASUI -- 34765","180803":"BANJIT -- 34766","180804":"BARADATU -- 34761","180805":"BAHUGA -- 34763","180806":"PAKUAN RATU -- 34762","180807":"NEGERI AGUNG -- 34769","180808":"WAY TUBA -- 34767","180809":"REBANG TANGKAS -- 34767","180810":"GUNUNG LABUHAN -- 34768","180811":"NEGARA BATIN -- 34769","180812":"NEGERI BESAR -- 34769","180813":"BUAY BAHUGA -- 34767","180814":"BUMI AGUNG -- 34763","180901":"GEDONG TATAAN -- 35366","180902":"NEGERI KATON -- 35353","180903":"TEGINENENG -- 35363","180904":"WAY LIMA -- 35367","180905":"PADANG CERMIN -- 35451","180906":"PUNDUH PIDADA -- 35453","180907":"KEDONDONG -- 35368","180908":"MARGA PUNDUH -- 35453","180909":"WAY KHILAU -- 35368","181001":"PRINGSEWU -- 35373","181002":"GADING REJO -- 35372","181003":"AMBARAWA -- 35376","181004":"PARDASUKA -- 35682","181005":"PAGELARAN -- 35376","181006":"BANYUMAS -- 35373","181007":"ADILUWIH -- 35674","181008":"SUKOHARJO -- 35674","181009":"PAGELARAN UTARA -- 35376","181101":"MESUJI -- 34697","181102":"MESUJI TIMUR -- 34697","181103":"RAWA JITU UTARA -- 34696","181104":"WAY SERDANG -- 34684","181105":"SIMPANG PEMATANG -- 34698","181106":"PANCA JAYA -- 34698","181107":"TANJUNG RAYA -- 34598","181201":"TULANG BAWANG TENGAH -- 34693","181202":"TUMIJAJAR -- 34594","181203":"TULANG BAWANG UDIK -- 34691","181204":"GUNUNG TERANG -- 34683","181205":"GUNUNG AGUNG -- 34683","181206":"WAY KENANGA -- 34388","181207":"LAMBU KIBANG -- 34388","181208":"PAGAR DEWA -- 34885","181301":"PESISIR TENGAH -- 34874","181302":"PESISIR SELATAN -- 34875","181303":"LEMONG -- 34877","181304":"PESISIR UTARA -- 34876","181305":"KARYA PENGGAWA -- 34878","181306":"PULAUPISANG -- 34876","181307":"WAY KRUI -- 34874","181308":"KRUI SELATAN -- 34874","181309":"NGAMBUR -- 34883","181310":"BENGKUNAT -- 34883","181311":"BENGKUNAT BELIMBING -- 34883","187101":"KEDATON -- 35141","187102":"SUKARAME -- 35131","187103":"TANJUNGKARANG BARAT -- 35151","187104":"PANJANG -- 35245","187105":"TANJUNGKARANG TIMUR -- 35121","187106":"TANJUNGKARANG PUSAT -- 35116","187107":"TELUKBETUNG SELATAN -- 35222","187108":"TELUKBETUNG BARAT -- 35238","187109":"TELUKBETUNG UTARA -- 35214","187110":"RAJABASA -- 35552","187111":"TANJUNG SENANG -- 35142","187112":"SUKABUMI -- 35122","187113":"KEMILING -- 35158","187114":"LABUHAN RATU -- 35149","187115":"WAY HALIM -- 35136","187116":"LANGKAPURA -- 35155","187117":"ENGGAL -- 34613","187118":"KEDAMAIAN -- 35122","187119":"TELUKBETUNG TIMUR -- 35235","187120":"BUMI WARAS -- 35228","187201":"METRO PUSAT -- 34113","187202":"METRO UTARA -- 34117","187203":"METRO BARAT -- 34114","187204":"METRO TIMUR -- 34112","187205":"METRO SELATAN -- 34119","190101":"SUNGAILIAT -- 33211","190102":"BELINYU -- 33253","190103":"MERAWANG -- 33172","190104":"MENDO BARAT -- 33173","190105":"PEMALI -- 33255","190106":"BAKAM -- 33252","190107":"RIAU SILIP -- 33253","190108":"PUDING BESAR -- 33179","190201":"TANJUNG PANDAN -- 33411","190202":"MEMBALONG -- 33452","190203":"SELAT NASIK -- 33481","190204":"SIJUK -- 33414","190205":"BADAU -- 33451","190301":"TOBOALI -- 33783","190302":"LEPAR PONGOK -- 33791","190303":"AIR GEGAS -- 33782","190304":"SIMPANG RIMBA -- 33777","190305":"PAYUNG -- 33778","190306":"TUKAK SADAI -- 33783","190307":"PULAUBESAR -- 33778","190308":"KEPULAUAN PONGOK -- 33791","190401":"KOBA -- 33681","190402":"PANGKALAN BARU -- 33684","190403":"SUNGAI SELAN -- 33675","190404":"SIMPANG KATIS -- 33674","190405":"NAMANG -- 33681","190406":"LUBUK BESAR -- 33681","190501":"MENTOK -- 33351","190502":"SIMPANG TERITIP -- 33366","190503":"JEBUS -- 33362","190504":"KELAPA -- 33364","190505":"TEMPILANG -- 33365","190506":"PARITTIGA -- 33362","190601":"MANGGAR -- 33512","190602":"GANTUNG -- 33562","190603":"DENDANG -- 33561","190604":"KELAPA KAMPIT -- 33571","190605":"DAMAR -- 33571","190606":"SIMPANG RENGGIANG -- 33562","190607":"SIMPANG PESAK -- 33561","197101":"BUKITINTAN -- 33149","197102":"TAMAN SARI -- 33121","197103":"PANGKAL BALAM -- 33113","197104":"RANGKUI -- 33135","197105":"GERUNGGANG -- 33123","197106":"GABEK -- 33111","197107":"GIRIMAYA -- 33141","210104":"GUNUNG KIJANG -- 29151","210106":"BINTAN TIMUR -- 29151","210107":"BINTAN UTARA -- 29152","210108":"TELUK BINTAN -- 29133","210109":"TAMBELAN -- 29193","210110":"TELOK SEBONG -- -","210112":"TOAPAYA -- 29151","210113":"MANTANG -- 29151","210114":"BINTAN PESISIR -- 29151","210115":"SERI KUALA LOBAM -- -","210201":"MORO -- 29663","210202":"KUNDUR -- 29662","210203":"KARIMUN -- 29661","210204":"MERAL -- 29664","210205":"TEBING -- 29663","210206":"BURU -- 29664","210207":"KUNDUR UTARA -- 29662","210208":"KUNDUR BARAT -- 29662","210209":"DURAI -- 29664","210210":"MERAL BARAT -- 29664","210211":"UNGAR -- 29662","210212":"BELAT -- 29662","210304":"MIDAI -- 29784","210305":"BUNGURAN BARAT -- 29782","210306":"SERASAN -- 29781","210307":"BUNGURAN TIMUR -- 29783","210308":"BUNGURAN UTARA -- 29783","210309":"SUBI -- 29781","210310":"PULAU LAUT -- 29783","210311":"PULAU TIGA -- 29783","210315":"BUNGURAN TIMUR LAUT -- 29783","210316":"BUNGURAN TENGAH -- 29783","210401":"SINGKEP -- 29875","210402":"LINGGA -- 29874","210403":"SENAYANG -- 29873","210404":"SINGKEP BARAT -- 29875","210405":"LINGGA UTARA -- 29874","210406":"SINGKEP PESISIR -- 29871","210407":"LINGGA TIMUR -- 29872","210408":"SELAYAR -- 29872","210409":"SINGKEP SELATAN -- -","210501":"SIANTAN -- 29783","210502":"PALMATAK -- 29783","210503":"SIANTAN TIMUR -- 29791","210504":"SIANTAN SELATAN -- 29791","210505":"JEMAJA TIMUR -- 29792","210506":"JEMAJA -- 29792","210507":"SIANTAN TENGAH -- 29783","217101":"BELAKANG PADANG -- 29413","217102":"BATU AMPAR -- 29452","217103":"SEKUPANG -- 29427","217104":"NONGSA -- 29466","217105":"BULANG -- 29474","217106":"LUBUK BAJA -- 29432","217107":"SEI BEDUK -- -","217108":"GALANG -- 29483","217109":"BENGKONG -- 29458","217110":"BATAM KOTA -- 29431","217111":"SAGULUNG -- 29439","217112":"BATU AJI -- 29438","217201":"TANJUNG PINANG BARAT -- 29111","217202":"TANJUNG PINANG TIMUR -- 29122","217203":"TANJUNG PINANG KOTA -- 29115","217204":"BUKIT BESTARI -- 29124","310101":"KEPULAUAN SERIBU UTARA -- 14540","310102":"KEPULAUAN SERIBU SELATAN. -- -","317101":"GAMBIR -- 10150","317102":"SAWAH BESAR -- 10720","317103":"KEMAYORAN -- 10640","317104":"SENEN -- 10460","317105":"CEMPAKA PUTIH -- 10520","317106":"MENTENG -- 10330","317107":"TANAH ABANG -- 10210","317108":"JOHAR BARU -- 10530","317201":"PENJARINGAN -- 14470","317202":"TANJUNG PRIOK -- 14320","317203":"KOJA -- 14210","317204":"CILINCING -- 14120","317205":"PADEMANGAN -- 14430","317206":"KELAPA GADING -- 14240","317301":"CENGKARENG -- 11730","317302":"GROGOL PETAMBURAN -- 11450","317303":"TAMAN SARI -- 11120","317304":"TAMBORA -- 11330","317305":"KEBON JERUK -- 11510","317306":"KALIDERES -- 11840","317307":"PAL MERAH -- 11430","317308":"KEMBANGAN -- 11640","317401":"TEBET -- 12840","317402":"SETIABUDI -- 12980","317403":"MAMPANG PRAPATAN -- 12730","317404":"PASAR MINGGU -- 12560","317405":"KEBAYORAN LAMA -- 12230","317406":"CILANDAK -- 12430","317407":"KEBAYORAN BARU -- 12150","317408":"PANCORAN -- 12770","317409":"JAGAKARSA -- 12630","317410":"PESANGGRAHAN -- 12330","317501":"MATRAMAN -- 13130","317502":"PULOGADUNG -- 13240","317503":"JATINEGARA -- 13310","317504":"KRAMATJATI -- 13530","317505":"PASAR REBO -- 13780","317506":"CAKUNG -- 13910","317507":"DUREN SAWIT -- 13440","317508":"MAKASAR -- 13620","317509":"CIRACAS -- 13720","317510":"CIPAYUNG -- 13890","320101":"CIBINONG -- 43271","320102":"GUNUNG PUTRI -- 16969","320103":"CITEUREUP -- 16810","320104":"SUKARAJA -- 16710","320105":"BABAKAN MADANG -- 16810","320106":"JONGGOL -- 16830","320107":"CILEUNGSI -- 16820","320108":"CARIU -- 16840","320109":"SUKAMAKMUR -- 16830","320110":"PARUNG -- 43357","320111":"GUNUNG SINDUR -- 16340","320112":"KEMANG -- 16310","320113":"BOJONG GEDE -- 16920","320114":"LEUWILIANG -- 16640","320115":"CIAMPEA -- 16620","320116":"CIBUNGBULANG -- 16630","320117":"PAMIJAHAN -- 16810","320118":"RUMPIN -- 16350","320119":"JASINGA -- 16670","320120":"PARUNG PANJANG -- 16360","320121":"NANGGUNG -- 16650","320122":"CIGUDEG -- 16660","320123":"TENJO -- 16370","320124":"CIAWI -- 16720","320125":"CISARUA -- 45355","320126":"MEGAMENDUNG -- 16770","320127":"CARINGIN -- 43154","320128":"CIJERUK -- 16740","320129":"CIOMAS -- 16610","320130":"DRAMAGA -- 16680","320131":"TAMANSARI -- 46196","320132":"KLAPANUNGGAL -- 16710","320133":"CISEENG -- 16120","320134":"RANCA BUNGUR -- 16310","320135":"SUKAJAYA -- 16660","320136":"TANJUNGSARI -- 16840","320137":"TAJURHALANG -- 16320","320138":"CIGOMBONG -- 16110","320139":"LEUWISADENG -- 16640","320140":"TENJOLAYA -- 16370","320201":"PELABUHANRATU -- -","320202":"SIMPENAN -- 43361","320203":"CIKAKAK -- 43365","320204":"BANTARGADUNG -- 43363","320205":"CISOLOK -- 43366","320206":"CIKIDANG -- 43367","320207":"LENGKONG -- 40262","320208":"JAMPANG TENGAH -- 43171","320209":"WARUNGKIARA -- 43362","320210":"CIKEMBAR -- 43157","320211":"CIBADAK -- 43351","320212":"NAGRAK -- 43356","320213":"PARUNGKUDA -- 43357","320214":"BOJONGGENTENG -- 43353","320215":"PARAKANSALAK -- 43355","320216":"CICURUG -- 43359","320217":"CIDAHU -- 43358","320218":"KALAPANUNGGAL -- 43354","320219":"KABANDUNGAN -- 43368","320220":"WALURAN -- 43175","320221":"JAMPANG KULON -- 43178","320222":"CIEMAS -- 43177","320223":"KALIBUNDER -- 43185","320224":"SURADE -- 43179","320225":"CIBITUNG -- 43172","320226":"CIRACAP -- 43176","320227":"GUNUNGGURUH -- 43156","320228":"CICANTAYAN -- 43155","320229":"CISAAT -- 43152","320230":"KADUDAMPIT -- 43153","320231":"CARINGIN -- 43154","320232":"SUKABUMI -- 43151","320233":"SUKARAJA -- 16710","320234":"KEBONPEDES -- 43194","320235":"CIREUNGHAS -- 43193","320236":"SUKALARANG -- 43191","320237":"PABUARAN -- 41262","320238":"PURABAYA -- 43187","320239":"NYALINDUNG -- 43196","320240":"GEGERBITUNG -- 43197","320241":"SAGARANTEN -- 43181","320242":"CURUGKEMBAR -- 43182","320243":"CIDOLOG -- 46352","320244":"CIDADAP -- 43183","320245":"TEGALBULEUD -- 43186","320246":"CIMANGGU -- 43178","320247":"CIAMBAR -- 43356","320301":"CIANJUR -- 43211","320302":"WARUNGKONDANG -- 43261","320303":"CIBEBER -- 43262","320304":"CILAKU -- 43285","320305":"CIRANJANG -- 43282","320306":"BOJONGPICUNG -- 43283","320307":"KARANGTENGAH -- 43281","320308":"MANDE -- 43292","320309":"SUKALUYU -- 43284","320310":"PACET -- 43253","320311":"CUGENANG -- 43252","320312":"CIKALONGKULON -- 43291","320313":"SUKARESMI -- 43254","320314":"SUKANAGARA -- 43264","320315":"CAMPAKA -- 41181","320316":"TAKOKAK -- 43265","320317":"KADUPANDAK -- 43268","320318":"PAGELARAN -- 43266","320319":"TANGGEUNG -- 43267","320320":"CIBINONG -- 43271","320321":"SINDANGBARANG -- 43272","320322":"AGRABINTA -- 43273","320323":"CIDAUN -- 43275","320324":"NARINGGUL -- 43274","320325":"CAMPAKAMULYA -- 43269","320326":"CIKADU -- 43284","320327":"GEKBRONG -- 43261","320328":"CIPANAS -- 43253","320329":"CIJATI -- 43284","320330":"LELES -- 44119","320331":"HAURWANGI -- 43283","320332":"PASIRKUDA -- 43267","320405":"CILEUNYI -- 40626","320406":"CIMENYAN -- -","320407":"CILENGKRANG -- 40615","320408":"BOJONGSOANG -- 40288","320409":"MARGAHAYU -- 40226","320410":"MARGAASIH -- 40214","320411":"KATAPANG -- 40921","320412":"DAYEUHKOLOT -- 40239","320413":"BANJARAN -- 40377","320414":"PAMEUNGPEUK -- 44175","320415":"PANGALENGAN -- 40378","320416":"ARJASARI -- 40379","320417":"CIMAUNG -- 40374","320425":"CICALENGKA -- 40395","320426":"NAGREG -- 40215","320427":"CIKANCUNG -- 40396","320428":"RANCAEKEK -- 40394","320429":"CIPARAY -- 40223","320430":"PACET -- 43253","320431":"KERTASARI -- 40386","320432":"BALEENDAH -- 40375","320433":"MAJALAYA -- 41371","320434":"SOLOKANJERUK -- 40376","320435":"PASEH -- 45381","320436":"IBUN -- 43185","320437":"SOREANG -- 40914","320438":"PASIRJAMBU -- 40972","320439":"CIWIDEY -- 40973","320440":"RANCABALI -- 40973","320444":"CANGKUANG -- 40238","320446":"KUTAWARINGIN -- 40911","320501":"GARUT KOTA -- 44113","320502":"KARANGPAWITAN -- 44182","320503":"WANARAJA -- 44183","320504":"TAROGONG KALER -- 44151","320505":"TAROGONG KIDUL -- 44151","320506":"BANYURESMI -- 44191","320507":"SAMARANG -- 44161","320508":"PASIRWANGI -- 44161","320509":"LELES -- 44119","320510":"KADUNGORA -- 44153","320511":"LEUWIGOONG -- 44192","320512":"CIBATU -- 44185","320513":"KERSAMANAH -- 44185","320514":"MALANGBONG -- 44188","320515":"SUKAWENING -- 44184","320516":"KARANGTENGAH -- 43281","320517":"BAYONGBONG -- 44162","320518":"CIGEDUG -- 44116","320519":"CILAWU -- 44181","320520":"CISURUPAN -- 44163","320521":"SUKARESMI -- 43254","320522":"CIKAJANG -- 44171","320523":"BANJARWANGI -- 44172","320524":"SINGAJAYA -- 44173","320525":"CIHURIP -- 44173","320526":"PEUNDEUY -- 41272","320527":"PAMEUNGPEUK -- 44175","320528":"CISOMPET -- 44174","320529":"CIBALONG -- 46185","320530":"CIKELET -- 44177","320531":"BUNGBULANG -- 44165","320532":"MEKARMUKTI -- 44165","320533":"PAKENJENG -- 44164","320534":"PAMULIHAN -- 45365","320535":"CISEWU -- 44166","320536":"CARINGIN -- 43154","320537":"TALEGONG -- 44167","320538":"BL. LIMBANGAN -- -","320539":"SELAAWI -- 44187","320540":"CIBIUK -- 44193","320541":"PANGATIKAN -- 44183","320542":"SUCINARAJA -- 44115","320601":"CIPATUJAH -- 46187","320602":"KARANGNUNGGAL -- 46186","320603":"CIKALONG -- 46195","320604":"PANCATENGAH -- 46194","320605":"CIKATOMAS -- 46193","320606":"CIBALONG -- 46185","320607":"PARUNGPONTENG -- 46185","320608":"BANTARKALONG -- 46187","320609":"BOJONGASIH -- 46475","320610":"CULAMEGA -- 46188","320611":"BOJONGGAMBIR -- 46475","320612":"SODONGHILIR -- 46473","320613":"TARAJU -- 46474","320614":"SALAWU -- 46471","320615":"PUSPAHIANG -- 46471","320616":"TANJUNGJAYA -- 46184","320617":"SUKARAJA -- 16710","320618":"SALOPA -- 46192","320619":"JATIWARAS -- 46185","320620":"CINEAM -- 46198","320621":"KARANG JAYA -- 46198","320622":"MANONJAYA -- 46197","320623":"GUNUNG TANJUNG -- 46418","320624":"SINGAPARNA -- 46418","320625":"MANGUNREJA -- 46462","320626":"SUKARAME -- 46461","320627":"CIGALONTANG -- 46463","320628":"LEUWISARI -- 46464","320629":"PADAKEMBANG -- 46466","320630":"SARIWANGI -- 46465","320631":"SUKARATU -- 46415","320632":"CISAYONG -- 46153","320633":"SUKAHENING -- 46155","320634":"RAJAPOLAH -- 46155","320635":"JAMANIS -- 46175","320636":"CIAWI -- 16720","320637":"KADIPATEN -- 45452","320638":"PAGERAGEUNG -- 46158","320639":"SUKARESIK -- 46418","320701":"CIAMIS -- 46217","320702":"CIKONENG -- 46261","320703":"CIJEUNGJING -- 46271","320704":"SADANANYA -- 46256","320705":"CIDOLOG -- 46352","320706":"CIHAURBEUTI -- 46262","320707":"PANUMBANGAN -- 46263","320708":"PANJALU -- 46264","320709":"KAWALI -- 46253","320710":"PANAWANGAN -- 46255","320711":"CIPAKU -- 46252","320712":"JATINAGARA -- 46273","320713":"RAJADESA -- 46254","320714":"SUKADANA -- 46272","320715":"RANCAH -- 46387","320716":"TAMBAKSARI -- 46388","320717":"LAKBOK -- 46385","320718":"BANJARSARI -- 46383","320719":"PAMARICAN -- 46382","320729":"CIMARAGAS -- 46381","320730":"CISAGA -- 46386","320731":"SINDANGKASIH -- 46268","320732":"BAREGBEG -- 46274","320733":"SUKAMANTRI -- 46264","320734":"LUMBUNG -- 46258","320735":"PURWADADI -- 46385","320801":"KADUGEDE -- 45561","320802":"CINIRU -- 45565","320803":"SUBANG -- 45586","320804":"CIWARU -- 45583","320805":"CIBINGBIN -- 45587","320806":"LURAGUNG -- 45581","320807":"LEBAKWANGI -- 45574","320808":"GARAWANGI -- 45571","320809":"KUNINGAN -- 45514","320810":"CIAWIGEBANG -- 45591","320811":"CIDAHU -- 43358","320812":"JALAKSANA -- 45554","320813":"CILIMUS -- 45556","320814":"MANDIRANCAN -- 45558","320815":"SELAJAMBE -- 45566","320816":"KRAMATMULYA -- 45553","320817":"DARMA -- 45562","320818":"CIGUGUR -- 45552","320819":"PASAWAHAN -- 45559","320820":"NUSAHERANG -- 45563","320821":"CIPICUNG -- 45592","320822":"PANCALANG -- 45557","320823":"JAPARA -- 45555","320824":"CIMAHI -- 40521","320825":"CILEBAK -- 45585","320826":"HANTARA -- 45564","320827":"KALIMANGGIS -- 45594","320828":"CIBEUREUM -- 46196","320829":"KARANG KANCANA -- 45584","320830":"MALEBER -- 45575","320831":"SINDANG AGUNG -- 45573","320832":"CIGANDAMEKAR -- 45556","320901":"WALED -- 45187","320902":"CILEDUG -- 45188","320903":"LOSARI -- 45192","320904":"PABEDILAN -- 45193","320905":"BABAKAN -- 40223","320906":"KARANGSEMBUNG -- 45186","320907":"LEMAHABANG -- 45183","320908":"SUSUKAN LEBAK -- 45185","320909":"SEDONG -- 45189","320910":"ASTANAJAPURA -- 45181","320911":"PANGENAN -- 45182","320912":"MUNDU -- 45173","320913":"BEBER -- 45172","320914":"TALUN -- 45171","320915":"SUMBER -- 45612","320916":"DUKUPUNTANG -- 45652","320917":"PALIMANAN -- 45161","320918":"PLUMBON -- 45155","320919":"WERU -- 45154","320920":"KEDAWUNG -- 45153","320921":"GUNUNG JATI -- 45151","320922":"KAPETAKAN -- 45152","320923":"KLANGENAN -- 45156","320924":"ARJAWINANGUN -- 45162","320925":"PANGURAGAN -- 45163","320926":"CIWARINGIN -- 45167","320927":"SUSUKAN -- 45166","320928":"GEGESIK -- 45164","320929":"KALIWEDI -- 45165","320930":"GEBANG -- 17151","320931":"DEPOK -- 45155","320932":"PASALEMAN -- 45187","320933":"PABUARAN -- 41262","320934":"KARANGWARENG -- 45186","320935":"TENGAH TANI -- 45153","320936":"PLERED -- 41162","320937":"GEMPOL -- 45161","320938":"GREGED -- 45172","320939":"SURANENGGALA -- 45152","320940":"JAMBLANG -- 45156","321001":"LEMAHSUGIH -- 45465","321002":"BANTARUJEG -- 45464","321003":"CIKIJING -- 45466","321004":"TALAGA -- 45463","321005":"ARGAPURA -- 45462","321006":"MAJA -- 16417","321007":"MAJALENGKA -- 45419","321008":"SUKAHAJI -- 45471","321009":"RAJAGALUH -- 45472","321010":"LEUWIMUNDING -- 45473","321011":"JATIWANGI -- 45454","321012":"DAWUAN -- 45453","321013":"KADIPATEN -- 45452","321014":"KERTAJATI -- 45457","321015":"JATITUJUH -- 45458","321016":"LIGUNG -- 45456","321017":"SUMBERJAYA -- 45468","321018":"PANYINGKIRAN -- 45459","321019":"PALASAH -- 45475","321020":"CIGASONG -- 45476","321021":"SINDANGWANGI -- 45474","321022":"BANJARAN -- 40377","321023":"CINGAMBUL -- 45467","321024":"KASOKANDEL -- 45453","321025":"SINDANG -- 45227","321026":"MALAUSMA -- 45464","321101":"WADO -- 45373","321102":"JATINUNGGAL -- 45376","321103":"DARMARAJA -- 45372","321104":"CIBUGEL -- 45375","321105":"CISITU -- 45363","321106":"SITURAJA -- 45371","321107":"CONGGEANG -- 45391","321108":"PASEH -- 45381","321109":"SURIAN -- 45393","321110":"BUAHDUA -- 45392","321111":"TANJUNGSARI -- 16840","321112":"SUKASARI -- 41254","321113":"PAMULIHAN -- 45365","321114":"CIMANGGUNG -- 45364","321115":"JATINANGOR -- 45363","321116":"RANCAKALONG -- 45361","321117":"SUMEDANG SELATAN -- 45311","321118":"SUMEDANG UTARA -- 45321","321119":"GANEAS -- 45356","321120":"TANJUNGKERTA -- 45354","321121":"TANJUNGMEDAR -- 45354","321122":"CIMALAKA -- 45353","321123":"CISARUA -- 45355","321124":"TOMO -- 45382","321125":"UJUNGJAYA -- 45383","321126":"JATIGEDE -- 45377","321201":"HAURGEULIS -- 45264","321202":"KROYA -- 45265","321203":"GABUSWETAN -- 45263","321204":"CIKEDUNG -- 45262","321205":"LELEA -- 45261","321206":"BANGODUA -- 45272","321207":"WIDASARI -- 45271","321208":"KERTASEMAYA -- 45274","321209":"KRANGKENG -- 45284","321210":"KARANGAMPEL -- 45283","321211":"JUNTINYUAT -- 45282","321212":"SLIYEG -- 45281","321213":"JATIBARANG -- 45273","321214":"BALONGAN -- 45217","321215":"INDRAMAYU -- 45214","321216":"SINDANG -- 45227","321217":"CANTIGI -- 45258","321218":"LOHBENER -- 45252","321219":"ARAHAN -- 45365","321220":"LOSARANG -- 45253","321221":"KANDANGHAUR -- 45254","321222":"BONGAS -- 45255","321223":"ANJATAN -- 45256","321224":"SUKRA -- 45257","321225":"GANTAR -- 45264","321226":"TRISI -- 45262","321227":"SUKAGUMIWANG -- 45274","321228":"KEDOKAN BUNDER -- 45283","321229":"PASEKAN -- 45219","321230":"TUKDANA -- 45272","321231":"PATROL -- 45257","321301":"SAGALAHERANG -- 41282","321302":"CISALAK -- 41283","321303":"SUBANG -- 45586","321304":"KALIJATI -- 41271","321305":"PABUARAN -- 41262","321306":"PURWADADI -- 46385","321307":"PAGADEN -- 41252","321308":"BINONG -- 43271","321309":"CIASEM -- 41256","321310":"PUSAKANAGARA -- 41255","321311":"PAMANUKAN -- 41254","321312":"JALANCAGAK -- 41281","321313":"BLANAKAN -- 41259","321314":"TANJUNGSIANG -- 41284","321315":"COMPRENG -- 41258","321316":"PATOKBEUSI -- 41263","321317":"CIBOGO -- 41285","321318":"CIPUNAGARA -- 41257","321319":"CIJAMBE -- 41286","321320":"CIPEUNDUEY -- -","321321":"LEGONKULON -- 41254","321322":"CIKAUM -- 41253","321323":"SERANGPANJANG -- 41282","321324":"SUKASARI -- 41254","321325":"TAMBAKDAHAN -- 41253","321326":"KASOMALANG -- 41283","321327":"DAWUAN -- 45453","321328":"PAGADEN BARAT -- 41252","321329":"CIATER -- 41281","321330":"PUSAKAJAYA -- 41255","321401":"PURWAKARTA -- 41113","321402":"CAMPAKA -- 41181","321403":"JATILUHUR -- 41161","321404":"PLERED -- 41162","321405":"SUKATANI -- 17630","321406":"DARANGDAN -- 41163","321407":"MANIIS -- 41166","321408":"TEGALWARU -- 41165","321409":"WANAYASA -- 41174","321410":"PASAWAHAN -- 45559","321411":"BOJONG -- 40232","321412":"BABAKANCIKAO -- 41151","321413":"BUNGURSARI -- 46151","321414":"CIBATU -- 44185","321415":"SUKASARI -- 41254","321416":"PONDOKSALAM -- 41115","321417":"KIARAPEDES -- 41175","321501":"KARAWANG BARAT -- 41311","321502":"PANGKALAN -- 41362","321503":"TELUKJAMBE TIMUR -- 41361","321504":"CIAMPEL -- 41363","321505":"KLARI -- 41371","321506":"RENGASDENGKLOK -- 41352","321507":"KUTAWALUYA -- 41358","321508":"BATUJAYA -- 41354","321509":"TIRTAJAYA -- 41357","321510":"PEDES -- 43194","321511":"CIBUAYA -- 41356","321512":"PAKISJAYA -- 41355","321513":"CIKAMPEK -- 41373","321514":"JATISARI -- 41374","321515":"CILAMAYA WETAN -- 41384","321516":"TIRTAMULYA -- 41372","321517":"TELAGASARI -- -","321518":"RAWAMERTA -- 41382","321519":"LEMAHABANG -- 45183","321520":"TEMPURAN -- 41385","321521":"MAJALAYA -- 41371","321522":"JAYAKERTA -- 41352","321523":"CILAMAYA KULON -- 41384","321524":"BANYUSARI -- 41374","321525":"KOTA BARU -- 41374","321526":"KARAWANG TIMUR -- 41314","321527":"TELUKJAMBE BARAT -- 41361","321528":"TEGALWARU -- 41165","321529":"PURWASARI -- 41373","321530":"CILEBAR -- 41353","321601":"TARUMAJAYA -- 17216","321602":"BABELAN -- 17610","321603":"SUKAWANGI -- 17620","321604":"TAMBELANG -- 17620","321605":"TAMBUN UTARA -- 17510","321606":"TAMBUN SELATAN -- 17510","321607":"CIBITUNG -- 43172","321608":"CIKARANG BARAT -- 17530","321609":"CIKARANG UTARA -- 17530","321610":"KARANG BAHAGIA -- 17530","321611":"CIKARANG TIMUR -- 17530","321612":"KEDUNG WARINGIN -- 17540","321613":"PEBAYURAN -- 17710","321614":"SUKAKARYA -- 17630","321615":"SUKATANI -- 17630","321616":"CABANGBUNGIN -- 17720","321617":"MUARAGEMBONG -- 17730","321618":"SETU -- 17320","321619":"CIKARANG SELATAN -- 17530","321620":"CIKARANG PUSAT -- 17530","321621":"SERANG BARU -- 17330","321622":"CIBARUSAH -- 17340","321623":"BOJONGMANGU -- 17352","321701":"LEMBANG -- 40391","321702":"PARONGPONG -- 40559","321703":"CISARUA -- 45355","321704":"CIKALONGWETAN -- 40556","321705":"CIPEUNDEUY -- 41272","321706":"NGAMPRAH -- 40552","321707":"CIPATAT -- 40554","321708":"PADALARANG -- 40553","321709":"BATUJAJAR -- 40561","321710":"CIHAMPELAS -- 40562","321711":"CILILIN -- 40562","321712":"CIPONGKOR -- 40564","321713":"RONGGA -- 40566","321714":"SINDANGKERTA -- 40563","321715":"GUNUNGHALU -- 40565","321716":"SAGULING -- 40561","321801":"PARIGI -- 46393","321802":"CIJULANG -- 46394","321803":"CIMERAK -- 46395","321804":"CIGUGUR -- 45552","321805":"LANGKAPLANCAR -- 46391","321806":"MANGUNJAYA -- 46371","321807":"PADAHERANG -- 46384","321808":"KALIPUCANG -- 46397","321809":"PANGANDARAN -- 46396","321810":"SIDAMULIH -- 46365","327101":"BOGOR SELATAN -- 16133","327102":"BOGOR TIMUR -- 16143","327103":"BOGOR TENGAH -- 16126","327104":"BOGOR BARAT -- 16116","327105":"BOGOR UTARA -- 16153","327106":"TANAH SAREAL -- -","327201":"GUNUNG PUYUH -- 43123","327202":"CIKOLE -- 43113","327203":"CITAMIANG -- 43142","327204":"WARUDOYONG -- 43132","327205":"BAROS -- 43161","327206":"LEMBURSITU -- 43168","327207":"CIBEUREUM -- 46196","327301":"SUKASARI -- 41254","327302":"COBLONG -- 40131","327303":"BABAKAN CIPARAY -- 40223","327304":"BOJONGLOA KALER -- 40232","327305":"ANDIR -- 40184","327306":"CICENDO -- 40172","327307":"SUKAJADI -- 40162","327308":"CIDADAP -- 43183","327309":"BANDUNG WETAN -- 40114","327310":"ASTANA ANYAR -- 40241","327311":"REGOL -- 40254","327312":"BATUNUNGGAL -- 40275","327313":"LENGKONG -- 40262","327314":"CIBEUNYING KIDUL -- 40121","327315":"BANDUNG KULON -- 40212","327316":"KIARACONDONG -- 40283","327317":"BOJONGLOA KIDUL -- 40239","327318":"CIBEUNYING KALER -- 40191","327319":"SUMUR BANDUNG -- 40117","327320":"ANTAPANI -- 40291","327321":"BANDUNG KIDUL -- 40266","327322":"BUAHBATU -- 40287","327323":"RANCASARI -- 40292","327324":"ARCAMANIK -- 40293","327325":"CIBIRU -- 40614","327326":"UJUNG BERUNG -- 40611","327327":"GEDEBAGE -- 40294","327328":"PANYILEUKAN -- 40614","327329":"CINAMBO -- 40294","327330":"MANDALAJATI -- 40195","327401":"KEJAKSAN -- 45121","327402":"LEMAH WUNGKUK -- 45114","327403":"HARJAMUKTI -- 45145","327404":"PEKALIPAN -- 45115","327405":"KESAMBI -- 45133","327501":"BEKASI TIMUR -- 17111","327502":"BEKASI BARAT -- 17136","327503":"BEKASI UTARA -- 17123","327504":"BEKASI SELATAN -- 17146","327505":"RAWA LUMBU -- 17117","327506":"MEDAN SATRIA -- 17143","327507":"BANTAR GEBANG -- 17151","327508":"PONDOK GEDE -- 17412","327509":"JATIASIH -- 17422","327510":"JATI SEMPURNA -- -","327511":"MUSTIKA JAYA -- 17155","327512":"PONDOK MELATI -- 17414","327601":"PANCORAN MAS -- 16432","327602":"CIMANGGIS -- 16452","327603":"SAWANGAN -- 16519","327604":"LIMO -- 16512","327605":"SUKMAJAYA -- 16417","327606":"BEJI -- 16422","327607":"CIPAYUNG -- 16436","327608":"CILODONG -- 16414","327609":"CINERE -- 16514","327610":"TAPOS -- 16458","327611":"BOJONGSARI -- 16516","327701":"CIMAHI SELATAN -- 40531","327702":"CIMAHI TENGAH -- 40521","327703":"CIMAHI UTARA -- 40513","327801":"CIHIDEUNG -- 46122","327802":"CIPEDES -- 46133","327803":"TAWANG -- 46114","327804":"INDIHIANG -- 46151","327805":"KAWALU -- 46182","327806":"CIBEUREUM -- 46196","327807":"TAMANSARI -- 46196","327808":"MANGKUBUMI -- 46181","327809":"BUNGURSARI -- 46151","327810":"PURBARATU -- 46196","327901":"BANJAR -- 46312","327902":"PATARUMAN -- 46326","327903":"PURWAHARJA -- 46332","327904":"LANGENSARI -- 46325","330101":"KEDUNGREJA -- 53263","330102":"KESUGIHAN -- 53274","330103":"ADIPALA -- 53271","330104":"BINANGUN -- 53281","330105":"NUSAWUNGU -- 53283","330106":"KROYA -- 53282","330107":"MAOS -- 53272","330108":"JERUKLEGI -- 53252","330109":"KAWUNGANTEN -- 53253","330110":"GANDRUNGMANGU -- 53254","330111":"SIDAREJA -- 53261","330112":"KARANGPUCUNG -- 53255","330113":"CIMANGGU -- 53256","330114":"MAJENANG -- 53257","330115":"WANAREJA -- 53265","330116":"DAYEUHLUHUR -- 53266","330117":"SAMPANG -- 53273","330118":"CIPARI -- 53262","330119":"PATIMUAN -- 53264","330120":"BANTARSARI -- 53281","330121":"CILACAP SELATAN -- 53211","330122":"CILACAP TENGAH -- 53222","330123":"CILACAP UTARA -- 53231","330124":"KAMPUNG LAUT -- 53253","330201":"LUMBIR -- 53177","330202":"WANGON -- 53176","330203":"JATILAWANG -- 53174","330204":"RAWALO -- 53173","330205":"KEBASEN -- 53172","330206":"KEMRANJEN -- 53194","330207":"SUMPIUH -- 53195","330208":"TAMBAK -- 59174","330209":"SOMAGEDE -- 53193","330210":"KALIBAGOR -- 53191","330211":"BANYUMAS -- 53192","330212":"PATIKRAJA -- 53171","330213":"PURWOJATI -- 53175","330214":"AJIBARANG -- 53163","330215":"GUMELAR -- 53165","330216":"PEKUNCEN -- 53164","330217":"CILONGOK -- 53162","330218":"KARANGLEWAS -- 53161","330219":"SOKARAJA -- 53181","330220":"KEMBARAN -- 53182","330221":"SUMBANG -- 53183","330222":"BATURRADEN -- -","330223":"KEDUNGBANTENG -- 53152","330224":"PURWOKERTO SELATAN -- 53146","330225":"PURWOKERTO BARAT -- 53133","330226":"PURWOKERTO TIMUR -- 53113","330227":"PURWOKERTO UTARA -- 53121","330301":"KEMANGKON -- 53381","330302":"BUKATEJA -- 53382","330303":"KEJOBONG -- 53392","330304":"KALIGONDANG -- 53391","330305":"PURBALINGGA -- 53316","330306":"KALIMANAH -- 53371","330307":"KUTASARI -- 53361","330308":"MREBET -- 53352","330309":"BOBOTSARI -- 53353","330310":"KARANGREJA -- 53357","330311":"KARANGANYAR -- 59582","330312":"KARANGMONCOL -- 53355","330313":"REMBANG -- 53356","330314":"BOJONGSARI -- 53362","330315":"PADAMARA -- 53372","330316":"PENGADEGAN -- 53393","330317":"KARANGJAMBU -- 53357","330318":"KERTANEGARA -- 53354","330401":"SUSUKAN -- 50777","330402":"PURWOREJA KLAMPOK -- -","330403":"MANDIRAJA -- 53473","330404":"PURWANEGARA -- -","330405":"BAWANG -- 53471","330406":"BANJARNEGARA -- 53418","330407":"SIGALUH -- 53481","330408":"MADUKARA -- 53482","330409":"BANJARMANGU -- 53452","330410":"WANADADI -- 53461","330411":"RAKIT -- 53463","330412":"PUNGGELAN -- 53462","330413":"KARANGKOBAR -- 53453","330414":"PAGENTAN -- 53455","330415":"PEJAWARAN -- 53454","330416":"BATUR -- 53456","330417":"WANAYASA -- 53457","330418":"KALIBENING -- 53458","330419":"PANDANARUM -- 53458","330420":"PAGEDONGAN -- 53418","330501":"AYAH -- 54473","330502":"BUAYAN -- 54474","330503":"PURING -- 54383","330504":"PETANAHAN -- 54382","330505":"KLIRONG -- 54381","330506":"BULUSPESANTREN -- 54391","330507":"AMBAL -- 54392","330508":"MIRIT -- 54395","330509":"PREMBUN -- 54394","330510":"KUTOWINANGUN -- 54393","330511":"ALIAN -- 56153","330512":"KEBUMEN -- 54317","330513":"PEJAGOAN -- 54361","330514":"SRUWENG -- 54362","330515":"ADIMULYO -- 54363","330516":"KUWARASAN -- 54366","330517":"ROWOKELE -- 54472","330518":"SEMPOR -- 54421","330519":"GOMBONG -- 54416","330520":"KARANGANYAR -- 59582","330521":"KARANGGAYAM -- 54365","330522":"SADANG -- 54353","330523":"BONOROWO -- 54395","330524":"PADURESO -- 54394","330525":"PONCOWARNO -- 54393","330526":"KARANGSAMBUNG -- 54353","330601":"GRABAG -- 54265","330602":"NGOMBOL -- 54172","330603":"PURWODADI -- 54173","330604":"BAGELEN -- 54174","330605":"KALIGESING -- 54175","330606":"PURWOREJO -- 54118","330607":"BANYUURIP -- 54171","330608":"BAYAN -- 54224","330609":"KUTOARJO -- 54211","330610":"BUTUH -- 54264","330611":"PITURUH -- 54263","330612":"KEMIRI -- 54262","330613":"BRUNO -- 54261","330614":"GEBANG -- 54191","330615":"LOANO -- 54181","330616":"BENER -- 54183","330701":"WADASLINTANG -- 56365","330702":"KEPIL -- 56374","330703":"SAPURAN -- 56373","330704":"KALIWIRO -- 56364","330705":"LEKSONO -- 56362","330706":"SELOMERTO -- 56361","330707":"KALIKAJAR -- 56372","330708":"KERTEK -- 56371","330709":"WONOSOBO -- 56318","330710":"WATUMALANG -- 56352","330711":"MOJOTENGAH -- 56351","330712":"GARUNG -- 56353","330713":"KEJAJAR -- 56354","330714":"SUKOHARJO -- 57551","330715":"KALIBAWANG -- 56373","330801":"SALAMAN -- 56162","330802":"BOROBUDUR -- 56553","330803":"NGLUWAR -- 56485","330804":"SALAM -- 56162","330805":"SRUMBUNG -- 56483","330806":"DUKUN -- 56482","330807":"SAWANGAN -- 56481","330808":"MUNTILAN -- 56415","330809":"MUNGKID -- 56512","330810":"MERTOYUDAN -- 56172","330811":"TEMPURAN -- 56161","330812":"KAJORAN -- 56163","330813":"KALIANGKRIK -- 56153","330814":"BANDONGAN -- 56151","330815":"CANDIMULYO -- 56191","330816":"PAKIS -- 56193","330817":"NGABLAK -- 56194","330818":"GRABAG -- 54265","330819":"TEGALREJO -- 56192","330820":"SECANG -- 56195","330821":"WINDUSARI -- 56152","330901":"SELO -- 56361","330902":"AMPEL -- 52364","330903":"CEPOGO -- 57362","330904":"MUSUK -- 57331","330905":"BOYOLALI -- 57313","330906":"MOJOSONGO -- 57322","330907":"TERAS -- 57372","330908":"SAWIT -- 57374","330909":"BANYUDONO -- 57373","330910":"SAMBI -- 57376","330911":"NGEMPLAK -- 57375","330912":"NOGOSARI -- 57378","330913":"SIMO -- 57377","330914":"KARANGGEDE -- 57381","330915":"KLEGO -- 57385","330916":"ANDONG -- 57384","330917":"KEMUSU -- 57383","330918":"WONOSEGORO -- 57382","330919":"JUWANGI -- 57391","331001":"PRAMBANAN -- 57454","331002":"GANTIWARNO -- 57455","331003":"WEDI -- 57461","331004":"BAYAT -- 57462","331005":"CAWAS -- 57463","331006":"TRUCUK -- 57467","331007":"KEBONARUM -- 57486","331008":"JOGONALAN -- 57452","331009":"MANISRENGGO -- 57485","331010":"KARANGNONGKO -- 57483","331011":"CEPER -- 57465","331012":"PEDAN -- 57468","331013":"KARANGDOWO -- 57464","331014":"JUWIRING -- 57472","331015":"WONOSARI -- 57473","331016":"DELANGGU -- 57471","331017":"POLANHARJO -- 57474","331018":"KARANGANOM -- 57475","331019":"TULUNG -- 57482","331020":"JATINOM -- 57481","331021":"KEMALANG -- 57484","331022":"NGAWEN -- 58254","331023":"KALIKOTES -- 57451","331024":"KLATEN UTARA -- 57438","331025":"KLATEN TENGAH -- 57414","331026":"KLATEN SELATAN -- 57425","331101":"WERU -- 57562","331102":"BULU -- 54391","331103":"TAWANGSARI -- 57561","331104":"SUKOHARJO -- 57551","331105":"NGUTER -- 57571","331106":"BENDOSARI -- 57528","331107":"POLOKARTO -- 57555","331108":"MOJOLABAN -- 57554","331109":"GROGOL -- 57552","331110":"BAKI -- 57556","331111":"GATAK -- 57557","331112":"KARTASURA -- 57169","331201":"PRACIMANTORO -- 57664","331202":"GIRITONTRO -- 57678","331203":"GIRIWOYO -- 57675","331204":"BATUWARNO -- 57674","331205":"TIRTOMOYO -- 57672","331206":"NGUNTORONADI -- 57671","331207":"BATURETNO -- 57673","331208":"EROMOKO -- 57663","331209":"WURYANTORO -- 57661","331210":"MANYARAN -- 57662","331211":"SELOGIRI -- 57652","331212":"WONOGIRI -- 57615","331213":"NGADIROJO -- 57681","331214":"SIDOHARJO -- 57281","331215":"JATIROTO -- 57692","331216":"KISMANTORO -- 57696","331217":"PURWANTORO -- 57695","331218":"BULUKERTO -- 57697","331219":"SLOGOHIMO -- 57694","331220":"JATISRONO -- 57691","331221":"JATIPURNO -- 57693","331222":"GIRIMARTO -- 57683","331223":"KARANGTENGAH -- 59561","331224":"PARANGGUPITO -- 57678","331225":"PUHPELEM -- 57698","331301":"JATIPURO -- 57784","331302":"JATIYOSO -- 57785","331303":"JUMAPOLO -- 57783","331304":"JUMANTONO -- 57782","331305":"MATESIH -- 57781","331306":"TAWANGMANGU -- 57792","331307":"NGARGOYOSO -- 57793","331308":"KARANGPANDAN -- 57791","331309":"KARANGANYAR -- 59582","331310":"TASIKMADU -- 57722","331311":"JATEN -- 57731","331312":"COLOMADU -- 57171","331313":"GONDANGREJO -- 57188","331314":"KEBAKKRAMAT -- 57762","331315":"MOJOGEDANG -- 57752","331316":"KERJO -- 57753","331317":"JENAWI -- 57794","331401":"KALIJAMBE -- 57275","331402":"PLUPUH -- 57283","331403":"MASARAN -- 57282","331404":"KEDAWUNG -- 57292","331405":"SAMBIREJO -- 57293","331406":"GONDANG -- 53391","331407":"SAMBUNGMACAN -- 57253","331408":"NGRAMPAL -- 57252","331409":"KARANGMALANG -- 57222","331410":"SRAGEN -- 57216","331411":"SIDOHARJO -- 57281","331412":"TANON -- 57277","331413":"GEMOLONG -- 57274","331414":"MIRI -- 57276","331415":"SUMBERLAWANG -- 57272","331416":"MONDOKAN -- 57271","331417":"SUKODONO -- 57263","331418":"GESI -- 57262","331419":"TANGEN -- 57261","331420":"JENAR -- 57256","331501":"KEDUNGJATI -- 58167","331502":"KARANGRAYUNG -- 58163","331503":"PENAWANGAN -- 58161","331504":"TOROH -- 58171","331505":"GEYER -- 58172","331506":"PULOKULON -- 58181","331507":"KRADENAN -- 58182","331508":"GABUS -- 59173","331509":"NGARINGAN -- 58193","331510":"WIROSARI -- 58192","331511":"TAWANGHARJO -- 58191","331512":"GROBOGAN -- 58152","331513":"PURWODADI -- 54173","331514":"BRATI -- 58153","331515":"KLAMBU -- 58154","331516":"GODONG -- 58162","331517":"GUBUG -- 58164","331518":"TEGOWANU -- 58165","331519":"TANGGUNGHARJO -- 58166","331601":"JATI -- 53174","331602":"RANDUBLATUNG -- 58382","331603":"KRADENAN -- 58182","331604":"KEDUNGTUBAN -- 58381","331605":"CEPU -- 58311","331606":"SAMBONG -- 58371","331607":"JIKEN -- 58372","331608":"JEPON -- 58261","331609":"BLORA -- 58219","331610":"TUNJUNGAN -- 58252","331611":"BANJAREJO -- 58253","331612":"NGAWEN -- 58254","331613":"KUNDURAN -- 58255","331614":"TODANAN -- 58256","331615":"BOGOREJO -- 58262","331616":"JAPAH -- 58257","331701":"SUMBER -- 59253","331702":"BULU -- 54391","331703":"GUNEM -- 59263","331704":"SALE -- 59265","331705":"SARANG -- 59274","331706":"SEDAN -- 59264","331707":"PAMOTAN -- 59261","331708":"SULANG -- 59254","331709":"KALIORI -- 59252","331710":"REMBANG -- 53356","331711":"PANCUR -- 59262","331712":"KRAGAN -- 59273","331713":"SLUKE -- 59272","331714":"LASEM -- 59271","331801":"SUKOLILO -- 59172","331802":"KAYEN -- 59171","331803":"TAMBAKROMO -- 59174","331804":"WINONG -- 59181","331805":"PUCAKWANGI -- 59183","331806":"JAKEN -- 59184","331807":"BATANGAN -- 59186","331808":"JUWANA -- 59185","331809":"JAKENAN -- 59182","331810":"PATI -- 59114","331811":"GABUS -- 59173","331812":"MARGOREJO -- 59163","331813":"GEMBONG -- 59162","331814":"TLOGOWUNGU -- 59161","331815":"WEDARIJAKSA -- 59152","331816":"MARGOYOSO -- 59154","331817":"GUNUNGWUNGKAL -- 59156","331818":"CLUWAK -- 59157","331819":"TAYU -- 59155","331820":"DUKUHSETI -- 59158","331821":"TRANGKIL -- 59153","331901":"KALIWUNGU -- 59332","331902":"KOTA KUDUS -- -","331903":"JATI -- 53174","331904":"UNDAAN -- 59372","331905":"MEJOBO -- 59381","331906":"JEKULO -- 59382","331907":"BAE -- 59325","331908":"GEBOG -- 59333","331909":"DAWE -- 59353","332001":"KEDUNG -- 51173","332002":"PECANGAAN -- 59462","332003":"WELAHAN -- 59464","332004":"MAYONG -- 59465","332005":"BATEALIT -- 59461","332006":"JEPARA -- 59432","332007":"MLONGGO -- 59452","332008":"BANGSRI -- 59453","332009":"KELING -- 59454","332010":"KARIMUN JAWA -- 59455","332011":"TAHUNAN -- 59422","332012":"NALUMSARI -- 59466","332013":"KALINYAMATAN -- 59462","332014":"KEMBANG -- 59453","332015":"PAKIS AJI -- 59452","332016":"DONOROJO -- 59454","332101":"MRANGGEN -- 59567","332102":"KARANGAWEN -- 59566","332103":"GUNTUR -- 59565","332104":"SAYUNG -- 59563","332105":"KARANGTENGAH -- 59561","332106":"WONOSALAM -- 59571","332107":"DEMPET -- 59573","332108":"GAJAH -- 59581","332109":"KARANGANYAR -- 59582","332110":"MIJEN -- 59583","332111":"DEMAK -- 59517","332112":"BONANG -- 59552","332113":"WEDUNG -- 59554","332114":"KEBONAGUNG -- 59583","332201":"GETASAN -- 50774","332202":"TENGARAN -- 50775","332203":"SUSUKAN -- 50777","332204":"SURUH -- 50776","332205":"PABELAN -- 50771","332206":"TUNTANG -- 50773","332207":"BANYUBIRU -- 50664","332208":"JAMBU -- 50663","332209":"SUMOWONO -- 50662","332210":"AMBARAWA -- 50614","332211":"BAWEN -- 50661","332212":"BRINGIN -- 50772","332213":"BERGAS -- 50552","332215":"PRINGAPUS -- 50214","332216":"BANCAK -- 50182","332217":"KALIWUNGU -- 59332","332218":"UNGARAN BARAT -- 50517","332219":"UNGARAN TIMUR -- 50519","332220":"BANDUNGAN -- 50614","332301":"BULU -- 54391","332302":"TEMBARAK -- 56261","332303":"TEMANGGUNG -- 56211","332304":"PRINGSURAT -- 56272","332305":"KALORAN -- 56282","332306":"KANDANGAN -- 56281","332307":"KEDU -- 51173","332308":"PARAKAN -- 56254","332309":"NGADIREJO -- 56255","332310":"JUMO -- 56256","332311":"TRETEP -- 56259","332312":"CANDIROTO -- 56257","332313":"KRANGGAN -- 56271","332314":"TLOGOMULYO -- 56263","332315":"SELOPAMPANG -- 56262","332316":"BANSARI -- 56265","332317":"KLEDUNG -- 56264","332318":"BEJEN -- 56258","332319":"WONOBOYO -- 56266","332320":"GEMAWANG -- 56283","332401":"PLANTUNGAN -- 51362","332402":"PAGERUYUNG -- -","332403":"SUKOREJO -- 51363","332404":"PATEAN -- 51364","332405":"SINGOROJO -- 51382","332406":"LIMBANGAN -- 51383","332407":"BOJA -- 51381","332408":"KALIWUNGU -- 59332","332409":"BRANGSONG -- 51371","332410":"PEGANDON -- 51357","332411":"GEMUH -- 51356","332412":"WELERI -- 51355","332413":"CEPIRING -- 51352","332414":"PATEBON -- 51351","332415":"KENDAL -- 51312","332416":"ROWOSARI -- 51354","332417":"KANGKUNG -- 51353","332418":"RINGINARUM -- 51356","332419":"NGAMPEL -- 51357","332420":"KALIWUNGU SELATAN -- 51372","332501":"WONOTUNGGAL -- 51253","332502":"BANDAR -- 51254","332503":"BLADO -- 51255","332504":"REBAN -- 51273","332505":"BAWANG -- 53471","332506":"TERSONO -- 51272","332507":"GRINGSING -- 51281","332508":"LIMPUNG -- 51271","332509":"SUBAH -- 51262","332510":"TULIS -- 51261","332511":"BATANG -- 59186","332512":"WARUNGASEM -- 51252","332513":"KANDEMAN -- 51261","332514":"PECALUNGAN -- 51262","332515":"BANYUPUTIH -- 51281","332601":"KANDANGSERANG -- 51163","332602":"PANINGGARAN -- 51164","332603":"LEBAKBARANG -- 51183","332604":"PETUNGKRIYONO -- 51193","332605":"TALUN -- 51192","332606":"DORO -- 51191","332607":"KARANGANYAR -- 59582","332608":"KAJEN -- 51161","332609":"KESESI -- 51162","332610":"SRAGI -- 51155","332611":"BOJONG -- 51156","332612":"WONOPRINGGO -- 51181","332613":"KEDUNGWUNI -- 51173","332614":"BUARAN -- 51171","332615":"TIRTO -- 57672","332616":"WIRADESA -- 51152","332617":"SIWALAN -- 51137","332618":"KARANGDADAP -- 51174","332619":"WONOKERTO -- 51153","332701":"MOGA -- 52354","332702":"PULOSARI -- 52355","332703":"BELIK -- 52356","332704":"WATUKUMPUL -- 52357","332705":"BODEH -- 52365","332706":"BANTARBOLANG -- 52352","332707":"RANDUDONGKAL -- 52353","332708":"PEMALANG -- 52319","332709":"TAMAN -- 52361","332710":"PETARUKAN -- 52362","332711":"AMPELGADING -- 52364","332712":"COMAL -- 52363","332713":"ULUJAMI -- 52371","332714":"WARUNGPRING -- 52354","332801":"MARGASARI -- 52463","332802":"BUMIJAWA -- 52466","332803":"BOJONG -- 51156","332804":"BALAPULANG -- 52464","332805":"PAGERBARANG -- 52462","332806":"LEBAKSIU -- 52461","332807":"JATINEGARA -- 52473","332808":"KEDUNGBANTENG -- 53152","332809":"PANGKAH -- 52471","332810":"SLAWI -- 52419","332811":"ADIWERNA -- 52194","332812":"TALANG -- 52193","332813":"DUKUHTURI -- 52192","332814":"TARUB -- 52184","332815":"KRAMAT -- 57762","332816":"SURADADI -- -","332817":"WARUREJA -- -","332818":"DUKUHWARU -- 52451","332901":"SALEM -- 52275","332902":"BANTARKAWUNG -- 52274","332903":"BUMIAYU -- 52273","332904":"PAGUYANGAN -- 52276","332905":"SIRAMPOG -- 52272","332906":"TONJONG -- 52271","332907":"JATIBARANG -- 52261","332908":"WANASARI -- 52252","332909":"BREBES -- 52216","332910":"SONGGOM -- 52266","332911":"KERSANA -- 52264","332912":"LOSARI -- 52255","332913":"TANJUNG -- 52254","332914":"BULAKAMBA -- 52253","332915":"LARANGAN -- 52262","332916":"KETANGGUNGAN -- 52263","332917":"BANJARHARJO -- 52265","337101":"MAGELANG SELATAN -- 56123","337102":"MAGELANG UTARA -- 56114","337103":"MAGELANG TENGAH -- 56121","337201":"LAWEYAN -- 57149","337202":"SERENGAN -- 57156","337203":"PASAR KLIWON -- 57144","337204":"JEBRES -- 57122","337205":"BANJARSARI -- 57137","337301":"SIDOREJO -- 50715","337302":"TINGKIR -- 50743","337303":"ARGOMULYO -- 50736","337304":"SIDOMUKTI -- 50722","337401":"SEMARANG TENGAH -- 50138","337402":"SEMARANG UTARA -- 50175","337403":"SEMARANG TIMUR -- 50126","337404":"GAYAMSARI -- 50248","337405":"GENUK -- 50115","337406":"PEDURUNGAN -- 50246","337407":"SEMARANG SELATAN -- 50245","337408":"CANDISARI -- 50257","337409":"GAJAHMUNGKUR -- 50235","337410":"TEMBALANG -- 50277","337411":"BANYUMANIK -- 50264","337412":"GUNUNGPATI -- 50223","337413":"SEMARANG BARAT -- 50141","337414":"MIJEN -- 59583","337415":"NGALIYAN -- 50211","337416":"TUGU -- 50151","337501":"PEKALONGAN BARAT -- 51119","337502":"PEKALONGAN TIMUR -- 51129","337503":"PEKALONGAN UTARA -- 51143","337504":"PEKALONGAN SELATAN -- 51139","337601":"TEGAL BARAT -- 52115","337602":"TEGAL TIMUR -- 52124","337603":"TEGAL SELATAN -- 52137","337604":"MARGADANA -- 52147","340101":"TEMON -- 55654","340102":"WATES -- 55651","340103":"PANJATAN -- 55655","340104":"GALUR -- 55661","340105":"LENDAH -- 55663","340106":"SENTOLO -- 55664","340107":"PENGASIH -- 55652","340108":"KOKAP -- 55653","340109":"GIRIMULYO -- 55674","340110":"NANGGULAN -- 55671","340111":"SAMIGALUH -- 55673","340112":"KALIBAWANG -- 55672","340201":"SRANDAKAN -- 55762","340202":"SANDEN -- 55763","340203":"KRETEK -- 55772","340204":"PUNDONG -- 55771","340205":"BAMBANG LIPURO -- 55764","340206":"PANDAK -- 55761","340207":"PAJANGAN -- 55751","340208":"BANTUL -- 55711","340209":"JETIS -- 55231","340210":"IMOGIRI -- 55782","340211":"DLINGO -- 55783","340212":"BANGUNTAPAN -- 55198","340213":"PLERET -- 55791","340214":"PIYUNGAN -- 55792","340215":"SEWON -- 55188","340216":"KASIHAN -- 55184","340217":"SEDAYU -- 55752","340301":"WONOSARI -- 55811","340302":"NGLIPAR -- 55852","340303":"PLAYEN -- 55861","340304":"PATUK -- 55862","340305":"PALIYAN -- 55871","340306":"PANGGANG -- 55872","340307":"TEPUS -- 55881","340308":"SEMANU -- 55893","340309":"KARANGMOJO -- 55891","340310":"PONJONG -- 55892","340311":"RONGKOP -- 55883","340312":"SEMIN -- 55854","340313":"NGAWEN -- 55853","340314":"GEDANGSARI -- 55863","340315":"SAPTOSARI -- 55871","340316":"GIRISUBO -- 55883","340317":"TANJUNGSARI -- 55881","340318":"PURWOSARI -- 55872","340401":"GAMPING -- 55294","340402":"GODEAN -- 55264","340403":"MOYUDAN -- 55563","340404":"MINGGIR -- 55562","340405":"SEYEGAN -- 55561","340406":"MLATI -- 55285","340407":"DEPOK -- 55281","340408":"BERBAH -- 55573","340409":"PRAMBANAN -- 55572","340410":"KALASAN -- 55571","340411":"NGEMPLAK -- 55584","340412":"NGAGLIK -- 55581","340413":"SLEMAN -- 55515","340414":"TEMPEL -- 55552","340415":"TURI -- 55551","340416":"PAKEM -- 55582","340417":"CANGKRINGAN -- 55583","347101":"TEGALREJO -- 55243","347102":"JETIS -- 55231","347103":"GONDOKUSUMAN -- 55225","347104":"DANUREJAN -- 55211","347105":"GEDONGTENGEN -- 55272","347106":"NGAMPILAN -- 55261","347107":"WIROBRAJAN -- 55253","347108":"MANTRIJERON -- 55142","347109":"KRATON -- 55132","347110":"GONDOMANAN -- 55122","347111":"PAKUALAMAN -- 55111","347112":"MERGANGSAN -- 55153","347113":"UMBULHARJO -- 55163","347114":"KOTAGEDE -- 55172","350101":"DONOROJO -- 63554","350102":"PRINGKUKU -- 63552","350103":"PUNUNG -- 63553","350104":"PACITAN -- 63516","350105":"KEBONAGUNG -- 63561","350106":"ARJOSARI -- 63581","350107":"NAWANGAN -- 63584","350108":"BANDAR -- 61462","350109":"TEGALOMBO -- 63582","350110":"TULAKAN -- 63571","350111":"NGADIROJO -- 63572","350112":"SUDIMORO -- 63573","350201":"SLAHUNG -- 63463","350202":"NGRAYUN -- 63464","350203":"BUNGKAL -- 63462","350204":"SAMBIT -- 63474","350205":"SAWOO -- 63475","350206":"SOOKO -- 63482","350207":"PULUNG -- 63481","350208":"MLARAK -- 63472","350209":"JETIS -- 61352","350210":"SIMAN -- 62164","350211":"BALONG -- 61173","350212":"KAUMAN -- 66261","350213":"BADEGAN -- 63455","350214":"SAMPUNG -- 63454","350215":"SUKOREJO -- 63453","350216":"BABADAN -- 63491","350217":"PONOROGO -- 63419","350218":"JENANGAN -- 63492","350219":"NGEBEL -- 63493","350220":"JAMBON -- 63456","350221":"PUDAK -- 63418","350301":"PANGGUL -- 66364","350302":"MUNJUNGAN -- 66365","350303":"PULE -- 66362","350304":"DONGKO -- 66363","350305":"TUGU -- 66318","350306":"KARANGAN -- 63257","350307":"KAMPAK -- 66373","350308":"WATULIMO -- 66382","350309":"BENDUNGAN -- 66351","350310":"GANDUSARI -- 66187","350311":"TRENGGALEK -- 66318","350312":"POGALAN -- 66371","350313":"DURENAN -- 66381","350314":"SURUH -- 66362","350401":"TULUNGAGUNG -- 66218","350402":"BOYOLANGU -- 66233","350403":"KEDUNGWARU -- 66229","350404":"NGANTRU -- 66252","350405":"KAUMAN -- 66261","350406":"PAGERWOJO -- 66262","350407":"SENDANG -- 66254","350408":"KARANGREJO -- 66253","350409":"GONDANG -- 67174","350410":"SUMBERGEMPOL -- 66291","350411":"NGUNUT -- 66292","350412":"PUCANGLABAN -- 66284","350413":"REJOTANGAN -- 66293","350414":"KALIDAWIR -- 66281","350415":"BESUKI -- 66275","350416":"CAMPURDARAT -- 66272","350417":"BANDUNG -- 66274","350418":"PAKEL -- 66273","350419":"TANGGUNGGUNUNG -- 66283","350501":"WONODADI -- 66155","350502":"UDANAWU -- 66154","350503":"SRENGAT -- 66152","350504":"KADEMANGAN -- 66161","350505":"BAKUNG -- 66163","350506":"PONGGOK -- 66153","350507":"SANANKULON -- 66151","350508":"WONOTIRTO -- 66173","350509":"NGLEGOK -- 66181","350510":"KANIGORO -- 66171","350511":"GARUM -- 66182","350512":"SUTOJAYAN -- 66172","350513":"PANGGUNGREJO -- 66174","350514":"TALUN -- 66183","350515":"GANDUSARI -- 66187","350516":"BINANGUN -- 62293","350517":"WLINGI -- 66184","350518":"DOKO -- 66186","350519":"KESAMBEN -- 61484","350520":"WATES -- 64174","350521":"SELOREJO -- 66192","350522":"SELOPURO -- 66184","350601":"SEMEN -- 64161","350602":"MOJO -- 61382","350603":"KRAS -- 64172","350604":"NGADILUWIH -- 64171","350605":"KANDAT -- 64173","350606":"WATES -- 64174","350607":"NGANCAR -- 64291","350608":"PUNCU -- 64292","350609":"PLOSOKLATEN -- 64175","350610":"GURAH -- 64181","350611":"PAGU -- 64183","350612":"GAMPENGREJO -- 64182","350613":"GROGOL -- 64151","350614":"PAPAR -- 64153","350615":"PURWOASRI -- 64154","350616":"PLEMAHAN -- 64155","350617":"PARE -- 65166","350618":"KEPUNG -- 64293","350619":"KANDANGAN -- 64294","350620":"TAROKAN -- 64152","350621":"KUNJANG -- 64156","350622":"BANYAKAN -- 64157","350623":"RINGINREJO -- 64176","350624":"KAYEN KIDUL -- 64183","350625":"NGASEM -- 62154","350626":"BADAS -- 64221","350701":"DONOMULYO -- 65167","350702":"PAGAK -- 65168","350703":"BANTUR -- 65179","350704":"SUMBERMANJING WETAN -- 65176","350705":"DAMPIT -- 65181","350706":"AMPELGADING -- 65183","350707":"PONCOKUSUMO -- 65157","350708":"WAJAK -- 65173","350709":"TUREN -- 65175","350710":"GONDANGLEGI -- 65174","350711":"KALIPARE -- 65166","350712":"SUMBERPUCUNG -- 65165","350713":"KEPANJEN -- 65163","350714":"BULULAWANG -- 65171","350715":"TAJINAN -- 65172","350716":"TUMPANG -- 65156","350717":"JABUNG -- 65155","350718":"PAKIS -- 65154","350719":"PAKISAJI -- 65162","350720":"NGAJUNG -- 65164","350721":"WAGIR -- 65158","350722":"DAU -- 65151","350723":"KARANG PLOSO -- 65152","350724":"SINGOSARI -- 65153","350725":"LAWANG -- 65171","350726":"PUJON -- 65391","350727":"NGANTANG -- 65392","350728":"KASEMBON -- 65393","350729":"GEDANGAN -- 61254","350730":"TIRTOYUDO -- 65183","350731":"KROMENGAN -- 65165","350732":"WONOSARI -- 65164","350733":"PAGELARAN -- 65174","350801":"TEMPURSARI -- 67375","350802":"PRONOJIWO -- 67374","350803":"CANDIPURO -- 67373","350804":"PASIRIAN -- 67372","350805":"TEMPEH -- 67371","350806":"KUNIR -- 67292","350807":"YOSOWILANGUN -- 67382","350808":"ROWOKANGKUNG -- 67359","350809":"TEKUNG -- 67381","350810":"LUMAJANG -- 67316","350811":"PASRUJAMBE -- 67361","350812":"SENDURO -- 67361","350813":"GUCIALIT -- 67353","350814":"PADANG -- 67352","350815":"SUKODONO -- 61258","350816":"KEDUNGJAJANG -- 67358","350817":"JATIROTO -- 67355","350818":"RANDUAGUNG -- 67354","350819":"KLAKAH -- 67356","350820":"RANUYOSO -- 67357","350821":"SUMBERSUKO -- 67316","350901":"JOMBANG -- 61419","350902":"KENCONG -- 68167","350903":"SUMBERBARU -- 68156","350904":"GUMUKMAS -- 68165","350905":"UMBULSARI -- 68166","350906":"TANGGUL -- 61272","350907":"SEMBORO -- 68157","350908":"PUGER -- 68164","350909":"BANGSALSARI -- 68154","350910":"BALUNG -- 68161","350911":"WULUHAN -- 68162","350912":"AMBULU -- 68172","350913":"RAMBIPUJI -- 68152","350914":"PANTI -- 68153","350915":"SUKORAMBI -- 68151","350916":"JENGGAWAH -- 68171","350917":"AJUNG -- 68175","350918":"TEMPUREJO -- 68173","350919":"KALIWATES -- 68131","350920":"PATRANG -- 68118","350921":"SUMBERSARI -- 68125","350922":"ARJASA -- 69491","350923":"MUMBULSARI -- 68174","350924":"PAKUSARI -- 68181","350925":"JELBUK -- 68192","350926":"MAYANG -- 62184","350927":"KALISAT -- 68193","350928":"LEDOKOMBO -- 68196","350929":"SUKOWONO -- 68194","350930":"SILO -- 68184","350931":"SUMBERJAMBE -- 68195","351001":"PESANGGARAN -- 68488","351002":"BANGOREJO -- 68487","351003":"PURWOHARJO -- 68483","351004":"TEGALDLIMO -- 68484","351005":"MUNCAR -- 68472","351006":"CLURING -- 68482","351007":"GAMBIRAN -- 68486","351008":"SRONO -- 68471","351009":"GENTENG -- 69482","351010":"GLENMORE -- 68466","351011":"KALIBARU -- 68467","351012":"SINGOJURUH -- 68464","351013":"ROGOJAMPI -- 68462","351014":"KABAT -- 68461","351015":"GLAGAH -- 68431","351016":"BANYUWANGI -- 68419","351017":"GIRI -- 68424","351018":"WONGSOREJO -- 68453","351019":"SONGGON -- 68463","351020":"SEMPU -- 68468","351021":"KALIPURO -- 68455","351022":"SILIRAGUNG -- 68488","351023":"TEGALSARI -- 68485","351024":"LICIN -- 68454","351101":"MAESAN -- 68262","351102":"TAMANAN -- 68263","351103":"TLOGOSARI -- 68272","351104":"SUKOSARI -- 68287","351105":"PUJER -- 68271","351106":"GRUJUGAN -- 68261","351107":"CURAHDAMI -- 68251","351108":"TENGGARANG -- 68281","351109":"WONOSARI -- 65164","351110":"TAPEN -- 68283","351111":"BONDOWOSO -- 68214","351112":"WRINGIN -- 68252","351113":"TEGALAMPEL -- 68291","351114":"KLABANG -- 68284","351115":"CERMEE -- 68286","351116":"PRAJEKAN -- 68285","351117":"PAKEM -- 68253","351118":"SUMBERWRINGIN -- 68287","351119":"SEMPOL -- 68288","351120":"BINAKAL -- 68251","351121":"TAMAN KROCOK -- 68291","351122":"BOTOLINGGO -- 68284","351123":"JAMBESARI DARUS SHOLAH -- 68261","351201":"JATIBANTENG -- 68357","351202":"BESUKI -- 66275","351203":"SUBOH -- 68354","351204":"MLANDINGAN -- 68353","351205":"KENDIT -- 68352","351206":"PANARUKAN -- 68351","351207":"SITUBONDO -- 68311","351208":"PANJI -- 68321","351209":"MANGARAN -- 68363","351210":"KAPONGAN -- 68362","351211":"ARJASA -- 69491","351212":"JANGKAR -- 68372","351213":"ASEMBAGUS -- 68373","351214":"BANYUPUTIH -- 68374","351215":"SUMBERMALANG -- 68355","351216":"BANYUGLUGUR -- 68359","351217":"BUNGATAN -- 68358","351301":"SUKAPURA -- 67254","351302":"SUMBER -- 68355","351303":"KURIPAN -- 67262","351304":"BANTARAN -- 67261","351305":"LECES -- 67273","351306":"BANYUANYAR -- 67275","351307":"TIRIS -- 67287","351308":"KRUCIL -- 67288","351309":"GADING -- 65183","351310":"PAKUNIRAN -- 67292","351311":"KOTAANYAR -- 67293","351312":"PAITON -- 67291","351313":"BESUK -- 67283","351314":"KRAKSAAN -- 67282","351315":"KREJENGAN -- 67284","351316":"PEJARAKAN -- -","351317":"MARON -- 67276","351318":"GENDING -- 67272","351319":"DRINGU -- 67271","351320":"TEGALSIWALAN -- 67274","351321":"SUMBERASIH -- 67251","351322":"WONOMERTO -- 67253","351323":"TONGAS -- 67252","351324":"LUMBANG -- 67183","351401":"PURWODADI -- 67163","351402":"TUTUR -- 67165","351403":"PUSPO -- 67176","351404":"LUMBANG -- 67183","351405":"PASREPAN -- 67175","351406":"KEJAYAN -- 67172","351407":"WONOREJO -- 67173","351408":"PURWOSARI -- 67162","351409":"SUKOREJO -- 63453","351410":"PRIGEN -- 67157","351411":"PANDAAN -- 67156","351412":"GEMPOL -- 66291","351413":"BEJI -- 67154","351414":"BANGIL -- 62364","351415":"REMBANG -- 60179","351416":"KRATON -- 67151","351417":"POHJENTREK -- 67171","351418":"GONDANGWETAN -- 67174","351419":"WINONGAN -- 67182","351420":"GRATI -- 67184","351421":"NGULING -- 67185","351422":"LEKOK -- 67186","351423":"REJOSO -- 67181","351424":"TOSARI -- 67177","351501":"TARIK -- 61265","351502":"PRAMBON -- 64484","351503":"KREMBUNG -- 61275","351504":"PORONG -- 61274","351505":"JABON -- 61276","351506":"TANGGULANGIN -- 61272","351507":"CANDI -- 61271","351508":"SIDOARJO -- 61225","351509":"TULANGAN -- 61273","351510":"WONOAYU -- 61261","351511":"KRIAN -- 61262","351512":"BALONGBENDO -- 61263","351513":"TAMAN -- 63137","351514":"SUKODONO -- 61258","351515":"BUDURAN -- 61252","351516":"GEDANGAN -- 61254","351517":"SEDATI -- 61253","351518":"WARU -- 69353","351601":"JATIREJO -- 61373","351602":"GONDANG -- 67174","351603":"PACET -- 61374","351604":"TRAWAS -- 61375","351605":"NGORO -- 61473","351606":"PUNGGING -- 61384","351607":"KUTOREJO -- 61383","351608":"MOJOSARI -- 61382","351609":"DLANGGU -- 61371","351610":"BANGSAL -- 68154","351611":"PURI -- 61363","351612":"TROWULAN -- 61362","351613":"SOOKO -- 63482","351614":"GEDEG -- 61351","351615":"KEMLAGI -- 61353","351616":"JETIS -- 61352","351617":"DAWARBLANDONG -- 61354","351618":"MOJOANYAR -- 61364","351701":"PERAK -- 61461","351702":"GUDO -- 61463","351703":"NGORO -- 61473","351704":"BARENG -- 61474","351705":"WONOSALAM -- 61476","351706":"MOJOAGUNG -- 61482","351707":"MOJOWARNO -- 61475","351708":"DIWEK -- 61471","351709":"JOMBANG -- 61419","351710":"PETERONGAN -- 61481","351711":"SUMOBITO -- 61483","351712":"KESAMBEN -- 61484","351713":"TEMBELANG -- 61452","351714":"PLOSO -- 65152","351715":"PLANDAAN -- 61456","351716":"KABUH -- 61455","351717":"KUDU -- 61454","351718":"BANDARKEDUNGMULYO -- 61462","351719":"JOGOROTO -- 61485","351720":"MEGALUH -- 61457","351721":"NGUSIKAN -- 61454","351801":"SAWAHAN -- 63162","351802":"NGETOS -- 64474","351803":"BERBEK -- 64473","351804":"LOCERET -- 64471","351805":"PACE -- 64472","351806":"PRAMBON -- 64484","351807":"NGRONGGOT -- 64395","351808":"KERTOSONO -- 64311","351809":"PATIANROWO -- 64391","351810":"BARON -- 64394","351811":"TANJUNGANOM -- 64482","351812":"SUKOMORO -- 64481","351813":"NGANJUK -- 64419","351814":"BAGOR -- 64461","351815":"WILANGAN -- 64462","351816":"REJOSO -- 67181","351817":"GONDANG -- 67174","351818":"NGLUYU -- 64452","351819":"LENGKONG -- 64393","351820":"JATIKALEN -- 64392","351901":"KEBON SARI -- 63173","351902":"DOLOPO -- 63174","351903":"GEGER -- 63171","351904":"DAGANGAN -- 63172","351905":"KARE -- 63182","351906":"GEMARANG -- 63156","351907":"WUNGU -- 63181","351908":"MADIUN -- 63151","351909":"JIWAN -- 63161","351910":"BALEREJO -- 63152","351911":"MEJAYAN -- 63153","351912":"SARADAN -- 63155","351913":"PILANGKENCENG -- 63154","351914":"SAWAHAN -- 63162","351915":"WONOASRI -- 63157","352001":"PONCOL -- 63362","352002":"PARANG -- 63371","352003":"LEMBEYAN -- 63372","352004":"TAKERAN -- 63383","352005":"KAWEDANAN -- 63382","352006":"MAGETAN -- 63319","352007":"PLAOSAN -- 63361","352008":"PANEKAN -- 63352","352009":"SUKOMORO -- 64481","352010":"BENDO -- 61263","352011":"MAOSPATI -- 63392","352012":"BARAT -- 63395","352013":"KARANGREJO -- 66253","352014":"KARAS -- 63395","352015":"KARTOHARJO -- 63395","352016":"NGARIBOYO -- 63351","352017":"NGUNTORONADI -- 63383","352018":"SIDOREJO -- 63319","352101":"SINE -- 63264","352102":"NGRAMBE -- 63263","352103":"JOGOROGO -- 63262","352104":"KENDAL -- 63261","352105":"GENENG -- 63271","352106":"KWADUNGAN -- 63283","352107":"KARANGJATI -- 63284","352108":"PADAS -- 63281","352109":"NGAWI -- 63218","352110":"PARON -- 63253","352111":"KEDUNGGALAR -- 63254","352112":"WIDODAREN -- 63256","352113":"MANTINGAN -- 63261","352114":"PANGKUR -- 63282","352115":"BRINGIN -- 63285","352116":"PITU -- 63252","352117":"KARANGANYAR -- 63257","352118":"GERIH -- 63271","352119":"KASREMAN -- 63281","352201":"NGRAHO -- 62165","352202":"TAMBAKREJO -- 62166","352203":"NGAMBON -- 62167","352204":"NGASEM -- 62154","352205":"BUBULAN -- 62172","352206":"DANDER -- 62171","352207":"SUGIHWARAS -- 62183","352208":"KEDUNGADEM -- 62195","352209":"KEPOH BARU -- 62194","352210":"BAURENO -- 62192","352211":"KANOR -- 62193","352212":"SUMBEREJO -- -","352213":"BALEN -- 62182","352214":"KAPAS -- 62181","352215":"BOJONEGORO -- 62118","352216":"KALITIDU -- 62152","352217":"MALO -- 62153","352218":"PURWOSARI -- 67162","352219":"PADANGAN -- 62162","352220":"KASIMAN -- 62164","352221":"TEMAYANG -- 62184","352222":"MARGOMULYO -- 62168","352223":"TRUCUK -- 62155","352224":"SUKOSEWU -- 62183","352225":"KEDEWAN -- 62164","352226":"GONDANG -- 67174","352227":"SEKAR -- 62167","352228":"GAYAM -- 62154","352301":"KENDURUAN -- 62363","352302":"JATIROGO -- 62362","352303":"BANGILAN -- 62364","352304":"BANCAR -- 62354","352305":"SENORI -- 62365","352306":"TAMBAKBOYO -- 62353","352307":"SINGGAHAN -- 62361","352308":"KEREK -- 62356","352309":"PARENGAN -- 62366","352310":"MONTONG -- 62357","352311":"SOKO -- 62372","352312":"JENU -- 62352","352313":"MERAKURAK -- 62355","352314":"RENGEL -- 62371","352315":"SEMANDING -- 62381","352316":"TUBAN -- 62318","352317":"PLUMPANG -- 62382","352318":"PALANG -- 62391","352319":"WIDANG -- 62383","352320":"GRABAGAN -- 62371","352401":"SUKORAME -- 62276","352402":"BLULUK -- 62274","352403":"MODO -- 62275","352404":"NGIMBANG -- 62273","352405":"BABAT -- 62271","352406":"KEDUNGPRING -- 62272","352407":"BRONDONG -- 62263","352408":"LAREN -- 62262","352409":"SEKARAN -- 62261","352410":"MADURAN -- 62261","352411":"SAMBENG -- 62284","352412":"SUGIO -- 62256","352413":"PUCUK -- 62257","352414":"PACIRAN -- 62264","352415":"SOLOKURO -- 62265","352416":"MANTUP -- 62283","352417":"SUKODADI -- 62253","352418":"KARANGGENENG -- 62254","352419":"KEMBANGBAHU -- 62282","352420":"KALITENGAH -- 62255","352421":"TURI -- 62252","352422":"LAMONGAN -- 62212","352423":"TIKUNG -- 62281","352424":"KARANGBINANGUN -- 62293","352425":"DEKET -- 62291","352426":"GLAGAH -- 68431","352427":"SARIREJO -- 62281","352501":"DUKUN -- 61155","352502":"BALONGPANGGANG -- 61173","352503":"PANCENG -- 61156","352504":"BENJENG -- 61172","352505":"DUDUKSAMPEYAN -- 61162","352506":"WRINGINANOM -- 61176","352507":"UJUNGPANGKAH -- 61154","352508":"KEDAMEAN -- 61175","352509":"SIDAYU -- 61153","352510":"MANYAR -- 61151","352511":"CERME -- 68286","352512":"BUNGAH -- 61152","352513":"MENGANTI -- 61174","352514":"KEBOMAS -- 61124","352515":"DRIYOREJO -- 61177","352516":"GRESIK -- 61114","352517":"SANGKAPURA -- 61181","352518":"TAMBAK -- 62166","352601":"BANGKALAN -- 69112","352602":"SOCAH -- 69161","352603":"BURNEH -- 69121","352604":"KAMAL -- 69162","352605":"AROSBAYA -- 69151","352606":"GEGER -- 63171","352607":"KLAMPIS -- 69153","352608":"SEPULU -- 69154","352609":"TANJUNG BUMI -- 69156","352610":"KOKOP -- 69155","352611":"KWANYAR -- 69163","352612":"LABANG -- 69163","352613":"TANAH MERAH -- 69172","352614":"TRAGAH -- 69165","352615":"BLEGA -- 69174","352616":"MODUNG -- 69166","352617":"KONANG -- 69175","352618":"GALIS -- 69382","352701":"SRESEH -- 69273","352702":"TORJUN -- 69271","352703":"SAMPANG -- 69216","352704":"CAMPLONG -- 69281","352705":"OMBEN -- 69291","352706":"KEDUNGDUNG -- 69252","352707":"JRENGIK -- 69272","352708":"TAMBELANGAN -- 69253","352709":"BANYUATES -- 69263","352710":"ROBATAL -- 69254","352711":"SOKOBANAH -- 69262","352712":"KETAPANG -- 69261","352713":"PANGARENGAN -- 69271","352714":"KARANGPENANG -- 69254","352801":"TLANAKAN -- 69371","352802":"PADEMAWU -- 69323","352803":"GALIS -- 69382","352804":"PAMEKASAN -- 69317","352805":"PROPPO -- 69363","352806":"PALENGAAN -- -","352807":"PEGANTENAN -- 69361","352808":"LARANGAN -- 69383","352809":"PAKONG -- 69352","352810":"WARU -- 69353","352811":"BATUMARMAR -- 69354","352812":"KADUR -- 69355","352813":"PASEAN -- 69356","352901":"KOTA SUMENEP -- 69417","352902":"KALIANGET -- 69471","352903":"MANDING -- 62381","352904":"TALANGO -- 69481","352905":"BLUTO -- 69466","352906":"SARONGGI -- 69467","352907":"LENTENG -- 69461","352908":"GILI GINTING -- 69482","352909":"GULUK-GULUK -- -","352910":"GANDING -- 69462","352911":"PRAGAAN -- 69465","352912":"AMBUNTEN -- 69455","352913":"PASONGSONGAN -- 69457","352914":"DASUK -- 69454","352915":"RUBARU -- 69456","352916":"BATANG BATANG -- 69473","352917":"BATU PUTIH -- 69453","352918":"DUNGKEK -- 69474","352919":"GAPURA -- 69472","352920":"GAYAM -- 62154","352921":"NONGGUNONG -- 69484","352922":"RAAS -- 69485","352923":"MASALEMBU -- 69492","352924":"ARJASA -- 69491","352925":"SAPEKEN -- 69493","352926":"BATUAN -- 69451","352927":"KANGAYAN -- 69491","357101":"MOJOROTO -- 64118","357102":"KOTA -- 64129","357103":"PESANTREN -- 64133","357201":"KEPANJENKIDUL -- 66116","357202":"SUKOREJO -- 63453","357203":"SANANWETAN -- 66133","357301":"BLIMBING -- 65126","357302":"KLOJEN -- 65116","357303":"KEDUNGKANDANG -- 65132","357304":"SUKUN -- 65148","357305":"LOWOKWARU -- 65144","357401":"KADEMANGAN -- 66161","357402":"WONOASIH -- 67233","357403":"MAYANGAN -- 67217","357404":"KANIGARAN -- 67212","357405":"KEDOPAK -- 67229","357501":"GADINGREJO -- 67138","357502":"PURWOREJO -- 67116","357503":"BUGUL KIDUL -- 67128","357504":"PANGGUNGREJO -- 66174","357601":"PRAJURIT KULON -- 61327","357602":"MAGERSARI -- 61314","357701":"KARTOHARJO -- 63395","357702":"MANGUHARJO -- 63122","357703":"TAMAN -- 63137","357801":"KARANGPILANG -- 60221","357802":"WONOCOLO -- 60239","357803":"RUNGKUT -- 60293","357804":"WONOKROMO -- 60241","357805":"TEGALSARI -- 68485","357806":"SAWAHAN -- 63162","357807":"GENTENG -- 69482","357808":"GUBENG -- 60286","357809":"SUKOLILO -- 60117","357810":"TAMBAKSARI -- 60138","357811":"SIMOKERTO -- 60141","357812":"PABEAN CANTIKAN -- 60161","357813":"BUBUTAN -- 60174","357814":"TANDES -- 60186","357815":"KREMBANGAN -- 60179","357816":"SEMAMPIR -- 60151","357817":"KENJERAN -- 60127","357818":"LAKARSANTRI -- 60214","357819":"BENOWO -- 60199","357820":"WIYUNG -- 60227","357821":"DUKUHPAKIS -- 60225","357822":"GAYUNGAN -- 60234","357823":"JAMBANGAN -- 60232","357824":"TENGGILIS MEJOYO -- 60292","357825":"GUNUNG ANYAR -- 60294","357826":"MULYOREJO -- 60113","357827":"SUKOMANUNGGAL -- 60189","357828":"ASEM ROWO -- 60182","357829":"BULAK -- 60124","357830":"PAKAL -- 60197","357831":"SAMBIKEREP -- 60195","357901":"BATU -- 69453","357902":"BUMIAJI -- 65334","357903":"JUNREJO -- 65326","360101":"SUMUR -- 42283","360102":"CIMANGGU -- 42284","360103":"CIBALIUNG -- 42285","360104":"CIKEUSIK -- 42286","360105":"CIGEULIS -- 42282","360106":"PANIMBANG -- 42281","360107":"ANGSANA -- 42277","360108":"MUNJUL -- 42276","360109":"PAGELARAN -- 42265","360110":"BOJONG -- 42274","360111":"PICUNG -- 42275","360112":"LABUAN -- 42264","360113":"MENES -- 42262","360114":"SAKETI -- 42273","360115":"CIPEUCANG -- 42272","360116":"JIPUT -- 42263","360117":"MANDALAWANGI -- 42261","360118":"CIMANUK -- 42271","360119":"KADUHEJO -- 42253","360120":"BANJAR -- 42252","360121":"PANDEGLANG -- 42219","360122":"CADASARI -- 42251","360123":"CISATA -- 42273","360124":"PATIA -- 42265","360125":"KARANG TANJUNG -- 42251","360126":"CIKEDAL -- 42271","360127":"CIBITUNG -- 42285","360128":"CARITA -- 42264","360129":"SUKARESMI -- 42265","360130":"MEKARJAYA -- 42271","360131":"SINDANGRESMI -- 42276","360132":"PULOSARI -- 42273","360133":"KORONCONG -- 42251","360134":"MAJASARI -- 42214","360135":"SOBANG -- 42281","360201":"MALINGPING -- 42391","360202":"PANGGARANGAN -- 42392","360203":"BAYAH -- 42393","360204":"CIPANAS -- 42372","360205":"MUNCANG -- 42364","360206":"LEUWIDAMAR -- 42362","360207":"BOJONGMANIK -- 42363","360208":"GUNUNGKENCANA -- 42354","360209":"BANJARSARI -- 42355","360210":"CILELES -- 42353","360211":"CIMARGA -- 42361","360212":"SAJIRA -- 42371","360213":"MAJA -- 42381","360214":"RANGKASBITUNG -- 42317","360215":"WARUNGGUNUNG -- 42352","360216":"CIJAKU -- 42395","360217":"CIKULUR -- 42356","360218":"CIBADAK -- 42357","360219":"CIBEBER -- 42426","360220":"CILOGRANG -- 42393","360221":"WANASALAM -- 42396","360222":"SOBANG -- 42281","360223":"CURUG BITUNG -- 42381","360224":"KALANGANYAR -- 42312","360225":"LEBAKGEDONG -- 42372","360226":"CIHARA -- 42392","360227":"CIRINTEN -- 42363","360228":"CIGEMLONG -- -","360301":"BALARAJA -- 15610","360302":"JAYANTI -- 15610","360303":"TIGARAKSA -- 15720","360304":"JAMBE -- 15720","360305":"CISOKA -- 15730","360306":"KRESEK -- 15620","360307":"KRONJO -- 15550","360308":"MAUK -- 15530","360309":"KEMIRI -- 15530","360310":"SUKADIRI -- 15530","360311":"RAJEG -- 15540","360312":"PASAR KEMIS -- 15560","360313":"TELUKNAGA -- 15510","360314":"KOSAMBI -- 15212","360315":"PAKUHAJI -- 15570","360316":"SEPATAN -- 15520","360317":"CURUG -- 15810","360318":"CIKUPA -- 15710","360319":"PANONGAN -- 15710","360320":"LEGOK -- 15820","360322":"PAGEDANGAN -- 15336","360323":"CISAUK -- 15344","360327":"SUKAMULYA -- 15610","360328":"KELAPA DUA -- 15810","360329":"SINDANG JAYA -- 15540","360330":"SEPATAN TIMUR -- 15520","360331":"SOLEAR -- 15730","360332":"GUNUNG KALER -- 15620","360333":"MEKAR BARU -- 15550","360405":"KRAMATWATU -- 42161","360406":"WARINGINKURUNG -- 42453","360407":"BOJONEGARA -- 42454","360408":"PULO AMPEL -- 42455","360409":"CIRUAS -- 42182","360411":"KRAGILAN -- 42184","360412":"PONTANG -- 42192","360413":"TIRTAYASA -- 42193","360414":"TANARA -- 42194","360415":"CIKANDE -- 42186","360416":"KIBIN -- 42185","360417":"CARENANG -- 42195","360418":"BINUANG -- 42196","360419":"PETIR -- 42172","360420":"TUNJUNG TEJA -- 42174","360422":"BAROS -- 42173","360423":"CIKEUSAL -- 42175","360424":"PAMARAYAN -- 42176","360425":"KOPO -- 42178","360426":"JAWILAN -- 42177","360427":"CIOMAS -- 42164","360428":"PABUARAN -- 42163","360429":"PADARINCANG -- 42168","360430":"ANYAR -- 42166","360431":"CINANGKA -- 42167","360432":"MANCAK -- 42165","360433":"GUNUNG SARI -- 42163","360434":"BANDUNG -- 42176","367101":"TANGERANG -- 15118","367102":"JATIUWUNG -- 15133","367103":"BATUCEPER -- 15122","367104":"BENDA -- 15123","367105":"CIPONDOH -- 15148","367106":"CILEDUG -- 15153","367107":"KARAWACI -- 15115","367108":"PERIUK -- 15132","367109":"CIBODAS -- 15138","367110":"NEGLASARI -- 15121","367111":"PINANG -- 15142","367112":"KARANG TENGAH -- 15157","367113":"LARANGAN -- 15155","367201":"CIBEBER -- 42426","367202":"CILEGON -- 42419","367203":"PULOMERAK -- 42431","367204":"CIWANDAN -- 42441","367205":"JOMBANG -- 42413","367206":"GEROGOL -- 42438","367207":"PURWAKARTA -- 42433","367208":"CITANGKIL -- 42441","367301":"SERANG -- 42111","367302":"KASEMEN -- 42191","367303":"WALANTAKA -- 42183","367304":"CURUG -- 15810","367305":"CIPOCOK JAYA -- 42122","367306":"TAKTAKAN -- 42162","367401":"SERPONG -- 15310","367402":"SERPONG UTARA -- 15323","367403":"PONDOK AREN -- 15223","367404":"CIPUTAT -- 15412","367405":"CIPUTAT TIMUR -- 15412","367406":"PAMULANG -- 15415","367407":"SETU -- 15315","510101":"NEGARA -- 82212","510102":"MENDOYO -- 82261","510103":"PEKUTATAN -- 82262","510104":"MELAYA -- 82252","510105":"JEMBRANA -- 82218","510201":"SELEMADEG -- 82162","510202":"SALAMADEG TIMUR -- 82162","510203":"SALEMADEG BARAT -- 82162","510204":"KERAMBITAN -- 82161","510205":"TABANAN -- 82112","510206":"KEDIRI -- 82121","510207":"MARGA -- 82181","510208":"PENEBEL -- 82152","510209":"BATURITI -- 82191","510210":"PUPUAN -- 82163","510301":"KUTA -- 82262","510302":"MENGWI -- 80351","510303":"ABIANSEMAL -- 80352","510304":"PETANG -- 80353","510305":"KUTA SELATAN -- 80361","510306":"KUTA UTARA -- 80361","510401":"SUKAWATI -- 80582","510402":"BLAHBATUH -- 80581","510403":"GIANYAR -- 80515","510404":"TAMPAKSIRING -- 80552","510405":"UBUD -- 80571","510406":"TEGALALLANG -- -","510407":"PAYANGAN -- 80572","510501":"NUSA PENIDA -- 80771","510502":"BANJARANGKAN -- 80752","510503":"KLUNGKUNG -- 80716","510504":"DAWAN -- 80761","510601":"SUSUT -- 80661","510602":"BANGLI -- 80614","510603":"TEMBUKU -- 80671","510604":"KINTAMANI -- 80652","510701":"RENDANG -- 80863","510702":"SIDEMEN -- 80864","510703":"MANGGIS -- 80871","510704":"KARANGASEM -- 80811","510705":"ABANG -- 80852","510706":"BEBANDEM -- 80861","510707":"SELAT -- 80862","510708":"KUBU -- 80853","510801":"GEROKGAK -- 81155","510802":"SERIRIT -- 81153","510803":"BUSUNG BIU -- 81154","510804":"BANJAR -- 80752","510805":"SUKASADA -- 81161","510806":"BULELENG -- 81119","510807":"SAWAN -- 81171","510808":"KUBUTAMBAHAN -- 81172","510809":"TEJAKULA -- 81173","517101":"DENPASAR SELATAN -- 80225","517102":"DENPASAR TIMUR -- 80234","517103":"DENPASAR BARAT -- 80112","517104":"DENPASAR UTARA -- 80231","520101":"GERUNG -- 83363","520102":"KEDIRI -- 83362","520103":"NARMADA -- 83371","520107":"SEKOTONG -- 83365","520108":"LABUAPI -- 83361","520109":"GUNUNGSARI -- 83351","520112":"LINGSAR -- 83371","520113":"LEMBAR -- 83364","520114":"BATU LAYAR -- 83355","520115":"KURIPAN -- 83362","520201":"PRAYA -- 83511","520202":"JONGGAT -- 83561","520203":"BATUKLIANG -- 83552","520204":"PUJUT -- 83573","520205":"PRAYA BARAT -- 83572","520206":"PRAYA TIMUR -- 83581","520207":"JANAPRIA -- 83554","520208":"PRINGGARATA -- 83562","520209":"KOPANG -- 83553","520210":"PRAYA TENGAH -- 83582","520211":"PRAYA BARAT DAYA -- 83571","520212":"BATUKLIANG UTARA -- 83552","520301":"KERUAK -- 83672","520302":"SAKRA -- 83671","520303":"TERARA -- 83663","520304":"SIKUR -- 83662","520305":"MASBAGIK -- 83661","520306":"SUKAMULIA -- 83652","520307":"SELONG -- 83618","520308":"PRINGGABAYA -- 83654","520309":"AIKMEL -- 83653","520310":"SAMBELIA -- 83656","520311":"MONTONG GADING -- 83663","520312":"PRINGGASELA -- 83661","520313":"SURALAGA -- 83652","520314":"WANASABA -- 83653","520315":"SEMBALUN -- 83656","520316":"SUWELA -- 83654","520317":"LABUHAN HAJI -- 83614","520318":"SAKRA TIMUR -- 83671","520319":"SAKRA BARAT -- 83671","520320":"JEROWARU -- 83672","520402":"LUNYUK -- 84373","520405":"ALAS -- 84353","520406":"UTAN -- 84352","520407":"BATU LANTEH -- 84361","520408":"SUMBAWA -- 84314","520409":"MOYO HILIR -- 84381","520410":"MOYO HULU -- 84371","520411":"ROPANG -- 84372","520412":"LAPE -- 84382","520413":"PLAMPANG -- 84383","520414":"EMPANG -- 84384","520417":"ALAS BARAT -- 84353","520418":"LABUHAN BADAS -- 84316","520419":"LABANGKA -- 84383","520420":"BUER -- 84353","520421":"RHEE -- 84352","520422":"UNTER IWES -- 84316","520423":"MOYO UTARA -- 84381","520424":"MARONGE -- 84383","520425":"TARANO -- 84384","520426":"LOPOK -- 84382","520427":"LENANGGUAR -- 84372","520428":"ORONG TELU -- 84373","520429":"LANTUNG -- 84372","520501":"DOMPU -- 84211","520502":"KEMPO -- 84261","520503":"HU'U -- -","520504":"KILO -- 84252","520505":"WOJA -- 84251","520506":"PEKAT -- 84261","520507":"MANGGALEWA -- -","520508":"PAJO -- 84272","520601":"MONTA -- 84172","520602":"BOLO -- 84161","520603":"WOHA -- 84171","520604":"BELO -- 84173","520605":"WAWO -- 84181","520606":"SAPE -- 84182","520607":"WERA -- 84152","520608":"DONGGO -- 84162","520609":"SANGGAR -- 84191","520610":"AMBALAWI -- 84153","520611":"LANGGUDU -- 84181","520612":"LAMBU -- 84182","520613":"MADAPANGGA -- 84111","520614":"TAMBORA -- 84191","520615":"SOROMANDI -- 84162","520616":"PARADO -- 84172","520617":"LAMBITU -- 84181","520618":"PALIBELO -- 84173","520701":"JEREWEH -- 84456","520702":"TALIWANG -- 84455","520703":"SETELUK -- 84454","520704":"SEKONGKANG -- 84457","520705":"BRANG REA -- 84458","520706":"POTO TANO -- 84454","520707":"BRANG ENE -- 84455","520708":"MALUK -- 84456","520801":"TANJUNG -- 83352","520802":"GANGGA -- 83353","520803":"KAYANGAN -- 83353","520804":"BAYAN -- 83354","520805":"PEMENANG -- 83352","527101":"AMPENAN -- 83114","527102":"MATARAM -- 83121","527103":"CAKRANEGARA -- 83239","527104":"SEKARBELA -- 83116","527105":"SELAPRANG -- 83125","527106":"SANDUBAYA -- 83237","527201":"RASANAE BARAT -- 84119","527202":"RASANAE TIMUR -- 84119","527203":"ASAKOTA -- 84119","527204":"RABA -- 83671","527205":"MPUNDA -- 84119","530104":"SEMAU -- 85353","530105":"KUPANG BARAT -- 85351","530106":"KUPANG TIMUR -- 85362","530107":"SULAMU -- 85368","530108":"KUPANG TENGAH -- 85361","530109":"AMARASI -- 85367","530110":"FATULEU -- 85363","530111":"TAKARI -- 85369","530112":"AMFOANG SELATAN -- 85364","530113":"AMFOANG UTARA -- 85365","530116":"NEKAMESE -- 85391","530117":"AMARASI BARAT -- 85367","530118":"AMARASI SELATAN -- 85367","530119":"AMARASI TIMUR -- 85367","530120":"AMABI OEFETO TIMUR -- 85363","530121":"AMFOANG BARAT DAYA -- 85364","530122":"AMFOANG BARAT LAUT -- 85364","530123":"SEMAU SELATAN -- 85353","530124":"TAEBENU -- 85361","530125":"AMABI OEFETO -- 85363","530126":"AMFOANG TIMUR -- 85364","530127":"FATULEU BARAT -- 85223","530128":"FATULEU TENGAH -- 85223","530130":"AMFOANG TENGAH -- 85364","530201":"KOTA SOE -- 85519","530202":"MOLLO SELATAN -- 85561","530203":"MOLLO UTARA -- 85552","530204":"AMANUBAN TIMUR -- 85572","530205":"AMANUBAN TENGAH -- 85571","530206":"AMANUBAN SELATAN -- 85562","530207":"AMANUBAN BARAT -- 85551","530208":"AMANATUN SELATAN -- 85573","530209":"AMANATUN UTARA -- 85574","530210":"KI'E -- -","530211":"KUANFATU -- 85563","530212":"FATUMNASI -- 85561","530213":"POLEN -- 85561","530214":"BATU PUTIH -- 85562","530215":"BOKING -- 85573","530216":"TOIANAS -- 85574","530217":"NUNKOLO -- 85573","530218":"OENINO -- 85572","530219":"KOLBANO -- 85563","530220":"KOT OLIN -- 85575","530221":"KUALIN -- 85562","530222":"MOLLO BARAT -- 85561","530223":"KOK BAUN -- 85574","530224":"NOEBANA -- 85573","530225":"SANTIAN -- 85573","530226":"NOEBEBA -- 85562","530227":"KUATNANA -- 85551","530228":"FAUTMOLO -- 85572","530229":"FATUKOPA -- 85572","530230":"MOLLO TENGAH -- 85561","530231":"TOBU -- 85552","530232":"NUNBENA -- 85561","530301":"MIOMAFO TIMUR -- -","530302":"MIOMAFO BARAT -- -","530303":"BIBOKI SELATAN -- 85681","530304":"NOEMUTI -- 85661","530305":"KOTA KEFAMENANU -- 85614","530306":"BIBOKI UTARA -- 85682","530307":"BIBOKI ANLEU -- 85613","530308":"INSANA -- 85671","530309":"INSANA UTARA -- 85671","530310":"NOEMUTI TIMUR -- 85661","530311":"MIOMAFFO TENGAH -- 85661","530312":"MUSI -- 85661","530313":"MUTIS -- 85661","530314":"BIKOMI SELATAN -- 85651","530315":"BIKOMI TENGAH -- 85651","530316":"BIKOMI NILULAT -- 85651","530317":"BIKOMI UTARA -- 85651","530318":"NAIBENU -- 85651","530319":"INSANA FAFINESU -- 85671","530320":"INSANA BARAT -- 85671","530321":"INSANA TENGAH -- 85671","530322":"BIBOKI TAN PAH -- 85681","530323":"BIBOKI MOENLEU -- 85681","530324":"BIBOKI FEOTLEU -- 85682","530401":"LAMAKNEN -- 85772","530402":"TASIFETOTIMUR -- 85771","530403":"RAIHAT -- 85773","530404":"TASIFETO BARAT -- 85752","530405":"KAKULUK MESAK -- 85752","530412":"KOTA ATAMBUA -- -","530413":"RAIMANUK -- 85761","530417":"LASIOLAT -- 85771","530418":"LAMAKNEN SELATAN -- 85772","530421":"ATAMBUA BARAT -- 85715","530422":"ATAMBUA SELATAN -- 85717","530423":"NANAET DUABESI -- 85752","530501":"TELUK MUTIARA -- 85819","530502":"ALOR BARAT LAUT -- 85851","530503":"ALOR BARAT DAYA -- 85861","530504":"ALOR SELATAN -- 85871","530505":"ALOR TIMUR -- 85872","530506":"PANTAR -- 85881","530507":"ALOR TENGAH UTARA -- 85871","530508":"ALOR TIMUR LAUT -- 85872","530509":"PANTAR BARAT -- 85881","530510":"KABOLA -- 85851","530511":"PULAU PURA -- 85851","530512":"MATARU -- 85861","530513":"PUREMAN -- 85872","530514":"PANTAR TIMUR -- 85881","530515":"LEMBUR -- 85871","530516":"PANTAR TENGAH -- 85881","530517":"PANTAR BARU LAUT -- -","530601":"WULANGGITANG -- 86253","530602":"TITEHENA -- 86253","530603":"LARANTUKA -- 86219","530604":"ILE MANDIRI -- 86211","530605":"TANJUNG BUNGA -- 86252","530606":"SOLOR BARAT -- 86272","530607":"SOLOR TIMUR -- 86271","530608":"ADONARA BARAT -- 86262","530609":"WOTAN ULUMANDO -- -","530610":"ADONARA TIMUR -- 86261","530611":"KELUBAGOLIT -- 86262","530612":"WITIHAMA -- 86262","530613":"ILE BOLENG -- 86253","530614":"DEMON PAGONG -- 86219","530615":"LEWOLEMA -- 86252","530616":"ILE BURA -- 86253","530617":"ADONARA -- 86262","530618":"ADONARA TENGAH -- 86262","530619":"SOLOR SELATAN -- 86271","530701":"PAGA -- 86153","530702":"MEGO -- 86113","530703":"LELA -- 86516","530704":"NITA -- 86152","530705":"ALOK -- 86111","530706":"PALUE -- 86111","530707":"NELLE -- 86116","530708":"TALIBURA -- 86183","530709":"WAIGETE -- 86183","530710":"KEWAPANTE -- 86181","530711":"BOLA -- 85851","530712":"MAGEPANDA -- 86152","530713":"WAIBLAMA -- 86183","530714":"ALOK BARAT -- 86115","530715":"ALOK TIMUR -- 86111","530716":"KOTING -- 86116","530717":"TANA WAWO -- 86153","530718":"HEWOKLOANG -- 86181","530719":"KANGAE -- 86181","530720":"DORENG -- 86171","530721":"MAPITARA -- 86171","530801":"NANGAPANDA -- 86352","530802":"PULAU ENDE -- 86362","530803":"ENDE -- 86362","530804":"ENDE SELATAN -- 86313","530805":"NDONA -- 86361","530806":"DETUSOKO -- 86371","530807":"WEWARIA -- 86353","530808":"WOLOWARU -- 86372","530809":"WOLOJITA -- 86382","530810":"MAUROLE -- 86381","530811":"MAUKARO -- 86352","530812":"LIO TIMUR -- 86361","530813":"KOTA BARU -- 86111","530814":"KELIMUTU -- 86318","530815":"DETUKELI -- 86371","530816":"NDONA TIMUR -- 86361","530817":"NDORI -- 86372","530818":"ENDE UTARA -- 86319","530819":"ENDE TENGAH -- 86319","530820":"ENDE TIMUR -- 86361","530821":"LEPEMBUSU KELISOKE -- 86374","530901":"AIMERE -- 86452","530902":"GOLEWA -- 86461","530906":"BAJAWA -- 86413","530907":"SOA -- 86419","530909":"RIUNG -- 86419","530912":"JEREBUU -- 86452","530914":"RIUNG BARAT -- 86419","530915":"BAJAWA UTARA -- 86413","530916":"WOLOMEZE -- 86419","530918":"GOLEWA SELATAN -- 86461","530919":"GOLEWA BARAT -- 86461","530920":"INERIE -- 86452","531001":"WAE RII -- 86591","531003":"RUTENG -- 86516","531005":"SATAR MESE -- 86561","531006":"CIBAL -- 86591","531011":"REOK -- 86592","531012":"LANGKE REMBONG -- 86519","531013":"SATAR MESE BARAT -- 86561","531014":"RAHONG UTARA -- 86516","531015":"LELAK -- 86516","531016":"REOK BARAT -- 86592","531017":"CIBAL BARAT -- 86591","531101":"KOTA WAINGAPU -- 87112","531102":"HAHARU -- 87153","531103":"LEWA -- 86461","531104":"NGGAHA ORI ANGU -- 87152","531105":"TABUNDUNG -- 87161","531106":"PINU PAHAR -- 87161","531107":"PANDAWAI -- 87171","531108":"UMALULU -- 87181","531109":"RINDI -- 87181","531110":"PAHUNGA LODU -- 87182","531111":"WULLA WAIJELU -- -","531112":"PABERIWAI -- 87171","531113":"KARERA -- 87172","531114":"KAHAUNGU ETI -- 87171","531115":"MATAWAI LA PAWU -- -","531116":"KAMBERA -- 87114","531117":"KAMBATA MAPAMBUHANG -- 87171","531118":"LEWA TIDAHU -- 87152","531119":"KATALA HAMU LINGU -- 87152","531120":"KANATANG -- 87153","531121":"NGADU NGALA -- 87172","531122":"MAHU -- 87171","531204":"TANA RIGHU -- 87257","531210":"LOLI -- 87284","531211":"WANOKAKA -- 87272","531212":"LAMBOYA -- 87271","531215":"KOTA WAIKABUBAK -- 87217","531218":"LABOYA BARAT -- -","531301":"NAGA WUTUNG -- 86684","531302":"ATADEI -- 86685","531303":"ILE APE -- 86683","531304":"LEBATUKAN -- 86681","531305":"NUBATUKAN -- 86682","531306":"OMESURI -- 86691","531307":"BUYASURI -- 86692","531308":"WULANDONI -- 86685","531309":"ILE APE TIMUR -- 86683","531401":"ROTE BARAT DAYA -- 85982","531402":"ROTE BARAT LAUT -- 85981","531403":"LOBALAIN -- 85912","531404":"ROTE TENGAH -- 85972","531405":"PANTAI BARU -- 85973","531406":"ROTE TIMUR -- 85974","531407":"ROTE BARAT -- 85982","531408":"ROTE SELATAN -- 85972","531409":"NDAO NUSE -- 85983","531410":"LANDU LEKO -- 85974","531501":"MACANG PACAR -- 86756","531502":"KUWUS -- 86752","531503":"LEMBOR -- 86753","531504":"SANO NGGOANG -- 86757","531505":"KOMODO -- 86754","531506":"BOLENG -- 86754","531507":"WELAK -- 86753","531508":"NDOSO -- 86752","531509":"LEMBOR SELATAN -- 86753","531510":"MBELILING -- 86757","531601":"AESESA -- 86472","531602":"NANGARORO -- 86464","531603":"BOAWAE -- 86462","531604":"MAUPONGGO -- 86463","531605":"WOLOWAE -- 86472","531606":"KEO TENGAH -- 86464","531607":"AESESA SELATAN -- 86472","531701":"KATIKU TANA -- 87282","531702":"UMBU RATU NGGAY BARAT -- 87282","531703":"MAMBORO -- 87258","531704":"UMBU RATU NGGAY -- 87282","531705":"KATIKU TANA SELATAN -- 87282","531801":"LOURA -- 87254","531802":"WEWEWA UTARA -- 87252","531803":"WEWEWA TIMUR -- 87252","531804":"WEWEWA BARAT -- 87253","531805":"WEWEWA SELATAN -- 87263","531806":"KODI BANGEDO -- 87262","531807":"KODI -- 87262","531808":"KODI UTARA -- 87261","531809":"KOTA TAMBOLAKA -- 87255","531810":"WEWEWA TENGAH -- 87252","531811":"KODI BALAGHAR -- 87262","531901":"BORONG -- 86571","531902":"POCO RANAKA -- 86583","531903":"LAMBA LEDA -- 86582","531904":"SAMBI RAMPAS -- 86584","531905":"ELAR -- 86581","531906":"KOTA KOMBA -- 86572","531907":"RANA MESE -- 86571","531908":"POCO RANAKA TIMUR -- 86583","531909":"ELAR SELATAN -- 86581","532001":"SABU BARAT -- 85391","532002":"SABU TENGAH -- 85392","532003":"SABU TIMUR -- 85392","532004":"SABU LIAE -- 85391","532005":"HAWU MEHARA -- 85391","532006":"RAIJUA -- 85393","532101":"MALAKA TENGAH -- 85762","532102":"MALAKA BARAT -- 85763","532103":"WEWIKU -- 85763","532104":"WELIMAN -- 85763","532105":"RINHAT -- 85764","532106":"IO KUFEU -- 85765","532107":"SASITAMEAN -- 85765","532108":"LAENMANEN -- 85718","532109":"MALAKA TIMUR -- 85761","532110":"KOBALIMA TIMUR -- 85766","532111":"KOBALIMA -- 85766","532112":"BOTIN LEOBELE -- 85765","537101":"ALAK -- 85231","537102":"MAULAFA -- 85148","537103":"KELAPA LIMA -- 85228","537104":"OEBOBO -- 85116","537105":"KOTA RAJA -- 85119","537106":"KOTA LAMA -- 85229","610101":"SAMBAS -- 79462","610102":"TELUK KERAMAT -- 79465","610103":"JAWAI -- 79454","610104":"TEBAS -- 79461","610105":"PEMANGKAT -- 79453","610106":"SEJANGKUNG -- 79463","610107":"SELAKAU -- 79452","610108":"PALOH -- 79466","610109":"SAJINGAN BESAR -- 79467","610110":"SUBAH -- 79417","610111":"GALING -- 79453","610112":"TEKARANG -- 79465","610113":"SEMPARUK -- 79453","610114":"SAJAD -- 79462","610115":"SEBAWI -- 79462","610116":"JAWAI SELATAN -- 79154","610117":"TANGARAN -- 79465","610118":"SALATIGA -- 79453","610119":"SELAKAU TIMUR -- 79452","610201":"MEMPAWAH HILIR -- 78914","610206":"TOHO -- 78361","610207":"SUNGAI PINYUH -- 78353","610208":"SIANTAN -- 78351","610212":"SUNGAI KUNYIT -- 78371","610215":"SEGEDONG -- 78351","610216":"ANJONGAN -- 78353","610217":"SADANIANG -- 78361","610218":"MEMPAWAH TIMUR -- 78917","610301":"KAPUAS -- 78516","610302":"MUKOK -- 78581","610303":"NOYAN -- 78554","610304":"JANGKANG -- 78591","610305":"BONTI -- 78552","610306":"BEDUAI -- 78555","610307":"SEKAYAM -- 78556","610308":"KEMBAYAN -- 78553","610309":"PARINDU -- 78561","610310":"TAYAN HULU -- 78562","610311":"TAYAN HILIR -- 78564","610312":"BALAI -- 78563","610313":"TOBA -- 78572","610320":"MELIAU -- 78571","610321":"ENTIKONG -- 78557","610401":"MATAN HILIR UTARA -- 78813","610402":"MARAU -- 78863","610403":"MANIS MATA -- 78864","610404":"KENDAWANGAN -- 78862","610405":"SANDAI -- 78871","610407":"SUNGAI LAUR -- 78872","610408":"SIMPANG HULU -- 78854","610411":"NANGA TAYAP -- 78873","610412":"MATAN HILIR SELATAN -- 78822","610413":"TUMBANG TITI -- 78874","610414":"JELAI HULU -- 78876","610416":"DELTA PAWAN -- 78813","610417":"MUARA PAWAN -- 78813","610418":"BENUA KAYONG -- 78822","610419":"HULU SUNGAI -- 78871","610420":"SIMPANG DUA -- 78854","610421":"AIR UPAS -- 78863","610422":"SINGKUP -- 78863","610424":"PEMAHAN -- 78874","610425":"SUNGAI MELAYU RAYAK -- 78874","610501":"SINTANG -- 78617","610502":"TEMPUNAK -- 78661","610503":"SEPAUK -- 78662","610504":"KETUNGAU HILIR -- 78652","610505":"KETUNGAU TENGAH -- 78653","610506":"KETUNGAU HULU -- 78654","610507":"DEDAI -- 78691","610508":"KAYAN HILIR -- 78693","610509":"KAYAN HULU -- 78694","610514":"SERAWAI -- 78683","610515":"AMBALAU -- 78684","610519":"KELAM PERMAI -- 78656","610520":"SUNGAI TEBELIAN -- 78655","610521":"BINJAI HULU -- 78663","610601":"PUTUSSIBAU UTARA -- 78716","610602":"BIKA -- 78753","610603":"EMBALOH HILIR -- 78754","610604":"EMBALOH HULU -- 78755","610605":"BUNUT HILIR -- 78761","610606":"BUNUT HULU -- 78762","610607":"JONGKONG -- 78763","610608":"HULU GURUNG -- 78764","610609":"SELIMBAU -- 78765","610610":"SEMITAU -- 78771","610611":"SEBERUANG -- 78772","610612":"BATANG LUPAR -- 78766","610613":"EMPANANG -- 78768","610614":"BADAU -- 78767","610615":"SILAT HILIR -- 78773","610616":"SILAT HULU -- 78774","610617":"PUTUSSIBAU SELATAN -- 78714","610618":"KALIS -- 78756","610619":"BOYAN TANJUNG -- 78758","610620":"MENTEBAH -- 78757","610621":"PENGKADAN -- 78759","610622":"SUHAID -- 78775","610623":"PURING KENCANA -- 78769","610701":"SUNGAI RAYA -- 78391","610702":"SAMALANTAN -- 79281","610703":"LEDO -- 79284","610704":"BENGKAYANG -- 79211","610705":"SELUAS -- 79285","610706":"SANGGAU LEDO -- 79284","610707":"JAGOI BABANG -- 79286","610708":"MONTERADO -- 79181","610709":"TERIAK -- 79214","610710":"SUTI SEMARANG -- 79283","610711":"CAPKALA -- 79271","610712":"SIDING -- 79286","610713":"LUMAR -- 79283","610714":"SUNGAI BETUNG -- 79211","610715":"SUNGAI RAYA KEPULAUAN -- 79271","610716":"LEMBAH BAWANG -- 79281","610717":"TUJUH BELAS -- 79251","610801":"NGABANG -- 79357","610802":"MEMPAWAH HULU -- 79363","610803":"MENJALIN -- 79362","610804":"MANDOR -- 79355","610805":"AIR BESAR -- 79365","610806":"MENYUKE -- 79364","610807":"SENGAH TEMILA -- 79356","610808":"MERANTI -- 79366","610809":"KUALA BEHE -- 79367","610810":"SEBANGKI -- 79358","610811":"JELIMPO -- 79357","610812":"BANYUKE HULU -- 79364","610813":"SOMPAK -- 79363","610901":"SEKADAU HILIR -- 79582","610902":"SEKADAU HULU -- 79583","610903":"NANGA TAMAN -- 79584","610904":"NANGA MAHAP -- 79585","610905":"BELITANG HILIR -- 79586","610906":"BELITANG HULU -- 79587","610907":"BELITANG -- 79587","611001":"BELIMBING -- 79671","611002":"NANGA PINOH -- 79672","611003":"ELLA HILIR -- 79681","611004":"MENUKUNG -- 79682","611005":"SAYAN -- 79673","611006":"TANAH PINOH -- 79674","611007":"SOKAN -- 79675","611008":"PINOH UTARA -- 79672","611009":"PINOH SELATAN -- 79672","611010":"BELIMBING HULU -- 79671","611011":"TANAH PINOH BARAT -- 79674","611101":"SUKADANA -- 78852","611102":"SIMPANG HILIR -- 78853","611103":"TELUK BATANG -- 78856","611105":"SEPONTI -- 78857","611106":"KEPULAUAN KARIMATA -- 78855","611201":"SUNGAI RAYA -- 78391","611202":"KUALA MANDOR B -- -","611203":"SUNGAI AMBAWANG -- 78393","611204":"TERENTANG -- 78392","611205":"BATU AMPAR -- 78385","611206":"KUBU -- 78384","611207":"RASAU JAYA -- 78382","611208":"TELUK PAKEDAI -- -","611209":"SUNGAI KAKAP -- 78381","617101":"PONTIANAK SELATAN -- 78121","617102":"PONTIANAK TIMUR -- 78233","617103":"PONTIANAK BARAT -- 78114","617104":"PONTIANAK UTARA -- 78244","617105":"PONTIANAK KOTA -- 78117","617106":"PONTIANAK TENGGARA -- 78124","617201":"SINGKAWANG TENGAH -- 79114","617202":"SINGKAWANG BARAT -- 79124","617203":"SINGKAWANG TIMUR -- 79251","617204":"SINGKAWANG UTARA -- 79151","617205":"SINGKAWANG SELATAN -- 79163","620101":"KUMAI -- 74181","620102":"ARUT SELATAN -- 74113","620103":"KOTAWARINGIN LAMA -- 74161","620104":"ARUT UTARA -- 74152","620105":"PANGKALAN LADA -- 74184","620106":"PANGKALAN BANTENG -- 74183","620201":"KOTA BESI -- 74353","620202":"CEMPAGA -- 74354","620203":"MENTAYA HULU -- 74356","620204":"PARENGGEAN -- 74355","620205":"BAAMANG -- 74312","620206":"MENTAWA BARU KETAPANG -- -","620207":"MENTAYA HILIR UTARA -- 74361","620208":"MENTAYA HILIR SELATAN -- 74363","620209":"PULAU HANAUT -- 74362","620210":"ANTANG KALANG -- 74352","620211":"TELUK SAMPIT -- 74363","620212":"SERANAU -- 74315","620213":"CEMPAGA HULU -- 74354","620214":"TELAWANG -- 74353","620215":"BUKIT SANTUAI -- -","620216":"TUALAN HULU -- 74355","620217":"TELAGA ANTANG -- 74352","620301":"SELAT -- 74113","620302":"KAPUAS HILIR -- 73525","620303":"KAPUAS TIMUR -- 73581","620304":"KAPUAS KUALA -- 73583","620305":"KAPUAS BARAT -- 73552","620306":"PULAU PETAK -- 73592","620307":"KAPUAS MURUNG -- 73593","620308":"BASARANG -- 73564","620309":"MANTANGAI -- 73553","620310":"TIMPAH -- 73554","620311":"KAPUAS TENGAH -- 73555","620312":"KAPUAS HULU -- 74581","620313":"TAMBAN CATUR -- 73583","620314":"PASAK TALAWANG -- 73555","620315":"MANDAU TALAWANG -- 73555","620316":"DADAHUP -- 73593","620317":"BATAGUH -- 73516","620401":"JENAMAS -- 73763","620402":"DUSUN HILIR -- 73762","620403":"KARAU KUALA -- 73761","620404":"DUSUN UTARA -- 73752","620405":"GN. BINTANG AWAI -- -","620406":"DUSUN SELATAN -- 73713","620501":"MONTALLAT -- 73861","620502":"GUNUNG TIMANG -- 73862","620503":"GUNUNG PUREI -- 73871","620504":"TEWEH TIMUR -- 73881","620505":"TEWEH TENGAH -- 73814","620506":"LAHEI -- 73852","620508":"TEWEH SELATAN -- 73814","620509":"LAHEI BARAT -- 73852","620601":"KAMPIANG -- -","620602":"KATINGAN HILIR -- 74413","620603":"TEWANG SANGALANG GARING -- -","620604":"PULAU MALAN -- 74453","620605":"KATINGAN TENGAH -- 74454","620606":"SANAMAN MANTIKEI -- 74455","620607":"MARIKIT -- 74456","620608":"KATINGAN HULU -- 74457","620609":"MENDAWAI -- 74463","620610":"KATINGAN KUALA -- 74463","620611":"TASIK PAYAWAN -- 74461","620612":"PETAK MALAI -- 74455","620613":"BUKIT RAYA -- 74457","620701":"SERUYAN HILIR -- 74215","620702":"SERUYAN TENGAH -- 74281","620703":"DANAU SEMBULUH -- 74261","620704":"HANAU -- 74362","620705":"SERUYAN HULU -- 74291","620706":"SERUYAN HILIR TIMUR -- 74215","620707":"SERUYAN RAYA -- 74261","620708":"DANAU SELULUK -- 74271","620709":"BATU AMPAR -- 74281","620710":"SULING TAMBUN -- 74291","620801":"SUKAMARA -- 74172","620802":"JELAI -- 74171","620803":"BALAI RIAM -- 74173","620804":"PANTAI LUNCI -- 74171","620805":"PERMATA KECUBUNG -- 74173","620901":"LAMANDAU -- 74663","620902":"DELANG -- 74664","620903":"BULIK -- 74162","620904":"BULIK TIMUR -- 74162","620905":"MENTHOBI RAYA -- 74162","620906":"SEMATU JAYA -- 74162","620907":"BELANTIKAN RAYA -- 74663","620908":"BATANG KAWA -- 74664","621001":"SEPANG SIMIN -- 74571","621002":"KURUN -- 74511","621003":"TEWAH -- 74552","621004":"KAHAYAN HULU UTARA -- 74553","621005":"RUNGAN -- 74561","621006":"MANUHING -- 74562","621007":"MIHING RAYA -- 74571","621008":"DAMANG BATU -- 74553","621009":"MIRI MANASA -- 74553","621010":"RUNGAN HULU -- 74561","621011":"MAHUNING RAYA -- -","621101":"PANDIH BATU -- 74871","621102":"KAHAYAN KUALA -- 74872","621103":"KAHAYAN TENGAH -- 74862","621104":"BANAMA TINGANG -- 74863","621105":"KAHAYAN HILIR -- 74813","621106":"MALIKU -- 74873","621107":"JABIREN -- 74816","621108":"SEBANGAU KUALA -- 74874","621201":"MURUNG -- 73911","621202":"TANAH SIANG -- 73961","621203":"LAUNG TUHUP -- 73991","621204":"PERMATA INTAN -- 73971","621205":"SUMBER BARITO -- 73981","621206":"BARITO TUHUP RAYA -- 73991","621207":"TANAH SIANG SELATAN -- 73961","621208":"SUNGAI BABUAT -- 73971","621209":"SERIBU RIAM -- 73981","621210":"UUT MURUNG -- 73981","621301":"DUSUN TIMUR -- 73618","621302":"BANUA LIMA -- -","621303":"PATANGKEP TUTUI -- 73671","621304":"AWANG -- 73681","621305":"DUSUN TENGAH -- 73652","621306":"PEMATANG KARAU -- 73653","621307":"PAJU EPAT -- 73617","621308":"RAREN BATUAH -- 73652","621309":"PAKU -- 73652","621310":"KARUSEN JANANG -- 73652","627101":"PAHANDUT -- 73111","627102":"BUKIT BATU -- 73224","627103":"JEKAN RAYA -- 73112","627104":"SABANGAU -- -","627105":"RAKUMPIT -- 73229","630101":"TAKISUNG -- 70861","630102":"JORONG -- 70881","630103":"PELAIHARI -- 70815","630104":"KURAU -- 70853","630105":"BATI BATI -- -","630106":"PANYIPATAN -- 70871","630107":"KINTAP -- 70883","630108":"TAMBANG ULANG -- 70854","630109":"BATU AMPAR -- 70882","630110":"BAJUIN -- 70815","630111":"BUMI MAKMUR -- 70853","630201":"PULAUSEMBILAN -- 72181","630202":"PULAULAUT BARAT -- 72153","630203":"PULAULAUT SELATAN -- 72154","630204":"PULAULAUT TIMUR -- 72152","630205":"PULAUSEBUKU -- 72155","630206":"PULAULAUT UTARA -- 72115","630207":"KELUMPANG SELATAN -- 72161","630208":"KELUMPANG HULU -- 72162","630209":"KELUMPANG TENGAH -- 72164","630210":"KELUMPANG UTARA -- 72165","630211":"PAMUKAN SELATAN -- 72168","630212":"SAMPANAHAN -- 72166","630213":"PAMUKAN UTARA -- 72169","630214":"HAMPANG -- 72163","630215":"SUNGAIDURIAN -- 72167","630216":"PULAULAUT TENGAH -- 72156","630217":"KELUMPANG HILIR -- 72161","630218":"KELUMPANG BARAT -- 72164","630219":"PAMUKAN BARAT -- 72169","630220":"PULAULAUT KEPULAUAN -- 72154","630221":"PULAULAUT TANJUNG SELAYAR -- 72153","630301":"ALUH ALUH -- -","630302":"KERTAK HANYAR -- 70654","630303":"GAMBUT -- 70652","630304":"SUNGAI TABUK -- 70653","630305":"MARTAPURA -- 70617","630306":"KARANG INTAN -- 70661","630307":"ASTAMBUL -- 70671","630308":"SIMPANG EMPAT -- 70673","630309":"PENGAROM -- -","630310":"SUNGAI PINANG -- 70675","630311":"ARANIO -- 70671","630312":"MATARAMAN -- 70672","630313":"BERUNTUNG BARU -- 70655","630314":"MARTAPURA BARAT -- 70618","630315":"MARTAPURA TIMUR -- 70617","630316":"SAMBUNG MAKMUR -- 70674","630317":"PARAMASAN -- -","630318":"TELAGA BAUNTUNG -- 70673","630319":"TATAH MAKMUR -- 70654","630401":"TABUNGANEN -- 70567","630402":"TAMBAN -- 70854","630403":"ANJIR PASAR -- 70565","630404":"ANJIR MUARA -- 70564","630405":"ALALAK -- 70582","630406":"MANDASTANA -- 70581","630407":"RANTAU BADAUH -- 70561","630408":"BELAWANG -- 70563","630409":"CERBON -- 70571","630410":"BAKUMPAI -- 70513","630411":"KURIPAN -- 70552","630412":"TABUKAN -- 70553","630413":"MEKARSARI -- 70568","630414":"BARAMBAI -- 70562","630415":"MARABAHAN -- 70511","630416":"WANARAYA -- 70562","630417":"JEJANGKIT -- 70581","630501":"BINUANG -- 71183","630502":"TAPIN SELATAN -- 71181","630503":"TAPIN TENGAH -- 71161","630504":"TAPIN UTARA -- 71114","630505":"CANDI LARAS SELATAN -- 71162","630506":"CANDI LARAS UTARA -- 71171","630507":"BAKARANGAN -- 71152","630508":"PIANI -- 71191","630509":"BUNGUR -- 71153","630510":"LOKPAIKAT -- 71154","630511":"SALAM BABARIS -- 71185","630512":"HATUNGUN -- 71184","630601":"SUNGAI RAYA -- 71271","630602":"PADANG BATUNG -- 71281","630603":"TELAGA LANGSAT -- 71292","630604":"ANGKINANG -- 71291","630605":"KANDANGAN -- 71213","630606":"SIMPUR -- 71261","630607":"DAHA SELATAN -- 71252","630608":"DAHA UTARA -- 71253","630609":"KALUMPANG -- 71262","630610":"LOKSADO -- 71282","630611":"DAHA BARAT -- 71252","630701":"HARUYAN -- 71363","630702":"BATU BENAWA -- 71371","630703":"LABUAN AMAS SELATAN -- 71361","630704":"LABUAN AMAS UTARA -- 71362","630705":"PANDAWAN -- 71352","630706":"BARABAI -- 71315","630707":"BATANG ALAI SELATAN -- 71381","630708":"BATANG ALAI UTARA -- 71391","630709":"HANTAKAN -- 71372","630710":"BATANG ALAI TIMUR -- 71382","630711":"LIMPASU -- 71391","630801":"DANAU PANGGANG -- 71453","630802":"BABIRIK -- 71454","630803":"SUNGAI PANDAN -- 71455","630804":"AMUNTAI SELATAN -- 71452","630805":"AMUNTAI TENGAH -- 71419","630806":"AMUNTAI UTARA -- 71471","630807":"BANJANG -- 71416","630808":"HAUR GADING -- 71471","630809":"PAMINGGIR -- 71453","630810":"SUNGAI TABUKAN -- 71455","630901":"BANUA LAWAS -- 71553","630902":"KELUA -- 71552","630903":"TANTA -- 71561","630904":"TANJUNG -- 71514","630905":"HARUAI -- 71572","630906":"MURUNG PUDAK -- 71571","630907":"MUARA UYA -- 71573","630908":"MUARA HARUS -- 71555","630909":"PUGAAN -- 71554","630910":"UPAU -- 71575","630911":"JARO -- 71574","630912":"BINTANG ARA -- 71572","631001":"BATU LICIN -- 72271","631002":"KUSAN HILIR -- 72273","631003":"SUNGAI LOBAN -- 72274","631004":"SATUI -- 72275","631005":"KUSAN HULU -- 72272","631006":"SIMPANG EMPAT -- 70673","631007":"KARANG BINTANG -- 72211","631008":"MANTEWE -- 72211","631009":"ANGSANA -- 72275","631010":"KURANJI -- 72272","631101":"JUAI -- 71665","631102":"HALONG -- 71666","631103":"AWAYAN -- 71664","631104":"BATU MANDI -- 71663","631105":"LAMPIHONG -- 71661","631106":"PARINGIN -- 71662","631107":"PARINGIN SELATAN -- 71662","631108":"TEBING TINGGI -- 71664","637101":"BANJARMASIN SELATAN -- 70245","637102":"BANJARMASIN TIMUR -- 70239","637103":"BANJARMASIN BARAT -- 70245","637104":"BANJARMASIN UTARA -- 70126","637105":"BANJARMASIN TENGAH -- 70114","637202":"LANDASAN ULIN -- 70724","637203":"CEMPAKA -- 70732","637204":"BANJARBARU UTARA -- 70714","637205":"BANJARBARU SELATAN -- 70713","637206":"LIANG ANGGANG -- 70722","640101":"BATU SOPANG -- 76252","640102":"TANJUNG HARAPAN -- 76261","640103":"PASIR BALENGKONG -- -","640104":"TANAH GROGOT -- 76251","640105":"KUARO -- 76281","640106":"LONG IKIS -- 76282","640107":"MUARA KOMAM -- 76253","640108":"LONG KALI -- 76283","640109":"BATU ENGAU -- 76261","640110":"MUARA SAMU -- 76252","640201":"MUARA MUNTAI -- 75562","640202":"LOA KULU -- 75571","640203":"LOA JANAN -- 75391","640204":"ANGGANA -- 75381","640205":"MUARA BADAK -- 75382","640206":"TENGGARONG -- 75572","640207":"SEBULU -- 75552","640208":"KOTA BANGUN -- 75561","640209":"KENOHAN -- 75564","640210":"KEMBANG JANGGUT -- 75557","640211":"MUARA KAMAN -- 75553","640212":"TABANG -- 75561","640213":"SAMBOJA -- 75274","640214":"MUARA JAWA -- 75265","640215":"SANGA SANGA -- -","640216":"TENGGARONG SEBERANG -- 75572","640217":"MARANG KAYU -- 75385","640218":"MUARA WIS -- 75559","640301":"KELAY -- 77362","640302":"TALISAYAN -- 77372","640303":"SAMBALIUNG -- 77371","640304":"SEGAH -- 77361","640305":"TANJUNG REDEB -- 77312","640306":"GUNUNG TABUR -- 77352","640307":"PULAU DERAWAN -- 77381","640308":"BIDUK-BIDUK -- 77373","640309":"TELUK BAYUR -- 77352","640310":"TABALAR -- 77372","640311":"MARATUA -- 77381","640312":"BATU PUTIH -- 77373","640313":"BIATAN -- 77372","640705":"LONG IRAM -- 75766","640706":"MELAK -- 75765","640707":"BARONG TONGKOK -- 75776","640708":"DAMAI -- 75777","640709":"MUARA LAWA -- 75775","640710":"MUARA PAHU -- 75774","640711":"JEMPANG -- 75773","640712":"BONGAN -- 75772","640713":"PENYINGGAHAN -- 75763","640714":"BENTIAN BESAR -- 75778","640715":"LINGGANG BIGUNG -- 75576","640716":"NYUATAN -- 75776","640717":"SILUQ NGURAI -- 75774","640718":"MOOK MANAAR BULATN -- 75774","640719":"TERING -- 75766","640720":"SEKOLAQ DARAT -- 75765","640801":"MUARA ANCALONG -- 75656","640802":"MUARA WAHAU -- 75655","640803":"MUARA BENGKAL -- 75654","640804":"SANGATTA UTARA -- 75683","640805":"SANGKULIRANG -- 75684","640806":"BUSANG -- 75556","640807":"TELEN -- 75555","640808":"KOMBENG -- -","640809":"BENGALON -- 75618","640810":"KALIORANG -- 75618","640811":"SANDARAN -- 75685","640812":"SANGATTA SELATAN -- 75683","640813":"TELUK PANDAN -- 75683","640814":"RANTAU PULUNG -- 75683","640815":"KAUBUN -- 75619","640816":"KARANGAN -- 75684","640817":"BATU AMPAR -- 75654","640818":"LONG MESANGAT -- 75656","640901":"PENAJAM -- 76141","640902":"WARU -- 76284","640903":"BABULU -- 76285","640904":"SEPAKU -- 76147","641101":"LONG BAGUN -- 75767","641102":"LONG HUBUNG -- 75779","641103":"LAHAM -- 75779","641104":"LONG APARI -- 75769","641105":"LONG PAHANGAI -- 75768","647101":"BALIKPAPAN TIMUR -- 76117","647102":"BALIKPAPAN BARAT -- 76131","647103":"BALIKPAPAN UTARA -- 76136","647104":"BALIKPAPAN TENGAH -- 76121","647105":"BALIKPAPAN SELATAN -- 76114","647106":"BALIKPAPAN KOTA -- 76114","647201":"PALARAN -- 75253","647202":"SAMARINDA SEBERANG -- 75131","647203":"SAMARINDA ULU -- 75124","647204":"SAMARINDA ILIR -- 75117","647205":"SAMARINDA UTARA -- 75118","647206":"SUNGAI KUNJANG -- 75125","647207":"SAMBUTAN -- 75115","647208":"SUNGAI PINANG -- 75119","647209":"SAMARINDA KOTA -- 75121","647210":"LOA JANAN ILIR -- 75131","647401":"BONTANG UTARA -- 75311","647402":"BONTANG SELATAN -- 75325","647403":"BONTANG BARAT -- 75313","650101":"TANJUNG PALAS -- 77211","650102":"TANJUNG PALAS BARAT -- 77217","650103":"TANJUNG PALAS UTARA -- 77215","650104":"TANJUNG PALAS TIMUR -- 77215","650105":"TANJUNG SELOR -- 77212","650106":"TANJUNG PALAS TENGAH -- 77216","650107":"PESO -- 77261","650108":"PESO HILIR -- 77261","650109":"SEKATAK -- 77263","650110":"BUNYU -- 77281","650201":"MENTARANG -- 77555","650202":"MALINAU KOTA -- 77554","650203":"PUJUNGAN -- 77562","650204":"KAYAN HILIR -- 77571","650205":"KAYAN HULU -- 77572","650206":"MALINAU SELATAN -- 77554","650207":"MALINAU UTARA -- 77554","650208":"MALINAU BARAT -- 77554","650209":"SUNGAI BOH -- 77573","650210":"KAYAN SELATAN -- 77573","650211":"BAHAU HULU -- 77562","650212":"MENTARANG HULU -- 77155","650213":"MALINAU SELATAN HILIR -- 77554","650214":"MALINAU SELATAN HULU -- 77554","650215":"SUNGAI TUBU -- 77555","650301":"SEBATIK -- 77483","650302":"NUNUKAN -- 77482","650303":"SEMBAKUNG -- 77453","650304":"LUMBIS -- 77457","650305":"KRAYAN -- 77456","650306":"SEBUKU -- 77482","650307":"KRAYAN SELATAN -- 77456","650308":"SEBATIK BARAT -- 77483","650309":"NUNUKAN SELATAN -- 77482","650310":"SEBATIK TIMUR -- 77483","650311":"SEBATIK UTARA -- 77483","650312":"SEBATIK TENGAH -- 77483","650313":"SEI MENGGARIS -- 77482","650314":"TULIN ONSOI -- 77482","650315":"LUMBIS OGONG -- 77457","650316":"SEMBAKUNG ATULAI -- -","650401":"SESAYAP -- 77152","650402":"SESAYAP HILIR -- 77152","650403":"TANA LIA -- 77453","650405":"MURUK RIAN -- -","657101":"TARAKAN BARAT -- 77111","657102":"TARAKAN TENGAH -- 77113","657103":"TARAKAN TIMUR -- 77115","657104":"TARAKAN UTARA -- 77116","710105":"SANG TOMBOLANG -- 95762","710109":"DUMOGA BARAT -- 95773","710110":"DUMOGA TIMUR -- 95772","710111":"DUMOGA UTARA -- 95772","710112":"LOLAK -- 95761","710113":"BOLAANG -- 95752","710114":"LOLAYAN -- 95771","710119":"PASSI BARAT -- 95751","710120":"POIGAR -- 95753","710122":"PASSI TIMUR -- 95751","710131":"BOLAANG TIMUR -- 95752","710132":"BILALANG -- 95751","710133":"DUMOGA -- 95772","710134":"DUMOGA TENGGARA -- 95772","710135":"DUMOGA TENGAH -- 95773","710201":"TONDANO BARAT -- 95616","710202":"TONDANO TIMUR -- 95612","710203":"ERIS -- 95683","710204":"KOMBI -- 95684","710205":"LEMBEAN TIMUR -- 95683","710206":"KAKAS -- 95682","710207":"TOMPASO -- 95693","710208":"REMBOKEN -- 95681","710209":"LANGOWAN TIMUR -- 95694","710210":"LANGOWAN BARAT -- 95694","710211":"SONDER -- 95691","710212":"KAWANGKOAN -- 95692","710213":"PINELENG -- 95661","710214":"TOMBULU -- 95661","710215":"TOMBARIRI -- 95651","710216":"TONDANO UTARA -- 95614","710217":"LANGOWAN SELATAN -- 95694","710218":"TONDANO SELATAN -- 95618","710219":"LANGOWAN UTARA -- 95694","710220":"KAKAS BARAT -- 95682","710221":"KAWANGKOAN UTARA -- 95692","710222":"KAWANGKOAN BARAT -- 95692","710223":"MANDOLANG -- 95661","710224":"TOMBARIRI TIMUR -- 95651","710225":"TOMPASO BARAT -- 95693","710308":"TABUKAN UTARA -- 95856","710309":"NUSA TABUKAN -- 95856","710310":"MANGANITU SELATAN -- 95854","710311":"TATOARENG -- 95854","710312":"TAMAKO -- 95855","710313":"MANGANITU -- 95853","710314":"TABUKAN TENGAH -- 95857","710315":"TABUKAN SELATAN -- 95858","710316":"KENDAHE -- 95852","710317":"TAHUNA -- 95818","710319":"TABUKAN SELATAN TENGAH -- 95858","710320":"TABUKAN SELATAN TENGGARA -- 95858","710323":"TAHUNA BARAT -- 95818","710324":"TAHUNA TIMUR -- 95814","710325":"KEPULAUAN MARORE -- 95856","710401":"LIRUNG -- 95871","710402":"BEO -- 95881","710403":"RAINIS -- 95882","710404":"ESSANG -- 95883","710405":"NANUSA -- 95884","710406":"KABARUAN -- 95872","710407":"MELONGUANE -- 95885","710408":"GEMEH -- 95883","710409":"DAMAU -- 95872","710410":"TAMPAN' AMMA -- -","710411":"SALIBABU -- 95871","710412":"KALONGAN -- 95871","710413":"MIANGAS -- 95884","710414":"BEO UTARA -- 95881","710415":"PULUTAN -- 95882","710416":"MELONGUANE TIMUR -- 95885","710417":"MORONGE -- 95871","710418":"BEO SELATAN -- 95881","710419":"ESSANG SELATAN -- 95883","710501":"MODOINDING -- 95958","710502":"TOMPASO BARU -- 95357","710503":"RANOYAPO -- 95999","710507":"MOTOLING -- 95956","710508":"SINONSAYANG -- 95959","710509":"TENGA -- 95775","710510":"AMURANG -- 95954","710512":"TUMPAAN -- 95352","710513":"TARERAN -- 95953","710515":"KUMELEMBUAI -- 95956","710516":"MAESAAN -- 95357","710517":"AMURANG BARAT -- 95955","710518":"AMURANG TIMUR -- 95954","710519":"TATAPAAN -- 95352","710521":"MOTOLING BARAT -- 95956","710522":"MOTOLING TIMUR -- 95956","710523":"SULUUN TARERAN -- 95953","710601":"KEMA -- 95379","710602":"KAUDITAN -- 95372","710603":"AIRMADIDI -- 95371","710604":"WORI -- 95376","710605":"DIMEMBE -- 95373","710606":"LIKUPANG BARAT -- 95377","710607":"LIKUPANG TIMUR -- 95375","710608":"KALAWAT -- 95378","710609":"TALAWAAN -- 95373","710610":"LIKUPANG SELATAN -- 95375","710701":"RATAHAN -- 95995","710702":"PUSOMAEN -- 95997","710703":"BELANG -- 95997","710704":"RATATOTOK -- 95997","710705":"TOMBATU -- 95996","710706":"TOULUAAN -- 95998","710707":"TOULUAAN SELATAN -- 95998","710708":"SILIAN RAYA -- 95998","710709":"TOMBATU TIMUR -- 95996","710710":"TOMBATU UTARA -- 95996","710711":"PASAN -- 95995","710712":"RATAHAN TIMUR -- 95995","710801":"SANGKUB -- 95762","710802":"BINTAUNA -- 95763","710803":"BOLANGITANG TIMUR -- 95764","710804":"BOLANGITANG BARAT -- 95764","710805":"KAIDIPANG -- 95765","710806":"PINOGALUMAN -- 95765","710901":"SIAU TIMUR -- 95861","710902":"SIAU BARAT -- 95862","710903":"TAGULANDANG -- 95863","710904":"SIAU TIMUR SELATAN -- 95861","710905":"SIAU BARAT SELATAN -- 95862","710906":"TAGULANDANG UTARA -- 95863","710907":"BIARO -- 95864","710908":"SIAU BARAT UTARA -- 95862","710909":"SIAU TENGAH -- 95861","710910":"TAGULANDANG SELATAN -- 95863","711001":"TUTUYAN -- 95782","711002":"KOTABUNAN -- 95782","711003":"NUANGAN -- 95775","711004":"MODAYAG -- 95781","711005":"MODAYAG BARAT -- 95781","711101":"BOLAANG UKI -- 95774","711102":"POSIGADAN -- 95774","711103":"PINOLOSIAN -- 95775","711104":"PINOLOSIAN TENGAH -- 95775","711105":"PINOLOSIAN TIMUR -- 95775","717101":"BUNAKEN -- 95231","717102":"TUMINITING -- 95239","717103":"SINGKIL -- 95231","717104":"WENANG -- 95113","717105":"TIKALA -- 95125","717106":"SARIO -- 95116","717107":"WANEA -- 95117","717108":"MAPANGET -- 95251","717109":"MALALAYANG -- 95115","717110":"BUNAKEN KEPULAUAN -- 95231","717111":"PAAL DUA -- 95127","717201":"LEMBEH SELATAN -- 95552","717202":"MADIDIR -- 95513","717203":"RANOWULU -- 95537","717204":"AERTEMBAGA -- 95526","717205":"MATUARI -- 95545","717206":"GIRIAN -- 95544","717207":"MAESA -- 95511","717208":"LEMBEH UTARA -- 95551","717301":"TOMOHON SELATAN -- 95433","717302":"TOMOHON TENGAH -- 95441","717303":"TOMOHON UTARA -- 95416","717304":"TOMOHON BARAT -- 95424","717305":"TOMOHON TIMUR -- 95449","717401":"KOTAMOBAGU UTARA -- 95713","717402":"KOTAMOBAGU TIMUR -- 95719","717403":"KOTAMOBAGU SELATAN -- 95717","717404":"KOTAMOBAGU BARAT -- 95715","720101":"BATUI -- 94762","720102":"BUNTA -- 94753","720103":"KINTOM -- 94761","720104":"LUWUK -- 94711","720105":"LAMALA -- 94771","720106":"BALANTAK -- 94773","720107":"PAGIMANA -- 94752","720108":"BUALEMO -- 94752","720109":"TOILI -- 94765","720110":"MASAMA -- 94772","720111":"LUWUK TIMUR -- 94723","720112":"TOILI BARAT -- 94765","720113":"NUHON -- 94753","720114":"MOILONG -- 94765","720115":"BATUI SELATAN -- 94762","720116":"LOBU -- 94752","720117":"SIMPANG RAYA -- 94753","720118":"BALANTAK SELATAN -- 94773","720119":"BALANTAK UTARA -- 94773","720120":"LUWUK SELATAN -- 94717","720121":"LUWUK UTARA -- 94711","720122":"MANTOH -- 94771","720123":"NAMBO -- 94761","720201":"POSO KOTA -- 94616","720202":"POSO PESISIR -- 94652","720203":"LAGE -- 94661","720204":"PAMONA PUSELEMBA -- 94663","720205":"PAMONA TIMUR -- 94663","720206":"PAMONA SELATAN -- 94664","720207":"LORE UTARA -- 94653","720208":"LORE TENGAH -- 94653","720209":"LORE SELATAN -- 94654","720218":"POSO PESISIR UTARA -- 94652","720219":"POSO PESISIR SELATAN -- 94652","720220":"PAMONA BARAT -- 94664","720221":"POSO KOTA SELATAN -- 94614","720222":"POSO KOTA UTARA -- 94616","720223":"LORE BARAT -- 94654","720224":"LORE TIMUR -- 94653","720225":"LORE PIORE -- 94653","720226":"PAMONA TENGGARA -- 94664","720227":"PAMONA UTARA -- 94663","720304":"RIO PAKAVA -- 94362","720308":"BANAWA -- 94351","720309":"LABUAN -- 94352","720310":"SINDUE -- 94353","720311":"SIRENJA -- 94354","720312":"BALAESANG -- 94355","720314":"SOJOL -- 94356","720318":"BANAWA SELATAN -- 94351","720319":"TANANTOVEA -- 94352","720321":"PANEMBANI -- -","720324":"SINDUE TOMBUSABORA -- 94353","720325":"SINDUE TOBATA -- 94353","720327":"BANAWA TENGAH -- 94351","720330":"SOJOL UTARA -- 94356","720331":"BALAESANG TANJUNG -- 94355","720401":"DAMPAL SELATAN -- 94554","720402":"DAMPAL UTARA -- 94553","720403":"DONDO -- 94552","720404":"BASIDONDO -- 94552","720405":"OGODEIDE -- 94516","720406":"LAMPASIO -- 94518","720407":"BAOLAN -- 94514","720408":"GALANG -- 94561","720409":"TOLI-TOLI UTARA -- -","720410":"DAKO PEMEAN -- -","720501":"MOMUNU -- 94565","720502":"LAKEA -- 94563","720503":"BOKAT -- 94566","720504":"BUNOBOGU -- 94567","720505":"PALELEH -- 94568","720507":"TILOAN -- 94565","720508":"BUKAL -- 94563","720509":"GADUNG -- 94568","720510":"KARAMAT -- 94563","720511":"PALELEH BARAT -- 94568","720605":"BUNGKU TENGAH -- 94973","720606":"BUNGKU SELATAN -- 94974","720607":"MENUI KEPULAUAN -- 94975","720608":"BUNGKU BARAT -- 94976","720609":"BUMI RAYA -- 94976","720610":"BAHODOPI -- 94974","720612":"WITA PONDA -- 94976","720615":"BUNGKU PESISIR -- 94974","720618":"BUNGKU TIMUR -- 94973","720703":"TOTIKUM -- 94884","720704":"TINANGKUNG -- 94885","720705":"LIANG -- 94883","720706":"BULAGI -- 94882","720707":"BUKO -- 94881","720709":"BULAGI SELATAN -- 94882","720711":"TINANGKUNG SELATAN -- 94885","720715":"TOTIKUM SELATAN -- 94884","720716":"PELING TENGAH -- 94883","720717":"BULAGI UTARA -- 94882","720718":"BUKO SELATAN -- 94881","720719":"TINANGKUNG UTARA -- 94885","720801":"PARIGI -- 94471","720802":"AMPIBABO -- 94474","720803":"TINOMBO -- 94475","720804":"MOUTONG -- 94479","720805":"TOMINI -- 94476","720806":"SAUSU -- 94473","720807":"BOLANO LAMBUNU -- 94479","720808":"KASIMBAR -- 94474","720809":"TORUE -- 94473","720810":"TINOMBO SELATAN -- 94475","720811":"PARIGI SELATAN -- 94471","720812":"MEPANGA -- 94476","720813":"TORIBULU -- 94474","720814":"TAOPA -- 94479","720815":"BALINGGI -- 94473","720816":"PARIGI BARAT -- 94471","720817":"SINIU -- 94474","720818":"PALASA -- 94476","720819":"PARIGI UTARA -- 94471","720820":"PARIGI TENGAH -- 94471","720821":"BOLANO -- 94479","720822":"ONGKA MALINO -- 94479","720823":"SIDOAN -- -","720901":"UNA UNA -- -","720902":"TOGEAN -- 94683","720903":"WALEA KEPULAUAN -- 94692","720904":"AMPANA TETE -- 94684","720905":"AMPANA KOTA -- 94683","720906":"ULUBONGKA -- 94682","720907":"TOJO BARAT -- 94681","720908":"TOJO -- 94681","720909":"WALEA BESAR -- 94692","720910":"RATOLINDO -- -","720911":"BATUDAKA -- -","720912":"TALATAKO -- -","721001":"SIGI BIROMARU -- 94364","721002":"PALOLO -- 94364","721003":"NOKILALAKI -- 94364","721004":"LINDU -- 94363","721005":"KULAWI -- 94363","721006":"KULAWI SELATAN -- 94363","721007":"PIPIKORO -- 94112","721008":"GUMBASA -- 94364","721009":"DOLO SELATAN -- 94361","721010":"TANAMBULAVA -- 94364","721011":"DOLO BARAT -- 94361","721012":"DOLO -- 94361","721013":"KINOVARO -- -","721014":"MARAWOLA -- 94362","721015":"MARAWOLA BARAT -- 94362","721101":"BANGGAI -- 94891","721102":"BANGGAI UTARA -- 94891","721103":"BOKAN KEPULAUAN -- 94892","721104":"BANGKURUNG -- 94892","721105":"LABOBO -- 94892","721106":"BANGGAI SELATAN -- 94891","721107":"BANGGAI TENGAH -- 94891","721201":"PETASIA -- 94971","721202":"PETASIA TIMUR -- 94971","721203":"LEMBO RAYA -- 94966","721204":"LEMBO -- 94966","721205":"MORI ATAS -- 94965","721206":"MORI UTARA -- 94965","721207":"SOYO JAYA -- 94971","721208":"BUNGKU UTARA -- 94972","721209":"MAMOSALATO -- 94972","727101":"PALU TIMUR -- 94111","727102":"PALU BARAT -- 94226","727103":"PALU SELATAN -- 94231","727104":"PALU UTARA -- 94146","727105":"ULUJADI -- 94228","727106":"TATANGA -- 94221","727107":"TAWAELI -- 94142","727108":"MANTIKULORE -- 94233","730101":"BENTENG -- 92812","730102":"BONTOHARU -- 92811","730103":"BONTOMATENE -- 92854","730104":"BONTOMANAI -- 92851","730105":"BONTOSIKUYU -- 92855","730106":"PASIMASUNGGU -- 92861","730107":"PASIMARANNU -- 92862","730108":"TAKA BONERATE -- 92861","730109":"PASILAMBENA -- 92863","730110":"PASIMASUNGGU TIMUR -- 92861","730111":"BUKI -- 92854","730201":"GANTORANG -- 92561","730202":"UJUNG BULU -- 92511","730203":"BONTO BAHARI -- 92571","730204":"BONTO TIRO -- 92572","730205":"HERLANG -- 92573","730206":"KAJANG -- 92574","730207":"BULUKUMPA -- 92552","730208":"KINDANG -- 92517","730209":"UJUNGLOE -- 92661","730210":"RILAUALE -- 92552","730301":"BISSAPPU -- 92451","730302":"BANTAENG -- 92411","730303":"EREMERASA -- 92415","730304":"TOMPO BULU -- 92461","730305":"PAJUKUKANG -- 92461","730306":"ULUERE -- 92451","730307":"GANTARANG KEKE -- 92461","730308":"SINOA -- 92451","730401":"BANGKALA -- 92352","730402":"TAMALATEA -- 92351","730403":"BINAMU -- 92316","730404":"BATANG -- 92361","730405":"KELARA -- 92371","730406":"BANGKALA BARAT -- 92352","730407":"BONTORAMBA -- 92351","730408":"TURATEA -- 92313","730409":"ARUNGKEKE -- 92361","730410":"RUMBIA -- 92371","730411":"TAROWANG -- 92361","730501":"MAPPAKASUNGGU -- 92232","730502":"MANGARABOMBANG -- 92261","730503":"POLOMBANGKENG SELATAN -- 92252","730504":"POLOMBANGKENG UTARA -- 92221","730505":"GALESONG SELATAN -- 92254","730506":"GALESONG UTARA -- 92255","730507":"PATTALLASSANG -- 92171","730508":"SANROBONE -- 92231","730509":"GALESONG -- 92255","730601":"BONTONOMPO -- 92153","730602":"BAJENG -- 92152","730603":"TOMPOBULLU -- -","730604":"TINGGIMONCONG -- 92174","730605":"PARANGLOE -- 92173","730606":"BONTOMARANNU -- 92171","730607":"PALANGGA -- -","730608":"SOMBA UPU -- -","730609":"BUNGAYA -- 92176","730610":"TOMBOLOPAO -- 92171","730611":"BIRINGBULU -- 90244","730612":"BAROMBONG -- 90225","730613":"PATTALASANG -- -","730614":"MANUJU -- 92173","730615":"BONTOLEMPANGANG -- 92176","730616":"BONTONOMPO SELATAN -- 92153","730617":"PARIGI -- 92174","730618":"BAJENG BARAT -- 92152","730701":"SINJAI BARAT -- 92653","730702":"SINJAI SELATAN -- 92661","730703":"SINJAI TIMUR -- 92671","730704":"SINJAI TENGAH -- 92652","730705":"SINJAI UTARA -- 92616","730706":"BULUPODDO -- 92654","730707":"SINJAI BORONG -- 92662","730708":"TELLU LIMPOE -- 91662","730709":"PULAU SEMBILAN -- 92616","730801":"BONTOCANI -- 92768","730802":"KAHU -- 92767","730803":"KAJUARA -- 92776","730804":"SALOMEKKO -- 92775","730805":"TONRA -- 92774","730806":"LIBURENG -- 92766","730807":"MARE -- 92773","730808":"SIBULUE -- 92781","730809":"BAREBBO -- 92771","730810":"CINA -- 92772","730811":"PONRE -- 92765","730812":"LAPPARIAJA -- 92763","730813":"LAMURU -- 92764","730814":"ULAWENG -- 92762","730815":"PALAKKA -- 92761","730816":"AWANGPONE -- 92776","730817":"TELLU SIATTINGE -- 92752","730818":"AJANGALE -- 92755","730819":"DUA BOCCOE -- 92753","730820":"CENRANA -- 92754","730821":"TANETE RIATTANG -- 92716","730822":"TANETE RIATTANG BARAT -- 92735","730823":"TANETE RIATTANG TIMUR -- 92716","730824":"AMALI -- 92755","730825":"TELLULIMPOE -- 91662","730826":"BENGO -- 92763","730827":"PATIMPENG -- 92768","730901":"MANDAI -- 90552","730902":"CAMBA -- 90562","730903":"BANTIMURUNG -- 90561","730904":"MAROS BARU -- 90516","730905":"BONTOA -- 90554","730906":"MALLLAWA -- -","730907":"TANRALILI -- 90553","730908":"MARUSU -- 90552","730909":"SIMBANG -- 90561","730910":"CENRANA -- 92754","730911":"TOMPOBULU -- 92461","730912":"LAU -- 90871","730913":"MONCONG LOE -- 90562","730914":"TURIKALE -- 90516","731001":"LIUKANG TANGAYA -- 90673","731002":"LIUKANG KALMAS -- 90672","731003":"LIUKANG TUPABBIRING -- 90671","731004":"PANGKAJENE -- 90612","731005":"BALOCCI -- 90661","731006":"BUNGORO -- 90651","731007":"LABAKKANG -- 90653","731008":"MARANG -- 90654","731009":"SEGERI -- 90655","731010":"MINASA TENE -- 90614","731011":"MANDALLE -- 90655","731012":"TONDONG TALLASA -- 90561","731101":"TANETE RIAJA -- 90762","731102":"TANETE RILAU -- 90761","731103":"BARRU -- 90712","731104":"SOPPENG RIAJA -- 90752","731105":"MALLUSETASI -- 90753","731106":"PUJANANTING -- 90762","731107":"BALUSU -- 91855","731201":"MARIORIWAWO -- 90862","731202":"LILIRAJA -- 90861","731203":"LILIRILAU -- 90871","731204":"LALABATA -- 90814","731205":"MARIORIAWA -- 90852","731206":"DONRI DONRI -- -","731207":"GANRA -- 90861","731208":"CITTA -- 90861","731301":"SABANGPARU -- -","731302":"PAMMANA -- 90971","731303":"TAKKALALLA -- 90981","731304":"SAJOANGING -- 90982","731305":"MAJAULENG -- 90991","731306":"TEMPE -- 91992","731307":"BELAWA -- 90953","731308":"TANASITOLO -- 90951","731309":"MANIANGPAJO -- 90952","731310":"PITUMPANUA -- 90992","731311":"BOLA -- 90984","731312":"PENRANG -- 90983","731313":"GILIRENG -- 90954","731314":"KEERA -- 90993","731401":"PANCA LAUTAN -- 91672","731402":"TELLU LIMPOE -- 91662","731403":"WATANG PULU -- 91661","731404":"BARANTI -- 91652","731405":"PANCA RIJANG -- 91651","731406":"KULO -- 91653","731407":"MARITENGNGAE -- 91611","731408":"WT. SIDENRENG -- -","731409":"DUA PITUE -- 91681","731410":"PITU RIAWA -- 91683","731411":"PITU RAISE -- 91691","731501":"MATIRRO SOMPE -- -","731502":"SUPPA -- 91272","731503":"MATTIRO BULU -- 91271","731504":"WATANG SAWITO -- -","731505":"PATAMPANUA -- 91252","731506":"DUAMPANUA -- 91253","731507":"LEMBANG -- 91254","731508":"CEMPA -- 91262","731509":"TIROANG -- 91256","731510":"LANSIRANG -- -","731511":"PALETEANG -- 91215","731512":"BATU LAPPA -- 91253","731601":"MAIWA -- 91761","731602":"ENREKANG -- 91711","731603":"BARAKA -- 91753","731604":"ANGGERAJA -- 91752","731605":"ALLA -- 90981","731606":"BUNGIN -- 91761","731607":"CENDANA -- 91711","731608":"CURIO -- 91754","731609":"MALUA -- 91752","731610":"BUNTU BATU -- 91753","731611":"MASALLE -- 91754","731612":"BAROKO -- 91754","731701":"BASSE SANGTEMPE -- 91992","731702":"LAROMPONG -- 91998","731703":"SULI -- 91996","731704":"BAJO -- 91995","731705":"BUA PONRANG -- 91993","731706":"WALENRANG -- 91951","731707":"BELOPA -- 91994","731708":"BUA -- 91993","731709":"LAMASI -- 91952","731710":"LAROMPONG SELATAN -- 91998","731711":"PONRANG -- 91999","731712":"LATIMOJONG -- 91921","731713":"KAMANRE -- 91994","731714":"BELOPA UTARA -- 91994","731715":"WALENRANG BARAT -- 91951","731716":"WALENRANG UTARA -- 91952","731717":"WALENRANG TIMUR -- 91951","731718":"LAMASI TIMUR -- 91951","731719":"SULI BARAT -- 91996","731720":"BAJO BARAT -- 91995","731721":"PONRANG SELATAN -- 91999","731722":"BASSE SANGTEMPE UTARA -- 91992","731801":"SALUPUTI -- -","731802":"BITTUANG -- 91856","731803":"BONGGAKARADENG -- 91872","731805":"MAKALE -- 91811","731809":"SIMBUANG -- 91873","731811":"RANTETAYO -- 91862","731812":"MENGKENDEK -- 91871","731813":"SANGALLA -- 91881","731819":"GANDANGBATU SILLANAN -- 91871","731820":"REMBON -- 91861","731827":"MAKALE UTARA -- 91812","731828":"MAPPAK -- 92232","731829":"MAKALE SELATAN -- 91815","731831":"MASANDA -- 91854","731833":"SANGALLA SELATAN -- 91881","731834":"SANGALLA UTARA -- 91881","731835":"MALIMBONG BALEPE -- 91861","731837":"RANO -- 91872","731838":"KURRA -- 91862","732201":"MALANGKE -- 92957","732202":"BONE BONE -- -","732203":"MASAMBA -- 92916","732204":"SABBANG -- 92955","732205":"LIMBONG -- 91861","732206":"SUKAMAJU -- 92963","732207":"SEKO -- 92956","732208":"MALANGKE BARAT -- 92957","732209":"RAMPI -- 92964","732210":"MAPPEDECENG -- 92917","732211":"BAEBUNTA -- 92965","732212":"TANA LILI -- 91966","732401":"MANGKUTANA -- 92973","732402":"NUHA -- 92983","732403":"TOWUTI -- 92982","732404":"MALILI -- 92981","732405":"ANGKONA -- 92985","732406":"WOTU -- 92971","732407":"BURAU -- 92975","732408":"TOMONI -- 92972","732409":"TOMONI TIMUR -- 92972","732410":"KALAENA -- 92973","732411":"WASUPONDA -- 92983","732601":"RANTEPAO -- 91835","732602":"SESEAN -- 91853","732603":"NANGGALA -- 91855","732604":"RINDINGALLO -- 91854","732605":"BUNTAO -- 91853","732606":"SA'DAN -- -","732607":"SANGGALANGI -- 91852","732608":"SOPAI -- 92119","732609":"TIKALA -- 91833","732610":"BALUSU -- 91855","732611":"TALLUNGLIPU -- 91832","732612":"DENDE' PIONGAN NAPO -- -","732613":"BUNTU PEPASAN -- 91854","732614":"BARUPPU -- 91854","732615":"KESU -- 91852","732616":"TONDON -- 90561","732617":"BANGKELEKILA -- 91853","732618":"RANTEBUA -- 91853","732619":"SESEAN SULOARA -- 91853","732620":"KAPALA PITU -- 91854","732621":"AWAN RANTE KARUA -- 91854","737101":"MARISO -- 90126","737102":"MAMAJANG -- 90131","737103":"MAKASAR -- -","737104":"UJUNG PANDANG -- 90111","737105":"WAJO -- 90173","737106":"BONTOALA -- 90153","737107":"TALLO -- 90212","737108":"UJUNG TANAH -- 90167","737109":"PANAKUKKANG -- -","737110":"TAMALATE -- 90224","737111":"BIRINGKANAYA -- 90243","737112":"MANGGALA -- 90234","737113":"RAPPOCINI -- 90222","737114":"TAMALANREA -- 90244","737201":"BACUKIKI -- 91121","737202":"UJUNG -- 92661","737203":"SOREANG -- 91131","737204":"BACUKIKI BARAT -- 91121","737301":"WARA -- 91922","737302":"WARA UTARA -- 91911","737303":"WARA SELATAN -- 91959","737304":"TELLUWANUA -- 91958","737305":"WARA TIMUR -- 91921","737306":"WARA BARAT -- 91921","737307":"SENDANA -- 91925","737308":"MUNGKAJANG -- 91925","737309":"BARA -- 92653","740101":"WUNDULAKO -- 93561","740104":"KOLAKA -- 93517","740107":"POMALAA -- 93562","740108":"WATUBANGGA -- -","740110":"WOLO -- 93754","740112":"BAULA -- 93561","740114":"LATAMBAGA -- 93512","740118":"TANGGETADA -- 93563","740120":"SAMATURU -- 93915","740124":"TOARI -- 93563","740125":"POLINGGONA -- 93563","740127":"IWOIMENDAA -- -","740201":"LAMBUYA -- 93464","740202":"UNAAHA -- 93413","740203":"WAWOTOBI -- 93461","740204":"PONDIDAHA -- 93463","740205":"SAMPARA -- 93354","740210":"ABUKI -- 93452","740211":"SOROPIA -- 93351","740215":"TONGAUNA -- 93461","740216":"LATOMA -- 93461","740217":"PURIALA -- 93464","740218":"UEPAI -- 93464","740219":"WONGGEDUKU -- 93463","740220":"BESULUTU -- 93354","740221":"BONDOALA -- 93354","740223":"ROUTA -- 93653","740224":"ANGGABERI -- 93419","740225":"MELUHU -- 93461","740228":"AMONGGEDO -- 93463","740231":"ASINUA -- 93452","740232":"KONAWE -- 93461","740233":"KAPOIALA -- 93354","740236":"LALONGGASUMEETO -- 93351","740237":"ONEMBUTE -- 93464","740306":"NAPABALANO -- 93654","740307":"MALIGANO -- 93674","740313":"WAKORUMBA SELATAN -- 93674","740314":"LASALEPA -- 93654","740315":"BATALAIWARU -- 93614","740316":"KATOBU -- 93611","740317":"DURUKA -- 93659","740318":"LOHIA -- 93658","740319":"WATOPUTE -- 93656","740320":"KONTUNAGA -- 93658","740323":"KABANGKA -- 93664","740324":"KABAWO -- 93661","740325":"PARIGI -- 93663","740326":"BONE -- 93663","740327":"TONGKUNO -- 93662","740328":"PASIR PUTIH -- 93674","740330":"KONTU KOWUNA -- 93661","740331":"MAROBO -- 93663","740332":"TONGKUNO SELATAN -- 93662","740333":"PASI KOLAGA -- 93674","740334":"BATUKARA -- 93674","740337":"TOWEA -- 93654","740411":"PASARWAJO -- 93754","740422":"KAPONTORI -- 93755","740423":"LASALIMU -- 93756","740424":"LASALIMU SELATAN -- 93756","740427":"SIOTAPINA -- -","740428":"WOLOWA -- 93754","740429":"WABULA -- 93754","740501":"TINANGGEA -- 93885","740502":"ANGATA -- 93875","740503":"ANDOOLO -- 93819","740504":"PALANGGA -- 93883","740505":"LANDONO -- 93873","740506":"LAINEA -- 93881","740507":"KONDA -- 93874","740508":"RANOMEETO -- 93871","740509":"KOLONO -- 93395","740510":"MORAMO -- 93891","740511":"LAONTI -- 93892","740512":"LALEMBUU -- 93885","740513":"BENUA -- 93875","740514":"PALANGGA SELATAN -- 93883","740515":"MOWILA -- 93873","740516":"MORAMO UTARA -- 93891","740517":"BUKE -- 93815","740518":"WOLASI -- 93874","740519":"LAEYA -- 93881","740520":"BAITO -- 93883","740521":"BASALA -- 93875","740522":"RANOMEETO BARAT -- 93871","740601":"POLEANG -- 93773","740602":"POLEANG TIMUR -- 93773","740603":"RAROWATU -- 93774","740604":"RUMBIA -- 93771","740605":"KABAENA -- 93781","740606":"KABAENA TIMUR -- 93783","740607":"POLEANG BARAT -- 93772","740608":"MATA OLEO -- 93771","740609":"RAROWATU UTARA -- 93774","740610":"POLEANG UTARA -- 93773","740611":"POLEANG SELATAN -- 93773","740612":"POLEANG TENGGARA -- 93773","740613":"KABAENA SELATAN -- 93781","740614":"KABAENA BARAT -- 93781","740615":"KABAENA UTARA -- 93781","740616":"KABAENA TENGAH -- 93783","740617":"KEP. MASALOKA RAYA -- -","740618":"RUMBIA TENGAH -- 93771","740619":"POLEANG TENGAH -- 93772","740620":"TONTONUNU -- 93772","740621":"LANTARI JAYA -- 93774","740622":"MATA USU -- 93774","740701":"WANGI-WANGI -- 93795","740702":"KALEDUPA -- 93792","740703":"TOMIA -- 93793","740704":"BINONGKO -- 93794","740705":"WANGI WANGI SELATAN -- -","740706":"KALEDUPA SELATAN -- 93792","740707":"TOMIA TIMUR -- 93793","740708":"TOGO BINONGKO -- 93794","740801":"LASUSUA -- 93916","740802":"PAKUE -- 93954","740803":"BATU PUTIH -- 93955","740804":"RANTE ANGIN -- 93956","740805":"KODEOHA -- 93957","740806":"NGAPA -- 93958","740807":"WAWO -- 93461","740808":"LAMBAI -- 93956","740809":"WATUNOHU -- 93958","740810":"PAKUE TENGAH -- 93954","740811":"PAKUE UTARA -- 93954","740812":"POREHU -- 93955","740813":"TOLALA -- 93955","740814":"TIWU -- 93957","740815":"KATOI -- 93913","740901":"ASERA -- 93353","740902":"WIWIRANO -- 93353","740903":"LANGGIKIMA -- 93352","740904":"MOLAWE -- 93352","740905":"LASOLO -- 93352","740906":"LEMBO -- 93352","740907":"SAWA -- 93352","740908":"OHEO -- 93353","740909":"ANDOWIA -- 93353","740910":"MOTUI -- 93352","741001":"KULISUSU -- 93672","741002":"KAMBOWA -- 93673","741003":"BONEGUNU -- 93673","741004":"KULISUSU BARAT -- 93672","741005":"KULISUSU UTARA -- 93672","741006":"WAKORUMBA UTARA -- 93671","741101":"TIRAWUTA -- 93572","741102":"LOEA -- 93572","741103":"LADONGI -- 93573","741104":"POLI POLIA -- 93573","741105":"LAMBANDIA -- 93573","741106":"LALOLAE -- 93572","741107":"MOWEWE -- 93571","741108":"ULUIWOI -- 93575","741109":"TINONDO -- 93571","741110":"AERE -- -","741111":"UEESI -- -","741201":"WAWONII BARAT -- 93393","741202":"WAWONII UTARA -- 93393","741203":"WAWONII TIMUR LAUT -- 93393","741204":"WAWONII TIMUR -- 93393","741205":"WAWONII TENGGARA -- 93393","741206":"WAWONII SELATAN -- 93393","741207":"WAWONII TENGAH -- 93393","741301":"SAWERIGADI -- 93657","741302":"BARANGKA -- 93652","741303":"LAWA -- 93753","741304":"WADAGA -- 93652","741305":"TIWORO SELATAN -- 93664","741306":"MAGINTI -- 93664","741307":"TIWORO TENGAH -- 93653","741308":"TIWORO UTARA -- 93653","741309":"TIWORO KEPULAUAN -- 93653","741310":"KUSAMBI -- 93655","741311":"NAPANO KUSAMBI -- 93655","741401":"LAKUDO -- 93763","741402":"MAWASANGKA TIMUR -- 93762","741403":"MAWASANGKA TENGAH -- 93762","741404":"MAWASANGKA -- 93762","741405":"TALAGA RAYA -- 93781","741406":"GU -- 93761","741407":"SANGIA WAMBULU -- -","741501":"BATAUGA -- 93752","741502":"SAMPOLAWA -- 93753","741503":"LAPANDEWA -- 93753","741504":"BATU ATAS -- 93753","741505":"SIOMPU BARAT -- 93752","741506":"SIOMPU -- 93752","741507":"KADATUA -- 93752","747101":"MANDONGA -- 93112","747102":"KENDARI -- 93123","747103":"BARUGA -- 93116","747104":"POASIA -- 93231","747105":"KENDARI BARAT -- 93123","747106":"ABELI -- 93234","747107":"WUA-WUA -- 93118","747108":"KADIA -- 93118","747109":"PUUWATU -- 93115","747110":"KAMBU -- 93231","747201":"BETOAMBARI -- 93724","747202":"WOLIO -- 93714","747203":"SORA WALIO -- 93757","747204":"BUNGI -- 93758","747205":"KOKALUKUNA -- 93716","747206":"MURHUM -- 93727","747207":"LEA-LEA -- 93758","747208":"BATUPOARO -- 93721","750101":"LIMBOTO -- 96212","750102":"TELAGA -- 96181","750103":"BATUDAA -- 96271","750104":"TIBAWA -- 96251","750105":"BATUDAA PANTAI -- 96272","750109":"BOLIYOHUTO -- 96264","750110":"TELAGA BIRU -- 96181","750111":"BONGOMEME -- 96271","750113":"TOLANGOHULA -- 96214","750114":"MOOTILANGO -- 96127","750116":"PULUBALA -- 96127","750117":"LIMBOTO BARAT -- 96215","750118":"TILANGO -- 96123","750119":"TABONGO -- 96271","750120":"BILUHU -- 96272","750121":"ASPARAGA -- 96214","750122":"TALAGA JAYA -- -","750123":"BILATO -- 96264","750124":"DUNGALIYO -- 96271","750201":"PAGUYAMAN -- 96261","750202":"WONOSARI -- 96262","750203":"DULUPI -- 96263","750204":"TILAMUTA -- 96263","750205":"MANANGGU -- 96265","750206":"BOTUMOITA -- 96264","750207":"PAGUYAMAN PANTAI -- 96261","750301":"TAPA -- 96582","750302":"KABILA -- 96583","750303":"SUWAWA -- 96584","750304":"BONEPANTAI -- 96585","750305":"BULANGO UTARA -- 96582","750306":"TILONGKABILA -- 96583","750307":"BOTUPINGGE -- 96583","750308":"KABILA BONE -- 96583","750309":"BONE -- 96585","750310":"BONE RAYA -- 96585","750311":"SUWAWA TIMUR -- 96584","750312":"SUWAWA SELATAN -- 96584","750313":"SUWAWA TENGAH -- 96584","750314":"BULANGO ULU -- 96582","750315":"BULANGO SELATAN -- 96582","750316":"BULANGO TIMUR -- 96582","750317":"BULAWA -- 96585","750318":"PINOGU -- 96584","750401":"POPAYATO -- 96467","750402":"LEMITO -- 96468","750403":"RANDANGAN -- 96469","750404":"MARISA -- 96266","750405":"PAGUAT -- 96265","750406":"PATILANGGIO -- 96266","750407":"TALUDITI -- 96469","750408":"DENGILO -- 96265","750409":"BUNTULIA -- 96266","750410":"DUHIADAA -- 96266","750411":"WANGGARASI -- 96468","750412":"POPAYATO TIMUR -- 96467","750413":"POPAYATO BARAT -- 96467","750501":"ATINGGOLA -- 96253","750502":"KWANDANG -- 96252","750503":"ANGGREK -- 96525","750504":"SUMALATA -- 96254","750505":"TOLINGGULA -- 96524","750506":"GENTUMA RAYA -- 96253","750507":"TOMOLITO -- 96252","750508":"PONELO KEPULAUAN -- 96252","750509":"MONANO -- 96525","750510":"BIAU -- 96524","750511":"SUMALATA TIMUR -- 96254","757101":"KOTA BARAT -- 96136","757102":"KOTA SELATAN -- 96111","757103":"KOTA UTARA -- 96121","757104":"DUNGINGI -- 96138","757105":"KOTA TIMUR -- 96114","757106":"KOTA TENGAH -- 96128","757107":"SIPATANA -- 96124","757108":"DUMBO RAYA -- 96118","757109":"HULONTHALANGI -- 96116","760101":"BAMBALAMOTU -- 91574","760102":"PASANGKAYU -- 91571","760103":"BARAS -- 91572","760104":"SARUDU -- 91573","760105":"DAPURANG -- 91512","760106":"DURIPOKU -- 91573","760107":"BULU TABA -- 91572","760108":"TIKKE RAYA -- 91571","760109":"PEDONGGA -- 91571","760110":"BAMBAIRA -- 91574","760111":"SARJO -- 91574","760112":"LARIANG -- 91572","760201":"MAMUJU -- 91514","760202":"TAPALANG -- 91352","760203":"KALUKKU -- 91561","760204":"KALUMPANG -- 91562","760207":"PAPALANG -- 91563","760208":"SAMPAGA -- 91563","760211":"TOMMO -- 91562","760212":"SIMBORO DAN KEPULAUAN -- 91512","760213":"TAPALANG BARAT -- 91352","760215":"BONEHAU -- 91562","760216":"KEP. BALA BALAKANG -- 91512","760301":"MAMBI -- 91371","760302":"ARALLE -- 91373","760303":"MAMASA -- 91362","760304":"PANA -- 91363","760305":"TABULAHAN -- 91372","760306":"SUMARORONG -- 91361","760307":"MESSAWA -- 91361","760308":"SESENAPADANG -- 91365","760309":"TANDUK KALUA -- 91366","760310":"TABANG -- 91364","760311":"BAMBANG -- 91371","760312":"BALLA -- 91366","760313":"NOSU -- 91363","760314":"TAWALIAN -- 91365","760315":"RANTEBULAHAN TIMUR -- 91371","760316":"BUNTUMALANGKA -- 91373","760317":"MEHALAAN -- 91371","760401":"TINAMBUNG -- 91354","760402":"CAMPALAGIAN -- 91353","760403":"WONOMULYO -- 91352","760404":"POLEWALI -- 91314","760405":"TUTAR -- 91355","760406":"BINUANG -- 91312","760407":"TAPANGO -- 91352","760408":"MAPILLI -- 91353","760409":"MATANGNGA -- 91352","760410":"LUYO -- 91353","760411":"LIMBORO -- 91413","760412":"BALANIPA -- 91354","760413":"ANREAPI -- 91315","760414":"MATAKALI -- 91352","760415":"ALLU -- 96365","760416":"BULO -- 91353","760501":"BANGGAE -- 91411","760502":"PAMBOANG -- 91451","760503":"SENDANA -- 91452","760504":"MALUNDA -- 91453","760505":"ULUMANDA -- -","760506":"TAMMERODO SENDANA -- -","760507":"TUBO SENDANA -- 91452","760508":"BANGGAE TIMUR -- 91411","760601":"TOBADAK -- 91563","760602":"PANGALE -- 91563","760603":"BUDONG-BUDONG -- 91563","760604":"TOPOYO -- 91563","760605":"KAROSSA -- 91512","810101":"AMAHAI -- 97516","810102":"TEON NILA SERUA -- 97558","810106":"SERAM UTARA -- 97557","810109":"BANDA -- 97593","810111":"TEHORU -- 97553","810112":"SAPARUA -- 97592","810113":"PULAU HARUKU -- 97591","810114":"SALAHUTU -- 97582","810115":"LEIHITU -- 97581","810116":"NUSA LAUT -- 97511","810117":"KOTA MASOHI -- -","810120":"SERAM UTARA BARAT -- 97557","810121":"TELUK ELPAPUTIH -- 97516","810122":"LEIHITU BARAT -- 97581","810123":"TELUTIH -- 97553","810124":"SERAM UTARA TIMUR SETI -- 97557","810125":"SERAM UTARA TIMUR KOBI -- 97557","810126":"SAPARUA TIMUR -- 97592","810201":"KEI KECIL -- 97615","810203":"KEI BESAR -- 97661","810204":"KEI BESAR SELATAN -- 97661","810205":"KEI BESAR UTARA TIMUR -- 97661","810213":"KEI KECIL TIMUR -- 97615","810214":"KEI KECIL BARAT -- 97615","810215":"MANYEUW -- 97611","810216":"HOAT SORBAY -- 97611","810217":"KEI BESAR UTARA BARAT -- 97661","810218":"KEI BESAR SELATAN BARAT -- 97661","810219":"KEI KECIL TIMUR SELATAN -- 97615","810301":"TANIMBAR SELATAN -- 97464","810302":"SELARU -- 97453","810303":"WER TAMRIAN -- 97464","810304":"WER MAKTIAN -- 97464","810305":"TANIMBAR UTARA -- 97463","810306":"YARU -- 97463","810307":"WUAR LABOBAR -- 97463","810308":"KORMOMOLIN -- 97463","810309":"NIRUNMAS -- 97463","810318":"MOLU MARU -- 97463","810401":"NAMLEA -- 97571","810402":"AIR BUAYA -- 97572","810403":"WAEAPO -- 97574","810406":"WAPLAU -- 97571","810410":"BATABUAL -- 97574","810411":"LOLONG GUBA -- 97574","810412":"WAELATA -- 97574","810413":"FENA LEISELA -- 97572","810414":"TELUK KAIELY -- 97574","810415":"LILIALY -- 97571","810501":"BULA -- 97554","810502":"SERAM TIMUR -- 97594","810503":"WERINAMA -- 97554","810504":"PULAU GOROM -- -","810505":"WAKATE -- 97595","810506":"TUTUK TOLU -- 97594","810507":"SIWALALAT -- 97554","810508":"KILMURY -- 97594","810509":"PULAU PANJANG -- 97599","810510":"TEOR -- 97597","810511":"GOROM TIMUR -- 97596","810512":"BULA BARAT -- 97554","810513":"KIAN DARAT -- 97594","810514":"SIRITAUN WIDA TIMUR -- 97598","810515":"TELUK WARU -- 97554","810601":"KAIRATU -- 97566","810602":"SERAM BARAT -- 97562","810603":"TANIWEL -- 97561","810604":"HUAMUAL BELAKANG -- 97567","810605":"AMALATU -- 97566","810606":"INAMOSOL -- 97566","810607":"KAIRATU BARAT -- 97566","810608":"HUAMUAL -- 97567","810609":"KEPULAUAN MANIPA -- 97567","810610":"TANIWEL TIMUR -- 97561","810611":"ELPAPUTIH -- 97566","810701":"PULAU-PULAU ARU -- 97662","810702":"ARU SELATAN -- 97666","810703":"ARU TENGAH -- 97665","810704":"ARU UTARA -- 97663","810705":"ARU UTARA TIMUR BATULEY -- 97663","810706":"SIR-SIR -- 97664","810707":"ARU TENGAH TIMUR -- 97665","810708":"ARU TENGAH SELATAN -- 97665","810709":"ARU SELATAN TIMUR -- 97666","810710":"ARU SELATAN UTARA -- 97668","810801":"MOA LAKOR -- 97454","810802":"DAMER -- 97128","810803":"MNDONA HIERA -- -","810804":"PULAU-PULAU BABAR -- 97652","810805":"PULAU-PULAU BABAR TIMUR -- 97652","810806":"WETAR -- 97454","810807":"PULAU-PULAU TERSELATAN -- -","810808":"PULAU LETI -- -","810809":"PULAU MASELA -- 97652","810810":"DAWELOR DAWERA -- 97652","810811":"PULAU WETANG -- 97452","810812":"PULAU LAKOR -- 97454","810813":"WETAR UTARA -- 97454","810814":"WETAR BARAT -- 97454","810815":"WETAR TIMUR -- 97454","810816":"KEPULAUAN ROMANG -- 97454","810817":"KISAR UTARA -- 97454","810901":"NAMROLE -- 97573","810902":"WAESAMA -- 97574","810903":"AMBALAU -- 97512","810904":"KEPALA MADAN -- 97572","810905":"LEKSULA -- 97573","810906":"FENA FAFAN -- 97573","817101":"NUSANIWE -- 97117","817102":"SIRIMAU -- 97127","817103":"BAGUALA -- 97231","817104":"TELUK AMBON -- 97234","817105":"LEITIMUR SELATAN -- 97129","817201":"PULAU DULLAH UTARA -- 97611","817202":"PULAU DULLAH SELATAN -- 97611","817203":"TAYANDO TAM -- 97611","817204":"PULAU-PULAU KUR -- 97652","817205":"KUR SELATAN -- 97652","820101":"JAILOLO -- 97752","820102":"LOLODA -- 97755","820103":"IBU -- 97754","820104":"SAHU -- 97753","820105":"JAILOLO SELATAN -- 97752","820107":"IBU UTARA -- 97754","820108":"IBU SELATAN -- 97754","820109":"SAHU TIMUR -- 97753","820201":"WEDA -- 97853","820202":"PATANI -- 97854","820203":"PULAU GEBE -- 97854","820204":"WEDA UTARA -- 97853","820205":"WEDA SELATAN -- 97853","820206":"PATANI UTARA -- 97854","820207":"WEDA TENGAH -- 97853","820208":"PATANI BARAT -- 97854","820304":"GALELA -- 97761","820305":"TOBELO -- 97762","820306":"TOBELO SELATAN -- 97762","820307":"KAO -- 97752","820308":"MALIFUT -- 97764","820309":"LOLODA UTARA -- 97755","820310":"TOBELO UTARA -- 97762","820311":"TOBELO TENGAH -- 97762","820312":"TOBELO TIMUR -- 97762","820313":"TOBELO BARAT -- 97762","820314":"GALELA BARAT -- 97761","820315":"GALELA UTARA -- 97761","820316":"GALELA SELATAN -- 97761","820319":"LOLODA KEPULAUAN -- 97755","820320":"KAO UTARA -- 97764","820321":"KAO BARAT -- 97764","820322":"KAO TELUK -- 97752","820401":"PULAU MAKIAN -- 97756","820402":"KAYOA -- 97781","820403":"GANE TIMUR -- 97783","820404":"GANE BARAT -- 97782","820405":"OBI SELATAN -- 97792","820406":"OBI -- 97792","820407":"BACAN TIMUR -- 97791","820408":"BACAN -- 97791","820409":"BACAN BARAT -- 97791","820410":"MAKIAN BARAT -- 97756","820411":"KAYOA BARAT -- 97781","820412":"KAYOA SELATAN -- 97781","820413":"KAYOA UTARA -- 97781","820414":"BACAN BARAT UTARA -- 97791","820415":"KASIRUTA BARAT -- 97791","820416":"KASIRUTA TIMUR -- 97791","820417":"BACAN SELATAN -- 97791","820418":"KEPULAUAN BOTANGLOMANG -- 97791","820419":"MANDIOLI SELATAN -- 97791","820420":"MANDIOLI UTARA -- 97791","820421":"BACAN TIMUR SELATAN -- 97791","820422":"BACAN TIMUR TENGAH -- 97791","820423":"GANE BARAT SELATAN -- 97782","820424":"GANE BARAT UTARA -- 97782","820425":"KEPULAUAN JORONGA -- 97782","820426":"GANE TIMUR SELATAN -- 97783","820427":"GANE TIMUR TENGAH -- 97783","820428":"OBI BARAT -- 97792","820429":"OBI TIMUR -- 97792","820430":"OBI UTARA -- 97792","820501":"MANGOLI TIMUR -- 97793","820502":"SANANA -- 97795","820503":"SULABESI BARAT -- 97795","820506":"MANGOLI BARAT -- 97792","820507":"SULABESI TENGAH -- 97795","820508":"SULABESI TIMUR -- 97795","820509":"SULABESI SELATAN -- 97795","820510":"MANGOLI UTARA TIMUR -- 97793","820511":"MANGOLI TENGAH -- 97793","820512":"MANGOLI SELATAN -- 97793","820513":"MANGOLI UTARA -- 97793","820518":"SANANA UTARA -- 97795","820601":"WASILE -- 97863","820602":"MABA -- 97862","820603":"MABA SELATAN -- 97862","820604":"WASILE SELATAN -- 97863","820605":"WASILE TENGAH -- 97863","820606":"WASILE UTARA -- 97863","820607":"WASILE TIMUR -- 97863","820608":"MABA TENGAH -- 97862","820609":"MABA UTARA -- 97862","820610":"KOTA MABA -- 97862","820701":"MOROTAI SELATAN -- 97771","820702":"MOROTAI SELATAN BARAT -- 97771","820703":"MOROTAI JAYA -- 97772","820704":"MOROTAI UTARA -- 97772","820705":"MOROTAI TIMUR -- 97772","820801":"TALIABU BARAT -- 97794","820802":"TALIABU BARAT LAUT -- 97794","820803":"LEDE -- 97793","820804":"TALIABU UTARA -- 97794","820805":"TALIABU TIMUR -- 97793","820806":"TALIABU TIMUR SELATAN -- 97793","820807":"TALIABU SELATAN -- 97794","820808":"TABONA -- -","827101":"PULAU TERNATE -- 97751","827102":"KOTA TERNATE SELATAN -- -","827103":"KOTA TERNATE UTARA -- -","827104":"PULAU MOTI -- 97751","827105":"PULAU BATANG DUA -- 97751","827106":"KOTA TERNATE TENGAH -- -","827107":"PULAU HIRI -- 97751","827201":"TIDORE -- 97813","827202":"OBA UTARA -- 97852","827203":"OBA -- 97852","827204":"TIDORE SELATAN -- 97813","827205":"TIDORE UTARA -- 97813","827206":"OBA TENGAH -- 97852","827207":"OBA SELATAN -- 97852","827208":"TIDORE TIMUR -- 97813","910101":"MERAUKE -- 99616","910102":"MUTING -- 99652","910103":"OKABA -- 99654","910104":"KIMAAM -- 99655","910105":"SEMANGGA -- 99651","910106":"TANAH MIRING -- 99651","910107":"JAGEBOB -- 99656","910108":"SOTA -- 99656","910109":"ULILIN -- 99652","910110":"ELIKOBAL -- -","910111":"KURIK -- 99656","910112":"NAUKENJERAI -- 99616","910113":"ANIMHA -- 99656","910114":"MALIND -- 99656","910115":"TUBANG -- 99654","910116":"NGGUTI -- 99654","910117":"KAPTEL -- 99654","910118":"TABONJI -- 99655","910119":"WAAN -- 99655","910120":"ILWAYAB -- -","910201":"WAMENA -- 99511","910203":"KURULU -- 99552","910204":"ASOLOGAIMA -- 99554","910212":"HUBIKOSI -- 99566","910215":"BOLAKME -- 99557","910225":"WALELAGAMA -- 99511","910227":"MUSATFAK -- 99554","910228":"WOLO -- 99557","910229":"ASOLOKOBAL -- 99511","910234":"PELEBAGA -- 99566","910235":"YALENGGA -- 99557","910240":"TRIKORA -- 99511","910241":"NAPUA -- 99511","910242":"WALAIK -- 99511","910243":"WOUMA -- 99511","910244":"HUBIKIAK -- 99566","910245":"IBELE -- 99566","910246":"TAELAREK -- 99566","910247":"ITLAY HISAGE -- 99511","910248":"SIEPKOSI -- 99511","910249":"USILIMO -- 99552","910250":"WITA WAYA -- 99552","910251":"LIBAREK -- 99552","910252":"WADANGKU -- 99552","910253":"PISUGI -- 99552","910254":"KORAGI -- 99557","910255":"TAGIME -- 99561","910256":"MOLAGALOME -- 99557","910257":"TAGINERI -- 99561","910258":"SILO KARNO DOGA -- 99554","910259":"PIRAMID -- 99554","910260":"MULIAMA -- 99554","910261":"BUGI -- 99557","910262":"BPIRI -- 99557","910263":"WELESI -- 99511","910264":"ASOTIPO -- 99511","910265":"MAIMA -- 99511","910266":"POPUGOBA -- 99511","910267":"WAME -- 99511","910268":"WESAPUT -- 99511","910301":"SENTANI -- 99359","910302":"SENTANI TIMUR -- 99359","910303":"DEPAPRE -- 99353","910304":"SENTANI BARAT -- 99358","910305":"KEMTUK -- 99357","910306":"KEMTUK GRESI -- 99357","910307":"NIMBORAN -- 99361","910308":"NIMBOKRANG -- 99362","910309":"UNURUM GUAY -- 99356","910310":"DEMTA -- 99354","910311":"KAUREH -- 99364","910312":"EBUNGFA -- 99352","910313":"WAIBU -- 99358","910314":"NAMBLUONG -- 99361","910315":"YAPSI -- 99364","910316":"AIRU -- 99364","910317":"RAVENI RARA -- 99353","910318":"GRESI SELATAN -- 99357","910319":"YOKARI -- 99354","910401":"NABIRE -- 98856","910402":"NAPAN -- 98861","910403":"YAUR -- 98852","910406":"UWAPA -- 98853","910407":"WANGGAR -- 98856","910410":"SIRIWO -- 98854","910411":"MAKIMI -- 98861","910412":"TELUK UMAR -- 98852","910416":"TELUK KIMI -- 98818","910417":"YARO -- 98853","910421":"WAPOGA -- 98261","910422":"NABIRE BARAT -- 98856","910423":"MOORA -- 98861","910424":"DIPA -- 98768","910425":"MENOU -- 98853","910501":"YAPEN SELATAN -- 98214","910502":"YAPEN BARAT -- 98253","910503":"YAPEN TIMUR -- 98252","910504":"ANGKAISERA -- 98255","910505":"POOM -- 98254","910506":"KOSIWO -- 98215","910507":"YAPEN UTARA -- 98252","910508":"RAIMBAWI -- 98252","910509":"TELUK AMPIMOI -- 98252","910510":"KEPULAUAN AMBAI -- 98255","910511":"WONAWA -- 98253","910512":"WINDESI -- 98254","910513":"PULAU KURUDU -- 98252","910514":"PULAU YERUI -- 98253","910601":"BIAK KOTA -- 98118","910602":"BIAK UTARA -- 98153","910603":"BIAK TIMUR -- 98152","910604":"NUMFOR BARAT -- 98172","910605":"NUMFOR TIMUR -- 98171","910608":"BIAK BARAT -- 98154","910609":"WARSA -- 98157","910610":"PADAIDO -- 98158","910611":"YENDIDORI -- 98155","910612":"SAMOFA -- 98156","910613":"YAWOSI -- 98153","910614":"ANDEY -- 98153","910615":"SWANDIWE -- 98154","910616":"BRUYADORI -- 98171","910617":"ORKERI -- 98172","910618":"POIRU -- 98171","910619":"AIMANDO PADAIDO -- 98158","910620":"ORIDEK -- 98152","910621":"BONDIFUAR -- 98157","910701":"MULIA -- 98911","910703":"ILU -- 98916","910706":"FAWI -- 98917","910707":"MEWOLUK -- 98918","910708":"YAMO -- 98913","910710":"NUME -- -","910711":"TORERE -- 98914","910712":"TINGGINAMBUT -- 98912","910717":"PAGALEME -- -","910718":"GURAGE -- -","910719":"IRIMULI -- -","910720":"MUARA -- 99351","910721":"ILAMBURAWI -- -","910722":"YAMBI -- -","910723":"LUMO -- -","910724":"MOLANIKIME -- -","910725":"DOKOME -- -","910726":"KALOME -- -","910727":"WANWI -- -","910728":"YAMONERI -- -","910729":"WAEGI -- -","910730":"NIOGA -- -","910731":"GUBUME -- -","910732":"TAGANOMBAK -- -","910733":"DAGAI -- -","910734":"KIYAGE -- -","910801":"PANIAI TIMUR -- 98711","910802":"PANIAI BARAT -- 98763","910804":"ARADIDE -- 98766","910807":"BOGABAIDA -- -","910809":"BIBIDA -- 98782","910812":"DUMADAMA -- 98782","910813":"SIRIWO -- 98854","910819":"KEBO -- 98715","910820":"YATAMO -- 98725","910821":"EKADIDE -- 98766","910901":"MIMIKA BARU -- 99910","910902":"AGIMUGA -- 99964","910903":"MIMIKA TIMUR -- 99972","910904":"MIMIKA BARAT -- 99974","910905":"JITA -- 99965","910906":"JILA -- 99966","910907":"MIMIKA TIMUR JAUH -- 99971","910908":"MIMIKA TENGAH -- -","910909":"KUALA KENCANA -- 99968","910910":"TEMBAGAPURA -- 99967","910911":"MIMIKA BARAT JAUH -- 99974","910912":"MIMIKA BARAT TENGAH -- 99973","910913":"KWAMKI NARAMA -- -","910914":"HOYA -- -","910916":"WANIA -- -","910917":"AMAR -- -","910918":"ALAMA -- 99564","911001":"SARMI -- 99373","911002":"TOR ATAS -- 99372","911003":"PANTAI BARAT -- 99374","911004":"PANTAI TIMUR -- 99371","911005":"BONGGO -- 99355","911009":"APAWER HULU -- 99374","911012":"SARMI SELATAN -- 99373","911013":"SARMI TIMUR -- 99373","911014":"PANTAI TIMUR BAGIAN BARAT -- -","911015":"BONGGO TIMUR -- 99355","911101":"WARIS -- 99467","911102":"ARSO -- 99468","911103":"SENGGI -- 99465","911104":"WEB -- 99466","911105":"SKANTO -- 99469","911106":"ARSO TIMUR -- 99468","911107":"TOWE -- 99466","911201":"OKSIBIL -- 99573","911202":"KIWIROK -- 99574","911203":"OKBIBAB -- 99572","911204":"IWUR -- 99575","911205":"BATOM -- 99576","911206":"BORME -- 99577","911207":"KIWIROK TIMUR -- 99574","911208":"ABOY -- 99572","911209":"PEPERA -- 99573","911210":"BIME -- 99577","911211":"ALEMSOM -- 99573","911212":"OKBAPE -- 99573","911213":"KALOMDOL -- 99573","911214":"OKSOP -- 99573","911215":"SERAMBAKON -- 99573","911216":"OK AOM -- 99573","911217":"KAWOR -- 99575","911218":"AWINBON -- 99575","911219":"TARUP -- 99575","911220":"OKHIKA -- 99574","911221":"OKSAMOL -- 99574","911222":"OKLIP -- 99574","911223":"OKBEMTAU -- 99574","911224":"OKSEBANG -- 99574","911225":"OKBAB -- 99572","911226":"BATANI -- 99576","911227":"WEIME -- 99576","911228":"MURKIM -- 99576","911229":"MOFINOP -- 99576","911230":"JETFA -- 99572","911231":"TEIRAPLU -- 99572","911232":"EIPUMEK -- 99577","911233":"PAMEK -- 99577","911234":"NONGME -- 99576","911301":"KURIMA -- 99571","911302":"ANGGRUK -- 99582","911303":"NINIA -- 99578","911306":"SILIMO -- 99552","911307":"SAMENAGE -- 99571","911308":"NALCA -- 99582","911309":"DEKAI -- 99571","911310":"OBIO -- 99571","911311":"SURU SURU -- 99571","911312":"WUSAMA -- -","911313":"AMUMA -- 99571","911314":"MUSAIK -- 99571","911315":"PASEMA -- 99571","911316":"HOGIO -- 99571","911317":"MUGI -- 99564","911318":"SOBA -- 99578","911319":"WERIMA -- 99571","911320":"TANGMA -- 99571","911321":"UKHA -- 99571","911322":"PANGGEMA -- 99582","911323":"KOSAREK -- 99582","911324":"NIPSAN -- 99582","911325":"UBAHAK -- 99582","911326":"PRONGGOLI -- 99582","911327":"WALMA -- 99582","911328":"YAHULIAMBUT -- 99578","911329":"HEREAPINI -- 99582","911330":"UBALIHI -- 99582","911331":"TALAMBO -- 99582","911332":"PULDAMA -- 99582","911333":"ENDOMEN -- 99582","911334":"KONA -- 99571","911335":"DIRWEMNA -- 99582","911336":"HOLUON -- 99578","911337":"LOLAT -- 99578","911338":"SOLOIKMA -- 99578","911339":"SELA -- 99373","911340":"KORUPUN -- 99578","911341":"LANGDA -- 99578","911342":"BOMELA -- 99578","911343":"SUNTAMON -- 99578","911344":"SEREDELA -- 99571","911345":"SOBAHAM -- 99578","911346":"KABIANGGAMA -- 99578","911347":"KWELEMDUA -- 99578","911348":"KWIKMA -- 99578","911349":"HILIPUK -- 99578","911350":"DURAM -- 99582","911351":"YOGOSEM -- 99571","911352":"KAYO -- 99571","911353":"SUMO -- 99571","911401":"KARUBAGA -- 99562","911402":"BOKONDINI -- 99561","911403":"KANGGIME -- 99568","911404":"KEMBU -- 99569","911405":"GOYAGE -- 99562","911406":"WUNIM -- -","911407":"WINA -- 99569","911408":"UMAGI -- 99569","911409":"PANAGA -- 99569","911410":"WONIKI -- 99568","911411":"KUBU -- 99562","911412":"KONDA/ KONDAGA -- -","911413":"NELAWI -- 99562","911414":"KUARI -- 99562","911415":"BOKONERI -- 99561","911416":"BEWANI -- 99561","911418":"NABUNAGE -- 99568","911419":"GILUBANDU -- 99568","911420":"NUNGGAWI -- 99568","911421":"GUNDAGI -- 99569","911422":"NUMBA -- 99562","911423":"TIMORI -- 99569","911424":"DUNDU -- 99569","911425":"GEYA -- 99562","911426":"EGIAM -- 99569","911427":"POGANERI -- 99569","911428":"KAMBONERI -- 99561","911429":"AIRGARAM -- 99562","911430":"WARI/TAIYEVE II -- -","911431":"DOW -- 99569","911432":"TAGINERI -- 99561","911433":"YUNERI -- 99562","911434":"WAKUWO -- 99568","911435":"GIKA -- 99569","911436":"TELENGGEME -- 99569","911437":"ANAWI -- 99562","911438":"WENAM -- 99562","911439":"WUGI -- 99562","911440":"DANIME -- 99561","911441":"TAGIME -- 99561","911442":"KAI -- 99562","911443":"AWEKU -- 99568","911444":"BOGONUK -- 99568","911445":"LI ANOGOMMA -- 99562","911446":"BIUK -- 99562","911447":"YUKO -- 99569","911501":"WAROPEN BAWAH -- 98261","911503":"MASIREI -- 98263","911507":"RISEI SAYATI -- 98263","911508":"UREI FAISEI -- -","911509":"INGGERUS -- 98261","911510":"KIRIHI -- 98263","911514":"WONTI -- -","911515":"SOYOI MAMBAI -- -","911601":"MANDOBO -- 99663","911602":"MINDIPTANA -- 99662","911603":"WAROPKO -- 99664","911604":"KOUH -- 99661","911605":"JAIR -- 99661","911606":"BOMAKIA -- 99663","911607":"KOMBUT -- 99662","911608":"INIYANDIT -- 99662","911609":"ARIMOP -- 99663","911610":"FOFI -- 99663","911611":"AMBATKWI -- 99664","911612":"MANGGELUM -- 99665","911613":"FIRIWAGE -- 99665","911614":"YANIRUMA -- 99664","911615":"SUBUR -- 99661","911616":"KOMBAY -- 99664","911617":"NINATI -- 99664","911618":"SESNUK -- 99662","911619":"KI -- 99663","911620":"KAWAGIT -- 99665","911701":"OBAA -- 99871","911702":"MAMBIOMAN BAPAI -- -","911703":"CITAK-MITAK -- -","911704":"EDERA -- 99853","911705":"HAJU -- 99881","911706":"ASSUE -- 99874","911707":"KAIBAR -- 99875","911708":"PASSUE -- 99871","911709":"MINYAMUR -- 99872","911710":"VENAHA -- 99853","911711":"SYAHCAME -- 99853","911712":"YAKOMI -- 99853","911713":"BAMGI -- 99853","911714":"PASSUE BAWAH -- 99875","911715":"TI ZAIN -- 99875","911801":"AGATS -- 99777","911802":"ATSJ -- 99776","911803":"SAWA ERMA -- 99778","911804":"AKAT -- 99779","911805":"FAYIT -- 99782","911806":"PANTAI KASUARI -- 99773","911807":"SUATOR -- 99766","911808":"SURU-SURU -- 99778","911809":"KOLF BRAZA -- 99766","911810":"UNIR SIRAU -- 99778","911811":"JOERAT -- 99778","911812":"PULAU TIGA -- 99778","911813":"JETSY -- 99777","911814":"DER KOUMUR -- 99773","911815":"KOPAY -- 99773","911816":"SAFAN -- 99773","911817":"SIRETS -- 99776","911818":"AYIP -- 99776","911819":"BETCBAMU -- 99776","911901":"SUPIORI SELATAN -- 98161","911902":"SUPIORI UTARA -- 98162","911903":"SUPIORI TIMUR -- 98161","911904":"KEPULAUAN ARURI -- 98161","911905":"SUPIORI BARAT -- 98162","912001":"MAMBERAMO TENGAH -- 99376","912002":"MAMBERAMO HULU -- 99377","912003":"RUFAER -- 99377","912004":"MAMBERAMO TENGAH TIMUR -- 99376","912005":"MAMBERAMO HILIR -- 99375","912006":"WAROPEN ATAS -- 98262","912007":"BENUKI -- 98262","912008":"SAWAI -- 98262","912101":"KOBAGMA -- -","912102":"KELILA -- 99553","912103":"ERAGAYAM -- 99553","912104":"MEGAMBILIS -- 99558","912105":"ILUGWA -- 99557","912201":"ELELIM -- 99584","912202":"APALAPSILI -- 99586","912203":"ABENAHO -- 99587","912204":"BENAWA -- 99583","912205":"WELAREK -- 99585","912301":"TIOM -- 99563","912302":"PIRIME -- 99567","912303":"MAKKI -- 99555","912304":"GAMELIA -- 99556","912305":"DIMBA -- 99567","912306":"MELAGINERI -- -","912307":"BALINGGA -- 99567","912308":"TIOMNERI -- 99563","912309":"KUYAWAGE -- 99563","912310":"POGA -- 99556","912311":"NINAME -- -","912312":"NOGI -- -","912313":"YIGINUA -- -","912314":"TIOM OLLO -- -","912315":"YUGUNGWI -- -","912316":"MOKONI -- -","912317":"WEREKA -- -","912318":"MILIMBO -- -","912319":"WIRINGGAMBUT -- -","912320":"GOLLO -- -","912321":"AWINA -- -","912322":"AYUMNATI -- -","912323":"WANO BARAT -- -","912324":"GOA BALIM -- -","912325":"BRUWA -- -","912326":"BALINGGA BARAT -- -","912327":"GUPURA -- -","912328":"KOLAWA -- -","912329":"GELOK BEAM -- -","912330":"KULY LANNY -- -","912331":"LANNYNA -- -","912332":"KARU -- 99562","912333":"YILUK -- -","912334":"GUNA -- -","912335":"KELULOME -- -","912336":"NIKOGWE -- -","912337":"MUARA -- 99351","912338":"BUGUK GONA -- -","912339":"MELAGI -- -","912401":"KENYAM -- 99565","912402":"MAPENDUMA -- 99564","912403":"YIGI -- 99564","912404":"WOSAK -- 99565","912405":"GESELMA -- 99564","912406":"MUGI -- 99564","912407":"MBUWA -- -","912408":"GEAREK -- 99565","912409":"KOROPTAK -- 99564","912410":"KEGAYEM -- 99564","912411":"PARO -- 99564","912412":"MEBAROK -- 99564","912413":"YENGGELO -- 99564","912414":"KILMID -- 99564","912415":"ALAMA -- 99564","912416":"YAL -- 99557","912417":"MAM -- 99872","912418":"DAL -- 99571","912419":"NIRKURI -- 99564","912420":"INIKGAL -- 99564","912421":"INIYE -- 99564","912422":"MBULMU YALMA -- 99564","912423":"MBUA TENGAH -- 99565","912424":"EMBETPEN -- 99565","912425":"KORA -- 99511","912426":"WUSI -- 99565","912427":"PIJA -- 99565","912428":"MOBA -- 99565","912429":"WUTPAGA -- 99564","912430":"NENGGEAGIN -- 99564","912431":"KREPKURI -- 99565","912432":"PASIR PUTIH -- 99565","912501":"ILAGA -- 98972","912502":"WANGBE -- 98971","912503":"BEOGA -- 98971","912504":"DOUFO -- -","912505":"POGOMA -- 98973","912506":"SINAK -- 98973","912507":"AGANDUGUME -- -","912508":"GOME -- 98972","912601":"KAMU -- 98863","912602":"MAPIA -- 98854","912603":"PIYAIYE -- 98857","912604":"KAMU UTARA -- 98863","912605":"SUKIKAI SELATAN -- 98857","912606":"MAPIA BARAT -- 98854","912607":"KAMU SELATAN -- 98862","912608":"KAMU TIMUR -- 98863","912609":"MAPIA TENGAH -- 98854","912610":"DOGIYAI -- 98862","912701":"SUGAPA -- 98768","912702":"HOMEYO -- 98767","912703":"WANDAI -- 98784","912704":"BIANDOGA -- 98784","912705":"AGISIGA -- 98783","912706":"HITADIPA -- 98768","912707":"UGIMBA -- -","912708":"TOMOSIGA -- -","912801":"TIGI -- 98764","912802":"TIGI TIMUR -- 98781","912803":"BOWOBADO -- 98781","912804":"TIGI BARAT -- 98764","912805":"KAPIRAYA -- 98727","917101":"JAYAPURA UTARA -- 99113","917102":"JAYAPURA SELATAN -- 99223","917103":"ABEPURA -- 99351","917104":"MUARA TAMI -- 99351","917105":"HERAM -- 99351","920101":"MAKBON -- 98471","920104":"BERAUR -- 98453","920105":"SALAWATI -- 98452","920106":"SEGET -- 98452","920107":"AIMAS -- 98457","920108":"KLAMONO -- 98456","920110":"SAYOSA -- 98471","920112":"SEGUN -- 98452","920113":"MAYAMUK -- 98451","920114":"SALAWATI SELATAN -- 98452","920117":"KLABOT -- 98453","920118":"KLAWAK -- 98453","920120":"MAUDUS -- 98472","920139":"MARIAT -- 98457","920140":"KLAILI -- -","920141":"KLASO -- 98472","920142":"MOISEGEN -- 98451","920203":"WARMARE -- 98352","920204":"PRAFI -- 98356","920205":"MASNI -- 98357","920212":"MANOKWARI BARAT -- 98314","920213":"MANOKWARI TIMUR -- 98311","920214":"MANOKWARI UTARA -- 98315","920215":"MANOKWARI SELATAN -- 98315","920217":"TANAH RUBUH -- 98315","920221":"SIDEY -- 98357","920301":"FAK-FAK -- -","920302":"FAK-FAK BARAT -- -","920303":"FAK-FAK TIMUR -- -","920304":"KOKAS -- 98652","920305":"FAK-FAK TENGAH -- -","920306":"KARAS -- 98662","920307":"BOMBERAY -- 98662","920308":"KRAMONGMONGGA -- 98652","920309":"TELUK PATIPI -- 98661","920310":"PARIWARI -- -","920311":"WARTUTIN -- -","920312":"FAKFAK TIMUR TENGAH -- -","920313":"ARGUNI -- 98653","920314":"MBAHAMDANDARA -- -","920315":"KAYAUNI -- -","920316":"FURWAGI -- -","920317":"TOMAGE -- -","920401":"TEMINABUAN -- 98454","920404":"INANWATAN -- 98455","920406":"SAWIAT -- 98456","920409":"KOKODA -- 98455","920410":"MOSWAREN -- 98454","920411":"SEREMUK -- 98454","920412":"WAYER -- 98454","920414":"KAIS -- 98455","920415":"KONDA -- 98454","920420":"MATEMANI -- 98455","920421":"KOKODA UTARA -- 98455","920422":"SAIFI -- 98454","920424":"FOKOUR -- 98456","920501":"MISOOL (MISOOL UTARA) -- 98483","920502":"WAIGEO UTARA -- 98481","920503":"WAIGEO SELATAN -- 98482","920504":"SALAWATI UTARA -- 98484","920505":"KEPULAUAN AYAU -- 98481","920506":"MISOOL TIMUR -- 98483","920507":"WAIGEO BARAT -- 98481","920508":"WAIGEO TIMUR -- 98482","920509":"TELUK MAYALIBIT -- 98482","920510":"KOFIAU -- 98483","920511":"MEOS MANSAR -- 98482","920513":"MISOOL SELATAN -- 98483","920514":"WARWARBOMI -- -","920515":"WAIGEO BARAT KEPULAUAN -- 98481","920516":"MISOOL BARAT -- 98483","920517":"KEPULAUAN SEMBILAN -- 98483","920518":"KOTA WAISAI -- 98482","920519":"TIPLOL MAYALIBIT -- 98482","920520":"BATANTA UTARA -- 98484","920521":"SALAWATI BARAT -- 98484","920522":"SALAWATI TENGAH -- 98484","920523":"SUPNIN -- 98481","920524":"AYAU -- 98481","920525":"BATANTA SELATAN -- 98484","920601":"BINTUNI -- 98364","920602":"MERDEY -- 98373","920603":"BABO -- 98363","920604":"ARANDAY -- 98365","920605":"MOSKONA SELATAN -- 98365","920606":"MOSKONA UTARA -- 98373","920607":"WAMESA -- 98361","920608":"FAFURWAR -- 98363","920609":"TEMBUNI -- 98364","920610":"KURI -- 98362","920611":"MANIMERI -- 98364","920612":"TUHIBA -- 98364","920613":"DATARAN BEIMES -- 98364","920614":"SUMURI -- 98363","920615":"KAITARO -- 98363","920616":"AROBA -- 98363","920617":"MASYETA -- 98373","920618":"BISCOOP -- 98373","920619":"TOMU -- 98365","920620":"KAMUNDAN -- 98365","920621":"WERIAGAR -- 98365","920622":"MOSKONA BARAT -- 98365","920623":"MEYADO -- -","920624":"MOSKONA TIMUR -- 98373","920701":"WASIOR -- 98362","920702":"WINDESI -- 98361","920703":"TELUK DUAIRI -- 98362","920704":"WONDIBOY -- 98362","920705":"WAMESA -- 98361","920706":"RUMBERPON -- 98361","920707":"NAIKERE -- 98362","920708":"RASIEI -- 98362","920709":"KURI WAMESA -- 98362","920710":"ROON -- 98362","920711":"ROSWAR -- 98361","920712":"NIKIWAR -- 98361","920713":"SOUG JAYA -- 98361","920801":"KAIMANA -- 98654","920802":"BURUWAY -- 98656","920803":"TELUK ARGUNI ATAS -- 98653","920804":"TELUK ETNA -- 98655","920805":"KAMBRAU -- -","920806":"TELUK ARGUNI BAWAH -- 98653","920807":"YAMOR -- 98655","920901":"FEF -- 98473","920902":"MIYAH -- 98473","920903":"YEMBUN -- 98474","920904":"KWOOR -- 98473","920905":"SAUSAPOR -- 98473","920906":"ABUN -- 98473","920907":"SYUJAK -- 98473","920913":"BIKAR -- -","920914":"BAMUSBAMA -- -","920916":"MIYAH SELATAN -- -","920917":"IRERES -- -","920918":"TOBOUW -- -","920919":"WILHEM ROUMBOUTS -- -","920920":"TINGGOUW -- -","920921":"KWESEFO -- -","920922":"MAWABUAN -- -","920923":"KEBAR TIMUR -- -","920924":"KEBAR SELATAN -- -","920925":"MANEKAR -- -","920926":"MPUR -- -","920927":"AMBERBAKEN BARAT -- -","920928":"KASI -- -","920929":"SELEMKAI -- -","921001":"AIFAT -- 98463","921002":"AIFAT UTARA -- 98463","921003":"AIFAT TIMUR -- 98463","921004":"AIFAT SELATAN -- 98463","921005":"AITINYO BARAT -- 98462","921006":"AITINYO -- 98462","921007":"AITINYO UTARA -- 98462","921008":"AYAMARU -- 98461","921009":"AYAMARU UTARA -- 98461","921010":"AYAMARU TIMUR -- 98461","921011":"MARE -- 98461","921101":"RANSIKI -- 98355","921102":"ORANSBARI -- 98353","921103":"NENEY -- 98355","921104":"DATARAN ISIM -- 98359","921105":"MOMI WAREN -- 98355","921106":"TAHOTA -- 98355","921201":"ANGGI -- 98354","921202":"ANGGI GIDA -- 98354","921203":"MEMBEY -- 98354","921204":"SURUREY -- 98359","921205":"DIDOHU -- 98359","921206":"TAIGE -- 98354","921207":"CATUBOUW -- 98358","921208":"TESTEGA -- 98357","921209":"MINYAMBAOUW -- -","921210":"HINGK -- 98357","927101":"SORONG -- 98413","927102":"SORONG TIMUR -- 98418","927103":"SORONG BARAT -- 98412","927104":"SORONG KEPULAUAN -- 98413","927105":"SORONG UTARA -- 98416","927106":"SORONG MANOI -- 98414","927107":"SORONG KOTA -- -","927108":"KLAURUNG -- -","927109":"MALAIMSIMSA -- -","927110":"MALADUM MES -- -"}}` + +var jsonData = []byte(Wilayah) + +type Hasil struct { + NIK string `json:"nik"` + Sex string `json:"jenis_kelamin"` + Ttl string `json:"tanggal_lahir"` + Provinsi string `json:"provinsi"` + Kabkot string `json:"kabupaten/kota"` + Kecamatan string `json:"kecamatan"` + KodPos string `json:"kodepos"` + Uniq string `json:"uniqcode"` + Status string `json:"status"` + Pesan string `json:"pesan"` +} + +func FetchNIKData(nik string) Hasil { + var dt map[string]map[string]interface{} + json.Unmarshal(jsonData, &dt) + + hasil := Hasil{} + + if len(nik) == 16 && nil != dt["provinsi"][nik[0:2]] && nil != dt["kabkot"][nik[0:4]] && nil != dt["kecamatan"][nik[0:6]] { + var ( + thnNIK = nik[10:12] + t = nik[6:8] + KecKodPos = strings.Split(fmt.Sprintf("%v", dt["kecamatan"][nik[0:6]]), " -- ") + kecamatan = KecKodPos[0] + kodepos = KecKodPos[1] + sex = "LAKI-LAKI" + tgllahir string + bulanlahir string + thnlahir string + ) + if t > "40" { + sex = "PEREMPUAN" + } + // t, _ = strconv.ParseInt(, 10, 64) + x, _ := strconv.ParseInt(t, 10, 64) + + // tgllahir = strconv.FormatInt(x, 10) + if x > 40 { + tgl := x - 40 + tgllahir = strconv.FormatInt(tgl, 10) + if len(strconv.FormatInt(tgl, 10)) == 1 { + tgllahir = fmt.Sprintf("0" + strconv.FormatInt(tgl, 10)) + // log.Println("0" + strconv.FormatInt(tgl, 10)) + } + } else { + if len(strconv.FormatInt(x, 10)) == 1 { + tgllahir = fmt.Sprintf("0" + strconv.FormatInt(x, 10)) + // log.Println("0" + strconv.FormatInt(x, 10)) + } else { + tgllahir = strconv.FormatInt(x, 10) + } + } + tahunNIK, _ := strconv.ParseInt(thnNIK, 10, 64) + year, _, _ := time.Now().Date() + year = year % 1e2 + if tahunNIK <= int64(year) { + tahunNIK += 2000 + thnlahir = fmt.Sprint(tahunNIK) + } else { + tahunNIK += 1900 + thnlahir = fmt.Sprint(tahunNIK) + } + bulanlahir = nik[8:10] + hasil.NIK = nik + hasil.Sex = sex + // hasil.Ttl = bulanlahir + "-" + bulanlahir + "-" + tgllahir + hasil.Ttl = tgllahir + "-" + bulanlahir + "-" + thnlahir + hasil.Provinsi = fmt.Sprintf("%v", dt["provinsi"][nik[0:2]]) + hasil.Kabkot = fmt.Sprintf("%v", dt["kabkot"][nik[0:4]]) + hasil.Kecamatan = kecamatan + hasil.KodPos = kodepos + hasil.Uniq = nik[12:16] + hasil.Status = "Sukses" + // fmt.Println(thnlahir, tgllahir, bulanlahir, kecamatan, kodepos, sex) + return hasil + } + hasil.Status = "Gagal" + hasil.Pesan = "format salah / tidak ada dalam database" + return hasil +} diff --git a/utils/redis_caching.go b/utils/redis_caching.go index 24e748e..7b343f3 100644 --- a/utils/redis_caching.go +++ b/utils/redis_caching.go @@ -5,10 +5,10 @@ import ( "encoding/json" "fmt" "log" + "rijig/config" "time" "github.com/go-redis/redis/v8" - "github.com/pahmiudahgede/senggoldong/config" ) var ctx = context.Background() @@ -16,7 +16,6 @@ var ctx = context.Background() const defaultExpiration = 1 * time.Hour func SetData[T any](key string, value T, expiration time.Duration) error { - if expiration == 0 { expiration = defaultExpiration } @@ -35,6 +34,27 @@ func SetData[T any](key string, value T, expiration time.Duration) error { return nil } +func SaveSessionTokenToRedis(userID string, deviceID string, token string) error { + + sessionKey := "session:" + userID + ":" + deviceID + + err := config.RedisClient.Set(ctx, sessionKey, token, 24*time.Hour).Err() + if err != nil { + return err + } + log.Printf("Session token saved to Redis with key: %s", sessionKey) + return nil +} + +func GetSessionTokenFromRedis(userID string, deviceID string) (string, error) { + sessionKey := "session:" + userID + ":" + deviceID + token, err := config.RedisClient.Get(ctx, sessionKey).Result() + if err != nil { + return "", err + } + return token, nil +} + func GetData(key string) (string, error) { val, err := config.RedisClient.Get(ctx, key).Result() if err == redis.Nil { @@ -88,12 +108,60 @@ func GetJSONData(key string) (map[string]interface{}, error) { return data, nil } -func DeleteSessionData(userID string) error { - sessionKey := "session:" + userID - return DeleteData(sessionKey) +func DeleteSessionData(userID string, deviceID string) error { + sessionKey := "session:" + userID + ":" + deviceID + sessionTokenKey := "session_token:" + userID + ":" + deviceID + + log.Printf("Attempting to delete session data with keys: %s, %s", sessionKey, sessionTokenKey) + + err := DeleteData(sessionKey) + if err != nil { + return fmt.Errorf("failed to delete session data: %w", err) + } + err = DeleteData(sessionTokenKey) + if err != nil { + return fmt.Errorf("failed to delete session token: %w", err) + } + + log.Printf("Successfully deleted session data for userID: %s, deviceID: %s", userID, deviceID) + return nil } func logAndReturnError(message string, err error) error { log.Printf("%s: %v", message, err) return err } + +func SetStringData(key, value string, expiration time.Duration) error { + if expiration == 0 { + expiration = defaultExpiration + } + + err := config.RedisClient.Set(ctx, key, value, expiration).Err() + if err != nil { + return logAndReturnError(fmt.Sprintf("Error setting string data in Redis with key: %s", key), err) + } + + log.Printf("String data stored in Redis with key: %s", key) + return nil +} + +func GetStringData(key string) (string, error) { + val, err := config.RedisClient.Get(ctx, key).Result() + if err == redis.Nil { + return "", nil + } else if err != nil { + return "", logAndReturnError(fmt.Sprintf("Error retrieving string data from Redis with key: %s", key), err) + } + + return val, nil +} + +func CheckSessionExists(userID string, deviceID string) (bool, error) { + sessionKey := "session:" + userID + ":" + deviceID + val, err := config.RedisClient.Exists(ctx, sessionKey).Result() + if err != nil { + return false, err + } + return val > 0, nil +} diff --git a/utils/redis_utility.go b/utils/redis_utility.go new file mode 100644 index 0000000..ecdd132 --- /dev/null +++ b/utils/redis_utility.go @@ -0,0 +1,231 @@ +package utils + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "rijig/config" + + "github.com/go-redis/redis/v8" +) + +func SetCache(key string, value interface{}, expiration time.Duration) error { + jsonData, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to marshal data: %v", err) + } + + err = config.RedisClient.Set(config.Ctx, key, jsonData, expiration).Err() + if err != nil { + return fmt.Errorf("failed to set cache: %v", err) + } + + return nil +} + +func GetCache(key string, dest interface{}) error { + val, err := config.RedisClient.Get(config.Ctx, key).Result() + if err != nil { + if err == redis.Nil { + return errors.New("ErrCacheMiss") + } + return fmt.Errorf("failed to get cache: %v", err) + } + + err = json.Unmarshal([]byte(val), dest) + if err != nil { + return fmt.Errorf("failed to unmarshal cache data: %v", err) + } + + return nil +} + +func DeleteCache(key string) error { + err := config.RedisClient.Del(config.Ctx, key).Err() + if err != nil { + return fmt.Errorf("failed to delete cache: %v", err) + } + return nil +} + +func ScanAndDelete(pattern string) error { + var cursor uint64 + for { + keys, nextCursor, err := config.RedisClient.Scan(config.Ctx, cursor, pattern, 10).Result() + if err != nil { + return err + } + if len(keys) > 0 { + if err := config.RedisClient.Del(config.Ctx, keys...).Err(); err != nil { + return err + } + } + if nextCursor == 0 { + break + } + cursor = nextCursor + } + return nil +} + +func CacheExists(key string) (bool, error) { + exists, err := config.RedisClient.Exists(config.Ctx, key).Result() + if err != nil { + return false, fmt.Errorf("failed to check cache existence: %v", err) + } + return exists > 0, nil +} + +func SetCacheWithTTL(key string, value interface{}, expiration time.Duration) error { + return SetCache(key, value, expiration) +} + +func GetTTL(key string) (time.Duration, error) { + ttl, err := config.RedisClient.TTL(config.Ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to get TTL: %v", err) + } + return ttl, nil +} + +func RefreshTTL(key string, expiration time.Duration) error { + err := config.RedisClient.Expire(config.Ctx, key, expiration).Err() + if err != nil { + return fmt.Errorf("failed to refresh TTL: %v", err) + } + return nil +} + +func IncrementCounter(key string, expiration time.Duration) (int64, error) { + val, err := config.RedisClient.Incr(config.Ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to increment counter: %v", err) + } + + if val == 1 { + config.RedisClient.Expire(config.Ctx, key, expiration) + } + + return val, nil +} + +func DecrementCounter(key string) (int64, error) { + val, err := config.RedisClient.Decr(config.Ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to decrement counter: %v", err) + } + return val, nil +} + +func GetCounter(key string) (int64, error) { + val, err := config.RedisClient.Get(config.Ctx, key).Int64() + if err != nil { + if err == redis.Nil { + return 0, nil + } + return 0, fmt.Errorf("failed to get counter: %v", err) + } + return val, nil +} + +func SetList(key string, values []interface{}, expiration time.Duration) error { + + config.RedisClient.Del(config.Ctx, key) + + if len(values) > 0 { + err := config.RedisClient.LPush(config.Ctx, key, values...).Err() + if err != nil { + return fmt.Errorf("failed to set list: %v", err) + } + + if expiration > 0 { + config.RedisClient.Expire(config.Ctx, key, expiration) + } + } + + return nil +} + +func GetList(key string) ([]string, error) { + vals, err := config.RedisClient.LRange(config.Ctx, key, 0, -1).Result() + if err != nil { + return nil, fmt.Errorf("failed to get list: %v", err) + } + return vals, nil +} + +func AddToList(key string, value interface{}, expiration time.Duration) error { + err := config.RedisClient.LPush(config.Ctx, key, value).Err() + if err != nil { + return fmt.Errorf("failed to add to list: %v", err) + } + + if expiration > 0 { + config.RedisClient.Expire(config.Ctx, key, expiration) + } + + return nil +} + +func SetHash(key string, fields map[string]interface{}, expiration time.Duration) error { + err := config.RedisClient.HMSet(config.Ctx, key, fields).Err() + if err != nil { + return fmt.Errorf("failed to set hash: %v", err) + } + + if expiration > 0 { + config.RedisClient.Expire(config.Ctx, key, expiration) + } + + return nil +} + +func GetHash(key string) (map[string]string, error) { + vals, err := config.RedisClient.HGetAll(config.Ctx, key).Result() + if err != nil { + return nil, fmt.Errorf("failed to get hash: %v", err) + } + return vals, nil +} + +func GetHashField(key, field string) (string, error) { + val, err := config.RedisClient.HGet(config.Ctx, key, field).Result() + if err != nil { + if err == redis.Nil { + return "", fmt.Errorf("hash field not found") + } + return "", fmt.Errorf("failed to get hash field: %v", err) + } + return val, nil +} + +func SetHashField(key, field string, value interface{}, expiration time.Duration) error { + err := config.RedisClient.HSet(config.Ctx, key, field, value).Err() + if err != nil { + return fmt.Errorf("failed to set hash field: %v", err) + } + + if expiration > 0 { + config.RedisClient.Expire(config.Ctx, key, expiration) + } + + return nil +} + +func FlushDB() error { + err := config.RedisClient.FlushDB(config.Ctx).Err() + if err != nil { + return fmt.Errorf("failed to flush database: %v", err) + } + return nil +} + +func GetAllKeys(pattern string) ([]string, error) { + keys, err := config.RedisClient.Keys(config.Ctx, pattern).Result() + if err != nil { + return nil, fmt.Errorf("failed to get keys: %v", err) + } + return keys, nil +} diff --git a/utils/reset_password.go b/utils/reset_password.go new file mode 100644 index 0000000..431987e --- /dev/null +++ b/utils/reset_password.go @@ -0,0 +1,202 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "gopkg.in/gomail.v2" +) + +type ResetPasswordData struct { + Token string `json:"token"` + Email string `json:"email"` + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` + Used bool `json:"used"` + CreatedAt int64 `json:"created_at"` +} + +const ( + RESET_TOKEN_EXPIRY = 30 * time.Minute + RESET_TOKEN_LENGTH = 32 +) + +// Generate secure reset token +func GenerateResetToken() (string, error) { + bytes := make([]byte, RESET_TOKEN_LENGTH) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate reset token: %v", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +// Store reset password token di Redis +func StoreResetToken(email, userID, token string) error { + key := fmt.Sprintf("reset_password:%s", email) + + // Delete any existing reset token for this email + DeleteCache(key) + + data := ResetPasswordData{ + Token: token, + Email: email, + UserID: userID, + ExpiresAt: time.Now().Add(RESET_TOKEN_EXPIRY).Unix(), + Used: false, + CreatedAt: time.Now().Unix(), + } + + return SetCache(key, data, RESET_TOKEN_EXPIRY) +} + +// Validate reset password token +func ValidateResetToken(email, inputToken string) (*ResetPasswordData, error) { + key := fmt.Sprintf("reset_password:%s", email) + + var data ResetPasswordData + err := GetCache(key, &data) + if err != nil { + return nil, fmt.Errorf("token reset tidak ditemukan atau sudah kadaluarsa") + } + + // Check if token is expired + if time.Now().Unix() > data.ExpiresAt { + DeleteCache(key) + return nil, fmt.Errorf("token reset sudah kadaluarsa") + } + + // Check if token is already used + if data.Used { + return nil, fmt.Errorf("token reset sudah digunakan") + } + + // Validate token + if !ConstantTimeCompare(data.Token, inputToken) { + return nil, fmt.Errorf("token reset tidak valid") + } + + return &data, nil +} + +// Mark reset token as used +func MarkResetTokenAsUsed(email string) error { + key := fmt.Sprintf("reset_password:%s", email) + + var data ResetPasswordData + err := GetCache(key, &data) + if err != nil { + return err + } + + data.Used = true + remaining := time.Until(time.Unix(data.ExpiresAt, 0)) + + return SetCache(key, data, remaining) +} + +// Check if reset token exists and still valid +func IsResetTokenValid(email string) bool { + key := fmt.Sprintf("reset_password:%s", email) + + var data ResetPasswordData + err := GetCache(key, &data) + if err != nil { + return false + } + + return time.Now().Unix() <= data.ExpiresAt && !data.Used +} + +// Get remaining reset token time +func GetResetTokenRemainingTime(email string) (time.Duration, error) { + key := fmt.Sprintf("reset_password:%s", email) + + var data ResetPasswordData + err := GetCache(key, &data) + if err != nil { + return 0, err + } + + remaining := time.Until(time.Unix(data.ExpiresAt, 0)) + if remaining < 0 { + return 0, fmt.Errorf("token expired") + } + + return remaining, nil +} + +// Send reset password email +func (e *EmailService) SendResetPasswordEmail(email, name, token string) error { + // Create reset URL - in real app this would be frontend URL + resetURL := fmt.Sprintf("http://localhost:3000/reset-password?token=%s&email=%s", token, email) + + m := gomail.NewMessage() + m.SetHeader("From", m.FormatAddress(e.from, e.fromName)) + m.SetHeader("To", email) + m.SetHeader("Subject", "Reset Password Administrator - Rijig") + + // Email template + body := fmt.Sprintf(` + + + + + + + +
+
+

πŸ” Reset Password

+
+
+

Halo %s,

+

Kami menerima permintaan untuk reset password akun Administrator Anda.

+ +

Klik tombol di bawah ini untuk reset password:

+
+ Reset Password +
+ +

Atau copy paste link berikut ke browser Anda:

+
%s
+ +

Penting:

+ + +

⚠️ Jika Anda tidak melakukan permintaan reset password, abaikan email ini dan password Anda tidak akan berubah.

+
+ +
+ + + `, name, resetURL, resetURL) + + m.SetBody("text/html", body) + + d := gomail.NewDialer(e.host, e.port, e.username, e.password) + + if err := d.DialAndSend(m); err != nil { + return fmt.Errorf("failed to send reset password email: %v", err) + } + + return nil +} diff --git a/utils/response.go b/utils/response.go deleted file mode 100644 index b004ba8..0000000 --- a/utils/response.go +++ /dev/null @@ -1,107 +0,0 @@ -package utils - -import ( - "github.com/gofiber/fiber/v2" -) - -type MetaData struct { - Status int `json:"status"` - Page int `json:"page,omitempty"` - Limit int `json:"limit,omitempty"` - Total int `json:"total,omitempty"` - Message string `json:"message"` -} - -type APIResponse struct { - Meta MetaData `json:"meta"` - Data interface{} `json:"data,omitempty"` -} - -func PaginatedResponse(c *fiber.Ctx, data interface{}, page, limit, total int, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusOK, - Page: page, - Limit: limit, - Total: total, - Message: message, - }, - Data: data, - } - return c.Status(fiber.StatusOK).JSON(response) -} - -func NonPaginatedResponse(c *fiber.Ctx, data interface{}, total int, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusOK, - Total: total, - Message: message, - }, - Data: data, - } - return c.Status(fiber.StatusOK).JSON(response) -} - -func ErrorResponse(c *fiber.Ctx, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusNotFound, - Message: message, - }, - } - return c.Status(fiber.StatusNotFound).JSON(response) -} - -func ValidationErrorResponse(c *fiber.Ctx, errors map[string][]string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusBadRequest, - Message: "invalid user request", - }, - Data: errors, - } - return c.Status(fiber.StatusBadRequest).JSON(response) -} - -func InternalServerErrorResponse(c *fiber.Ctx, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusInternalServerError, - Message: message, - }, - } - return c.Status(fiber.StatusInternalServerError).JSON(response) -} - -func GenericResponse(c *fiber.Ctx, status int, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: status, - Message: message, - }, - } - return c.Status(status).JSON(response) -} - -func SuccessResponse(c *fiber.Ctx, data interface{}, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusOK, - Message: message, - }, - Data: data, - } - return c.Status(fiber.StatusOK).JSON(response) -} - -func CreateResponse(c *fiber.Ctx, data interface{}, message string) error { - response := APIResponse{ - Meta: MetaData{ - Status: fiber.StatusCreated, - Message: message, - }, - Data: data, - } - return c.Status(fiber.StatusOK).JSON(response) -} diff --git a/utils/role_permission.go b/utils/role_permission.go deleted file mode 100644 index e892bdd..0000000 --- a/utils/role_permission.go +++ /dev/null @@ -1,8 +0,0 @@ -package utils - -const ( - RoleAdministrator = "46f75bb9-7f64-44b7-b378-091a67b3e229" - RoleMasyarakat = "6cfa867b-536c-448d-ba11-fe060b5af971" - RolePengepul = "8171883c-ea9e-4d17-9f28-a7896d88380f" - RolePengelola = "84d72ddb-68a8-430c-9b79-5d71f90cb1be" -) diff --git a/utils/todo_validation.go b/utils/todo_validation.go new file mode 100644 index 0000000..51aab56 --- /dev/null +++ b/utils/todo_validation.go @@ -0,0 +1,95 @@ +package utils + +import ( + "errors" + "fmt" + "log" + "regexp" + "strings" + + "crypto/rand" + "math/big" + + "golang.org/x/crypto/bcrypt" +) + +func IsValidPhoneNumber(phone string) bool { + re := regexp.MustCompile(`^628\d{9,14}$`) + return re.MatchString(phone) +} + +func IsValidDate(date string) bool { + re := regexp.MustCompile(`^\d{2}-\d{2}-\d{4}$`) + return re.MatchString(date) +} + + +func IsValidEmail(email string) bool { + re := regexp.MustCompile(`^[a-z0-9]+@[a-z0-9]+\.[a-z]{2,}$`) + return re.MatchString(email) +} + +func IsValidPassword(password string) bool { + + if len(password) < 8 { + return false + } + + hasUpper := false + hasDigit := false + hasSpecial := false + + for _, char := range password { + if char >= 'A' && char <= 'Z' { + hasUpper = true + } else if char >= '0' && char <= '9' { + hasDigit = true + } else if isSpecialCharacter(char) { + hasSpecial = true + } + } + + return hasUpper && hasDigit && hasSpecial +} + +func isSpecialCharacter(char rune) bool { + specialChars := "!@#$%^&*()-_=+[]{}|;:'\",.<>?/`~" + return strings.ContainsRune(specialChars, char) +} + +func HashingPlainText(plainText string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost) + if err != nil { + log.Println("Error hashing password:", err) + } + return string(bytes), nil +} + +func CompareHashAndPlainText(hashedText, plaintext string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedText), []byte(plaintext)) + return err == nil +} + +func IsNumeric(s string) bool { + re := regexp.MustCompile(`^[0-9]+$`) + return re.MatchString(s) +} + +func ValidatePin(pin string) error { + if len(pin) != 6 { + return errors.New("PIN must be 6 digits") + } + if !IsNumeric(pin) { + return errors.New("PIN must contain only numbers") + } + return nil +} + +func GenerateOTP() (string, error) { + max := big.NewInt(9999) + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + return fmt.Sprintf("%04d", n.Int64()), nil +} diff --git a/utils/token_management.go b/utils/token_management.go new file mode 100644 index 0000000..fa1eb5c --- /dev/null +++ b/utils/token_management.go @@ -0,0 +1,661 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strings" + "time" + + "rijig/config" + + "github.com/golang-jwt/jwt/v5" +) + +type TokenType string + +const ( + RoleAdministrator = "administrator" + RolePengelola = "pengelola" + RolePengepul = "pengepul" + RoleMasyarakat = "masyarakat" +) + +const ( + TokenTypePartial TokenType = "partial" + TokenTypeFull TokenType = "full" + TokenTypeRefresh TokenType = "refresh" +) + +const ( + RegStatusIncomplete = "uncomplete" + RegStatusPending = "awaiting_approval" + RegStatusConfirmed = "approved" + RegStatusComplete = "complete" + RegStatusRejected = "rejected" +) + +const ( + ProgressOTPVerified = 1 + ProgressDataSubmitted = 2 + ProgressComplete = 3 +) + +type JWTClaims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + DeviceID string `json:"device_id"` + RegistrationStatus string `json:"registration_status"` + RegistrationProgress int `json:"registration_progress"` + TokenType TokenType `json:"token_type"` + SessionID string `json:"session_id,omitempty"` + jwt.RegisteredClaims +} + +type RefreshTokenData struct { + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` + DeviceID string `json:"device_id"` + Role string `json:"role"` + RegistrationStatus string `json:"registration_status"` + RegistrationProgress int `json:"registration_progress"` + SessionID string `json:"session_id"` + CreatedAt int64 `json:"created_at"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int64 `json:"expires_in"` + TokenType TokenType `json:"token_type"` + RegistrationStatus string `json:"registration_status"` + RegistrationProgress int `json:"registration_progress"` + NextStep string `json:"next_step,omitempty"` + SessionID string `json:"session_id"` + RequiresAdminApproval bool `json:"requires_admin_approval,omitempty"` +} + +type RegistrationStepInfo struct { + Step int `json:"step"` + Status string `json:"status"` + Description string `json:"description"` + RequiresAdminApproval bool `json:"requires_admin_approval"` + IsAccessible bool `json:"is_accessible"` + IsCompleted bool `json:"is_completed"` +} + +func GetTTLFromEnv(key string, fallback time.Duration) time.Duration { + raw := os.Getenv(key) + if raw == "" { + return fallback + } + + ttl, err := time.ParseDuration(raw) + if err != nil { + return fallback + } + return ttl +} + +var ( + ACCESS_TOKEN_EXPIRY = GetTTLFromEnv("ACCESS_TOKEN_EXPIRY", 23*time.Hour) + REFRESH_TOKEN_EXPIRY = GetTTLFromEnv("REFRESH_TOKEN_EXPIRY", 28*24*time.Hour) + PARTIAL_TOKEN_EXPIRY = GetTTLFromEnv("PARTIAL_TOKEN_EXPIRY", 2*time.Hour) +) + +func GenerateSessionID() (string, error) { + bytes := make([]byte, 16) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate session ID: %v", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func GenerateJTI() string { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return base64.URLEncoding.EncodeToString(bytes) +} + +func ConstantTimeCompare(a, b string) bool { + if len(a) != len(b) { + return false + } + result := 0 + for i := 0; i < len(a); i++ { + result |= int(a[i]) ^ int(b[i]) + } + return result == 0 +} + +func ExtractTokenFromHeader(authHeader string) (string, error) { + if authHeader == "" { + return "", fmt.Errorf("authorization header is empty") + } + + const bearerPrefix = "Bearer " + if len(authHeader) < len(bearerPrefix) || !strings.HasPrefix(authHeader, bearerPrefix) { + return "", fmt.Errorf("invalid authorization header format") + } + + token := strings.TrimSpace(authHeader[len(bearerPrefix):]) + if token == "" { + return "", fmt.Errorf("token is empty") + } + + return token, nil +} + +func GenerateAccessToken(userID, role, deviceID, registrationStatus string, registrationProgress int, tokenType TokenType, sessionID string) (string, error) { + secretKey := config.GetSecretKey() + if secretKey == "" { + return "", fmt.Errorf("secret key not found") + } + + if userID == "" || role == "" || deviceID == "" { + return "", fmt.Errorf("required fields cannot be empty") + } + + now := time.Now() + expiry := ACCESS_TOKEN_EXPIRY + if tokenType == TokenTypePartial { + expiry = PARTIAL_TOKEN_EXPIRY + } + + claims := JWTClaims{ + UserID: userID, + Role: role, + DeviceID: deviceID, + RegistrationStatus: registrationStatus, + RegistrationProgress: registrationProgress, + TokenType: tokenType, + SessionID: sessionID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(expiry)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: "rijig-api", + Subject: userID, + ID: GenerateJTI(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + return "", fmt.Errorf("failed to sign token: %v", err) + } + + return tokenString, nil +} + +func GenerateRefreshToken() (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate refresh token: %v", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func ValidateAccessToken(tokenString string) (*JWTClaims, error) { + secretKey := config.GetSecretKey() + if secretKey == "" { + return nil, fmt.Errorf("secret key not found") + } + + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secretKey), nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %v", err) + } + + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + if IsTokenBlacklisted(claims.ID) { + return nil, fmt.Errorf("token has been revoked") + } + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +func ValidateTokenWithChecks(tokenString string, requiredTokenType TokenType, requireCompleteReg bool) (*JWTClaims, error) { + claims, err := ValidateAccessToken(tokenString) + if err != nil { + return nil, err + } + + if requiredTokenType != "" && claims.TokenType != requiredTokenType { + return nil, fmt.Errorf("invalid token type: expected %s, got %s", requiredTokenType, claims.TokenType) + } + + if requireCompleteReg && !IsRegistrationComplete(claims.RegistrationStatus) { + return nil, fmt.Errorf("registration not complete") + } + + return claims, nil +} + +func ValidateTokenForStep(tokenString string, role string, requiredStep int) (*JWTClaims, error) { + claims, err := ValidateAccessToken(tokenString) + if err != nil { + return nil, err + } + + if claims.Role != role { + return nil, fmt.Errorf("role mismatch") + } + + if claims.RegistrationProgress < requiredStep { + return nil, fmt.Errorf("step not accessible yet: current step %d, required step %d", + claims.RegistrationProgress, requiredStep) + } + + return claims, nil +} + +func StoreRefreshToken(userID, deviceID, refreshToken, role, registrationStatus string, registrationProgress int, sessionID string) error { + key := fmt.Sprintf("refresh_token:%s:%s", userID, deviceID) + + DeleteCache(key) + + data := RefreshTokenData{ + RefreshToken: refreshToken, + ExpiresAt: time.Now().Add(REFRESH_TOKEN_EXPIRY).Unix(), + DeviceID: deviceID, + Role: role, + RegistrationStatus: registrationStatus, + RegistrationProgress: registrationProgress, + SessionID: sessionID, + CreatedAt: time.Now().Unix(), + } + + err := SetCache(key, data, REFRESH_TOKEN_EXPIRY) + if err != nil { + return fmt.Errorf("failed to store refresh token: %v", err) + } + + return nil +} + +func ValidateRefreshToken(userID, deviceID, refreshToken string) (*RefreshTokenData, error) { + key := fmt.Sprintf("refresh_token:%s:%s", userID, deviceID) + + var data RefreshTokenData + err := GetCache(key, &data) + if err != nil { + return nil, fmt.Errorf("refresh token not found or invalid") + } + + if !ConstantTimeCompare(data.RefreshToken, refreshToken) { + return nil, fmt.Errorf("refresh token mismatch") + } + + if time.Now().Unix() > data.ExpiresAt { + DeleteCache(key) + return nil, fmt.Errorf("refresh token expired") + } + + return &data, nil +} + +func RefreshAccessToken(userID, deviceID, refreshToken string) (*TokenResponse, error) { + data, err := ValidateRefreshToken(userID, deviceID, refreshToken) + if err != nil { + return nil, err + } + + tokenType := DetermineTokenType(data.RegistrationStatus) + + accessToken, err := GenerateAccessToken( + userID, + data.Role, + deviceID, + data.RegistrationStatus, + data.RegistrationProgress, + tokenType, + data.SessionID, + ) + if err != nil { + return nil, fmt.Errorf("failed to generate new access token: %v", err) + } + + expiry := ACCESS_TOKEN_EXPIRY + if tokenType == TokenTypePartial { + expiry = PARTIAL_TOKEN_EXPIRY + } + + nextStep := GetNextRegistrationStep(data.Role, data.RegistrationProgress, data.RegistrationStatus) + requiresAdminApproval := RequiresAdminApproval(data.Role, data.RegistrationProgress, data.RegistrationStatus) + + return &TokenResponse{ + AccessToken: accessToken, + ExpiresIn: int64(expiry.Seconds()), + TokenType: tokenType, + RegistrationStatus: data.RegistrationStatus, + RegistrationProgress: data.RegistrationProgress, + NextStep: nextStep, + SessionID: data.SessionID, + RequiresAdminApproval: requiresAdminApproval, + }, nil +} + +func GenerateTokenPair(userID, role, deviceID, registrationStatus string, registrationProgress int) (*TokenResponse, error) { + if userID == "" || role == "" || deviceID == "" { + return nil, fmt.Errorf("required parameters cannot be empty") + } + + sessionID, err := GenerateSessionID() + if err != nil { + return nil, fmt.Errorf("failed to generate session ID: %v", err) + } + + tokenType := DetermineTokenType(registrationStatus) + + accessToken, err := GenerateAccessToken( + userID, + role, + deviceID, + registrationStatus, + registrationProgress, + tokenType, + sessionID, + ) + if err != nil { + return nil, err + } + + refreshToken, err := GenerateRefreshToken() + if err != nil { + return nil, err + } + + err = StoreRefreshToken(userID, deviceID, refreshToken, role, registrationStatus, registrationProgress, sessionID) + if err != nil { + return nil, err + } + + expiry := ACCESS_TOKEN_EXPIRY + if tokenType == TokenTypePartial { + expiry = PARTIAL_TOKEN_EXPIRY + } + + nextStep := GetNextRegistrationStep(role, registrationProgress, registrationStatus) + requiresAdminApproval := RequiresAdminApproval(role, registrationProgress, registrationStatus) + + return &TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: int64(expiry.Seconds()), + TokenType: tokenType, + RegistrationStatus: registrationStatus, + RegistrationProgress: registrationProgress, + NextStep: nextStep, + SessionID: sessionID, + RequiresAdminApproval: requiresAdminApproval, + }, nil +} + +func GenerateTokenForRole(userID, role, deviceID string, progress int, status string) (*TokenResponse, error) { + switch role { + case RoleAdministrator: + + return GenerateTokenPair(userID, role, deviceID, RegStatusComplete, ProgressComplete) + default: + + return GenerateTokenPair(userID, role, deviceID, status, progress) + } +} + +func DetermineTokenType(registrationStatus string) TokenType { + if registrationStatus == RegStatusComplete { + return TokenTypeFull + } + return TokenTypePartial +} + +func IsRegistrationComplete(registrationStatus string) bool { + return registrationStatus == RegStatusComplete +} + +func RequiresAdminApproval(role string, progress int, status string) bool { + switch role { + case RolePengelola, RolePengepul: + return progress == ProgressDataSubmitted && status == RegStatusPending + default: + return false + } +} + +func GetNextRegistrationStep(role string, progress int, status string) string { + switch role { + case RoleAdministrator: + return "completed" + + case RoleMasyarakat: + switch progress { + case ProgressOTPVerified: + return "complete_personal_data" + case ProgressDataSubmitted: + return "create_pin" + case ProgressComplete: + return "completed" + } + + case RolePengepul: + switch progress { + case ProgressOTPVerified: + return "upload_ktp" + case ProgressDataSubmitted: + if status == RegStatusPending { + return "awaiting_admin_approval" + } else if status == RegStatusConfirmed { + return "create_pin" + } + case ProgressComplete: + return "completed" + } + + case RolePengelola: + switch progress { + case ProgressOTPVerified: + return "complete_company_data" + case ProgressDataSubmitted: + if status == RegStatusPending { + return "awaiting_admin_approval" + } else if status == RegStatusConfirmed { + return "create_pin" + } + case ProgressComplete: + return "completed" + } + } + return "unknown" +} + +func GetRegistrationStepInfo(role string, currentProgress int, currentStatus string) *RegistrationStepInfo { + switch role { + case RoleAdministrator: + return &RegistrationStepInfo{ + Step: ProgressComplete, + Status: RegStatusComplete, + Description: "Administrator registration complete", + IsAccessible: true, + IsCompleted: true, + } + + case RoleMasyarakat: + return getMasyarakatStepInfo(currentProgress, currentStatus) + + case RolePengepul: + return getPengepulStepInfo(currentProgress, currentStatus) + + case RolePengelola: + return getPengelolaStepInfo(currentProgress, currentStatus) + } + + return &RegistrationStepInfo{ + Step: 0, + Status: "unknown", + Description: "Unknown role", + IsAccessible: false, + IsCompleted: false, + } +} + +func getMasyarakatStepInfo(progress int, status string) *RegistrationStepInfo { + switch progress { + case ProgressOTPVerified: + return &RegistrationStepInfo{ + Step: ProgressOTPVerified, + Status: status, + Description: "Complete personal data", + IsAccessible: true, + IsCompleted: false, + } + case ProgressDataSubmitted: + return &RegistrationStepInfo{ + Step: ProgressDataSubmitted, + Status: status, + Description: "Create PIN", + IsAccessible: true, + IsCompleted: false, + } + case ProgressComplete: + return &RegistrationStepInfo{ + Step: ProgressComplete, + Status: RegStatusComplete, + Description: "Registration complete", + IsAccessible: true, + IsCompleted: true, + } + } + return nil +} + +func getPengepulStepInfo(progress int, status string) *RegistrationStepInfo { + switch progress { + case ProgressOTPVerified: + return &RegistrationStepInfo{ + Step: ProgressOTPVerified, + Status: status, + Description: "Upload KTP", + IsAccessible: true, + IsCompleted: false, + } + case ProgressDataSubmitted: + if status == RegStatusPending { + return &RegistrationStepInfo{ + Step: ProgressDataSubmitted, + Status: status, + Description: "Awaiting admin approval", + RequiresAdminApproval: true, + IsAccessible: false, + IsCompleted: false, + } + } else if status == RegStatusConfirmed { + return &RegistrationStepInfo{ + Step: ProgressDataSubmitted, + Status: status, + Description: "Create PIN", + IsAccessible: true, + IsCompleted: false, + } + } + case ProgressComplete: + return &RegistrationStepInfo{ + Step: ProgressComplete, + Status: RegStatusComplete, + Description: "Registration complete", + IsAccessible: true, + IsCompleted: true, + } + } + return nil +} + +func getPengelolaStepInfo(progress int, status string) *RegistrationStepInfo { + switch progress { + case ProgressOTPVerified: + return &RegistrationStepInfo{ + Step: ProgressOTPVerified, + Status: status, + Description: "Complete company data", + IsAccessible: true, + IsCompleted: false, + } + case ProgressDataSubmitted: + if status == RegStatusPending { + return &RegistrationStepInfo{ + Step: ProgressDataSubmitted, + Status: status, + Description: "Awaiting admin approval", + RequiresAdminApproval: true, + IsAccessible: false, + IsCompleted: false, + } + } else if status == RegStatusConfirmed { + return &RegistrationStepInfo{ + Step: ProgressDataSubmitted, + Status: status, + Description: "Create PIN", + IsAccessible: true, + IsCompleted: false, + } + } + case ProgressComplete: + return &RegistrationStepInfo{ + Step: ProgressComplete, + Status: RegStatusComplete, + Description: "Registration complete", + IsAccessible: true, + IsCompleted: true, + } + } + return nil +} + +func RevokeRefreshToken(userID, deviceID string) error { + key := fmt.Sprintf("refresh_token:%s:%s", userID, deviceID) + err := DeleteCache(key) + if err != nil { + return fmt.Errorf("failed to revoke refresh token: %v", err) + } + return nil +} + +func RevokeAllRefreshTokens(userID string) error { + pattern := fmt.Sprintf("refresh_token:%s:*", userID) + err := ScanAndDelete(pattern) + if err != nil { + return fmt.Errorf("failed to revoke all refresh tokens: %v", err) + } + return nil +} + +func BlacklistToken(jti string, expiresAt time.Time) error { + key := fmt.Sprintf("blacklist:%s", jti) + ttl := time.Until(expiresAt) + if ttl <= 0 { + return nil + } + return SetCache(key, true, ttl) +} + +func IsTokenBlacklisted(jti string) bool { + key := fmt.Sprintf("blacklist:%s", jti) + var exists bool + err := GetCache(key, &exists) + return err == nil && exists +}