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

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

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

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

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

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

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

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