first commit
This commit is contained in:
56
internal/database/database.go
Normal file
56
internal/database/database.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"cardverse/config"
|
||||
"cardverse/internal/modules/auth"
|
||||
"cardverse/internal/modules/cardmaster"
|
||||
"cardverse/internal/modules/seriessetmaster"
|
||||
"cardverse/internal/modules/user"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// Connect membuka koneksi ke database PostgreSQL menggunakan config yang sudah di-load
|
||||
func Connect(cfg *config.Config) *gorm.DB {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBSSLMode,
|
||||
)
|
||||
|
||||
logLevel := logger.Silent
|
||||
if cfg.AppEnv == "development" {
|
||||
logLevel = logger.Info
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("gagal konek ke database: %v", err)
|
||||
}
|
||||
|
||||
DB = db
|
||||
log.Println("berhasil konek ke database")
|
||||
return DB
|
||||
}
|
||||
|
||||
// AutoMigrate mendaftarkan semua model dari setiap module untuk di-migrate.
|
||||
// Tambahkan model module baru di sini ketika membuat module baru.
|
||||
func AutoMigrate(db *gorm.DB) {
|
||||
err := db.AutoMigrate(
|
||||
&user.User{},
|
||||
&seriessetmaster.SeriesSetMaster{},
|
||||
&cardmaster.CardMaster{},
|
||||
&auth.RefreshToken{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("gagal migrasi database: %v", err)
|
||||
}
|
||||
log.Println("migrasi database selesai")
|
||||
}
|
||||
484
internal/database/seeder.go
Normal file
484
internal/database/seeder.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"cardverse/internal/modules/cardmaster"
|
||||
"cardverse/internal/modules/seriessetmaster"
|
||||
"cardverse/internal/modules/user"
|
||||
"cardverse/internal/pkg/utils"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Seed(db *gorm.DB) error {
|
||||
if err := seedAdminUser(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := seedSeriesData(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := seedSetData(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := seedCardData(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedAdminUser(db *gorm.DB) error {
|
||||
repo := user.NewRepository(db)
|
||||
|
||||
const adminEmail = "admin@example.com"
|
||||
const adminPassword = "admin12345"
|
||||
|
||||
existing, _ := repo.FindByEmail(adminEmail)
|
||||
if existing != nil {
|
||||
log.Println("seed: admin user sudah ada, skip")
|
||||
return nil
|
||||
}
|
||||
|
||||
hashed, err := utils.HashPassword(adminPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
admin := &user.User{
|
||||
Name: "Administrator",
|
||||
Email: adminEmail,
|
||||
Password: hashed,
|
||||
Role: user.RoleSuperAdmin,
|
||||
Status: user.StatusActive,
|
||||
IsEmailVerified: true,
|
||||
}
|
||||
|
||||
if err := repo.Create(admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("seed: admin user berhasil dibuat (email: %s / password: %s)\n", adminEmail, adminPassword)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seriesJSON merepresentasikan struktur JSON dari file series.json
|
||||
type seriesJSON struct {
|
||||
Name string `json:"name"`
|
||||
ImageLocalPath string `json:"image_local_path"`
|
||||
ExpansionCount string `json:"expansion_count"`
|
||||
CardCount string `json:"card_count"`
|
||||
}
|
||||
|
||||
func seedSeriesData(db *gorm.DB) error {
|
||||
var count int64
|
||||
db.Model(&seriessetmaster.SeriesSetMaster{}).Where("parent_id IS NULL").Count(&count)
|
||||
if count > 0 {
|
||||
log.Println("seed: data series sudah ada, skip")
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.ReadFile("public/json/series.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var rawSeries []seriesJSON
|
||||
if err := json.Unmarshal(file, &rawSeries); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := make([]seriessetmaster.SeriesSetMaster, 0, len(rawSeries))
|
||||
for _, s := range rawSeries {
|
||||
expansionCount, _ := strconv.Atoi(s.ExpansionCount)
|
||||
cardCount, _ := strconv.Atoi(s.CardCount)
|
||||
|
||||
records = append(records, seriessetmaster.SeriesSetMaster{
|
||||
SeriesName: s.Name,
|
||||
Image: s.ImageLocalPath,
|
||||
ExpansionCount: expansionCount,
|
||||
CardCount: cardCount,
|
||||
})
|
||||
}
|
||||
|
||||
if err := db.Create(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("seed: %d data series berhasil dimasukkan\n", len(records))
|
||||
return nil
|
||||
}
|
||||
|
||||
// setJSON merepresentasikan struktur JSON dari file set (misal MA1.json)
|
||||
type setJSON struct {
|
||||
SetCode string `json:"set_code"`
|
||||
SetName string `json:"set_name"`
|
||||
SeriesName string `json:"series_name"`
|
||||
CoverImageLocalPath string `json:"cover_image_local_path"`
|
||||
LogoImageLocalPath string `json:"logo_image_local_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalCards string `json:"total_cards"`
|
||||
Cards []json.RawMessage `json:"cards"`
|
||||
}
|
||||
|
||||
const imagesBasePath = "public/images"
|
||||
|
||||
// folder yang harus di-skip saat scan series
|
||||
var skipFolders = map[string]bool{
|
||||
"elements": true,
|
||||
"series": true,
|
||||
}
|
||||
|
||||
func seedSetData(db *gorm.DB) error {
|
||||
var count int64
|
||||
db.Model(&seriessetmaster.SeriesSetMaster{}).Where("parent_id IS NOT NULL").Count(&count)
|
||||
if count > 0 {
|
||||
log.Println("seed: data set sudah ada, skip")
|
||||
return nil
|
||||
}
|
||||
|
||||
var allSeries []seriessetmaster.SeriesSetMaster
|
||||
db.Where("parent_id IS NULL").Find(&allSeries)
|
||||
|
||||
seriesMap := make(map[string]uint, len(allSeries))
|
||||
for _, s := range allSeries {
|
||||
seriesMap[s.SeriesName] = s.ID
|
||||
}
|
||||
|
||||
seriesFolders, err := os.ReadDir(imagesBasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalSetsInserted int
|
||||
for _, seriesFolder := range seriesFolders {
|
||||
if !seriesFolder.IsDir() || skipFolders[seriesFolder.Name()] {
|
||||
continue
|
||||
}
|
||||
|
||||
seriesPath := filepath.Join(imagesBasePath, seriesFolder.Name())
|
||||
setFolders, err := os.ReadDir(seriesPath)
|
||||
if err != nil {
|
||||
log.Printf("seed: gagal baca folder %s: %v\n", seriesPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var setRecords []seriessetmaster.SeriesSetMaster
|
||||
var totalCardsInSeries int
|
||||
|
||||
for _, setFolder := range setFolders {
|
||||
if !setFolder.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
setPath := filepath.Join(seriesPath, setFolder.Name())
|
||||
jsonFiles, err := filepath.Glob(filepath.Join(setPath, "*.json"))
|
||||
if err != nil || len(jsonFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rawFile, err := os.ReadFile(jsonFiles[0])
|
||||
if err != nil {
|
||||
log.Printf("seed: gagal baca file %s: %v\n", jsonFiles[0], err)
|
||||
continue
|
||||
}
|
||||
|
||||
var setData setJSON
|
||||
if err := json.Unmarshal(rawFile, &setData); err != nil {
|
||||
log.Printf("seed: gagal parse JSON %s: %v\n", jsonFiles[0], err)
|
||||
continue
|
||||
}
|
||||
|
||||
parentID, exists := seriesMap[setData.SeriesName]
|
||||
if !exists {
|
||||
log.Printf("seed: series '%s' tidak ditemukan di DB, skip set '%s'\n", setData.SeriesName, setData.SetCode)
|
||||
continue
|
||||
}
|
||||
|
||||
cardCount := len(setData.Cards)
|
||||
totalCardsInSeries += cardCount
|
||||
|
||||
var releaseDate *time.Time
|
||||
if setData.ReleaseDate != "" {
|
||||
parsed, err := time.Parse("2006-01-02", setData.ReleaseDate)
|
||||
if err == nil {
|
||||
releaseDate = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
setRecords = append(setRecords, seriessetmaster.SeriesSetMaster{
|
||||
ParentID: &parentID,
|
||||
SetCode: setData.SetCode,
|
||||
SetName: setData.SetName,
|
||||
SeriesName: setData.SeriesName,
|
||||
Image: setData.CoverImageLocalPath,
|
||||
Logo: setData.LogoImageLocalPath,
|
||||
ReleaseDate: releaseDate,
|
||||
TotalCards: cardCount,
|
||||
})
|
||||
}
|
||||
|
||||
if len(setRecords) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := db.Create(&setRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seriesName := setRecords[0].SeriesName
|
||||
if parentID, exists := seriesMap[seriesName]; exists {
|
||||
db.Model(&seriessetmaster.SeriesSetMaster{}).
|
||||
Where("id = ?", parentID).
|
||||
Updates(map[string]interface{}{
|
||||
"expansion_count": len(setRecords),
|
||||
"card_count": totalCardsInSeries,
|
||||
})
|
||||
}
|
||||
|
||||
totalSetsInserted += len(setRecords)
|
||||
log.Printf("seed: %d set untuk series '%s' berhasil dimasukkan (total kartu: %d)\n",
|
||||
len(setRecords), seriesName, totalCardsInSeries)
|
||||
}
|
||||
|
||||
log.Printf("seed: total %d data set berhasil dimasukkan\n", totalSetsInserted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cardJSON merepresentasikan struktur card dari JSON
|
||||
type cardJSON struct {
|
||||
Name string `json:"name"`
|
||||
Number string `json:"number"`
|
||||
ImageLocalPath string `json:"image_local_path"`
|
||||
Stage string `json:"stage"`
|
||||
Element string `json:"element"`
|
||||
EvolvesFrom string `json:"evolves_from"`
|
||||
Illustrator string `json:"illustrator"`
|
||||
Regulation string `json:"regulation"`
|
||||
Rarity string `json:"rarity"`
|
||||
IDPriceChartingEng string `json:"id_price_charting_eng"`
|
||||
LinkPriceChartingEng string `json:"link_price_charting_eng"`
|
||||
IDPriceChartingJpn string `json:"id_price_charting_jpn"`
|
||||
LinkPriceChartingJpn string `json:"link_price_charting_jpn"`
|
||||
HP json.RawMessage `json:"hp"`
|
||||
Attacks json.RawMessage `json:"attacks"`
|
||||
Abilities json.RawMessage `json:"abilities"`
|
||||
Battle json.RawMessage `json:"battle"`
|
||||
EvolutionLine json.RawMessage `json:"evolution_line"`
|
||||
PokedexInfo json.RawMessage `json:"pokedex_info"`
|
||||
Effects json.RawMessage `json:"effects"`
|
||||
}
|
||||
|
||||
const cardBatchSize = 100
|
||||
|
||||
func seedCardData(db *gorm.DB) error {
|
||||
var count int64
|
||||
db.Model(&cardmaster.CardMaster{}).Count(&count)
|
||||
if count > 0 {
|
||||
log.Println("seed: data card sudah ada, skip")
|
||||
return nil
|
||||
}
|
||||
|
||||
var allSets []seriessetmaster.SeriesSetMaster
|
||||
db.Where("parent_id IS NOT NULL").Find(&allSets)
|
||||
|
||||
setMap := make(map[string]uint, len(allSets))
|
||||
for _, s := range allSets {
|
||||
setMap[s.SetCode] = s.ID
|
||||
}
|
||||
|
||||
seriesFolders, err := os.ReadDir(imagesBasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalCardsInserted int
|
||||
for _, seriesFolder := range seriesFolders {
|
||||
if !seriesFolder.IsDir() || skipFolders[seriesFolder.Name()] {
|
||||
continue
|
||||
}
|
||||
|
||||
seriesPath := filepath.Join(imagesBasePath, seriesFolder.Name())
|
||||
setFolders, err := os.ReadDir(seriesPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, setFolder := range setFolders {
|
||||
if !setFolder.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
setPath := filepath.Join(seriesPath, setFolder.Name())
|
||||
jsonFiles, err := filepath.Glob(filepath.Join(setPath, "*.json"))
|
||||
if err != nil || len(jsonFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rawFile, err := os.ReadFile(jsonFiles[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var setData setJSON
|
||||
if err := json.Unmarshal(rawFile, &setData); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
setID, exists := setMap[setData.SetCode]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
cardRecords := make([]cardmaster.CardMaster, 0, len(setData.Cards))
|
||||
for _, rawCard := range setData.Cards {
|
||||
var c cardJSON
|
||||
if err := json.Unmarshal(rawCard, &c); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
enrichedHP := enrichHP(c.HP)
|
||||
enrichedAttacks := enrichAttacks(c.Attacks)
|
||||
enrichedBattle := enrichBattle(c.Battle)
|
||||
|
||||
cardRecords = append(cardRecords, cardmaster.CardMaster{
|
||||
IDSet: setID,
|
||||
Name: c.Name,
|
||||
Number: c.Number,
|
||||
Image: c.ImageLocalPath,
|
||||
Stage: c.Stage,
|
||||
Element: c.Element,
|
||||
ElementImage: cardmaster.GetElementImage(c.Element),
|
||||
EvolvesFrom: c.EvolvesFrom,
|
||||
Illustrator: c.Illustrator,
|
||||
Regulation: c.Regulation,
|
||||
Rarity: c.Rarity,
|
||||
IDPriceChartingEng: c.IDPriceChartingEng,
|
||||
LinkPriceChartingEng: c.LinkPriceChartingEng,
|
||||
IDPriceChartingJpn: c.IDPriceChartingJpn,
|
||||
LinkPriceChartingJpn: c.LinkPriceChartingJpn,
|
||||
HP: cardmaster.JSONB(enrichedHP),
|
||||
Attacks: cardmaster.JSONB(enrichedAttacks),
|
||||
Abilities: cardmaster.JSONB(c.Abilities),
|
||||
Battle: cardmaster.JSONB(enrichedBattle),
|
||||
EvolutionLine: cardmaster.JSONB(c.EvolutionLine),
|
||||
PokedexInfo: cardmaster.JSONB(c.PokedexInfo),
|
||||
Effects: cardmaster.JSONB(c.Effects),
|
||||
})
|
||||
}
|
||||
|
||||
if len(cardRecords) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := db.CreateInBatches(&cardRecords, cardBatchSize).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
totalCardsInserted += len(cardRecords)
|
||||
log.Printf("seed: %d kartu untuk set '%s' berhasil dimasukkan\n", len(cardRecords), setData.SetCode)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("seed: total %d data kartu berhasil dimasukkan\n", totalCardsInserted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func enrichHP(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var hp cardmaster.HP
|
||||
if err := json.Unmarshal(raw, &hp); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
hp.ElementImage = cardmaster.GetElementImage(hp.Element)
|
||||
enriched, err := json.Marshal(hp)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return enriched
|
||||
}
|
||||
|
||||
func enrichAttacks(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var rawAttacks []struct {
|
||||
Cost []string `json:"cost"`
|
||||
Name string `json:"name"`
|
||||
Damage string `json:"damage,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rawAttacks); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
enriched := make([]cardmaster.Attack, 0, len(rawAttacks))
|
||||
for _, a := range rawAttacks {
|
||||
costs := make([]cardmaster.AttackCost, 0, len(a.Cost))
|
||||
for _, c := range a.Cost {
|
||||
costs = append(costs, cardmaster.AttackCost{
|
||||
Element: c,
|
||||
Image: cardmaster.GetElementImage(c),
|
||||
})
|
||||
}
|
||||
enriched = append(enriched, cardmaster.Attack{
|
||||
Cost: costs,
|
||||
Name: a.Name,
|
||||
Damage: a.Damage,
|
||||
Description: a.Description,
|
||||
})
|
||||
}
|
||||
|
||||
result, err := json.Marshal(enriched)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func enrichBattle(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var battle cardmaster.Battle
|
||||
if err := json.Unmarshal(raw, &battle); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
for i := range battle.Weakness {
|
||||
battle.Weakness[i].ElementImage = cardmaster.GetElementImage(battle.Weakness[i].Element)
|
||||
}
|
||||
for i := range battle.Resistance {
|
||||
battle.Resistance[i].ElementImage = cardmaster.GetElementImage(battle.Resistance[i].Element)
|
||||
}
|
||||
for i := range battle.Retreat {
|
||||
battle.Retreat[i].ElementImage = cardmaster.GetElementImage(battle.Retreat[i].Element)
|
||||
}
|
||||
|
||||
enriched, err := json.Marshal(battle)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return enriched
|
||||
}
|
||||
|
||||
func seriesFolderName(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
lower = strings.ReplaceAll(lower, " & ", "-")
|
||||
lower = strings.ReplaceAll(lower, " ", "-")
|
||||
return lower
|
||||
}
|
||||
69
internal/middleware/auth.go
Normal file
69
internal/middleware/auth.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Error(c, http.StatusUnauthorized, "token tidak ditemukan", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
response.Error(c, http.StatusUnauthorized, "format token tidak valid", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusUnauthorized, "token tidak valid atau kedaluwarsa", err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRoles(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
roleVal, exists := c.Get("role")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "akses ditolak: autentikasi diperlukan", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userRole, ok := roleVal.(string)
|
||||
if !ok {
|
||||
response.Error(c, http.StatusForbidden, "akses ditolak: role tidak valid", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
for _, role := range allowedRoles {
|
||||
if strings.EqualFold(userRole, role) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Error(c, http.StatusForbidden, "akses ditolak: anda tidak memiliki hak akses", nil)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
23
internal/middleware/cors.go
Normal file
23
internal/middleware/cors.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS mengizinkan request dari origin lain
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
78
internal/middleware/logger.go
Normal file
78
internal/middleware/logger.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Logger mencatat setiap request: method, path, status, durasi, IP, dan body request
|
||||
// (kalau JSON) supaya gampang di-debug. Field sensitif seperti password, token, dsb
|
||||
// OTOMATIS disensor oleh package logger (lihat internal/pkg/logger), jadi aman
|
||||
// dipasang bahkan di endpoint login/register.
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
bodyFields := readBodyForLog(c)
|
||||
|
||||
c.Next()
|
||||
|
||||
duration := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
fields := logger.Fields{
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"status": status,
|
||||
"duration": duration.String(),
|
||||
"ip": c.ClientIP(),
|
||||
}
|
||||
if bodyFields != nil {
|
||||
fields["body"] = bodyFields
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
fields["gin_errors"] = c.Errors.String()
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
logger.Error("request selesai", fields)
|
||||
case status >= 400:
|
||||
logger.Warn("request selesai", fields)
|
||||
default:
|
||||
logger.Info("request selesai", fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readBodyForLog membaca body request (kalau berupa JSON) untuk keperluan log,
|
||||
// lalu mengembalikan body itu ke request supaya handler asli tetap bisa membacanya
|
||||
// seperti biasa. Field sensitif di dalam body (misal "password") akan disensor
|
||||
// otomatis oleh package logger saat log ditulis, bukan di sini.
|
||||
func readBodyForLog(c *gin.Context) map[string]interface{} {
|
||||
if c.Request.Body == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Request.Method != "POST" && c.Request.Method != "PUT" && c.Request.Method != "PATCH" {
|
||||
return nil
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// kembalikan body supaya bisa dibaca lagi oleh ShouldBindJSON di handler
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &parsed); err != nil {
|
||||
return nil // bukan JSON (atau body kosong), tidak masalah, cukup skip
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
94
internal/middleware/rate_limitter.go
Normal file
94
internal/middleware/rate_limitter.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// visitor menyimpan limiter token bucket milik satu IP + waktu terakhir dia request
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// visitorStore menyimpan limiter per-IP secara thread-safe.
|
||||
// Setiap IP punya "kantong token" sendiri, jadi satu IP yang spam tidak
|
||||
// mempengaruhi jatah IP lain.
|
||||
type visitorStore struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
rps rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func newVisitorStore(requestsPerSecond float64, burst int) *visitorStore {
|
||||
vs := &visitorStore{
|
||||
visitors: make(map[string]*visitor),
|
||||
rps: rate.Limit(requestsPerSecond),
|
||||
burst: burst,
|
||||
}
|
||||
go vs.cleanupLoop()
|
||||
return vs
|
||||
}
|
||||
|
||||
func (vs *visitorStore) getLimiter(ip string) *rate.Limiter {
|
||||
vs.mu.Lock()
|
||||
defer vs.mu.Unlock()
|
||||
|
||||
v, exists := vs.visitors[ip]
|
||||
if !exists {
|
||||
limiter := rate.NewLimiter(vs.rps, vs.burst)
|
||||
vs.visitors[ip] = &visitor{limiter: limiter, lastSeen: time.Now()}
|
||||
return limiter
|
||||
}
|
||||
|
||||
v.lastSeen = time.Now()
|
||||
return v.limiter
|
||||
}
|
||||
|
||||
// cleanupLoop membuang data IP yang sudah tidak aktif > 3 menit supaya memori tidak terus membengkak
|
||||
func (vs *visitorStore) cleanupLoop() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
|
||||
vs.mu.Lock()
|
||||
for ip, v := range vs.visitors {
|
||||
if time.Since(v.lastSeen) > 3*time.Minute {
|
||||
delete(vs.visitors, ip)
|
||||
}
|
||||
}
|
||||
vs.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimiter membatasi jumlah request per-IP menggunakan algoritma token bucket.
|
||||
//
|
||||
// - requestsPerSecond: rata-rata request yang diizinkan per detik (token yang "diisi ulang" per detik)
|
||||
// - burst: jumlah request maksimum yang boleh "meledak" sekaligus (kapasitas kantong token)
|
||||
//
|
||||
// Contoh: RateLimiter(5, 10) artinya rata-rata 5 request/detik diizinkan,
|
||||
// tapi boleh burst sampai 10 request sekaligus selama tokennya masih ada.
|
||||
//
|
||||
// Response saat limit terlampaui: HTTP 429 Too Many Requests.
|
||||
func RateLimiter(requestsPerSecond float64, burst int) gin.HandlerFunc {
|
||||
store := newVisitorStore(requestsPerSecond, burst)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
limiter := store.getLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
response.Error(c, http.StatusTooManyRequests, "terlalu banyak request, coba lagi nanti", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
78
internal/middleware/rate_limitter_test.go
Normal file
78
internal/middleware/rate_limitter_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupRouterWithRateLimit(rps float64, burst int) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(RateLimiter(rps, burst))
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRateLimiter_MengizinkanRequestSelamaTokenTersedia(t *testing.T) {
|
||||
// burst 3 artinya 3 request pertama harus tetap lolos meski dikirim beruntun
|
||||
r := setupRouterWithRateLimit(1, 3)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request ke-%d: mau status 200, dapat %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_MenolakSetelahBurstHabis(t *testing.T) {
|
||||
// burst 2: request ke-3 yang dikirim beruntun (tanpa jeda) harus ditolak 429
|
||||
r := setupRouterWithRateLimit(1, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request ke-%d seharusnya lolos, dapat status %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request ke-3 seharusnya ditolak 429, dapat status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_IPBerbedaPunyaJatahTerpisah(t *testing.T) {
|
||||
// burst 1: IP A dan IP B masing-masing harus dapat 1 jatah request sendiri-sendiri
|
||||
r := setupRouterWithRateLimit(1, 1)
|
||||
|
||||
reqA := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
reqA.RemoteAddr = "1.1.1.1:1234"
|
||||
wA := httptest.NewRecorder()
|
||||
r.ServeHTTP(wA, reqA)
|
||||
|
||||
reqB := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
reqB.RemoteAddr = "2.2.2.2:5678"
|
||||
wB := httptest.NewRecorder()
|
||||
r.ServeHTTP(wB, reqB)
|
||||
|
||||
if wA.Code != http.StatusOK {
|
||||
t.Errorf("IP A request pertama seharusnya lolos, dapat status %d", wA.Code)
|
||||
}
|
||||
if wB.Code != http.StatusOK {
|
||||
t.Errorf("IP B request pertama seharusnya lolos meski IP A sudah pakai jatahnya, dapat status %d", wB.Code)
|
||||
}
|
||||
}
|
||||
58
internal/modules/auth/dto.go
Normal file
58
internal/modules/auth/dto.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
type RegisterRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=150"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type GoogleLoginRequest struct {
|
||||
IDToken string `json:"id_token" binding:"required"`
|
||||
}
|
||||
|
||||
type VerifyEmailRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
ConfirmNewPassword string `json:"confirm_new_password" binding:"required,eqfield=NewPassword"`
|
||||
}
|
||||
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
IsEmailVerified bool `json:"is_email_verified"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
182
internal/modules/auth/handler.go
Normal file
182
internal/modules/auth/handler.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
}
|
||||
|
||||
func NewHandler(service Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
u, token, err := h.service.Register(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPasswordMismatch) {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrEmailAlreadyExists) {
|
||||
response.Error(c, http.StatusConflict, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mendaftar akun", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "registrasi berhasil, silakan verifikasi email anda", gin.H{
|
||||
"user_id": u.ID,
|
||||
"email": u.Email,
|
||||
"verification_token": token,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.Login(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrAccountSuspended) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal login", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "login berhasil", result)
|
||||
}
|
||||
|
||||
func (h *Handler) GoogleLogin(c *gin.Context) {
|
||||
var req GoogleLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.GoogleLogin(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGoogleVerificationFailed) {
|
||||
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrAccountSuspended) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal login via google", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "login via google berhasil", result)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
||||
var req RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.RefreshToken(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountSuspended) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "token berhasil diperbarui", result)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
var req LogoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(req.RefreshToken); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal logout", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "logout berhasil", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
var req VerifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.VerifyEmail(req.Token); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "email berhasil diverifikasi", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) ForgotPassword(c *gin.Context) {
|
||||
var req ForgotPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
resetToken, err := h.service.ForgotPassword(req.Email)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "instruksi reset password telah dikirim", gin.H{
|
||||
"reset_token": resetToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.ResetPassword(req); err != nil {
|
||||
if errors.Is(err, ErrPasswordMismatch) {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "password berhasil diperbarui", nil)
|
||||
}
|
||||
15
internal/modules/auth/model.go
Normal file
15
internal/modules/auth/model.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
type RefreshToken struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Token string `json:"token" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (RefreshToken) TableName() string {
|
||||
return "refresh_tokens"
|
||||
}
|
||||
59
internal/modules/auth/repository.go
Normal file
59
internal/modules/auth/repository.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Repository interface {
|
||||
CreateRefreshToken(token *RefreshToken) error
|
||||
FindByToken(token string) (*RefreshToken, error)
|
||||
DeleteByToken(token string) error
|
||||
DeleteByUserID(userID uint) error
|
||||
ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error
|
||||
RotateRefreshToken(oldToken string, newToken *RefreshToken) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) CreateRefreshToken(token *RefreshToken) error {
|
||||
return r.db.Create(token).Error
|
||||
}
|
||||
|
||||
func (r *repository) FindByToken(token string) (*RefreshToken, error) {
|
||||
var rt RefreshToken
|
||||
err := r.db.Select("id", "user_id", "token", "expires_at", "created_at").Where("token = ?", token).First(&rt).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
func (r *repository) DeleteByToken(token string) error {
|
||||
return r.db.Where("token = ?", token).Delete(&RefreshToken{}).Error
|
||||
}
|
||||
|
||||
func (r *repository) DeleteByUserID(userID uint) error {
|
||||
return r.db.Where("user_id = ?", userID).Delete(&RefreshToken{}).Error
|
||||
}
|
||||
|
||||
func (r *repository) ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&RefreshToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(newToken).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) RotateRefreshToken(oldToken string, newToken *RefreshToken) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("token = ?", oldToken).Delete(&RefreshToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(newToken).Error
|
||||
})
|
||||
}
|
||||
28
internal/modules/auth/routes.go
Normal file
28
internal/modules/auth/routes.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cardverse/internal/middleware"
|
||||
"cardverse/internal/modules/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
userRepo := user.NewRepository(db)
|
||||
authRepo := NewRepository(db)
|
||||
service := NewService(userRepo, authRepo)
|
||||
handler := NewHandler(service)
|
||||
|
||||
authGroup := rg.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/register", handler.Register)
|
||||
authGroup.POST("/login", middleware.RateLimiter(1, 3), handler.Login)
|
||||
authGroup.POST("/google", middleware.RateLimiter(1, 3), handler.GoogleLogin)
|
||||
authGroup.POST("/refresh-token", handler.RefreshToken)
|
||||
authGroup.POST("/logout", handler.Logout)
|
||||
authGroup.POST("/verify-email", middleware.RateLimiter(1, 3), handler.VerifyEmail)
|
||||
authGroup.POST("/forgot-password", middleware.RateLimiter(1, 3), handler.ForgotPassword)
|
||||
authGroup.POST("/reset-password", handler.ResetPassword)
|
||||
}
|
||||
}
|
||||
348
internal/modules/auth/service.go
Normal file
348
internal/modules/auth/service.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
"cardverse/internal/modules/user"
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("email atau password salah")
|
||||
var ErrEmailAlreadyExists = errors.New("email sudah terdaftar")
|
||||
var ErrAccountSuspended = errors.New("akun anda sedang disuspensi")
|
||||
var ErrInvalidToken = errors.New("token tidak valid atau sudah kedaluwarsa")
|
||||
var ErrPasswordMismatch = errors.New("konfirmasi password tidak cocok")
|
||||
var ErrGoogleVerificationFailed = errors.New("verifikasi google id token gagal")
|
||||
var ErrGoogleAccountNoPassword = errors.New("akun ini mendaftar menggunakan Google, silakan login via Google atau buat password melalui fitur Lupa Password")
|
||||
|
||||
type Service interface {
|
||||
Register(req RegisterRequest) (*user.User, string, error)
|
||||
Login(req LoginRequest) (*LoginResponse, error)
|
||||
GoogleLogin(req GoogleLoginRequest) (*LoginResponse, error)
|
||||
RefreshToken(req RefreshTokenRequest) (*TokenResponse, error)
|
||||
Logout(refreshToken string) error
|
||||
VerifyEmail(token string) error
|
||||
ForgotPassword(email string) (string, error)
|
||||
ResetPassword(req ResetPasswordRequest) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
userRepo user.Repository
|
||||
authRepo Repository
|
||||
}
|
||||
|
||||
func NewService(userRepo user.Repository, authRepo Repository) Service {
|
||||
return &service{
|
||||
userRepo: userRepo,
|
||||
authRepo: authRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Register(req RegisterRequest) (*user.User, string, error) {
|
||||
if req.Password != req.ConfirmPassword {
|
||||
return nil, "", ErrPasswordMismatch
|
||||
}
|
||||
|
||||
existing, _ := s.userRepo.FindByEmail(req.Email)
|
||||
if existing != nil {
|
||||
return nil, "", ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
verificationToken := generateRandomToken()
|
||||
|
||||
newUser := &user.User{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Role: user.RoleUser,
|
||||
Status: user.StatusUnverified,
|
||||
IsEmailVerified: false,
|
||||
VerificationToken: verificationToken,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(newUser); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return newUser, verificationToken, nil
|
||||
}
|
||||
|
||||
func (s *service) Login(req LoginRequest) (*LoginResponse, error) {
|
||||
u, err := s.userRepo.FindByEmail(req.Email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if u.Password == "" {
|
||||
return nil, ErrGoogleAccountNoPassword
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(u.Password, req.Password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if u.Status == user.StatusSuspended {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
return s.generateLoginResponse(u)
|
||||
}
|
||||
|
||||
type googleTokenPayload struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified string `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func (s *service) GoogleLogin(req GoogleLoginRequest) (*LoginResponse, error) {
|
||||
googleUser, err := verifyGoogleIDToken(req.IDToken)
|
||||
if err != nil || googleUser.Email == "" {
|
||||
return nil, ErrGoogleVerificationFailed
|
||||
}
|
||||
|
||||
u, _ := s.userRepo.FindByGoogleID(googleUser.Sub)
|
||||
if u == nil {
|
||||
u, _ = s.userRepo.FindByEmail(googleUser.Email)
|
||||
}
|
||||
|
||||
if u != nil {
|
||||
if u.Status == user.StatusSuspended {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
updated := false
|
||||
if u.GoogleID == nil || *u.GoogleID == "" {
|
||||
u.GoogleID = &googleUser.Sub
|
||||
updated = true
|
||||
}
|
||||
if (u.AvatarURL == nil || *u.AvatarURL == "") && googleUser.Picture != "" {
|
||||
u.AvatarURL = &googleUser.Picture
|
||||
updated = true
|
||||
}
|
||||
if !u.IsEmailVerified {
|
||||
u.IsEmailVerified = true
|
||||
u.Status = user.StatusActive
|
||||
updated = true
|
||||
}
|
||||
|
||||
if updated {
|
||||
_ = s.userRepo.Update(u)
|
||||
}
|
||||
} else {
|
||||
var avatar *string
|
||||
if googleUser.Picture != "" {
|
||||
avatar = &googleUser.Picture
|
||||
}
|
||||
googleID := googleUser.Sub
|
||||
|
||||
newUser := &user.User{
|
||||
Name: googleUser.Name,
|
||||
Email: googleUser.Email,
|
||||
GoogleID: &googleID,
|
||||
AvatarURL: avatar,
|
||||
Role: user.RoleUser,
|
||||
Status: user.StatusActive,
|
||||
IsEmailVerified: true,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(newUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = newUser
|
||||
}
|
||||
|
||||
return s.generateLoginResponse(u)
|
||||
}
|
||||
|
||||
func (s *service) generateLoginResponse(u *user.User) (*LoginResponse, error) {
|
||||
accessToken, err := utils.GenerateAccessToken(u.ID, u.Email, u.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := utils.GenerateRefreshToken(u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(config.Cfg.JWTRefreshExpiresHours) * time.Hour)
|
||||
rtRecord := &RefreshToken{
|
||||
UserID: u.ID,
|
||||
Token: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := s.authRepo.ReplaceUserRefreshToken(u.ID, rtRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
resp.User.ID = u.ID
|
||||
resp.User.Name = u.Name
|
||||
resp.User.Email = u.Email
|
||||
resp.User.AvatarURL = utils.FormatMediaURLPtr(u.AvatarURL)
|
||||
resp.User.Role = u.Role
|
||||
resp.User.Status = u.Status
|
||||
resp.User.IsEmailVerified = u.IsEmailVerified
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *service) RefreshToken(req RefreshTokenRequest) (*TokenResponse, error) {
|
||||
claims, err := utils.ValidateRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
savedToken, err := s.authRepo.FindByToken(req.RefreshToken)
|
||||
if err != nil || savedToken == nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if time.Now().After(savedToken.ExpiresAt) {
|
||||
_ = s.authRepo.DeleteByToken(req.RefreshToken)
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
u, err := s.userRepo.FindByID(claims.UserID)
|
||||
if err != nil || u == nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if u.Status == user.StatusSuspended {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
newAccessToken, err := utils.GenerateAccessToken(u.ID, u.Email, u.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newRefreshToken, err := utils.GenerateRefreshToken(u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(config.Cfg.JWTRefreshExpiresHours) * time.Hour)
|
||||
newRtRecord := &RefreshToken{
|
||||
UserID: u.ID,
|
||||
Token: newRefreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := s.authRepo.RotateRefreshToken(req.RefreshToken, newRtRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenResponse{
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Logout(refreshToken string) error {
|
||||
if refreshToken == "" {
|
||||
return nil
|
||||
}
|
||||
return s.authRepo.DeleteByToken(refreshToken)
|
||||
}
|
||||
|
||||
func (s *service) VerifyEmail(token string) error {
|
||||
u, err := s.userRepo.FindByVerificationToken(token)
|
||||
if err != nil || u == nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
u.IsEmailVerified = true
|
||||
u.Status = user.StatusActive
|
||||
u.VerificationToken = ""
|
||||
|
||||
return s.userRepo.Update(u)
|
||||
}
|
||||
|
||||
func (s *service) ForgotPassword(email string) (string, error) {
|
||||
u, err := s.userRepo.FindByEmail(email)
|
||||
if err != nil || u == nil {
|
||||
return "", errors.New("email tidak ditemukan")
|
||||
}
|
||||
|
||||
resetToken := generateRandomToken()
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
u.ResetPasswordToken = resetToken
|
||||
u.ResetPasswordExpiresAt = &expiresAt
|
||||
|
||||
if err := s.userRepo.Update(u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resetToken, nil
|
||||
}
|
||||
|
||||
func (s *service) ResetPassword(req ResetPasswordRequest) error {
|
||||
if req.NewPassword != req.ConfirmNewPassword {
|
||||
return ErrPasswordMismatch
|
||||
}
|
||||
|
||||
u, err := s.userRepo.FindByResetToken(req.Token)
|
||||
if err != nil || u == nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if u.ResetPasswordExpiresAt != nil && time.Now().After(*u.ResetPasswordExpiresAt) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.Password = hashedPassword
|
||||
u.ResetPasswordToken = ""
|
||||
u.ResetPasswordExpiresAt = nil
|
||||
|
||||
return s.userRepo.Update(u)
|
||||
}
|
||||
|
||||
func verifyGoogleIDToken(idToken string) (*googleTokenPayload, error) {
|
||||
resp, err := http.Get("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("invalid google id token response status")
|
||||
}
|
||||
|
||||
var payload googleTokenPayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func generateRandomToken() string {
|
||||
bytes := make([]byte, 16)
|
||||
_, _ = rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
280
internal/modules/auth/service_test.go
Normal file
280
internal/modules/auth/service_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
"cardverse/internal/modules/user"
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
config.Cfg = &config.Config{
|
||||
JWTSecret: "test-secret-key",
|
||||
JWTExpiresHours: 1,
|
||||
JWTRefreshSecret: "test-refresh-secret-key",
|
||||
JWTRefreshExpiresHours: 168,
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Register_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
createFunc: func(u *user.User) error {
|
||||
u.ID = 1
|
||||
return nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
req := RegisterRequest{
|
||||
Name: "User Test",
|
||||
Email: "register@mail.com",
|
||||
Password: "password123",
|
||||
ConfirmPassword: "password123",
|
||||
}
|
||||
u, token, err := svc.Register(req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("verification token should not be empty")
|
||||
}
|
||||
if u.Status != user.StatusUnverified {
|
||||
t.Errorf("expected status 'unverified', got: %s", u.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Register_PasswordMismatch(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
req := RegisterRequest{
|
||||
Name: "User Test",
|
||||
Email: "mismatch@mail.com",
|
||||
Password: "password123",
|
||||
ConfirmPassword: "differentpassword",
|
||||
}
|
||||
_, _, err := svc.Register(req)
|
||||
|
||||
if !errors.Is(err, ErrPasswordMismatch) {
|
||||
t.Fatalf("expected ErrPasswordMismatch, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Register_EmailAlreadyExists(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Email: email}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
req := RegisterRequest{
|
||||
Name: "User Test",
|
||||
Email: "existing@mail.com",
|
||||
Password: "password123",
|
||||
ConfirmPassword: "password123",
|
||||
}
|
||||
_, _, err := svc.Register(req)
|
||||
|
||||
if !errors.Is(err, ErrEmailAlreadyExists) {
|
||||
t.Fatalf("expected ErrEmailAlreadyExists, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
hashedPassword, _ := utils.HashPassword("rahasia123")
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Name: "Budi", Email: email, Password: hashedPassword, Role: user.RoleUser, Status: user.StatusActive}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{
|
||||
createRefreshTokenFunc: func(token *RefreshToken) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
result, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "rahasia123"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
t.Error("access_token should not be empty")
|
||||
}
|
||||
if result.RefreshToken == "" {
|
||||
t.Error("refresh_token should not be empty")
|
||||
}
|
||||
if result.User.Email != "budi@mail.com" {
|
||||
t.Errorf("expected email budi@mail.com, got %s", result.User.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_SuspendedAccount(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
hashedPassword, _ := utils.HashPassword("rahasia123")
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Name: "Budi", Email: email, Password: hashedPassword, Status: user.StatusSuspended}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
_, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "rahasia123"})
|
||||
|
||||
if !errors.Is(err, ErrAccountSuspended) {
|
||||
t.Fatalf("expected ErrAccountSuspended, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_InvalidPassword(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
hashedPassword, _ := utils.HashPassword("rahasia123")
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Email: email, Password: hashedPassword}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
_, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "wrong_password"})
|
||||
|
||||
if !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_RefreshToken_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
validRefreshToken, _ := utils.GenerateRefreshToken(42)
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByIDFunc: func(id uint) (*user.User, error) {
|
||||
return &user.User{ID: 42, Email: "test@mail.com", Role: user.RoleUser, Status: user.StatusActive}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{
|
||||
findByTokenFunc: func(token string) (*RefreshToken, error) {
|
||||
return &RefreshToken{
|
||||
UserID: 42,
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
res, err := svc.RefreshToken(RefreshTokenRequest{RefreshToken: validRefreshToken})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken expected no error, got: %v", err)
|
||||
}
|
||||
if res.AccessToken == "" || res.RefreshToken == "" {
|
||||
t.Error("new access_token and refresh_token should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Logout_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
deleted := false
|
||||
authRepo := &mockAuthRepository{
|
||||
deleteByTokenFunc: func(token string) error {
|
||||
deleted = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(&mockUserRepository{}, authRepo)
|
||||
|
||||
err := svc.Logout("token-to-delete")
|
||||
if err != nil {
|
||||
t.Fatalf("Logout expected no error, got: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Error("deleteByTokenFunc should have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_VerifyEmail_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByVerificationTokenFunc: func(token string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Status: user.StatusUnverified, IsEmailVerified: false, VerificationToken: token}, nil
|
||||
},
|
||||
updateFunc: func(u *user.User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
err := svc.VerifyEmail("valid-token")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ForgotPassword_AndResetPassword_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
var storedToken string
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Email: email}, nil
|
||||
},
|
||||
findByResetTokenFunc: func(token string) (*user.User, error) {
|
||||
expiry := time.Now().Add(1 * time.Hour)
|
||||
return &user.User{ID: 1, ResetPasswordToken: token, ResetPasswordExpiresAt: &expiry}, nil
|
||||
},
|
||||
updateFunc: func(u *user.User) error {
|
||||
storedToken = u.ResetPasswordToken
|
||||
return nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
resetToken, err := svc.ForgotPassword("user@mail.com")
|
||||
if err != nil {
|
||||
t.Fatalf("ForgotPassword expected no error, got: %v", err)
|
||||
}
|
||||
if resetToken == "" {
|
||||
t.Fatal("resetToken should not be empty")
|
||||
}
|
||||
if storedToken != resetToken {
|
||||
t.Errorf("expected storedToken to be %s, got %s", resetToken, storedToken)
|
||||
}
|
||||
|
||||
err = svc.ResetPassword(ResetPasswordRequest{
|
||||
Token: resetToken,
|
||||
NewPassword: "newpassword123",
|
||||
ConfirmNewPassword: "newpassword123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResetPassword expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
137
internal/modules/auth/user_repository_mock_test.go
Normal file
137
internal/modules/auth/user_repository_mock_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import "cardverse/internal/modules/user"
|
||||
|
||||
type mockUserRepository struct {
|
||||
createFunc func(u *user.User) error
|
||||
findAllFunc func() ([]user.User, error)
|
||||
findAllPaginatedFunc func(page, limit int, query user.ListUserQuery) ([]user.User, int64, error)
|
||||
findByIDFunc func(id uint) (*user.User, error)
|
||||
findByEmailFunc func(email string) (*user.User, error)
|
||||
findByGoogleIDFunc func(googleID string) (*user.User, error)
|
||||
findByVerificationTokenFunc func(token string) (*user.User, error)
|
||||
findByResetTokenFunc func(token string) (*user.User, error)
|
||||
updateFunc func(u *user.User) error
|
||||
deleteFunc func(id uint) error
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Create(u *user.User) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindAll() ([]user.User, error) {
|
||||
if m.findAllFunc != nil {
|
||||
return m.findAllFunc()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindAllPaginated(page, limit int, query user.ListUserQuery) ([]user.User, int64, error) {
|
||||
if m.findAllPaginatedFunc != nil {
|
||||
return m.findAllPaginatedFunc(page, limit, query)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByID(id uint) (*user.User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByEmail(email string) (*user.User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(email)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByGoogleID(googleID string) (*user.User, error) {
|
||||
if m.findByGoogleIDFunc != nil {
|
||||
return m.findByGoogleIDFunc(googleID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByVerificationToken(token string) (*user.User, error) {
|
||||
if m.findByVerificationTokenFunc != nil {
|
||||
return m.findByVerificationTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByResetToken(token string) (*user.User, error) {
|
||||
if m.findByResetTokenFunc != nil {
|
||||
return m.findByResetTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Update(u *user.User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Delete(id uint) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockAuthRepository struct {
|
||||
createRefreshTokenFunc func(token *RefreshToken) error
|
||||
findByTokenFunc func(token string) (*RefreshToken, error)
|
||||
deleteByTokenFunc func(token string) error
|
||||
deleteByUserIDFunc func(userID uint) error
|
||||
replaceUserRefreshTokenFunc func(userID uint, newToken *RefreshToken) error
|
||||
rotateRefreshTokenFunc func(oldToken string, newToken *RefreshToken) error
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) CreateRefreshToken(token *RefreshToken) error {
|
||||
if m.createRefreshTokenFunc != nil {
|
||||
return m.createRefreshTokenFunc(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) FindByToken(token string) (*RefreshToken, error) {
|
||||
if m.findByTokenFunc != nil {
|
||||
return m.findByTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) DeleteByToken(token string) error {
|
||||
if m.deleteByTokenFunc != nil {
|
||||
return m.deleteByTokenFunc(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) DeleteByUserID(userID uint) error {
|
||||
if m.deleteByUserIDFunc != nil {
|
||||
return m.deleteByUserIDFunc(userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error {
|
||||
if m.replaceUserRefreshTokenFunc != nil {
|
||||
return m.replaceUserRefreshTokenFunc(userID, newToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) RotateRefreshToken(oldToken string, newToken *RefreshToken) error {
|
||||
if m.rotateRefreshTokenFunc != nil {
|
||||
return m.rotateRefreshTokenFunc(oldToken, newToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
169
internal/modules/cardmaster/dto.go
Normal file
169
internal/modules/cardmaster/dto.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"cardverse/internal/pkg/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListCardMasterQuery struct {
|
||||
Page int `form:"page,default=1" binding:"omitempty,min=1"`
|
||||
Limit int `form:"limit,default=20" binding:"omitempty,min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
IDSet uint `form:"id_set" binding:"omitempty"`
|
||||
Element string `form:"element"`
|
||||
Rarity string `form:"rarity"`
|
||||
Stage string `form:"stage"`
|
||||
}
|
||||
|
||||
type CreateCardMasterRequest struct {
|
||||
IDSet uint `json:"id_set" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Number string `json:"number" binding:"omitempty,max=50"`
|
||||
Image string `json:"image" binding:"omitempty,max=255"`
|
||||
Stage string `json:"stage" binding:"omitempty,max=50"`
|
||||
Element string `json:"element" binding:"omitempty,max=50"`
|
||||
ElementImage string `json:"element_image" binding:"omitempty,max=255"`
|
||||
EvolvesFrom string `json:"evolves_from" binding:"omitempty,max=150"`
|
||||
Illustrator string `json:"illustrator" binding:"omitempty,max=150"`
|
||||
Regulation string `json:"regulation" binding:"omitempty,max=10"`
|
||||
Rarity string `json:"rarity" binding:"omitempty,max=50"`
|
||||
IDPriceChartingEng string `json:"id_price_charting_eng" binding:"omitempty,max=50"`
|
||||
LinkPriceChartingEng string `json:"link_price_charting_eng" binding:"omitempty"`
|
||||
IDPriceChartingJpn string `json:"id_price_charting_jpn" binding:"omitempty,max=50"`
|
||||
LinkPriceChartingJpn string `json:"link_price_charting_jpn" binding:"omitempty"`
|
||||
HP *HP `json:"hp" binding:"omitempty"`
|
||||
Attacks []Attack `json:"attacks" binding:"omitempty"`
|
||||
Abilities []Ability `json:"abilities" binding:"omitempty"`
|
||||
Battle *Battle `json:"battle" binding:"omitempty"`
|
||||
EvolutionLine []EvolutionStage `json:"evolution_line" binding:"omitempty"`
|
||||
PokedexInfo *PokedexInfo `json:"pokedex_info" binding:"omitempty"`
|
||||
Effects []Effect `json:"effects" binding:"omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCardMasterRequest struct {
|
||||
IDSet uint `json:"id_set" binding:"omitempty"`
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=150"`
|
||||
Number string `json:"number" binding:"omitempty,max=50"`
|
||||
Image string `json:"image" binding:"omitempty,max=255"`
|
||||
Stage string `json:"stage" binding:"omitempty,max=50"`
|
||||
Element string `json:"element" binding:"omitempty,max=50"`
|
||||
EvolvesFrom string `json:"evolves_from" binding:"omitempty,max=150"`
|
||||
Illustrator string `json:"illustrator" binding:"omitempty,max=150"`
|
||||
Regulation string `json:"regulation" binding:"omitempty,max=10"`
|
||||
Rarity string `json:"rarity" binding:"omitempty,max=50"`
|
||||
IDPriceChartingEng string `json:"id_price_charting_eng" binding:"omitempty,max=50"`
|
||||
LinkPriceChartingEng string `json:"link_price_charting_eng" binding:"omitempty"`
|
||||
IDPriceChartingJpn string `json:"id_price_charting_jpn" binding:"omitempty,max=50"`
|
||||
LinkPriceChartingJpn string `json:"link_price_charting_jpn" binding:"omitempty"`
|
||||
HP *HP `json:"hp" binding:"omitempty"`
|
||||
Attacks []Attack `json:"attacks" binding:"omitempty"`
|
||||
Abilities []Ability `json:"abilities" binding:"omitempty"`
|
||||
Battle *Battle `json:"battle" binding:"omitempty"`
|
||||
EvolutionLine []EvolutionStage `json:"evolution_line" binding:"omitempty"`
|
||||
PokedexInfo *PokedexInfo `json:"pokedex_info" binding:"omitempty"`
|
||||
Effects []Effect `json:"effects" binding:"omitempty"`
|
||||
}
|
||||
|
||||
type ElementResponse struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func GetElementsResponse() []ElementResponse {
|
||||
elements := []string{"Daun", "Api", "Air", "Listrik", "Psikis", "Petarung", "Kegelapan", "Logam", "Naga", "Peri", "Bening"}
|
||||
result := make([]ElementResponse, 0, len(elements))
|
||||
for _, name := range elements {
|
||||
if path, exists := ElementImageMap[name]; exists {
|
||||
result = append(result, ElementResponse{
|
||||
Name: name,
|
||||
Image: utils.FormatMediaURL(path),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type CardMasterResponse struct {
|
||||
ID uint `json:"id"`
|
||||
IDSet uint `json:"id_set"`
|
||||
Name string `json:"name"`
|
||||
Number *string `json:"number"`
|
||||
Image *string `json:"image"`
|
||||
Stage *string `json:"stage"`
|
||||
Element *string `json:"element"`
|
||||
ElementImage *string `json:"element_image"`
|
||||
EvolvesFrom *string `json:"evolves_from"`
|
||||
Illustrator *string `json:"illustrator"`
|
||||
Regulation *string `json:"regulation"`
|
||||
Rarity *string `json:"rarity"`
|
||||
IDPriceChartingEng *string `json:"id_price_charting_eng"`
|
||||
LinkPriceChartingEng *string `json:"link_price_charting_eng"`
|
||||
IDPriceChartingJpn *string `json:"id_price_charting_jpn"`
|
||||
LinkPriceChartingJpn *string `json:"link_price_charting_jpn"`
|
||||
HP JSONB `json:"hp"`
|
||||
Attacks JSONB `json:"attacks"`
|
||||
Abilities JSONB `json:"abilities"`
|
||||
Battle JSONB `json:"battle"`
|
||||
EvolutionLine JSONB `json:"evolution_line"`
|
||||
PokedexInfo JSONB `json:"pokedex_info"`
|
||||
Effects JSONB `json:"effects"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *uint `json:"created_by,omitempty"`
|
||||
UpdatedBy *uint `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
func ToCardMasterResponse(c CardMaster) CardMasterResponse {
|
||||
return CardMasterResponse{
|
||||
ID: c.ID,
|
||||
IDSet: c.IDSet,
|
||||
Name: c.Name,
|
||||
Number: strPtr(c.Number),
|
||||
Image: strPtrMedia(c.Image),
|
||||
Stage: strPtr(c.Stage),
|
||||
Element: strPtr(c.Element),
|
||||
ElementImage: strPtrMedia(c.ElementImage),
|
||||
EvolvesFrom: strPtr(c.EvolvesFrom),
|
||||
Illustrator: strPtr(c.Illustrator),
|
||||
Regulation: strPtr(c.Regulation),
|
||||
Rarity: strPtr(c.Rarity),
|
||||
IDPriceChartingEng: strPtr(c.IDPriceChartingEng),
|
||||
LinkPriceChartingEng: strPtr(c.LinkPriceChartingEng),
|
||||
IDPriceChartingJpn: strPtr(c.IDPriceChartingJpn),
|
||||
LinkPriceChartingJpn: strPtr(c.LinkPriceChartingJpn),
|
||||
HP: JSONB(utils.FormatJSONBMediaURLs(c.HP)),
|
||||
Attacks: JSONB(utils.FormatJSONBMediaURLs(c.Attacks)),
|
||||
Abilities: JSONB(utils.FormatJSONBMediaURLs(c.Abilities)),
|
||||
Battle: JSONB(utils.FormatJSONBMediaURLs(c.Battle)),
|
||||
EvolutionLine: JSONB(utils.FormatJSONBMediaURLs(c.EvolutionLine)),
|
||||
PokedexInfo: JSONB(utils.FormatJSONBMediaURLs(c.PokedexInfo)),
|
||||
Effects: JSONB(utils.FormatJSONBMediaURLs(c.Effects)),
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
CreatedBy: c.CreatedBy,
|
||||
UpdatedBy: c.UpdatedBy,
|
||||
}
|
||||
}
|
||||
|
||||
func ToCardMasterResponseList(list []CardMaster) []CardMasterResponse {
|
||||
result := make([]CardMasterResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
result = append(result, ToCardMasterResponse(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func strPtrMedia(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
formatted := utils.FormatMediaURL(s)
|
||||
return &formatted
|
||||
}
|
||||
314
internal/modules/cardmaster/handler.go
Normal file
314
internal/modules/cardmaster/handler.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/modules/seriessetmaster"
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/utils"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
seriesSetRepo seriessetmaster.Repository
|
||||
}
|
||||
|
||||
func NewHandler(service Service, seriesSetRepo seriessetmaster.Repository) *Handler {
|
||||
return &Handler{
|
||||
service: service,
|
||||
seriesSetRepo: seriesSetRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetAll(c *gin.Context) {
|
||||
var query ListCardMasterQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
list, total, err := h.service.GetAllPaginated(query)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengambil data kartu", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pagination := response.NewPagination(query.Page, query.Limit, total)
|
||||
response.SuccessWithPagination(c, http.StatusOK, "berhasil mengambil data kartu", ToCardMasterResponseList(list), pagination)
|
||||
}
|
||||
|
||||
func (h *Handler) GetElements(c *gin.Context) {
|
||||
elements := h.service.GetElements()
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data elemen", elements)
|
||||
}
|
||||
|
||||
func (h *Handler) GetByID(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cardData, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data kartu", ToCardMasterResponse(*cardData))
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req CreateCardMasterRequest
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if jsonData := c.PostForm("data"); jsonData != "" {
|
||||
if err := json.Unmarshal([]byte(jsonData), &req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "format data JSON pada multipart tidak valid", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("image")
|
||||
if err == nil && fileHeader != nil {
|
||||
if fileHeader.Size > 10*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 10MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.IDSet == 0 {
|
||||
response.Error(c, http.StatusBadRequest, "id_set wajib diisi untuk upload gambar", nil)
|
||||
return
|
||||
}
|
||||
|
||||
setInfo, err := h.seriesSetRepo.FindByID(req.IDSet)
|
||||
if err != nil || setInfo == nil {
|
||||
response.Error(c, http.StatusBadRequest, "set dengan id_set tersebut tidak ditemukan", nil)
|
||||
return
|
||||
}
|
||||
|
||||
seriesSlug := utils.Slugify(setInfo.SeriesName)
|
||||
setCode := setInfo.SetCode
|
||||
|
||||
uploadDir := filepath.Join("./public/images", seriesSlug, setCode, "images")
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori gambar kartu", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuka file gambar", err.Error())
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file bukan merupakan gambar yang valid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
filename := formatCardImageFilename(setCode, req.Number)
|
||||
dstPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat file WebP kartu", err.Error())
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengonversi gambar ke WebP", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.Image = fmt.Sprintf("/images/%s/%s/images/%s", seriesSlug, setCode, filename)
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
createdCard, err := h.service.Create(req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "kartu berhasil dibuat", ToCardMasterResponse(*createdCard))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateCardMasterRequest
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if jsonData := c.PostForm("data"); jsonData != "" {
|
||||
if err := json.Unmarshal([]byte(jsonData), &req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "format data JSON pada multipart tidak valid", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("image")
|
||||
if err == nil && fileHeader != nil {
|
||||
if fileHeader.Size > 10*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 10MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
existingCard, err := h.service.GetByID(id)
|
||||
if err != nil || existingCard == nil {
|
||||
response.Error(c, http.StatusNotFound, "kartu tidak ditemukan", nil)
|
||||
return
|
||||
}
|
||||
|
||||
targetIDSet := req.IDSet
|
||||
if targetIDSet == 0 {
|
||||
targetIDSet = existingCard.IDSet
|
||||
}
|
||||
|
||||
targetNumber := req.Number
|
||||
if targetNumber == "" {
|
||||
targetNumber = existingCard.Number
|
||||
}
|
||||
|
||||
setInfo, err := h.seriesSetRepo.FindByID(targetIDSet)
|
||||
if err != nil || setInfo == nil {
|
||||
response.Error(c, http.StatusBadRequest, "set dengan id_set tersebut tidak ditemukan", nil)
|
||||
return
|
||||
}
|
||||
|
||||
seriesSlug := utils.Slugify(setInfo.SeriesName)
|
||||
setCode := setInfo.SetCode
|
||||
|
||||
uploadDir := filepath.Join("./public/images", seriesSlug, setCode, "images")
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori gambar kartu", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuka file gambar", err.Error())
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file bukan merupakan gambar yang valid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
filename := formatCardImageFilename(setCode, targetNumber)
|
||||
dstPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat file WebP kartu", err.Error())
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengonversi gambar ke WebP", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.Image = fmt.Sprintf("/images/%s/%s/images/%s", seriesSlug, setCode, filename)
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updatedCard, err := h.service.Update(id, req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "kartu berhasil diupdate", ToCardMasterResponse(*updatedCard))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(id); err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "kartu berhasil dihapus", nil)
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, error) {
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func formatCardImageFilename(setCode, number string) string {
|
||||
cleanNum := strings.TrimSpace(number)
|
||||
if cleanNum != "" {
|
||||
cleanNum = strings.ReplaceAll(cleanNum, "/", "_")
|
||||
cleanNum = strings.ReplaceAll(cleanNum, "\\", "_")
|
||||
if !strings.HasSuffix(strings.ToLower(cleanNum), ".webp") {
|
||||
cleanNum = cleanNum + ".webp"
|
||||
}
|
||||
return cleanNum
|
||||
}
|
||||
return fmt.Sprintf("%s-1_%d.webp", setCode, time.Now().UnixNano())
|
||||
}
|
||||
161
internal/modules/cardmaster/model.go
Normal file
161
internal/modules/cardmaster/model.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONB merepresentasikan kolom JSONB di PostgreSQL
|
||||
type JSONB json.RawMessage
|
||||
|
||||
func (j JSONB) Value() (driver.Value, error) {
|
||||
if len(j) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return string(j), nil
|
||||
}
|
||||
|
||||
func (j *JSONB) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*j = append((*j)[0:0], v...)
|
||||
case string:
|
||||
*j = JSONB(v)
|
||||
default:
|
||||
return errors.New("type assertion ke []byte atau string gagal")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j JSONB) MarshalJSON() ([]byte, error) {
|
||||
if len(j) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.RawMessage(j).MarshalJSON()
|
||||
}
|
||||
|
||||
func (j *JSONB) UnmarshalJSON(data []byte) error {
|
||||
if j == nil {
|
||||
return errors.New("JSONB: UnmarshalJSON pada nil pointer")
|
||||
}
|
||||
*j = append((*j)[0:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CardMaster merepresentasikan tabel "card_masters" di database
|
||||
type CardMaster struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
IDSet uint `json:"id_set" gorm:"column:id_set;not null;index"`
|
||||
Name string `json:"name" gorm:"type:varchar(150);not null;index"`
|
||||
Number string `json:"number" gorm:"type:varchar(50)"`
|
||||
Image string `json:"image" gorm:"type:varchar(255)"`
|
||||
Stage string `json:"stage,omitempty" gorm:"type:varchar(50)"`
|
||||
Element string `json:"element,omitempty" gorm:"type:varchar(50);index"`
|
||||
ElementImage string `json:"element_image,omitempty" gorm:"type:varchar(255)"`
|
||||
EvolvesFrom string `json:"evolves_from,omitempty" gorm:"type:varchar(150)"`
|
||||
Illustrator string `json:"illustrator,omitempty" gorm:"type:varchar(150)"`
|
||||
Regulation string `json:"regulation,omitempty" gorm:"type:varchar(10)"`
|
||||
Rarity string `json:"rarity,omitempty" gorm:"type:varchar(50);index"`
|
||||
IDPriceChartingEng string `json:"id_price_charting_eng,omitempty" gorm:"type:varchar(50)"`
|
||||
LinkPriceChartingEng string `json:"link_price_charting_eng,omitempty" gorm:"type:text"`
|
||||
IDPriceChartingJpn string `json:"id_price_charting_jpn,omitempty" gorm:"type:varchar(50)"`
|
||||
LinkPriceChartingJpn string `json:"link_price_charting_jpn,omitempty" gorm:"type:text"`
|
||||
HP JSONB `json:"hp,omitempty" gorm:"type:jsonb"`
|
||||
Attacks JSONB `json:"attacks,omitempty" gorm:"type:jsonb"`
|
||||
Abilities JSONB `json:"abilities,omitempty" gorm:"type:jsonb"`
|
||||
Battle JSONB `json:"battle,omitempty" gorm:"type:jsonb"`
|
||||
EvolutionLine JSONB `json:"evolution_line,omitempty" gorm:"type:jsonb"`
|
||||
PokedexInfo JSONB `json:"pokedex_info,omitempty" gorm:"type:jsonb"`
|
||||
Effects JSONB `json:"effects,omitempty" gorm:"type:jsonb"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
CreatedBy *uint `json:"created_by" gorm:"default:null"`
|
||||
UpdatedBy *uint `json:"updated_by" gorm:"default:null"`
|
||||
}
|
||||
|
||||
func (CardMaster) TableName() string {
|
||||
return "card_masters"
|
||||
}
|
||||
|
||||
// Struct di bawah ini digunakan untuk parsing data JSON ke tipe Go yang terstruktur
|
||||
|
||||
type HP struct {
|
||||
Value string `json:"value"`
|
||||
Element string `json:"element"`
|
||||
ElementImage string `json:"element_image,omitempty"`
|
||||
}
|
||||
|
||||
type AttackCost struct {
|
||||
Element string `json:"element"`
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
type Attack struct {
|
||||
Cost []AttackCost `json:"cost"`
|
||||
Name string `json:"name"`
|
||||
Damage string `json:"damage,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type BattleStat struct {
|
||||
Element string `json:"element,omitempty"`
|
||||
ElementImage string `json:"element_image,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type Battle struct {
|
||||
Weakness []BattleStat `json:"weakness"`
|
||||
Resistance []BattleStat `json:"resistance"`
|
||||
Retreat []BattleStat `json:"retreat"`
|
||||
}
|
||||
|
||||
type EvolutionStage struct {
|
||||
Stage string `json:"stage"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type PokedexInfo struct {
|
||||
Number string `json:"number"`
|
||||
Height string `json:"height"`
|
||||
Weight string `json:"weight"`
|
||||
}
|
||||
|
||||
type Ability struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Effect struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ElementImageMap memetakan nama element (Bahasa Indonesia) ke path gambar di /images/elements/
|
||||
var ElementImageMap = map[string]string{
|
||||
"Daun": "/images/elements/Grass.png",
|
||||
"Api": "/images/elements/Fire.png",
|
||||
"Air": "/images/elements/Water.png",
|
||||
"Listrik": "/images/elements/Lightning.png",
|
||||
"Psikis": "/images/elements/Psychic.png",
|
||||
"Petarung": "/images/elements/Fighting.png",
|
||||
"Kegelapan": "/images/elements/Darkness.png",
|
||||
"Logam": "/images/elements/Metal.png",
|
||||
"Naga": "/images/elements/Dragon.png",
|
||||
"Peri": "/images/elements/Fairy.png",
|
||||
"Bening": "/images/elements/Colorless.png",
|
||||
}
|
||||
|
||||
// GetElementImage mengembalikan path gambar element berdasarkan nama element
|
||||
func GetElementImage(element string) string {
|
||||
if path, exists := ElementImageMap[element]; exists {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
158
internal/modules/cardmaster/repository.go
Normal file
158
internal/modules/cardmaster/repository.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
FindAllPaginated(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error)
|
||||
FindByID(id uint) (*CardMaster, error)
|
||||
Create(c *CardMaster) error
|
||||
Update(c *CardMaster, oldIDSet uint) error
|
||||
Delete(id uint, idSet uint) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) FindAllPaginated(page, limit int, queryParams ListCardMasterQuery) ([]CardMaster, int64, error) {
|
||||
var list []CardMaster
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&CardMaster{}).
|
||||
Select(
|
||||
"id", "id_set", "name", "number", "image", "stage", "element", "element_image",
|
||||
"evolves_from", "illustrator", "regulation", "rarity",
|
||||
"id_price_charting_eng", "link_price_charting_eng", "id_price_charting_jpn", "link_price_charting_jpn",
|
||||
"hp", "attacks", "abilities", "battle", "evolution_line", "pokedex_info", "effects",
|
||||
"created_at", "updated_at", "created_by", "updated_by",
|
||||
)
|
||||
|
||||
if queryParams.IDSet > 0 {
|
||||
query = query.Where("id_set = ?", queryParams.IDSet)
|
||||
}
|
||||
|
||||
if queryParams.Element != "" {
|
||||
query = query.Where("element ILIKE ?", queryParams.Element)
|
||||
}
|
||||
|
||||
if queryParams.Rarity != "" {
|
||||
query = query.Where("rarity ILIKE ?", queryParams.Rarity)
|
||||
}
|
||||
|
||||
if queryParams.Stage != "" {
|
||||
query = query.Where("stage ILIKE ?", queryParams.Stage)
|
||||
}
|
||||
|
||||
if queryParams.Search != "" {
|
||||
pattern := "%" + queryParams.Search + "%"
|
||||
query = query.Where("name ILIKE ? OR number ILIKE ? OR illustrator ILIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
err := query.Order("id ASC").Limit(limit).Offset(offset).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByID(id uint) (*CardMaster, error) {
|
||||
var c CardMaster
|
||||
err := r.db.Select(
|
||||
"id", "id_set", "name", "number", "image", "stage", "element", "element_image",
|
||||
"evolves_from", "illustrator", "regulation", "rarity",
|
||||
"id_price_charting_eng", "link_price_charting_eng", "id_price_charting_jpn", "link_price_charting_jpn",
|
||||
"hp", "attacks", "abilities", "battle", "evolution_line", "pokedex_info", "effects",
|
||||
"created_at", "updated_at", "created_by", "updated_by",
|
||||
).First(&c, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (r *repository) Create(c *CardMaster) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IDSet > 0 {
|
||||
recalculateSetAndSeriesStatsTx(tx, c.IDSet)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) Update(c *CardMaster, oldIDSet uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IDSet > 0 {
|
||||
recalculateSetAndSeriesStatsTx(tx, c.IDSet)
|
||||
}
|
||||
if oldIDSet > 0 && oldIDSet != c.IDSet {
|
||||
recalculateSetAndSeriesStatsTx(tx, oldIDSet)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) Delete(id uint, idSet uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Delete(&CardMaster{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if idSet > 0 {
|
||||
recalculateSetAndSeriesStatsTx(tx, idSet)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func recalculateSetAndSeriesStatsTx(tx *gorm.DB, setID uint) {
|
||||
if setID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
type seriesSetRef struct {
|
||||
ID uint
|
||||
ParentID *uint
|
||||
}
|
||||
var set seriesSetRef
|
||||
if err := tx.Table("series_set_masters").Select("id", "parent_id").Where("id = ?", setID).First(&set).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var totalCards int64
|
||||
_ = tx.Table("card_masters").Where("id_set = ?", setID).Count(&totalCards).Error
|
||||
_ = tx.Table("series_set_masters").Where("id = ?", setID).Update("total_cards", int(totalCards)).Error
|
||||
|
||||
if set.ParentID != nil {
|
||||
var expansionCount int64
|
||||
_ = tx.Table("series_set_masters").Where("parent_id = ?", *set.ParentID).Count(&expansionCount).Error
|
||||
|
||||
var cardCount int64
|
||||
_ = tx.Table("card_masters").
|
||||
Where("id_set IN (SELECT id FROM series_set_masters WHERE parent_id = ?)", *set.ParentID).
|
||||
Count(&cardCount).Error
|
||||
|
||||
_ = tx.Table("series_set_masters").
|
||||
Where("id = ? AND parent_id IS NULL", *set.ParentID).
|
||||
Updates(map[string]interface{}{
|
||||
"expansion_count": int(expansionCount),
|
||||
"card_count": int(cardCount),
|
||||
}).Error
|
||||
}
|
||||
}
|
||||
44
internal/modules/cardmaster/repository_mock_test.go
Normal file
44
internal/modules/cardmaster/repository_mock_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package cardmaster
|
||||
|
||||
type mockRepository struct {
|
||||
findAllPaginatedFunc func(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error)
|
||||
findByIDFunc func(id uint) (*CardMaster, error)
|
||||
createFunc func(c *CardMaster) error
|
||||
updateFunc func(c *CardMaster, oldIDSet uint) error
|
||||
deleteFunc func(id uint, idSet uint) error
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAllPaginated(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error) {
|
||||
if m.findAllPaginatedFunc != nil {
|
||||
return m.findAllPaginatedFunc(page, limit, query)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByID(id uint) (*CardMaster, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Create(c *CardMaster) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Update(c *CardMaster, oldIDSet uint) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(c, oldIDSet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Delete(id uint, idSet uint) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(id, idSet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/modules/cardmaster/routes.go
Normal file
27
internal/modules/cardmaster/routes.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"cardverse/internal/middleware"
|
||||
"cardverse/internal/modules/seriessetmaster"
|
||||
"cardverse/internal/modules/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router *gin.RouterGroup, db *gorm.DB) {
|
||||
repo := NewRepository(db)
|
||||
svc := NewService(repo)
|
||||
seriesSetRepo := seriessetmaster.NewRepository(db)
|
||||
handler := NewHandler(svc, seriesSetRepo)
|
||||
|
||||
cards := router.Group("/cards")
|
||||
{
|
||||
cards.GET("", handler.GetAll)
|
||||
cards.GET("/elements", handler.GetElements)
|
||||
cards.GET("/:id", handler.GetByID)
|
||||
cards.POST("", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Create)
|
||||
cards.PUT("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Update)
|
||||
cards.DELETE("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Delete)
|
||||
}
|
||||
}
|
||||
291
internal/modules/cardmaster/service.go
Normal file
291
internal/modules/cardmaster/service.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrCardNotFound = errors.New("kartu tidak ditemukan")
|
||||
|
||||
type Service interface {
|
||||
GetAllPaginated(query ListCardMasterQuery) ([]CardMaster, int64, error)
|
||||
GetByID(id uint) (*CardMaster, error)
|
||||
GetElements() []ElementResponse
|
||||
Create(req CreateCardMasterRequest) (*CardMaster, error)
|
||||
Update(id uint, req UpdateCardMasterRequest) (*CardMaster, error)
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) Service {
|
||||
return &service{
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) GetAllPaginated(query ListCardMasterQuery) ([]CardMaster, int64, error) {
|
||||
page := query.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
return s.repo.FindAllPaginated(page, limit, query)
|
||||
}
|
||||
|
||||
func (s *service) GetByID(id uint) (*CardMaster, error) {
|
||||
c, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrCardNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *service) GetElements() []ElementResponse {
|
||||
return GetElementsResponse()
|
||||
}
|
||||
|
||||
func (s *service) Create(req CreateCardMasterRequest) (*CardMaster, error) {
|
||||
elementImage := req.ElementImage
|
||||
if elementImage == "" && req.Element != "" {
|
||||
elementImage = GetElementImage(req.Element)
|
||||
}
|
||||
|
||||
c := &CardMaster{
|
||||
IDSet: req.IDSet,
|
||||
Name: req.Name,
|
||||
Number: req.Number,
|
||||
Image: req.Image,
|
||||
Stage: req.Stage,
|
||||
Element: req.Element,
|
||||
ElementImage: elementImage,
|
||||
EvolvesFrom: req.EvolvesFrom,
|
||||
Illustrator: req.Illustrator,
|
||||
Regulation: req.Regulation,
|
||||
Rarity: req.Rarity,
|
||||
IDPriceChartingEng: req.IDPriceChartingEng,
|
||||
LinkPriceChartingEng: req.LinkPriceChartingEng,
|
||||
IDPriceChartingJpn: req.IDPriceChartingJpn,
|
||||
LinkPriceChartingJpn: req.LinkPriceChartingJpn,
|
||||
}
|
||||
|
||||
if req.HP != nil {
|
||||
req.HP.ElementImage = GetElementImage(req.HP.Element)
|
||||
bytes, err := json.Marshal(req.HP)
|
||||
if err == nil {
|
||||
c.HP = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Attacks != nil {
|
||||
for i := range req.Attacks {
|
||||
for j := range req.Attacks[i].Cost {
|
||||
req.Attacks[i].Cost[j].Image = GetElementImage(req.Attacks[i].Cost[j].Element)
|
||||
}
|
||||
}
|
||||
bytes, err := json.Marshal(req.Attacks)
|
||||
if err == nil {
|
||||
c.Attacks = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Abilities != nil {
|
||||
bytes, err := json.Marshal(req.Abilities)
|
||||
if err == nil {
|
||||
c.Abilities = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Battle != nil {
|
||||
for i := range req.Battle.Weakness {
|
||||
req.Battle.Weakness[i].ElementImage = GetElementImage(req.Battle.Weakness[i].Element)
|
||||
}
|
||||
for i := range req.Battle.Resistance {
|
||||
req.Battle.Resistance[i].ElementImage = GetElementImage(req.Battle.Resistance[i].Element)
|
||||
}
|
||||
for i := range req.Battle.Retreat {
|
||||
req.Battle.Retreat[i].ElementImage = GetElementImage(req.Battle.Retreat[i].Element)
|
||||
}
|
||||
bytes, err := json.Marshal(req.Battle)
|
||||
if err == nil {
|
||||
c.Battle = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.EvolutionLine != nil {
|
||||
bytes, err := json.Marshal(req.EvolutionLine)
|
||||
if err == nil {
|
||||
c.EvolutionLine = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.PokedexInfo != nil {
|
||||
bytes, err := json.Marshal(req.PokedexInfo)
|
||||
if err == nil {
|
||||
c.PokedexInfo = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Effects != nil {
|
||||
bytes, err := json.Marshal(req.Effects)
|
||||
if err == nil {
|
||||
c.Effects = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.Create(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *service) Update(id uint, req UpdateCardMasterRequest) (*CardMaster, error) {
|
||||
c, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrCardNotFound
|
||||
}
|
||||
|
||||
var oldIDSet uint
|
||||
if req.IDSet > 0 && req.IDSet != c.IDSet {
|
||||
oldIDSet = c.IDSet
|
||||
c.IDSet = req.IDSet
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
c.Name = req.Name
|
||||
}
|
||||
if req.Number != "" {
|
||||
c.Number = req.Number
|
||||
}
|
||||
if req.Image != "" {
|
||||
if c.Image != "" && c.Image != req.Image {
|
||||
utils.DeleteLocalFile(c.Image)
|
||||
}
|
||||
c.Image = req.Image
|
||||
}
|
||||
if req.Stage != "" {
|
||||
c.Stage = req.Stage
|
||||
}
|
||||
if req.Element != "" {
|
||||
c.Element = req.Element
|
||||
c.ElementImage = GetElementImage(req.Element)
|
||||
}
|
||||
if req.EvolvesFrom != "" {
|
||||
c.EvolvesFrom = req.EvolvesFrom
|
||||
}
|
||||
if req.Illustrator != "" {
|
||||
c.Illustrator = req.Illustrator
|
||||
}
|
||||
if req.Regulation != "" {
|
||||
c.Regulation = req.Regulation
|
||||
}
|
||||
if req.Rarity != "" {
|
||||
c.Rarity = req.Rarity
|
||||
}
|
||||
if req.IDPriceChartingEng != "" {
|
||||
c.IDPriceChartingEng = req.IDPriceChartingEng
|
||||
}
|
||||
if req.LinkPriceChartingEng != "" {
|
||||
c.LinkPriceChartingEng = req.LinkPriceChartingEng
|
||||
}
|
||||
if req.IDPriceChartingJpn != "" {
|
||||
c.IDPriceChartingJpn = req.IDPriceChartingJpn
|
||||
}
|
||||
if req.LinkPriceChartingJpn != "" {
|
||||
c.LinkPriceChartingJpn = req.LinkPriceChartingJpn
|
||||
}
|
||||
|
||||
if req.HP != nil {
|
||||
req.HP.ElementImage = GetElementImage(req.HP.Element)
|
||||
bytes, err := json.Marshal(req.HP)
|
||||
if err == nil {
|
||||
c.HP = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Attacks != nil {
|
||||
for i := range req.Attacks {
|
||||
for j := range req.Attacks[i].Cost {
|
||||
req.Attacks[i].Cost[j].Image = GetElementImage(req.Attacks[i].Cost[j].Element)
|
||||
}
|
||||
}
|
||||
bytes, err := json.Marshal(req.Attacks)
|
||||
if err == nil {
|
||||
c.Attacks = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Abilities != nil {
|
||||
bytes, err := json.Marshal(req.Abilities)
|
||||
if err == nil {
|
||||
c.Abilities = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Battle != nil {
|
||||
for i := range req.Battle.Weakness {
|
||||
req.Battle.Weakness[i].ElementImage = GetElementImage(req.Battle.Weakness[i].Element)
|
||||
}
|
||||
for i := range req.Battle.Resistance {
|
||||
req.Battle.Resistance[i].ElementImage = GetElementImage(req.Battle.Resistance[i].Element)
|
||||
}
|
||||
for i := range req.Battle.Retreat {
|
||||
req.Battle.Retreat[i].ElementImage = GetElementImage(req.Battle.Retreat[i].Element)
|
||||
}
|
||||
bytes, err := json.Marshal(req.Battle)
|
||||
if err == nil {
|
||||
c.Battle = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.EvolutionLine != nil {
|
||||
bytes, err := json.Marshal(req.EvolutionLine)
|
||||
if err == nil {
|
||||
c.EvolutionLine = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.PokedexInfo != nil {
|
||||
bytes, err := json.Marshal(req.PokedexInfo)
|
||||
if err == nil {
|
||||
c.PokedexInfo = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Effects != nil {
|
||||
bytes, err := json.Marshal(req.Effects)
|
||||
if err == nil {
|
||||
c.Effects = JSONB(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.Update(c, oldIDSet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(id uint) error {
|
||||
c, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return ErrCardNotFound
|
||||
}
|
||||
|
||||
idSet := c.IDSet
|
||||
|
||||
if c.Image != "" {
|
||||
utils.DeleteLocalFile(c.Image)
|
||||
}
|
||||
|
||||
return s.repo.Delete(id, idSet)
|
||||
}
|
||||
179
internal/modules/cardmaster/service_test.go
Normal file
179
internal/modules/cardmaster/service_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package cardmaster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestService_GetAllPaginated_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findAllPaginatedFunc: func(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error) {
|
||||
return []CardMaster{
|
||||
{ID: 1, Name: "Pikachu"},
|
||||
{ID: 2, Name: "Charizard"},
|
||||
}, 2, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
list, total, err := svc.GetAllPaginated(ListCardMasterQuery{Page: 1, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("expected total 2, got: %d", total)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("expected list length 2, got: %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetAllPaginated_DefaultNormalization(t *testing.T) {
|
||||
var capturedPage, capturedLimit int
|
||||
|
||||
repo := &mockRepository{
|
||||
findAllPaginatedFunc: func(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error) {
|
||||
capturedPage, capturedLimit = page, limit
|
||||
return []CardMaster{{ID: 1, Name: "Bulbasaur"}}, 1, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, total, err := svc.GetAllPaginated(ListCardMasterQuery{Page: 0, Limit: 0})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected total 1, got: %d", total)
|
||||
}
|
||||
if capturedPage != 1 {
|
||||
t.Errorf("expected page normalized to 1, got: %d", capturedPage)
|
||||
}
|
||||
if capturedLimit != 20 {
|
||||
t.Errorf("expected limit normalized to 20, got: %d", capturedLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*CardMaster, error) {
|
||||
return &CardMaster{ID: id, Name: "Bulbasaur"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
c, err := svc.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if c.ID != 1 {
|
||||
t.Errorf("expected ID 1, got: %d", c.ID)
|
||||
}
|
||||
if c.Name != "Bulbasaur" {
|
||||
t.Errorf("expected Name 'Bulbasaur', got: '%s'", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*CardMaster, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.GetByID(999)
|
||||
if !errors.Is(err, ErrCardNotFound) {
|
||||
t.Fatalf("expected ErrCardNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*CardMaster, error) {
|
||||
return &CardMaster{ID: id, Name: "Old Name", Element: "Daun"}, nil
|
||||
},
|
||||
updateFunc: func(c *CardMaster, oldIDSet uint) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := UpdateCardMasterRequest{
|
||||
Name: "Bulbasaur Mega",
|
||||
Element: "Daun",
|
||||
}
|
||||
|
||||
updated, err := svc.Update(1, req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.Name != "Bulbasaur Mega" {
|
||||
t.Errorf("expected Name 'Bulbasaur Mega', got: '%s'", updated.Name)
|
||||
}
|
||||
if updated.ElementImage != "/images/elements/Grass.png" {
|
||||
t.Errorf("expected ElementImage '/images/elements/Grass.png', got: '%s'", updated.ElementImage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*CardMaster, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.Update(999, UpdateCardMasterRequest{Name: "New Name"})
|
||||
if !errors.Is(err, ErrCardNotFound) {
|
||||
t.Fatalf("expected ErrCardNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_EnrichElementImage(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*CardMaster, error) {
|
||||
return &CardMaster{ID: id, Name: "Charmander"}, nil
|
||||
},
|
||||
updateFunc: func(c *CardMaster, oldIDSet uint) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := UpdateCardMasterRequest{
|
||||
HP: &HP{Value: "70", Element: "Api"},
|
||||
Attacks: []Attack{
|
||||
{
|
||||
Name: "Flame Burst",
|
||||
Cost: []AttackCost{
|
||||
{Element: "Api"},
|
||||
},
|
||||
Damage: "30",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updated, err := svc.Update(1, req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
var parsedHP HP
|
||||
if err := json.Unmarshal(updated.HP, &parsedHP); err != nil {
|
||||
t.Fatalf("failed to unmarshal HP: %v", err)
|
||||
}
|
||||
if parsedHP.ElementImage != "/images/elements/Fire.png" {
|
||||
t.Errorf("expected HP ElementImage '/images/elements/Fire.png', got: '%s'", parsedHP.ElementImage)
|
||||
}
|
||||
|
||||
var parsedAttacks []Attack
|
||||
if err := json.Unmarshal(updated.Attacks, &parsedAttacks); err != nil {
|
||||
t.Fatalf("failed to unmarshal Attacks: %v", err)
|
||||
}
|
||||
if len(parsedAttacks) != 1 || parsedAttacks[0].Cost[0].Image != "/images/elements/Fire.png" {
|
||||
t.Errorf("expected Attack Cost Image '/images/elements/Fire.png', got: '%s'", parsedAttacks[0].Cost[0].Image)
|
||||
}
|
||||
}
|
||||
97
internal/modules/seriessetmaster/dto.go
Normal file
97
internal/modules/seriessetmaster/dto.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package seriessetmaster
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type ListSeriesQuery struct {
|
||||
Search string `form:"search"`
|
||||
}
|
||||
|
||||
type ListSetQuery struct {
|
||||
Search string `form:"search"`
|
||||
ParentID uint `form:"parent_id"`
|
||||
}
|
||||
|
||||
type CreateSeriesRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=150"`
|
||||
Image string `json:"image" binding:"omitempty,max=255"`
|
||||
}
|
||||
|
||||
type CreateSetRequest struct {
|
||||
ParentID uint `json:"parent_id" binding:"required"`
|
||||
SetCode string `json:"set_code" binding:"required,max=50"`
|
||||
SetName string `json:"set_name" binding:"required,max=150"`
|
||||
Image string `json:"image" binding:"omitempty,max=255"`
|
||||
Logo string `json:"logo" binding:"omitempty,max=255"`
|
||||
ReleaseDate *time.Time `json:"release_date" binding:"omitempty"`
|
||||
}
|
||||
|
||||
type UpdateSeriesRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=2,max=150"`
|
||||
Image string `json:"image" binding:"omitempty,max=255"`
|
||||
}
|
||||
|
||||
type UpdateSetRequest struct {
|
||||
ParentID *uint `json:"parent_id" binding:"omitempty"`
|
||||
SetCode string `json:"set_code" binding:"omitempty,max=50"`
|
||||
SetName string `json:"set_name" binding:"omitempty,max=150"`
|
||||
Image string `json:"image" binding:"omitempty,max=255"`
|
||||
Logo string `json:"logo" binding:"omitempty,max=255"`
|
||||
ReleaseDate *time.Time `json:"release_date" binding:"omitempty"`
|
||||
}
|
||||
|
||||
type SeriesSetMasterResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
SetCode string `json:"set_code,omitempty"`
|
||||
SetName string `json:"set_name,omitempty"`
|
||||
SeriesName string `json:"series_name"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
TotalCards int `json:"total_cards"`
|
||||
ExpansionCount int `json:"expansion_count"`
|
||||
CardCount int `json:"card_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *uint `json:"created_by,omitempty"`
|
||||
UpdatedBy *uint `json:"updated_by,omitempty"`
|
||||
Children []SeriesSetMasterResponse `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func ToSeriesSetMasterResponse(m SeriesSetMaster) SeriesSetMasterResponse {
|
||||
var children []SeriesSetMasterResponse
|
||||
if len(m.Children) > 0 {
|
||||
children = ToSeriesSetMasterResponseList(m.Children)
|
||||
}
|
||||
|
||||
return SeriesSetMasterResponse{
|
||||
ID: m.ID,
|
||||
ParentID: m.ParentID,
|
||||
SetCode: m.SetCode,
|
||||
SetName: m.SetName,
|
||||
SeriesName: m.SeriesName,
|
||||
Image: utils.FormatMediaURL(m.Image),
|
||||
Logo: utils.FormatMediaURL(m.Logo),
|
||||
ReleaseDate: m.ReleaseDate,
|
||||
TotalCards: m.TotalCards,
|
||||
ExpansionCount: m.ExpansionCount,
|
||||
CardCount: m.CardCount,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
||||
func ToSeriesSetMasterResponseList(list []SeriesSetMaster) []SeriesSetMasterResponse {
|
||||
res := make([]SeriesSetMasterResponse, len(list))
|
||||
for i, m := range list {
|
||||
res[i] = ToSeriesSetMasterResponse(m)
|
||||
}
|
||||
return res
|
||||
}
|
||||
550
internal/modules/seriessetmaster/handler.go
Normal file
550
internal/modules/seriessetmaster/handler.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package seriessetmaster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/utils"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
}
|
||||
|
||||
func NewHandler(service Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) GetAllSeries(c *gin.Context) {
|
||||
var query ListSeriesQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
list, err := h.service.GetAllSeries(query)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengambil data series", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data series", ToSeriesSetMasterResponseList(list))
|
||||
}
|
||||
|
||||
func (h *Handler) GetAllSets(c *gin.Context) {
|
||||
var query ListSetQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
list, err := h.service.GetAllSets(query)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengambil data set", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data set", ToSeriesSetMasterResponseList(list))
|
||||
}
|
||||
|
||||
func (h *Handler) GetByID(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data", ToSeriesSetMasterResponse(*m))
|
||||
}
|
||||
|
||||
func (h *Handler) GetSetByCode(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
if code == "" {
|
||||
response.Error(c, http.StatusBadRequest, "set_code tidak boleh kosong", nil)
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.service.GetSetByCode(code)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data set", ToSeriesSetMasterResponse(*m))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSeries(c *gin.Context) {
|
||||
var req CreateSeriesRequest
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
req.Name = c.PostForm("name")
|
||||
if req.Name == "" {
|
||||
response.Error(c, http.StatusBadRequest, "name wajib diisi", nil)
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("image")
|
||||
if err == nil && fileHeader != nil {
|
||||
if fileHeader.Size > 5*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 5MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err == nil {
|
||||
defer srcFile.Close()
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err == nil {
|
||||
slug := utils.UnderscoreString(req.Name)
|
||||
uploadDir := "./public/images/series"
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
filename := fmt.Sprintf("%s.webp", slug)
|
||||
filepathDst := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(filepathDst)
|
||||
if err == nil {
|
||||
defer out.Close()
|
||||
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err == nil {
|
||||
req.Image = fmt.Sprintf("/images/series/%s", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
created, err := h.service.CreateSeries(req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "series berhasil dibuat", ToSeriesSetMasterResponse(*created))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSet(c *gin.Context) {
|
||||
var req CreateSetRequest
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
if parentIDStr := c.PostForm("parent_id"); parentIDStr != "" {
|
||||
if pID, err := strconv.ParseUint(parentIDStr, 10, 64); err == nil {
|
||||
req.ParentID = uint(pID)
|
||||
}
|
||||
}
|
||||
req.SetCode = c.PostForm("set_code")
|
||||
req.SetName = c.PostForm("set_name")
|
||||
|
||||
if req.ParentID == 0 || req.SetCode == "" || req.SetName == "" {
|
||||
response.Error(c, http.StatusBadRequest, "parent_id, set_code, dan set_name wajib diisi", nil)
|
||||
return
|
||||
}
|
||||
|
||||
parentSeries, err := h.service.GetByID(req.ParentID)
|
||||
if err != nil || parentSeries == nil || parentSeries.ParentID != nil {
|
||||
response.Error(c, http.StatusBadRequest, "parent_id tidak ditemukan atau bukan merupakan series", nil)
|
||||
return
|
||||
}
|
||||
|
||||
seriesSlug := utils.Slugify(parentSeries.SeriesName)
|
||||
uploadDir := fmt.Sprintf("./public/images/%s/%s", seriesSlug, req.SetCode)
|
||||
|
||||
// Handle file 'image'
|
||||
fileHeader, err := c.FormFile("image")
|
||||
if err == nil && fileHeader != nil {
|
||||
if fileHeader.Size <= 5*1024*1024 {
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err == nil {
|
||||
defer srcFile.Close()
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err == nil {
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
imgSlug := utils.UnderscoreString(req.SetName)
|
||||
filename := fmt.Sprintf("%s.webp", imgSlug)
|
||||
filepathDst := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(filepathDst)
|
||||
if err == nil {
|
||||
defer out.Close()
|
||||
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err == nil {
|
||||
req.Image = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file 'logo'
|
||||
logoHeader, err := c.FormFile("logo")
|
||||
if err == nil && logoHeader != nil {
|
||||
if logoHeader.Size <= 5*1024*1024 {
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
origFilename := logoHeader.Filename
|
||||
ext := filepath.Ext(origFilename)
|
||||
baseName := strings.TrimSuffix(origFilename, ext)
|
||||
if baseName == "" {
|
||||
baseName = req.SetCode
|
||||
}
|
||||
|
||||
var logoFilename string
|
||||
if (len(req.SetCode) > 5 || len(baseName) > 5) && (strings.Contains(req.SetCode, "-P") || strings.Contains(baseName, "-P")) {
|
||||
logoFilename = "PROMO.svg"
|
||||
} else {
|
||||
logoFilename = fmt.Sprintf("%s.svg", baseName)
|
||||
}
|
||||
|
||||
filepathDstLogo := filepath.Join(uploadDir, logoFilename)
|
||||
|
||||
srcLogo, err := logoHeader.Open()
|
||||
if err == nil {
|
||||
defer srcLogo.Close()
|
||||
logoBytes, err := io.ReadAll(srcLogo)
|
||||
if err == nil {
|
||||
checkLen := len(logoBytes)
|
||||
if checkLen > 512 {
|
||||
checkLen = 512
|
||||
}
|
||||
isSVG := strings.EqualFold(ext, ".svg") || strings.Contains(strings.ToLower(string(logoBytes[:checkLen])), "<svg")
|
||||
|
||||
if isSVG {
|
||||
if err := os.WriteFile(filepathDstLogo, logoBytes, 0644); err == nil {
|
||||
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, logoFilename)
|
||||
}
|
||||
} else {
|
||||
img, _, err := image.Decode(bytes.NewReader(logoBytes))
|
||||
if err == nil && img != nil {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err == nil {
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d" viewBox="0 0 %d %d"><image width="%d" height="%d" xlink:href="data:image/png;base64,%s"/></svg>`, w, h, w, h, w, h, b64)
|
||||
|
||||
if err := os.WriteFile(filepathDstLogo, []byte(svgContent), 0644); err == nil {
|
||||
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, logoFilename)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := c.SaveUploadedFile(logoHeader, filepathDstLogo); err == nil {
|
||||
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, logoFilename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
created, err := h.service.CreateSet(req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "set berhasil dibuat", ToSeriesSetMasterResponse(*created))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSeries(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSeriesRequest
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
req.Name = c.PostForm("name")
|
||||
|
||||
fileHeader, err := c.FormFile("image")
|
||||
if err == nil && fileHeader != nil {
|
||||
if fileHeader.Size > 5*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 5MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membaca file gambar series", err.Error())
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file yang diunggah bukan format gambar yang valid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
seriesName := req.Name
|
||||
if seriesName == "" {
|
||||
m, err := h.service.GetByID(id)
|
||||
if err == nil && m != nil {
|
||||
seriesName = m.SeriesName
|
||||
}
|
||||
}
|
||||
|
||||
slug := utils.UnderscoreString(seriesName)
|
||||
|
||||
uploadDir := "./public/images/series"
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori penyimpanan series", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s.webp", slug)
|
||||
filepathDst := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(filepathDst)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat file gambar series", err.Error())
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengompresi gambar ke format WebP", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.Image = fmt.Sprintf("/images/series/%s", filename)
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.service.UpdateSeries(id, req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "series berhasil diupdate", ToSeriesSetMasterResponse(*updated))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSet(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mOld, err := h.service.GetByID(id)
|
||||
if err != nil || mOld == nil {
|
||||
response.Error(c, http.StatusNotFound, "data set tidak ditemukan", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSetRequest
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
req.SetCode = c.PostForm("set_code")
|
||||
req.SetName = c.PostForm("set_name")
|
||||
|
||||
if parentIDStr := c.PostForm("parent_id"); parentIDStr != "" {
|
||||
if pID, err := strconv.ParseUint(parentIDStr, 10, 64); err == nil {
|
||||
uintPID := uint(pID)
|
||||
req.ParentID = &uintPID
|
||||
}
|
||||
}
|
||||
|
||||
setCode := req.SetCode
|
||||
if setCode == "" {
|
||||
setCode = mOld.SetCode
|
||||
}
|
||||
setName := req.SetName
|
||||
if setName == "" {
|
||||
setName = mOld.SetName
|
||||
}
|
||||
|
||||
seriesName := mOld.SeriesName
|
||||
if req.ParentID != nil {
|
||||
parentSeries, err := h.service.GetByID(*req.ParentID)
|
||||
if err == nil && parentSeries != nil && parentSeries.ParentID == nil {
|
||||
seriesName = parentSeries.SeriesName
|
||||
}
|
||||
}
|
||||
|
||||
seriesSlug := utils.Slugify(seriesName)
|
||||
uploadDir := fmt.Sprintf("./public/images/%s/%s", seriesSlug, setCode)
|
||||
|
||||
// 1. Handle file 'image' (WebP format, nama file = utils.UnderscoreString(setName) + ".webp")
|
||||
fileHeader, err := c.FormFile("image")
|
||||
if err == nil && fileHeader != nil {
|
||||
if fileHeader.Size > 5*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 5MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err == nil {
|
||||
defer srcFile.Close()
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err == nil {
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
imgSlug := utils.UnderscoreString(setName)
|
||||
filename := fmt.Sprintf("%s.webp", imgSlug)
|
||||
filepathDst := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(filepathDst)
|
||||
if err == nil {
|
||||
defer out.Close()
|
||||
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err == nil {
|
||||
req.Image = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle file 'logo' (Format SVG: file SVG disimpan langsung, JPG/PNG/GIF/WebP dikonversi ke SVG XML)
|
||||
logoHeader, err := c.FormFile("logo")
|
||||
if err == nil && logoHeader != nil {
|
||||
if logoHeader.Size > 5*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file logo maksimal 5MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
|
||||
origFilename := logoHeader.Filename
|
||||
ext := filepath.Ext(origFilename)
|
||||
baseName := strings.TrimSuffix(origFilename, ext)
|
||||
if baseName == "" {
|
||||
baseName = setCode
|
||||
}
|
||||
|
||||
var logoFilename string
|
||||
if (len(setCode) > 5 || len(baseName) > 5) && (strings.Contains(setCode, "-P") || strings.Contains(baseName, "-P")) {
|
||||
logoFilename = "PROMO.svg"
|
||||
} else {
|
||||
logoFilename = fmt.Sprintf("%s.svg", baseName)
|
||||
}
|
||||
|
||||
filepathDstLogo := filepath.Join(uploadDir, logoFilename)
|
||||
|
||||
srcLogo, err := logoHeader.Open()
|
||||
if err == nil {
|
||||
defer srcLogo.Close()
|
||||
logoBytes, err := io.ReadAll(srcLogo)
|
||||
if err == nil {
|
||||
checkLen := len(logoBytes)
|
||||
if checkLen > 512 {
|
||||
checkLen = 512
|
||||
}
|
||||
isSVG := strings.EqualFold(ext, ".svg") || strings.Contains(strings.ToLower(string(logoBytes[:checkLen])), "<svg")
|
||||
|
||||
if isSVG {
|
||||
if err := os.WriteFile(filepathDstLogo, logoBytes, 0644); err == nil {
|
||||
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, logoFilename)
|
||||
}
|
||||
} else {
|
||||
img, _, err := image.Decode(bytes.NewReader(logoBytes))
|
||||
if err == nil && img != nil {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err == nil {
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d" viewBox="0 0 %d %d"><image width="%d" height="%d" xlink:href="data:image/png;base64,%s"/></svg>`, w, h, w, h, w, h, b64)
|
||||
|
||||
if err := os.WriteFile(filepathDstLogo, []byte(svgContent), 0644); err == nil {
|
||||
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, logoFilename)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := c.SaveUploadedFile(logoHeader, filepathDstLogo); err == nil {
|
||||
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, logoFilename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.service.UpdateSet(id, req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "set berhasil diupdate", ToSeriesSetMasterResponse(*updated))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(id); err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil menghapus data series/set", nil)
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, error) {
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
60
internal/modules/seriessetmaster/model.go
Normal file
60
internal/modules/seriessetmaster/model.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package seriessetmaster
|
||||
|
||||
import "time"
|
||||
|
||||
// SeriesSetMaster merepresentasikan tabel utama "series_set_masters" di database (digunakan untuk AutoMigrate GORM)
|
||||
type SeriesSetMaster struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ParentID *uint `json:"parent_id" gorm:"index;default:null"`
|
||||
SetCode string `json:"set_code,omitempty" gorm:"type:varchar(50);index"`
|
||||
SetName string `json:"set_name,omitempty" gorm:"type:varchar(150)"`
|
||||
SeriesName string `json:"series_name" gorm:"type:varchar(150);not null;index"`
|
||||
Image string `json:"image" gorm:"type:varchar(255)"`
|
||||
Logo string `json:"logo,omitempty" gorm:"type:varchar(255)"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty" gorm:"type:date"`
|
||||
TotalCards int `json:"total_cards" gorm:"default:0"`
|
||||
ExpansionCount int `json:"expansion_count" gorm:"default:0"`
|
||||
CardCount int `json:"card_count" gorm:"default:0"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
CreatedBy *uint `json:"created_by" gorm:"default:null"`
|
||||
UpdatedBy *uint `json:"updated_by" gorm:"default:null"`
|
||||
Parent *SeriesSetMaster `json:"parent,omitempty" gorm:"foreignKey:ParentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT;"`
|
||||
Children []SeriesSetMaster `json:"children,omitempty" gorm:"foreignKey:ParentID;references:ID"`
|
||||
}
|
||||
|
||||
func (SeriesSetMaster) TableName() string {
|
||||
return "series_set_masters"
|
||||
}
|
||||
|
||||
// Series merepresentasikan data khusus Series untuk kelola data/response
|
||||
type Series struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
ExpansionCount int `json:"expansion_count"`
|
||||
CardCount int `json:"card_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *uint `json:"created_by"`
|
||||
UpdatedBy *uint `json:"updated_by"`
|
||||
Sets []Set `json:"sets,omitempty"`
|
||||
}
|
||||
|
||||
// Set merepresentasikan data khusus Set untuk kelola data/response
|
||||
type Set struct {
|
||||
ID uint `json:"id"`
|
||||
ParentID uint `json:"parent_id"`
|
||||
SetCode string `json:"set_code"`
|
||||
SetName string `json:"set_name"`
|
||||
SeriesName string `json:"series_name"`
|
||||
Image string `json:"image"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
TotalCards int `json:"total_cards"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *uint `json:"created_by"`
|
||||
UpdatedBy *uint `json:"updated_by"`
|
||||
Series *Series `json:"series,omitempty"`
|
||||
}
|
||||
228
internal/modules/seriessetmaster/repository.go
Normal file
228
internal/modules/seriessetmaster/repository.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package seriessetmaster
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
FindAllSeries(search string) ([]SeriesSetMaster, error)
|
||||
FindAllSets(search string, parentID uint) ([]SeriesSetMaster, error)
|
||||
FindByID(id uint) (*SeriesSetMaster, error)
|
||||
FindBySetCode(setCode string) (*SeriesSetMaster, error)
|
||||
FindCardImagesBySetID(setID uint) ([]string, error)
|
||||
Create(m *SeriesSetMaster) error
|
||||
CreateSet(m *SeriesSetMaster) error
|
||||
Update(m *SeriesSetMaster) error
|
||||
UpdateSet(m *SeriesSetMaster, oldParentID *uint) error
|
||||
DeleteCascade(m *SeriesSetMaster) error
|
||||
RecalculateSeriesStats(seriesID uint) error
|
||||
RecalculateSetStats(setID uint) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) FindAllSeries(search string) ([]SeriesSetMaster, error) {
|
||||
var list []SeriesSetMaster
|
||||
|
||||
query := r.db.Model(&SeriesSetMaster{}).
|
||||
Select("id", "parent_id", "series_name", "image", "expansion_count", "card_count", "created_at", "updated_at", "created_by", "updated_by").
|
||||
Where("parent_id IS NULL")
|
||||
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
query = query.Where("series_name ILIKE ?", pattern)
|
||||
}
|
||||
|
||||
err := query.Order("id ASC").Preload("Children").Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindAllSets(search string, parentID uint) ([]SeriesSetMaster, error) {
|
||||
var list []SeriesSetMaster
|
||||
|
||||
query := r.db.Model(&SeriesSetMaster{}).
|
||||
Select("id", "parent_id", "set_code", "set_name", "series_name", "image", "logo", "release_date", "total_cards", "created_at", "updated_at", "created_by", "updated_by").
|
||||
Where("parent_id IS NOT NULL")
|
||||
|
||||
if parentID > 0 {
|
||||
query = query.Where("parent_id = ?", parentID)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
query = query.Where("set_name ILIKE ? OR set_code ILIKE ? OR series_name ILIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
err := query.Order("id ASC").Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByID(id uint) (*SeriesSetMaster, error) {
|
||||
var m SeriesSetMaster
|
||||
err := r.db.Select("id", "parent_id", "set_code", "set_name", "series_name", "image", "logo", "release_date", "total_cards", "expansion_count", "card_count", "created_at", "updated_at", "created_by", "updated_by").
|
||||
Preload("Children").
|
||||
First(&m, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindBySetCode(setCode string) (*SeriesSetMaster, error) {
|
||||
var m SeriesSetMaster
|
||||
err := r.db.Select("id", "parent_id", "set_code", "set_name", "series_name", "image", "logo", "release_date", "total_cards", "expansion_count", "card_count", "created_at", "updated_at", "created_by", "updated_by").
|
||||
Where("set_code = ? AND parent_id IS NOT NULL", setCode).
|
||||
First(&m).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindCardImagesBySetID(setID uint) ([]string, error) {
|
||||
var images []string
|
||||
err := r.db.Table("card_masters").Select("image").Where("id_set = ? AND image != ''", setID).Pluck("image", &images).Error
|
||||
return images, err
|
||||
}
|
||||
|
||||
func (r *repository) Create(m *SeriesSetMaster) error {
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *repository) CreateSet(m *SeriesSetMaster) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if m.ParentID != nil {
|
||||
return r.RecalculateSeriesStatsTx(tx, *m.ParentID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) Update(m *SeriesSetMaster) error {
|
||||
return r.db.Save(m).Error
|
||||
}
|
||||
|
||||
func (r *repository) UpdateSet(m *SeriesSetMaster, oldParentID *uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if m.ParentID != nil {
|
||||
if err := r.RecalculateSeriesStatsTx(tx, *m.ParentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if oldParentID != nil && (m.ParentID == nil || *oldParentID != *m.ParentID) {
|
||||
if err := r.RecalculateSeriesStatsTx(tx, *oldParentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) DeleteCascade(m *SeriesSetMaster) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
parentID := m.ParentID
|
||||
if m.ParentID == nil {
|
||||
if err := tx.Table("card_masters").Where("id_set IN (SELECT id FROM series_set_masters WHERE parent_id = ?)", m.ID).Delete(nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("parent_id = ?", m.ID).Delete(&SeriesSetMaster{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&SeriesSetMaster{}, m.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Table("card_masters").Where("id_set = ?", m.ID).Delete(nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&SeriesSetMaster{}, m.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if parentID != nil {
|
||||
if err := r.RecalculateSeriesStatsTx(tx, *parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) RecalculateSeriesStats(seriesID uint) error {
|
||||
return r.RecalculateSeriesStatsTx(r.db, seriesID)
|
||||
}
|
||||
|
||||
func (r *repository) RecalculateSeriesStatsTx(tx *gorm.DB, seriesID uint) error {
|
||||
if seriesID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var expansionCount int64
|
||||
if err := tx.Model(&SeriesSetMaster{}).Where("parent_id = ?", seriesID).Count(&expansionCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cardCount int64
|
||||
if err := tx.Table("card_masters").
|
||||
Where("id_set IN (SELECT id FROM series_set_masters WHERE parent_id = ?)", seriesID).
|
||||
Count(&cardCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&SeriesSetMaster{}).
|
||||
Where("id = ? AND parent_id IS NULL", seriesID).
|
||||
Updates(map[string]interface{}{
|
||||
"expansion_count": int(expansionCount),
|
||||
"card_count": int(cardCount),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *repository) RecalculateSetStats(setID uint) error {
|
||||
return r.RecalculateSetStatsTx(r.db, setID)
|
||||
}
|
||||
|
||||
func (r *repository) RecalculateSetStatsTx(tx *gorm.DB, setID uint) error {
|
||||
if setID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var set SeriesSetMaster
|
||||
if err := tx.Select("id", "parent_id").First(&set, setID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalCards int64
|
||||
if err := tx.Table("card_masters").Where("id_set = ?", setID).Count(&totalCards).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&SeriesSetMaster{}).Where("id = ?", setID).Update("total_cards", int(totalCards)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if set.ParentID != nil {
|
||||
return r.RecalculateSeriesStatsTx(tx, *set.ParentID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
100
internal/modules/seriessetmaster/repository_mock_test.go
Normal file
100
internal/modules/seriessetmaster/repository_mock_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package seriessetmaster
|
||||
|
||||
type mockRepository struct {
|
||||
findAllSeriesFunc func(search string) ([]SeriesSetMaster, error)
|
||||
findAllSetsFunc func(search string, parentID uint) ([]SeriesSetMaster, error)
|
||||
findByIDFunc func(id uint) (*SeriesSetMaster, error)
|
||||
findBySetCodeFunc func(setCode string) (*SeriesSetMaster, error)
|
||||
findCardImagesBySetIDFunc func(setID uint) ([]string, error)
|
||||
createFunc func(m *SeriesSetMaster) error
|
||||
createSetFunc func(m *SeriesSetMaster) error
|
||||
updateFunc func(m *SeriesSetMaster) error
|
||||
updateSetFunc func(m *SeriesSetMaster, oldParentID *uint) error
|
||||
deleteCascadeFunc func(m *SeriesSetMaster) error
|
||||
recalculateSeriesStatsFunc func(seriesID uint) error
|
||||
recalculateSetStatsFunc func(setID uint) error
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAllSeries(search string) ([]SeriesSetMaster, error) {
|
||||
if m.findAllSeriesFunc != nil {
|
||||
return m.findAllSeriesFunc(search)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAllSets(search string, parentID uint) ([]SeriesSetMaster, error) {
|
||||
if m.findAllSetsFunc != nil {
|
||||
return m.findAllSetsFunc(search, parentID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByID(id uint) (*SeriesSetMaster, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindBySetCode(setCode string) (*SeriesSetMaster, error) {
|
||||
if m.findBySetCodeFunc != nil {
|
||||
return m.findBySetCodeFunc(setCode)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindCardImagesBySetID(setID uint) ([]string, error) {
|
||||
if m.findCardImagesBySetIDFunc != nil {
|
||||
return m.findCardImagesBySetIDFunc(setID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Create(item *SeriesSetMaster) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) CreateSet(item *SeriesSetMaster) error {
|
||||
if m.createSetFunc != nil {
|
||||
return m.createSetFunc(item)
|
||||
}
|
||||
return m.Create(item)
|
||||
}
|
||||
|
||||
func (m *mockRepository) Update(item *SeriesSetMaster) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) UpdateSet(item *SeriesSetMaster, oldParentID *uint) error {
|
||||
if m.updateSetFunc != nil {
|
||||
return m.updateSetFunc(item, oldParentID)
|
||||
}
|
||||
return m.Update(item)
|
||||
}
|
||||
|
||||
func (m *mockRepository) DeleteCascade(item *SeriesSetMaster) error {
|
||||
if m.deleteCascadeFunc != nil {
|
||||
return m.deleteCascadeFunc(item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) RecalculateSeriesStats(seriesID uint) error {
|
||||
if m.recalculateSeriesStatsFunc != nil {
|
||||
return m.recalculateSeriesStatsFunc(seriesID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) RecalculateSetStats(setID uint) error {
|
||||
if m.recalculateSetStatsFunc != nil {
|
||||
return m.recalculateSetStatsFunc(setID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
36
internal/modules/seriessetmaster/routes.go
Normal file
36
internal/modules/seriessetmaster/routes.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package seriessetmaster
|
||||
|
||||
import (
|
||||
"cardverse/internal/middleware"
|
||||
"cardverse/internal/modules/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router *gin.RouterGroup, db *gorm.DB) {
|
||||
repo := NewRepository(db)
|
||||
svc := NewService(repo)
|
||||
handler := NewHandler(svc)
|
||||
|
||||
series := router.Group("/series")
|
||||
{
|
||||
series.GET("", handler.GetAllSeries)
|
||||
series.POST("", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.CreateSeries)
|
||||
series.PUT("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.UpdateSeries)
|
||||
}
|
||||
|
||||
sets := router.Group("/sets")
|
||||
{
|
||||
sets.GET("", handler.GetAllSets)
|
||||
sets.GET("/code/:code", handler.GetSetByCode)
|
||||
sets.POST("", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.CreateSet)
|
||||
sets.PUT("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.UpdateSet)
|
||||
}
|
||||
|
||||
master := router.Group("/series-set-masters")
|
||||
{
|
||||
master.GET("/:id", handler.GetByID)
|
||||
master.DELETE("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Delete)
|
||||
}
|
||||
}
|
||||
206
internal/modules/seriessetmaster/service.go
Normal file
206
internal/modules/seriessetmaster/service.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package seriessetmaster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrSeriesSetNotFound = errors.New("data series atau set tidak ditemukan")
|
||||
|
||||
type Service interface {
|
||||
GetAllSeries(query ListSeriesQuery) ([]SeriesSetMaster, error)
|
||||
GetAllSets(query ListSetQuery) ([]SeriesSetMaster, error)
|
||||
GetByID(id uint) (*SeriesSetMaster, error)
|
||||
GetSetByCode(code string) (*SeriesSetMaster, error)
|
||||
CreateSeries(req CreateSeriesRequest) (*SeriesSetMaster, error)
|
||||
CreateSet(req CreateSetRequest) (*SeriesSetMaster, error)
|
||||
UpdateSeries(id uint, req UpdateSeriesRequest) (*SeriesSetMaster, error)
|
||||
UpdateSet(id uint, req UpdateSetRequest) (*SeriesSetMaster, error)
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) Service {
|
||||
return &service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *service) GetAllSeries(query ListSeriesQuery) ([]SeriesSetMaster, error) {
|
||||
return s.repo.FindAllSeries(query.Search)
|
||||
}
|
||||
|
||||
func (s *service) GetAllSets(query ListSetQuery) ([]SeriesSetMaster, error) {
|
||||
return s.repo.FindAllSets(query.Search, query.ParentID)
|
||||
}
|
||||
|
||||
func (s *service) GetByID(id uint) (*SeriesSetMaster, error) {
|
||||
m, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrSeriesSetNotFound
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *service) GetSetByCode(code string) (*SeriesSetMaster, error) {
|
||||
m, err := s.repo.FindBySetCode(code)
|
||||
if err != nil {
|
||||
return nil, ErrSeriesSetNotFound
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *service) CreateSeries(req CreateSeriesRequest) (*SeriesSetMaster, error) {
|
||||
m := &SeriesSetMaster{
|
||||
SeriesName: req.Name,
|
||||
Image: req.Image,
|
||||
ParentID: nil,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *service) CreateSet(req CreateSetRequest) (*SeriesSetMaster, error) {
|
||||
parent, err := s.repo.FindByID(req.ParentID)
|
||||
if err != nil || parent == nil || parent.ParentID != nil {
|
||||
return nil, errors.New("parent_id tidak ditemukan atau bukan merupakan series")
|
||||
}
|
||||
|
||||
m := &SeriesSetMaster{
|
||||
ParentID: &req.ParentID,
|
||||
SetCode: req.SetCode,
|
||||
SetName: req.SetName,
|
||||
SeriesName: parent.SeriesName,
|
||||
Image: req.Image,
|
||||
Logo: req.Logo,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateSet(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdateSeries(id uint, req UpdateSeriesRequest) (*SeriesSetMaster, error) {
|
||||
m, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrSeriesSetNotFound
|
||||
}
|
||||
|
||||
if m.ParentID != nil {
|
||||
return nil, errors.New("record ini adalah set, bukan series")
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
m.SeriesName = req.Name
|
||||
}
|
||||
if req.Image != "" {
|
||||
if m.Image != "" && m.Image != req.Image {
|
||||
utils.DeleteLocalFile(m.Image)
|
||||
}
|
||||
m.Image = req.Image
|
||||
}
|
||||
|
||||
if err := s.repo.Update(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdateSet(id uint, req UpdateSetRequest) (*SeriesSetMaster, error) {
|
||||
m, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrSeriesSetNotFound
|
||||
}
|
||||
|
||||
if m.ParentID == nil {
|
||||
return nil, errors.New("record ini adalah series, bukan set")
|
||||
}
|
||||
|
||||
var oldParentID *uint
|
||||
if req.ParentID != nil && (m.ParentID == nil || *req.ParentID != *m.ParentID) {
|
||||
oldParentID = m.ParentID
|
||||
parent, err := s.repo.FindByID(*req.ParentID)
|
||||
if err != nil || parent == nil || parent.ParentID != nil {
|
||||
return nil, errors.New("parent_id tidak ditemukan atau bukan merupakan series")
|
||||
}
|
||||
m.ParentID = req.ParentID
|
||||
m.SeriesName = parent.SeriesName
|
||||
}
|
||||
|
||||
if req.SetCode != "" {
|
||||
m.SetCode = req.SetCode
|
||||
}
|
||||
if req.SetName != "" {
|
||||
m.SetName = req.SetName
|
||||
}
|
||||
if req.Image != "" {
|
||||
if m.Image != "" && m.Image != req.Image {
|
||||
utils.DeleteLocalFile(m.Image)
|
||||
}
|
||||
m.Image = req.Image
|
||||
}
|
||||
if req.Logo != "" {
|
||||
if m.Logo != "" && m.Logo != req.Logo {
|
||||
utils.DeleteLocalFile(m.Logo)
|
||||
}
|
||||
m.Logo = req.Logo
|
||||
}
|
||||
if req.ReleaseDate != nil {
|
||||
m.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateSet(m, oldParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(id uint) error {
|
||||
m, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return ErrSeriesSetNotFound
|
||||
}
|
||||
|
||||
if m.ParentID == nil {
|
||||
childSets, _ := s.repo.FindAllSets("", m.ID)
|
||||
for _, set := range childSets {
|
||||
cardImages, _ := s.repo.FindCardImagesBySetID(set.ID)
|
||||
for _, imgPath := range cardImages {
|
||||
utils.DeleteLocalFile(imgPath)
|
||||
}
|
||||
if set.Image != "" {
|
||||
utils.DeleteLocalFile(set.Image)
|
||||
}
|
||||
if set.Logo != "" {
|
||||
utils.DeleteLocalFile(set.Logo)
|
||||
}
|
||||
}
|
||||
if m.Image != "" {
|
||||
utils.DeleteLocalFile(m.Image)
|
||||
}
|
||||
} else {
|
||||
cardImages, _ := s.repo.FindCardImagesBySetID(m.ID)
|
||||
for _, imgPath := range cardImages {
|
||||
utils.DeleteLocalFile(imgPath)
|
||||
}
|
||||
if m.Image != "" {
|
||||
utils.DeleteLocalFile(m.Image)
|
||||
}
|
||||
if m.Logo != "" {
|
||||
utils.DeleteLocalFile(m.Logo)
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.DeleteCascade(m)
|
||||
}
|
||||
226
internal/modules/seriessetmaster/service_test.go
Normal file
226
internal/modules/seriessetmaster/service_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package seriessetmaster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestService_GetAllSeries_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findAllSeriesFunc: func(search string) ([]SeriesSetMaster, error) {
|
||||
return []SeriesSetMaster{
|
||||
{ID: 1, SeriesName: "Evolusi Mega"},
|
||||
{ID: 2, SeriesName: "Scarlet & Violet"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
list, err := svc.GetAllSeries(ListSeriesQuery{Search: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 items, got: %d", len(list))
|
||||
}
|
||||
if list[0].SeriesName != "Evolusi Mega" {
|
||||
t.Errorf("expected 'Evolusi Mega', got: %s", list[0].SeriesName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetAllSets_Success(t *testing.T) {
|
||||
parentID := uint(1)
|
||||
repo := &mockRepository{
|
||||
findAllSetsFunc: func(search string, pID uint) ([]SeriesSetMaster, error) {
|
||||
return []SeriesSetMaster{
|
||||
{ID: 10, ParentID: &parentID, SetCode: "MA1", SetName: "Evolusi Mega"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
list, err := svc.GetAllSets(ListSetQuery{Search: "MA1", ParentID: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 item, got: %d", len(list))
|
||||
}
|
||||
if list[0].SetCode != "MA1" {
|
||||
t.Errorf("expected 'MA1', got: %s", list[0].SetCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return &SeriesSetMaster{ID: id, SeriesName: "Pedang & Perisai"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
result, err := svc.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if result.ID != 1 {
|
||||
t.Errorf("expected ID 1, got: %d", result.ID)
|
||||
}
|
||||
if result.SeriesName != "Pedang & Perisai" {
|
||||
t.Errorf("expected 'Pedang & Perisai', got: %s", result.SeriesName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetSetByCode_Success(t *testing.T) {
|
||||
parentID := uint(1)
|
||||
repo := &mockRepository{
|
||||
findBySetCodeFunc: func(code string) (*SeriesSetMaster, error) {
|
||||
return &SeriesSetMaster{ID: 10, ParentID: &parentID, SetCode: code, SetName: "Evolusi Mega M-P"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
result, err := svc.GetSetByCode("M-P")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if result.SetCode != "M-P" {
|
||||
t.Errorf("expected 'M-P', got: %s", result.SetCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.GetByID(999)
|
||||
if !errors.Is(err, ErrSeriesSetNotFound) {
|
||||
t.Fatalf("expected ErrSeriesSetNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateSeries_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return &SeriesSetMaster{ID: id, ParentID: nil, SeriesName: "Old Name"}, nil
|
||||
},
|
||||
updateFunc: func(m *SeriesSetMaster) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := UpdateSeriesRequest{
|
||||
Name: "New Series Name",
|
||||
Image: "/images/series/new.png",
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateSeries(1, req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.SeriesName != "New Series Name" {
|
||||
t.Errorf("expected 'New Series Name', got: %s", updated.SeriesName)
|
||||
}
|
||||
if updated.Image != "/images/series/new.png" {
|
||||
t.Errorf("expected Image '/images/series/new.png', got: %s", updated.Image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateSeries_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.UpdateSeries(999, UpdateSeriesRequest{Name: "Test"})
|
||||
if !errors.Is(err, ErrSeriesSetNotFound) {
|
||||
t.Fatalf("expected ErrSeriesSetNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateSeries_InvalidRecordType(t *testing.T) {
|
||||
parentID := uint(1)
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return &SeriesSetMaster{ID: id, ParentID: &parentID, SetCode: "MA1"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.UpdateSeries(1, UpdateSeriesRequest{Name: "Test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when trying to update a Set using UpdateSeries, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateSet_Success(t *testing.T) {
|
||||
parentID := uint(1)
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
if id == 1 {
|
||||
return &SeriesSetMaster{ID: 1, ParentID: nil, SeriesName: "Evolusi Mega"}, nil
|
||||
}
|
||||
return &SeriesSetMaster{ID: id, ParentID: &parentID, SetCode: "OLD", SetName: "Old Set", SeriesName: "Evolusi Mega"}, nil
|
||||
},
|
||||
updateFunc: func(m *SeriesSetMaster) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
now := time.Now()
|
||||
newParentID := uint(1)
|
||||
req := UpdateSetRequest{
|
||||
ParentID: &newParentID,
|
||||
SetCode: "NEW1",
|
||||
SetName: "New Set Name",
|
||||
ReleaseDate: &now,
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateSet(10, req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.SetCode != "NEW1" {
|
||||
t.Errorf("expected 'NEW1', got: %s", updated.SetCode)
|
||||
}
|
||||
if updated.SetName != "New Set Name" {
|
||||
t.Errorf("expected 'New Set Name', got: %s", updated.SetName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateSet_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.UpdateSet(999, UpdateSetRequest{SetName: "Test"})
|
||||
if !errors.Is(err, ErrSeriesSetNotFound) {
|
||||
t.Fatalf("expected ErrSeriesSetNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateSet_InvalidRecordType(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
|
||||
return &SeriesSetMaster{ID: id, ParentID: nil, SeriesName: "Evolusi Mega"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.UpdateSet(1, UpdateSetRequest{SetName: "Test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when trying to update a Series using UpdateSet, got nil")
|
||||
}
|
||||
}
|
||||
72
internal/modules/user/dto.go
Normal file
72
internal/modules/user/dto.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"cardverse/internal/pkg/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=150"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=user admin superadmin"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=2,max=100"`
|
||||
Email string `json:"email" binding:"omitempty,email,max=150"`
|
||||
AvatarURL string `json:"avatar_url" binding:"omitempty,max=500"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=user admin superadmin"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active suspended unverified"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=2,max=100"`
|
||||
AvatarURL string `json:"avatar_url" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
type ListUserQuery struct {
|
||||
Page int `form:"page,default=1" binding:"omitempty,min=1"`
|
||||
Limit int `form:"limit,default=20" binding:"omitempty,min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
Role string `form:"role" binding:"omitempty"`
|
||||
Status string `form:"status" binding:"omitempty"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
IsEmailVerified bool `json:"is_email_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *uint `json:"created_by,omitempty"`
|
||||
UpdatedBy *uint `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
func ToUserResponse(u User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
AvatarURL: utils.FormatMediaURLPtr(u.AvatarURL),
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
IsEmailVerified: u.IsEmailVerified,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
CreatedBy: u.CreatedBy,
|
||||
UpdatedBy: u.UpdatedBy,
|
||||
}
|
||||
}
|
||||
|
||||
func ToUserResponseList(users []User) []UserResponse {
|
||||
result := make([]UserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, ToUserResponse(u))
|
||||
}
|
||||
return result
|
||||
}
|
||||
315
internal/modules/user/handler.go
Normal file
315
internal/modules/user/handler.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
}
|
||||
|
||||
func NewHandler(service Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) GetProfile(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
|
||||
return
|
||||
}
|
||||
userID := userIDVal.(uint)
|
||||
|
||||
u, err := h.service.GetByID(userID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil profil user", ToUserResponse(*u))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
|
||||
return
|
||||
}
|
||||
userID := userIDVal.(uint)
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.UpdateProfile(userID, req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengupdate profil", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "profil berhasil diupdate", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) UploadAvatar(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
|
||||
return
|
||||
}
|
||||
userID := userIDVal.(uint)
|
||||
|
||||
fileHeader, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file avatar wajib diunggah (form field: avatar)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Batas ukuran file 5 MB
|
||||
if fileHeader.Size > 5*1024*1024 {
|
||||
response.Error(c, http.StatusBadRequest, "ukuran file maksimal 5MB", nil)
|
||||
return
|
||||
}
|
||||
|
||||
srcFile, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membaca file avatar", err.Error())
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Dekode gambar (support JPG, JPEG, PNG, GIF)
|
||||
img, _, err := image.Decode(srcFile)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "file yang diunggah bukan format gambar yang valid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Simpan ke direktori: ./public/images/avatars
|
||||
uploadDir := "./public/images/avatars"
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori penyimpanan avatar", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("avatar_%d_%d.png", userID, time.Now().UnixNano())
|
||||
filepathDst := filepath.Join(uploadDir, filename)
|
||||
|
||||
out, err := os.Create(filepathDst)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat file avatar", err.Error())
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Encode & kompresi ke format PNG
|
||||
encoder := png.Encoder{CompressionLevel: png.BestCompression}
|
||||
if err := encoder.Encode(out, img); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengompresi gambar ke format PNG", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
avatarURL := fmt.Sprintf("/images/avatars/%s", filename)
|
||||
updatedUser, err := h.service.UpdateAvatar(userID, avatarURL)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal memperbarui avatar user", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "avatar berhasil diunggah", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
creatorID := getContextUserID(c)
|
||||
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
newUser, err := h.service.Create(creatorID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
response.Error(c, http.StatusConflict, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrForbiddenRoleCreation) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal membuat user", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "user berhasil dibuat", ToUserResponse(*newUser))
|
||||
}
|
||||
|
||||
func (h *Handler) GetAll(c *gin.Context) {
|
||||
var query ListUserQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
users, total, err := h.service.GetAllPaginated(query)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mengambil data user", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pagination := response.NewPagination(query.Page, query.Limit, total)
|
||||
response.SuccessWithPagination(c, http.StatusOK, "berhasil mengambil data user", ToUserResponseList(users), pagination)
|
||||
}
|
||||
|
||||
func (h *Handler) GetByID(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "berhasil mengambil data user", ToUserResponse(*u))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
modifierID := getContextUserIDValue(c)
|
||||
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.Update(modifierID, id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
response.Error(c, http.StatusConflict, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrForbiddenRoleCreation) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "user berhasil diupdate", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Suspend(c *gin.Context) {
|
||||
modifierID := getContextUserIDValue(c)
|
||||
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.SuspendUser(modifierID, id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "user berhasil disuspensi", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Unsuspend(c *gin.Context) {
|
||||
modifierID := getContextUserIDValue(c)
|
||||
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.service.UnsuspendUser(modifierID, id)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "suspensi user berhasil dicabut", ToUserResponse(*updatedUser))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
|
||||
{Field: "id", Message: "id harus berupa angka"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(id); err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "user berhasil dihapus", nil)
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, error) {
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func getContextUserID(c *gin.Context) *uint {
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
if userID, ok := val.(uint); ok {
|
||||
return &userID
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getContextUserIDValue(c *gin.Context) uint {
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
if userID, ok := val.(uint); ok {
|
||||
return userID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
36
internal/modules/user/model.go
Normal file
36
internal/modules/user/model.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package user
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
|
||||
StatusActive = "active"
|
||||
StatusSuspended = "suspended"
|
||||
StatusUnverified = "unverified"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
Email string `json:"email" gorm:"type:varchar(150);uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"type:varchar(255)"`
|
||||
GoogleID *string `json:"google_id,omitempty" gorm:"type:varchar(255);uniqueIndex;default:null"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty" gorm:"type:varchar(500);default:null"`
|
||||
Role string `json:"role" gorm:"type:varchar(30);not null;default:'user';index"`
|
||||
Status string `json:"status" gorm:"type:varchar(30);not null;default:'active';index"`
|
||||
IsEmailVerified bool `json:"is_email_verified" gorm:"default:false"`
|
||||
VerificationToken string `json:"-" gorm:"type:varchar(255)"`
|
||||
ResetPasswordToken string `json:"-" gorm:"type:varchar(255)"`
|
||||
ResetPasswordExpiresAt *time.Time `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
CreatedBy *uint `json:"created_by" gorm:"default:null"`
|
||||
UpdatedBy *uint `json:"updated_by" gorm:"default:null"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
125
internal/modules/user/repository.go
Normal file
125
internal/modules/user/repository.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package user
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Repository interface {
|
||||
Create(u *User) error
|
||||
FindAll() ([]User, error)
|
||||
FindAllPaginated(page, limit int, query ListUserQuery) ([]User, int64, error)
|
||||
FindByID(id uint) (*User, error)
|
||||
FindByEmail(email string) (*User, error)
|
||||
FindByGoogleID(googleID string) (*User, error)
|
||||
FindByVerificationToken(token string) (*User, error)
|
||||
FindByResetToken(token string) (*User, error)
|
||||
Update(u *User) error
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
var userSelectColumns = []string{
|
||||
"id", "name", "email", "password", "role", "status", "avatar_url",
|
||||
"google_id", "is_email_verified", "verification_token",
|
||||
"reset_password_token", "reset_password_expires_at", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
func (r *repository) Create(u *User) error {
|
||||
return r.db.Create(u).Error
|
||||
}
|
||||
|
||||
func (r *repository) FindAll() ([]User, error) {
|
||||
var users []User
|
||||
err := r.db.Select(userSelectColumns).Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *repository) FindAllPaginated(page, limit int, queryParams ListUserQuery) ([]User, int64, error) {
|
||||
var users []User
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&User{}).Select(userSelectColumns)
|
||||
|
||||
if queryParams.Role != "" {
|
||||
query = query.Where("role = ?", queryParams.Role)
|
||||
}
|
||||
|
||||
if queryParams.Status != "" {
|
||||
query = query.Where("status = ?", queryParams.Status)
|
||||
}
|
||||
|
||||
if queryParams.Search != "" {
|
||||
pattern := "%" + queryParams.Search + "%"
|
||||
query = query.Where("name ILIKE ? OR email ILIKE ?", pattern, pattern)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
err := query.Order("id DESC").Limit(limit).Offset(offset).Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByID(id uint) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).First(&u, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByEmail(email string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("email = ?", email).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByGoogleID(googleID string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("google_id = ?", googleID).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByVerificationToken(token string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("verification_token = ?", token).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByResetToken(token string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.Select(userSelectColumns).Where("reset_password_token = ?", token).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *repository) Update(u *User) error {
|
||||
return r.db.Save(u).Error
|
||||
}
|
||||
|
||||
func (r *repository) Delete(id uint) error {
|
||||
return r.db.Delete(&User{}, id).Error
|
||||
}
|
||||
84
internal/modules/user/repository_mock_test.go
Normal file
84
internal/modules/user/repository_mock_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package user
|
||||
|
||||
type mockRepository struct {
|
||||
createFunc func(u *User) error
|
||||
findAllFunc func() ([]User, error)
|
||||
findAllPaginatedFunc func(page, limit int, query ListUserQuery) ([]User, int64, error)
|
||||
findByIDFunc func(id uint) (*User, error)
|
||||
findByEmailFunc func(email string) (*User, error)
|
||||
findByGoogleIDFunc func(googleID string) (*User, error)
|
||||
findByVerificationTokenFunc func(token string) (*User, error)
|
||||
findByResetTokenFunc func(token string) (*User, error)
|
||||
updateFunc func(u *User) error
|
||||
deleteFunc func(id uint) error
|
||||
}
|
||||
|
||||
func (m *mockRepository) Create(u *User) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAll() ([]User, error) {
|
||||
if m.findAllFunc != nil {
|
||||
return m.findAllFunc()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindAllPaginated(page, limit int, query ListUserQuery) ([]User, int64, error) {
|
||||
if m.findAllPaginatedFunc != nil {
|
||||
return m.findAllPaginatedFunc(page, limit, query)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByID(id uint) (*User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByEmail(email string) (*User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(email)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByGoogleID(googleID string) (*User, error) {
|
||||
if m.findByGoogleIDFunc != nil {
|
||||
return m.findByGoogleIDFunc(googleID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByVerificationToken(token string) (*User, error) {
|
||||
if m.findByVerificationTokenFunc != nil {
|
||||
return m.findByVerificationTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) FindByResetToken(token string) (*User, error) {
|
||||
if m.findByResetTokenFunc != nil {
|
||||
return m.findByResetTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Update(u *User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRepository) Delete(id uint) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
internal/modules/user/routes.go
Normal file
37
internal/modules/user/routes.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"cardverse/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router *gin.RouterGroup, db *gorm.DB) {
|
||||
repo := NewRepository(db)
|
||||
svc := NewService(repo)
|
||||
handler := NewHandler(svc)
|
||||
|
||||
// Route khusus user yang sedang login untuk kelola profil sendiri
|
||||
me := router.Group("/users/me")
|
||||
me.Use(middleware.AuthRequired())
|
||||
{
|
||||
me.GET("", handler.GetProfile)
|
||||
me.PUT("", handler.UpdateProfile)
|
||||
me.POST("/avatar", handler.UploadAvatar)
|
||||
}
|
||||
|
||||
// Route khusus Admin/Superadmin untuk kelola semua data user
|
||||
admin := router.Group("/users")
|
||||
admin.Use(middleware.AuthRequired())
|
||||
admin.Use(middleware.RequireRoles(RoleAdmin, RoleSuperAdmin))
|
||||
{
|
||||
admin.GET("", handler.GetAll)
|
||||
admin.POST("", handler.Create)
|
||||
admin.GET("/:id", handler.GetByID)
|
||||
admin.PUT("/:id", handler.Update)
|
||||
admin.PUT("/:id/suspend", handler.Suspend)
|
||||
admin.PUT("/:id/unsuspend", handler.Unsuspend)
|
||||
admin.DELETE("/:id", handler.Delete)
|
||||
}
|
||||
}
|
||||
233
internal/modules/user/service.go
Normal file
233
internal/modules/user/service.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrEmailTaken = errors.New("email sudah terdaftar")
|
||||
var ErrUserNotFound = errors.New("user tidak ditemukan")
|
||||
var ErrUserSuspended = errors.New("akun anda sedang disuspensi")
|
||||
var ErrForbiddenRoleCreation = errors.New("hanya superadmin yang dapat membuat atau mengubah akun dengan role admin atau superadmin")
|
||||
|
||||
type Service interface {
|
||||
Create(creatorID *uint, req CreateUserRequest) (*User, error)
|
||||
GetAll() ([]User, error)
|
||||
GetAllPaginated(query ListUserQuery) ([]User, int64, error)
|
||||
GetByID(id uint) (*User, error)
|
||||
Update(modifierID uint, id uint, req UpdateUserRequest) (*User, error)
|
||||
UpdateProfile(userID uint, req UpdateProfileRequest) (*User, error)
|
||||
UpdateAvatar(userID uint, avatarURL string) (*User, error)
|
||||
SuspendUser(modifierID uint, id uint) (*User, error)
|
||||
UnsuspendUser(modifierID uint, id uint) (*User, error)
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) Service {
|
||||
return &service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *service) Create(creatorID *uint, req CreateUserRequest) (*User, error) {
|
||||
existing, _ := s.repo.FindByEmail(req.Email)
|
||||
if existing != nil {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = RoleUser
|
||||
}
|
||||
|
||||
// Batasan role: Hanya Superadmin yang boleh membuat akun dengan role admin atau superadmin
|
||||
if role == RoleAdmin || role == RoleSuperAdmin {
|
||||
if creatorID == nil {
|
||||
return nil, ErrForbiddenRoleCreation
|
||||
}
|
||||
creator, err := s.repo.FindByID(*creatorID)
|
||||
if err != nil || creator.Role != RoleSuperAdmin {
|
||||
return nil, ErrForbiddenRoleCreation
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newUser := &User{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Role: role,
|
||||
Status: StatusActive,
|
||||
IsEmailVerified: true,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(newUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
func (s *service) GetAll() ([]User, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
|
||||
func (s *service) GetAllPaginated(query ListUserQuery) ([]User, int64, error) {
|
||||
page := query.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
return s.repo.FindAllPaginated(page, limit, query)
|
||||
}
|
||||
|
||||
func (s *service) GetByID(id uint) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) Update(modifierID uint, id uint, req UpdateUserRequest) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
u.Name = req.Name
|
||||
}
|
||||
if req.Email != "" && req.Email != u.Email {
|
||||
existing, _ := s.repo.FindByEmail(req.Email)
|
||||
if existing != nil {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
u.Email = req.Email
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != req.AvatarURL {
|
||||
utils.DeleteLocalFile(*u.AvatarURL)
|
||||
}
|
||||
u.AvatarURL = &req.AvatarURL
|
||||
}
|
||||
if req.Role != "" && req.Role != u.Role {
|
||||
// Batasan role: Hanya Superadmin yang boleh mengubah role ke admin atau superadmin
|
||||
if req.Role == RoleAdmin || req.Role == RoleSuperAdmin {
|
||||
creator, err := s.repo.FindByID(modifierID)
|
||||
if err != nil || creator.Role != RoleSuperAdmin {
|
||||
return nil, ErrForbiddenRoleCreation
|
||||
}
|
||||
}
|
||||
u.Role = req.Role
|
||||
}
|
||||
if req.Status != "" {
|
||||
u.Status = req.Status
|
||||
}
|
||||
|
||||
u.UpdatedBy = &modifierID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdateProfile(userID uint, req UpdateProfileRequest) (*User, error) {
|
||||
u, err := s.repo.FindByID(userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
u.Name = req.Name
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != req.AvatarURL {
|
||||
utils.DeleteLocalFile(*u.AvatarURL)
|
||||
}
|
||||
u.AvatarURL = &req.AvatarURL
|
||||
}
|
||||
|
||||
u.UpdatedBy = &userID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdateAvatar(userID uint, avatarURL string) (*User, error) {
|
||||
u, err := s.repo.FindByID(userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// Hapus file avatar lama jika ada
|
||||
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != avatarURL {
|
||||
utils.DeleteLocalFile(*u.AvatarURL)
|
||||
}
|
||||
|
||||
u.AvatarURL = &avatarURL
|
||||
u.UpdatedBy = &userID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) SuspendUser(modifierID uint, id uint) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
u.Status = StatusSuspended
|
||||
u.UpdatedBy = &modifierID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) UnsuspendUser(modifierID uint, id uint) (*User, error) {
|
||||
u, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
u.Status = StatusActive
|
||||
u.UpdatedBy = &modifierID
|
||||
|
||||
if err := s.repo.Update(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(id uint) error {
|
||||
_, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
264
internal/modules/user/service_test.go
Normal file
264
internal/modules/user/service_test.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
func TestService_Create_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
createFunc: func(u *User) error {
|
||||
u.ID = 1
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := CreateUserRequest{Name: "Budi", Email: "budi@mail.com", Password: "rahasia123"}
|
||||
newUser, err := svc.Create(nil, req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if newUser.Email != req.Email {
|
||||
t.Errorf("expected email %s, got %s", req.Email, newUser.Email)
|
||||
}
|
||||
if newUser.Password == req.Password {
|
||||
t.Error("stored password should be hashed, not plain text")
|
||||
}
|
||||
if !utils.CheckPassword(newUser.Password, req.Password) {
|
||||
t.Error("hashed password does not match original password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_AdminRole_ForbiddenForRegularAdmin(t *testing.T) {
|
||||
adminID := uint(5)
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Role: RoleAdmin}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := CreateUserRequest{Name: "New Admin", Email: "admin2@mail.com", Password: "rahasia123", Role: RoleAdmin}
|
||||
_, err := svc.Create(&adminID, req)
|
||||
|
||||
if !errors.Is(err, ErrForbiddenRoleCreation) {
|
||||
t.Fatalf("expected ErrForbiddenRoleCreation for regular admin creating another admin, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_AdminRole_AllowedForSuperAdmin(t *testing.T) {
|
||||
superAdminID := uint(1)
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Role: RoleSuperAdmin}, nil
|
||||
},
|
||||
createFunc: func(u *User) error {
|
||||
u.ID = 10
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
req := CreateUserRequest{Name: "New Admin", Email: "admin2@mail.com", Password: "rahasia123", Role: RoleAdmin}
|
||||
newAdmin, err := svc.Create(&superAdminID, req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for superadmin creating an admin, got: %v", err)
|
||||
}
|
||||
if newAdmin.Role != RoleAdmin {
|
||||
t.Errorf("expected role %s, got %s", RoleAdmin, newAdmin.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_EmailAlreadyTaken(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByEmailFunc: func(email string) (*User, error) {
|
||||
return &User{ID: 99, Email: email}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.Create(nil, CreateUserRequest{Name: "Budi", Email: "budi@mail.com", Password: "rahasia123"})
|
||||
|
||||
if !errors.Is(err, ErrEmailTaken) {
|
||||
t.Fatalf("expected ErrEmailTaken, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Name: "Budi"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
u, err := svc.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if u.ID != 1 {
|
||||
t.Errorf("expected ID 1, got: %d", u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetByID_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.GetByID(999)
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Name: "Old Name"}, nil
|
||||
},
|
||||
updateFunc: func(u *User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
updated, err := svc.Update(1, 1, UpdateUserRequest{Name: "New Name"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.Name != "New Name" {
|
||||
t.Errorf("expected Name 'New Name', got: '%s'", updated.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateAvatar_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Name: "User Test"}, nil
|
||||
},
|
||||
updateFunc: func(u *User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
updated, err := svc.UpdateAvatar(1, "/images/avatars/avatar_1_123.png")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if updated.AvatarURL == nil || *updated.AvatarURL != "/images/avatars/avatar_1_123.png" {
|
||||
t.Errorf("expected AvatarURL '/images/avatars/avatar_1_123.png', got: '%v'", updated.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Update_NotFound(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.Update(1, 1, UpdateUserRequest{Name: "Anything"})
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SuspendAndUnsuspend_Success(t *testing.T) {
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id, Status: StatusActive}, nil
|
||||
},
|
||||
updateFunc: func(u *User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
suspended, err := svc.SuspendUser(1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if suspended.Status != StatusSuspended {
|
||||
t.Errorf("expected status 'suspended', got: '%s'", suspended.Status)
|
||||
}
|
||||
|
||||
unsuspended, err := svc.UnsuspendUser(1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if unsuspended.Status != StatusActive {
|
||||
t.Errorf("expected status 'active', got: '%s'", unsuspended.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Delete_Success(t *testing.T) {
|
||||
deleteCalled := false
|
||||
repo := &mockRepository{
|
||||
findByIDFunc: func(id uint) (*User, error) {
|
||||
return &User{ID: id}, nil
|
||||
},
|
||||
deleteFunc: func(id uint) error {
|
||||
deleteCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
if err := svc.Delete(1); err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if !deleteCalled {
|
||||
t.Error("repository.Delete should have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetAllPaginated_DefaultNormalization(t *testing.T) {
|
||||
var capturedPage, capturedLimit int
|
||||
var capturedSearch string
|
||||
|
||||
repo := &mockRepository{
|
||||
findAllPaginatedFunc: func(page, limit int, query ListUserQuery) ([]User, int64, error) {
|
||||
capturedPage, capturedLimit, capturedSearch = page, limit, query.Search
|
||||
return []User{{ID: 1}}, 1, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, total, err := svc.GetAllPaginated(ListUserQuery{Page: 0, Limit: 0, Search: "budi"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected total 1, got: %d", total)
|
||||
}
|
||||
if capturedPage != 1 {
|
||||
t.Errorf("expected page normalized to 1, got: %d", capturedPage)
|
||||
}
|
||||
if capturedLimit != 20 {
|
||||
t.Errorf("expected limit normalized to 20, got: %d", capturedLimit)
|
||||
}
|
||||
if capturedSearch != "budi" {
|
||||
t.Errorf("expected search 'budi', got: '%s'", capturedSearch)
|
||||
}
|
||||
}
|
||||
105
internal/pkg/logger/logger.go
Normal file
105
internal/pkg/logger/logger.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fields adalah data tambahan yang disisipkan ke satu baris log, contoh:
|
||||
//
|
||||
// logger.Info("request selesai", logger.Fields{"path": "/users", "status": 200})
|
||||
type Fields map[string]interface{}
|
||||
|
||||
var std = log.New(os.Stdout, "", 0)
|
||||
|
||||
// SetOutput mengganti tujuan tulis log. Berguna untuk unit test (redirect ke buffer)
|
||||
// atau kalau nanti mau kirim log ke file/agent lain.
|
||||
func SetOutput(w io.Writer) {
|
||||
std = log.New(w, "", 0)
|
||||
}
|
||||
|
||||
var sensitiveKeys = map[string]bool{
|
||||
"password": true,
|
||||
"password_confirmation": true,
|
||||
"old_password": true,
|
||||
"new_password": true,
|
||||
"confirm_password": true,
|
||||
"token": true,
|
||||
"access_token": true,
|
||||
"refresh_token": true,
|
||||
"authorization": true,
|
||||
"secret": true,
|
||||
"jwt_secret": true,
|
||||
"credit_card": true,
|
||||
"card_number": true,
|
||||
"cvv": true,
|
||||
"otp": true,
|
||||
}
|
||||
|
||||
const redactedValue = "***REDACTED***"
|
||||
|
||||
// sanitize menyensor field sensitif secara rekursif (termasuk di dalam nested object/body JSON)
|
||||
// supaya data seperti password tidak pernah ikut tercetak di log, walau caller lupa menyaringnya.
|
||||
func sanitize(fields Fields) Fields {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clean := make(Fields, len(fields))
|
||||
for key, value := range fields {
|
||||
if sensitiveKeys[strings.ToLower(key)] {
|
||||
clean[key] = redactedValue
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
clean[key] = sanitize(Fields(v))
|
||||
case Fields:
|
||||
clean[key] = sanitize(v)
|
||||
default:
|
||||
clean[key] = value
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields Fields `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func write(level, msg string, fields Fields) {
|
||||
e := entry{
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
Level: level,
|
||||
Message: msg,
|
||||
Fields: sanitize(fields),
|
||||
}
|
||||
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
// fallback kalau ternyata ada value yang gagal di-marshal, tetap harus ada jejak log-nya
|
||||
std.Printf("[%s] %s (gagal marshal fields: %v)", level, msg, err)
|
||||
return
|
||||
}
|
||||
|
||||
std.Println(string(b))
|
||||
}
|
||||
|
||||
// Info dipakai untuk mencatat aktivitas normal, contoh: request masuk dan sukses diproses.
|
||||
func Info(msg string, fields Fields) { write("INFO", msg, fields) }
|
||||
|
||||
// Warn dipakai untuk kondisi yang bukan bug tapi perlu diperhatikan, contoh: request 4xx
|
||||
// (input salah, tidak diotorisasi, dst).
|
||||
func Warn(msg string, fields Fields) { write("WARN", msg, fields) }
|
||||
|
||||
// Error dipakai untuk kegagalan sisi server (5xx) atau error tak terduga lainnya yang
|
||||
// butuh investigasi lebih lanjut oleh developer.
|
||||
func Error(msg string, fields Fields) { write("ERROR", msg, fields) }
|
||||
131
internal/pkg/response/response.go
Normal file
131
internal/pkg/response/response.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"cardverse/internal/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response adalah struktur baku untuk seluruh response API
|
||||
type Response struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Pagination berisi metadata pagination yang dikirim di field "meta"
|
||||
type Pagination struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalData int64 `json:"total_data"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
HasNext bool `json:"has_next"`
|
||||
HasPrev bool `json:"has_prev"`
|
||||
}
|
||||
|
||||
// NewPagination menghitung metadata pagination dari page, limit, dan total data.
|
||||
// Dipakai di service/handler setelah query COUNT(*) ke database.
|
||||
func NewPagination(page, limit int, totalData int64) Pagination {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(totalData) / float64(limit)))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return Pagination{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
TotalData: totalData,
|
||||
TotalPages: totalPages,
|
||||
HasNext: page < totalPages,
|
||||
HasPrev: page > 1,
|
||||
}
|
||||
}
|
||||
|
||||
func Success(c *gin.Context, statusCode int, message string, data interface{}) {
|
||||
c.JSON(statusCode, Response{
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func SuccessWithPagination(c *gin.Context, statusCode int, message string, data interface{}, pagination Pagination) {
|
||||
c.JSON(statusCode, Response{
|
||||
Message: message,
|
||||
Data: data,
|
||||
Meta: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
var defaultErrorMessages = map[int]string{
|
||||
http.StatusUnauthorized: "Anda tidak memiliki akses, silakan login kembali",
|
||||
http.StatusForbidden: "Anda tidak memiliki izin untuk melakukan aksi ini",
|
||||
http.StatusNotFound: "Data yang diminta tidak ditemukan",
|
||||
http.StatusConflict: "Data sudah ada atau bertentangan dengan data yang tersimpan",
|
||||
http.StatusUnprocessableEntity: "Data yang dikirim tidak dapat diproses",
|
||||
http.StatusTooManyRequests: "Terlalu banyak request, silakan coba lagi nanti",
|
||||
http.StatusInternalServerError: "Terjadi kesalahan pada server, silakan coba lagi nanti",
|
||||
http.StatusBadGateway: "Layanan sedang tidak tersedia, silakan coba lagi nanti",
|
||||
http.StatusServiceUnavailable: "Layanan sedang tidak tersedia, silakan coba lagi nanti",
|
||||
}
|
||||
|
||||
const fallbackErrorMessage = "Terjadi kesalahan, silakan coba lagi nanti"
|
||||
|
||||
// Error mengirim response gagal ke client.
|
||||
//
|
||||
// - Untuk 400 Bad Request: message & err (data) dikirim apa adanya ke client — biasanya
|
||||
// cuma detail validasi input, aman untuk membantu client memperbaiki request-nya.
|
||||
// - Untuk status lain (401, 403, 404, 409, 500, dst): client HANYA menerima pesan generik
|
||||
// dari defaultErrorMessages, TIDAK menerima err/message asli sama sekali. Detail asli
|
||||
// (message & err yang dikirim caller) dicatat lewat logger supaya tetap bisa di-debug
|
||||
// dari log server tanpa membocorkannya ke response API.
|
||||
func Error(c *gin.Context, statusCode int, message string, err interface{}) {
|
||||
if statusCode == http.StatusBadRequest {
|
||||
c.JSON(statusCode, Response{
|
||||
Message: message,
|
||||
Error: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logFields := logger.Fields{
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"status": statusCode,
|
||||
"ip": c.ClientIP(),
|
||||
"detail": message,
|
||||
}
|
||||
if err != nil {
|
||||
logFields["error"] = err
|
||||
}
|
||||
|
||||
if statusCode >= http.StatusInternalServerError {
|
||||
logger.Error("request gagal diproses", logFields)
|
||||
} else {
|
||||
logger.Warn("request ditolak", logFields)
|
||||
}
|
||||
|
||||
clientMessage := message
|
||||
|
||||
if clientMessage == "" {
|
||||
if msg, ok := defaultErrorMessages[statusCode]; ok {
|
||||
clientMessage = msg
|
||||
} else {
|
||||
clientMessage = fallbackErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(statusCode, Response{
|
||||
Message: clientMessage,
|
||||
})
|
||||
}
|
||||
28
internal/pkg/utils/file.go
Normal file
28
internal/pkg/utils/file.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeleteLocalFile menghapus file lokal jika file tersebut berada di folder ./public
|
||||
func DeleteLocalFile(relPath string) {
|
||||
if relPath == "" || strings.HasPrefix(relPath, "http://") || strings.HasPrefix(relPath, "https://") {
|
||||
return
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(relPath)
|
||||
var localPath string
|
||||
if strings.HasPrefix(cleaned, "/images/") {
|
||||
localPath = "./public" + cleaned
|
||||
} else if strings.HasPrefix(cleaned, "/public/") {
|
||||
localPath = "." + cleaned
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
_ = os.Remove(localPath)
|
||||
}
|
||||
}
|
||||
95
internal/pkg/utils/jwt.go
Normal file
95
internal/pkg/utils/jwt.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateAccessToken(userID uint, email string, role string) (string, error) {
|
||||
cfg := config.Cfg
|
||||
expiration := time.Now().Add(time.Duration(cfg.JWTExpiresHours) * time.Hour)
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiration),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(userID uint) (string, error) {
|
||||
cfg := config.Cfg
|
||||
expiration := time.Now().Add(time.Duration(cfg.JWTRefreshExpiresHours) * time.Hour)
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiration),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTRefreshSecret))
|
||||
}
|
||||
|
||||
func GenerateToken(userID uint, email string, role string) (string, error) {
|
||||
return GenerateAccessToken(userID, email, role)
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Cfg
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("metode signing token tidak valid")
|
||||
}
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("token tidak valid")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func ValidateRefreshToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Cfg
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("metode signing token tidak valid")
|
||||
}
|
||||
return []byte(cfg.JWTRefreshSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("refresh token tidak valid")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
67
internal/pkg/utils/jwt_test.go
Normal file
67
internal/pkg/utils/jwt_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
config.Cfg = &config.Config{
|
||||
JWTSecret: "test-secret-key",
|
||||
JWTExpiresHours: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken_AndValidateToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
token, err := GenerateToken(42, "user@example.com", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken expected no error, got: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("token should not be empty")
|
||||
}
|
||||
|
||||
claims, err := ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken expected no error for valid token, got: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != 42 {
|
||||
t.Errorf("UserID expected 42, got %d", claims.UserID)
|
||||
}
|
||||
if claims.Email != "user@example.com" {
|
||||
t.Errorf("Email expected user@example.com, got %s", claims.Email)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role expected admin, got %s", claims.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_InvalidToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
_, err := ValidateToken("invalid.random.token")
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken should fail for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_ExpiredToken(t *testing.T) {
|
||||
config.Cfg = &config.Config{
|
||||
JWTSecret: "test-secret-key",
|
||||
JWTExpiresHours: 0,
|
||||
}
|
||||
|
||||
token, _ := GenerateToken(1, "expired@example.com", "user")
|
||||
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
_, err := ValidateToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken should fail for expired token")
|
||||
}
|
||||
}
|
||||
15
internal/pkg/utils/password.go
Normal file
15
internal/pkg/utils/password.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword meng-hash plain password menggunakan bcrypt
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword membandingkan plain password dengan hash yang tersimpan
|
||||
func CheckPassword(hashedPassword, plainPassword string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
||||
return err == nil
|
||||
}
|
||||
34
internal/pkg/utils/password_test.go
Normal file
34
internal/pkg/utils/password_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashPassword_AndCheckPassword(t *testing.T) {
|
||||
plain := "rahasia123"
|
||||
|
||||
hashed, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword tidak boleh error, dapat: %v", err)
|
||||
}
|
||||
|
||||
if hashed == plain {
|
||||
t.Fatal("hasil hash tidak boleh sama dengan plain password")
|
||||
}
|
||||
|
||||
if !CheckPassword(hashed, plain) {
|
||||
t.Fatal("CheckPassword harus true untuk password yang benar")
|
||||
}
|
||||
|
||||
if CheckPassword(hashed, "password_salah") {
|
||||
t.Fatal("CheckPassword harus false untuk password yang salah")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword_HasilBerbedaSetiapKaliDipanggil(t *testing.T) {
|
||||
// bcrypt menyisipkan salt acak, jadi 2x hash dari password sama harus menghasilkan string berbeda
|
||||
hash1, _ := HashPassword("sama-sama")
|
||||
hash2, _ := HashPassword("sama-sama")
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("dua hash dari password yang sama seharusnya berbeda karena salt")
|
||||
}
|
||||
}
|
||||
31
internal/pkg/utils/string.go
Normal file
31
internal/pkg/utils/string.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var nonAlphanumericRegexp = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// UnderscoreString mengubah string judul/nama menjadi format_ular (misal: "Evolusi Mega" -> "evolusi_mega")
|
||||
func UnderscoreString(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "&", "and")
|
||||
s = nonAlphanumericRegexp.ReplaceAllString(s, "_")
|
||||
s = strings.Trim(s, "_")
|
||||
if s == "" {
|
||||
s = "underscore_string"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "&", "and")
|
||||
s = nonAlphanumericRegexp.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
s = "slugify"
|
||||
}
|
||||
return s
|
||||
}
|
||||
77
internal/pkg/utils/url.go
Normal file
77
internal/pkg/utils/url.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"cardverse/config"
|
||||
)
|
||||
|
||||
// getBaseURL mengambil Base URL dari config (APP_BASE_URL) atau environment variable dengan fallback ke http://localhost:PORT
|
||||
func getBaseURL() string {
|
||||
if config.Cfg != nil && config.Cfg.AppBaseURL != "" {
|
||||
return strings.TrimRight(config.Cfg.AppBaseURL, "/")
|
||||
}
|
||||
|
||||
if envURL := os.Getenv("APP_BASE_URL"); envURL != "" {
|
||||
return strings.TrimRight(envURL, "/")
|
||||
}
|
||||
|
||||
port := os.Getenv("APP_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
return "http://localhost:" + port
|
||||
}
|
||||
|
||||
// FormatMediaURL mengubah path relatif gambar (seperti /images/...) menjadi URL lengkap menggunakan APP_BASE_URL
|
||||
func FormatMediaURL(urlStr string) string {
|
||||
if urlStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Jika sudah berupa URL lengkap (http:// atau https://), kembalikan langsung
|
||||
if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") {
|
||||
return urlStr
|
||||
}
|
||||
|
||||
baseURL := getBaseURL()
|
||||
|
||||
if !strings.HasPrefix(urlStr, "/") {
|
||||
urlStr = "/" + urlStr
|
||||
}
|
||||
|
||||
return baseURL + urlStr
|
||||
}
|
||||
|
||||
// FormatMediaURLPtr mengubah pointer path relatif gambar menjadi pointer URL lengkap
|
||||
func FormatMediaURLPtr(urlPtr *string) *string {
|
||||
if urlPtr == nil || *urlPtr == "" {
|
||||
return urlPtr
|
||||
}
|
||||
formatted := FormatMediaURL(*urlPtr)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
// FormatJSONBMediaURLs mengubah seluruh path relatif gambar (/images/... atau /public/...) di dalam data byte JSONB menjadi URL lengkap
|
||||
func FormatJSONBMediaURLs(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
baseURL := getBaseURL()
|
||||
s := string(data)
|
||||
|
||||
// Replace "/images/" dengan "APP_BASE_URL/images/"
|
||||
targetImages := `"/images/`
|
||||
replaceImages := `"` + baseURL + `/images/`
|
||||
s = strings.ReplaceAll(s, targetImages, replaceImages)
|
||||
|
||||
// Replace "/public/" dengan "APP_BASE_URL/public/"
|
||||
targetPublic := `"/public/`
|
||||
replacePublic := `"` + baseURL + `/public/`
|
||||
s = strings.ReplaceAll(s, targetPublic, replacePublic)
|
||||
|
||||
return []byte(s)
|
||||
}
|
||||
96
internal/pkg/validator/validator.go
Normal file
96
internal/pkg/validator/validator.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
validatorlib "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// FieldError merepresentasikan 1 field DTO yang gagal validasi.
|
||||
// Field mengikuti nama di tag `json` DTO (bukan nama struct Go), supaya konsisten
|
||||
// dengan nama yang dikirim client di body request.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// RegisterJSONTagNameFunc membuat validator Gin melaporkan error pakai nama tag `json`
|
||||
// dari DTO (misal "email", "password"), bukan nama struct Go (misal "Email", "Password").
|
||||
// WAJIB dipanggil sekali saat startup aplikasi (lihat internal/router/router.go),
|
||||
// sebelum ada request yang divalidasi.
|
||||
func RegisterJSONTagNameFunc() {
|
||||
v, ok := binding.Validator.Engine().(*validatorlib.Validate)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
v.RegisterTagNameFunc(func(field reflect.StructField) string {
|
||||
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" || name == "" {
|
||||
return field.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func TranslateError(err error) []FieldError {
|
||||
var validationErrs validatorlib.ValidationErrors
|
||||
if !errors.As(err, &validationErrs) {
|
||||
return []FieldError{{
|
||||
Field: "body",
|
||||
Message: "format request tidak valid, pastikan body berupa JSON yang benar",
|
||||
}}
|
||||
}
|
||||
|
||||
fieldErrors := make([]FieldError, 0, len(validationErrs))
|
||||
for _, fe := range validationErrs {
|
||||
fieldErrors = append(fieldErrors, FieldError{
|
||||
Field: fe.Field(),
|
||||
Message: buildMessage(fe),
|
||||
})
|
||||
}
|
||||
return fieldErrors
|
||||
}
|
||||
|
||||
// buildMessage menerjemahkan tag validasi (required, email, min, dst) jadi kalimat
|
||||
// Bahasa Indonesia yang jelas. Tambahkan case baru di sini kalau ada tag validasi baru
|
||||
// yang dipakai di DTO tapi belum ada terjemahannya.
|
||||
func buildMessage(fe validatorlib.FieldError) string {
|
||||
field := fe.Field()
|
||||
|
||||
switch fe.Tag() {
|
||||
case "required":
|
||||
return fmt.Sprintf("%s wajib diisi", field)
|
||||
case "email":
|
||||
return fmt.Sprintf("%s harus berupa alamat email yang valid", field)
|
||||
case "min":
|
||||
if fe.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s minimal %s karakter", field, fe.Param())
|
||||
}
|
||||
return fmt.Sprintf("%s minimal bernilai %s", field, fe.Param())
|
||||
case "max":
|
||||
if fe.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s maksimal %s karakter", field, fe.Param())
|
||||
}
|
||||
return fmt.Sprintf("%s maksimal bernilai %s", field, fe.Param())
|
||||
case "gte":
|
||||
return fmt.Sprintf("%s minimal bernilai %s", field, fe.Param())
|
||||
case "lte":
|
||||
return fmt.Sprintf("%s maksimal bernilai %s", field, fe.Param())
|
||||
case "oneof":
|
||||
return fmt.Sprintf("%s harus salah satu dari: %s", field, fe.Param())
|
||||
case "len":
|
||||
return fmt.Sprintf("%s harus tepat %s karakter", field, fe.Param())
|
||||
case "numeric":
|
||||
return fmt.Sprintf("%s harus berupa angka", field)
|
||||
case "alphanum":
|
||||
return fmt.Sprintf("%s hanya boleh berisi huruf dan angka", field)
|
||||
default:
|
||||
return fmt.Sprintf("%s tidak valid", field)
|
||||
}
|
||||
}
|
||||
47
internal/router/router.go
Normal file
47
internal/router/router.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cardverse/config"
|
||||
"cardverse/internal/middleware"
|
||||
"cardverse/internal/modules/auth"
|
||||
"cardverse/internal/modules/cardmaster"
|
||||
"cardverse/internal/modules/seriessetmaster"
|
||||
"cardverse/internal/modules/user"
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||
validator.RegisterJSONTagNameFunc()
|
||||
|
||||
r := gin.New()
|
||||
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
r.Use(middleware.RateLimiter(cfg.RateLimitRequestsPerSecond, cfg.RateLimitBurst))
|
||||
|
||||
// Serve static asset files (images, avatars, cards, etc)
|
||||
r.Static("/images", "./public/images")
|
||||
r.Static("/public", "./public")
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
response.Success(c, http.StatusOK, "server sehat", gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
auth.RegisterRoutes(v1, db)
|
||||
user.RegisterRoutes(v1, db)
|
||||
seriessetmaster.RegisterRoutes(v1, db)
|
||||
cardmaster.RegisterRoutes(v1, db)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user