first commit

This commit is contained in:
2026-07-30 09:51:25 +07:00
commit e3aca176b7
82 changed files with 7847 additions and 0 deletions

View 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
}

View 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())
}

View 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 ""
}

View 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
}
}

View 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
}

View 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)
}
}

View 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)
}

View 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)
}
}