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

BIN
.DS_Store vendored Normal file
View File

Binary file not shown.

39
.air.toml Normal file
View File

@@ -0,0 +1,39 @@
root = "."
tmp_dir = "tmp"
[build]
# command yang dijalankan Air untuk build ulang binary setiap ada perubahan
cmd = "go build -o ./tmp/main ./cmd/api"
bin = "./tmp/main"
# kalau mau hot-reload untuk command lain (misal cmd/seed), ganti cmd & bin di atas sementara,
# atau bikin config terpisah misal .air.seed.toml
# extension file yang dipantau perubahannya
include_ext = ["go", "html", "tmpl", "env"]
# folder yang di-skip supaya Air tidak ikut mantau file hasil build / dependency
exclude_dir = ["tmp", "vendor", ".git", "internal/database/migrations"]
# skip file test supaya build ulang tidak ke-trigger cuma karena edit *_test.go
exclude_regex = ["_test\\.go"]
# delay sebelum rebuild, supaya kalau nyimpen banyak file sekaligus nggak rebuild berkali-kali
delay = 1000
# jangan exit kalau ada error, cukup tampilkan errornya dan tunggu perubahan berikutnya
stop_on_error = false
send_interrupt = true
kill_delay = "2s"
[log]
time = true
[color]
main = "cyan"
watcher = "yellow"
build = "green"
runner = "magenta"
[misc]
clean_on_exit = true

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# App
APP_ENV=development
APP_PORT=8080
APP_BASE_URL=http://localhost:8080
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=cardverse
DB_SSLMODE=disable
# JWT (Access Token & Refresh Token)
JWT_SECRET="FIU@&^SIAU@&sg187AS128&#YUD927"
JWT_EXPIRES_HOURS=1
JWT_REFRESH_SECRET="REFRESH_FIU@&^SIAU@&sg187AS128&#YUD927"
JWT_REFRESH_EXPIRES_HOURS=168
# Rate Limit (global, per-IP)
RATE_LIMIT_RPS=5
RATE_LIMIT_BURST=10
# Google OAuth
GOOGLE_CLIENT_ID="123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com"

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
.env
bin/
tmp/
*.log
.idea/
.vscode/
# isi upload runtime jangan ikut ke-commit, tapi foldernya tetap ada lewat .gitkeep
public/uploads/*
!public/uploads/.gitkeep
public/images/evolusi-mega/*
public/images/matahari-bulan/*
public/images/pedang-perisai/*
public/images/scarlet-violet/*

478
CONVENTION.md Normal file
View File

@@ -0,0 +1,478 @@
# CONVENTION.md — Cardverse API
Dokumen ini adalah panduan resmi penulisan kode di project ini. Tujuannya supaya
semua kontributor (termasuk diri sendiri 6 bulan dari sekarang) nulis kode dengan
gaya yang konsisten, gampang dibaca, dan gampang di-maintain.
**Prinsip utama:** kode dibaca jauh lebih sering daripada ditulis. Optimasi untuk
yang baca, bukan cuma untuk yang nulis.
---
## 1. Struktur Folder
Project ini pakai konsep **per-module** (feature-based / modular monolith).
Struktur lengkap & alasan tiap folder ada di [`README.md`](./README.md#struktur-folder) —
dokumen ini tidak mengulang, cukup aturan tambahannya:
```
cardverse/
├── cmd/ # entry point (main.go per binary: api, seed, dst)
├── config/ # load environment variable
├── internal/
│ ├── database/ # koneksi DB, migration, seeder
│ ├── middleware/ # auth, cors, logger, rate limiter
│ ├── modules/ # 1 folder = 1 domain/fitur (model, dto, repository, service, handler, routes)
│ ├── router/ # gabungkan semua route module
│ └── pkg/ # helper generik lintas module (response, logger, utils, validator)
├── public/ # data assets statis (lihat aturan di bawah)
│ ├── images/
│ └── uploads/
└── tests/ # HANYA untuk integration/e2e test, BUKAN unit test (lihat bagian 7)
```
### Aturan wajib
- **Module baru = folder baru** di `internal/modules/`. Jangan taruh logic satu fitur
nyebar di banyak tempat di luar folder module-nya sendiri.
- **Jangan bikin folder generik** kayak `utils/`, `helpers/`, `common/` di level module.
Kalau kode itu generik lintas module, taruh di `internal/pkg/`. Kalau spesifik ke 1
domain, dia bukan "helper" — dia bagian dari `service.go` module itu.
- **`public/`** khusus untuk data assets statis yang bisa diakses langsung (gambar
hasil upload, file yang di-generate, dsb) — **bukan** untuk kode maupun file konfigurasi.
- `public/images/` — gambar statis (logo, avatar default, dsb)
- `public/uploads/` — file hasil upload user (foto profil, dokumen, dsb)
- Isi folder ini **jangan pernah di-commit** kalau berupa file hasil upload runtime —
pastikan sudah ada di `.gitignore` (cukup commit `.gitkeep` biar foldernya tetap ada di git)
- Kalau nanti butuh serve folder ini lewat HTTP, daftarkan lewat `r.Static("/public", "./public")`
di `internal/router/router.go`, jangan bikin route manual per file
---
## 2. Penamaan (Naming)
**Aturan paling penting di seluruh dokumen ini: nama harus mendeskripsikan APA
isinya / APA yang dilakukan, bukan singkatan yang cuma dipahami penulisnya sendiri dan pakai bahasa inggris penamaannya**
### 2.1 Variable
```go
// ❌ HINDARI — nama tidak jelas, harus baca konteks buat ngerti
d := time.Now().Sub(start)
u, _ := repo.FindByID(id)
n := len(users)
// ✅ BENAR — jelas dari nama variabelnya sendiri
duration := time.Now().Sub(start)
existingUser, _ := repo.FindByID(id)
totalUsers := len(users)
```
- Variable **boolean** harus dimulai kata tanya: `isActive`, `hasPermission`, `canDelete`, `shouldRetry`
- Variable **jamak/slice** pakai bentuk jamak: `users` (bukan `userList` atau `userArr`)
- Variable **short-lived** di scope kecil (misal index loop `for i := range x`) boleh singkat (`i`, `err`), tapi begitu scope-nya lebih dari beberapa baris, kasih nama jelas
- **Jangan** pakai singkatan yang ambigu: `usr`, `pwd`, `req` OK kalau konvensi umum (`req`/`resp` untuk HTTP dipahami luas), tapi `tmp`, `data`, `val`, `obj` generik itu **hindari** — ganti dengan nama yang jelasin isinya apa
### 2.2 Function & Method
Pola: **KataKerja + Objek**, jelasin APA yang dilakukan dan KE APA.
```go
// ❌ HINDARI
func Process(u User) error
func Handle(c *gin.Context)
func Check(email string) bool
// ✅ BENAR
func HashPassword(plain string) (string, error)
func ValidateEmailFormat(email string) bool
func (h *Handler) CreateUser(c *gin.Context)
func (s *service) DeleteUserByID(id uint) error
```
- Function yang balikin `bool` namanya harus mulai kata tanya: `IsValid()`, `HasAccess()`
- Function private/internal (huruf kecil di awal) boleh lebih pendek karena scope-nya
udah jelas dari package-nya, tapi tetap harus jelas apa fungsinya
- Satu function idealnya **cuma ngerjain 1 hal** (Single Responsibility). Kalau nama
function-nya butuh kata "And" (`CreateUserAndSendEmail`), itu tanda harus dipecah
jadi 2 function
### 2.3 Constant & Package-level Variable
```go
// ✅ constant pakai PascalCase kalau exported, camelCase kalau private
const MaxLoginAttempts = 5
const defaultPageSize = 10
// ✅ untuk grup constant terkait, pakai type + iota biar type-safe
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid"
OrderStatusCancelled OrderStatus = "cancelled"
)
```
### 2.4 File
- Nama file **snake_case**, deskriptif: `rate_limiter.go`, `response_error_test.go`
- 1 file = 1 tanggung jawab. Kalau `service.go` sudah >300 baris dan ngerjain banyak
hal berbeda, pecah jadi beberapa file (`service.go`, `service_validation.go`, dst) —
tetap 1 package, cuma dipisah fisik biar gampang di-navigate
### 2.5 Package
- Nama package dan Folder **huruf kecil semua**, singular (bukan jamak): `user`, bukan `users`
- Hindari nama generik: `util`, `common`, `helper`, `base` sebagai nama package —
lihat aturan folder di bagian 1
---
## 3. Database & Query
### 3.1 JANGAN query di dalam loop (hindari N+1 problem)
Ini aturan yang paling sering dilanggar dan paling mahal dampaknya ke performa.
**Kumpulkan dulu data yang mau diproses, baru eksekusi 1 query bulk** — jangan
query satu-satu di dalam `for`.
```go
// ❌ SANGAT DIHINDARI — 1 query per iterasi, kalau ada 1000 data = 1000x round-trip ke DB
func (r *repository) CreateMany(users []User) error {
for _, u := range users {
if err := r.db.Create(&u).Error; err != nil {
return err
}
}
return nil
}
// ❌ SANGAT DIHINDARI — sama, tapi buat UPDATE
func (s *service) MarkAllAsRead(notifIDs []uint) error {
for _, id := range notifIDs {
s.db.Model(&Notification{}).Where("id = ?", id).Update("is_read", true)
}
return nil
}
```
```go
// ✅ BENAR — kumpulkan dulu ke slice, baru 1x bulk insert
func (r *repository) CreateMany(users []User) error {
if len(users) == 0 {
return nil
}
// GORM otomatis generate 1 statement INSERT dengan banyak VALUES sekaligus
return r.db.Create(&users).Error
}
// ✅ BENAR — kalau datanya sangat banyak (ribuan), pecah per batch biar
// nggak kena limit jumlah parameter di 1 query, TAPI tetap bukan query per-item
func (r *repository) CreateManyBatched(users []User) error {
if len(users) == 0 {
return nil
}
const batchSize = 100
return r.db.CreateInBatches(users, batchSize).Error
}
// ✅ BENAR — update banyak baris sekaligus pakai 1 query WHERE ... IN (...)
func (s *service) MarkAllAsRead(notifIDs []uint) error {
if len(notifIDs) == 0 {
return nil
}
return s.db.Model(&Notification{}).
Where("id IN ?", notifIDs).
Update("is_read", true).Error
}
```
**Pola umumnya:**
1. Kumpulkan semua data yang mau diproses ke **slice** dulu (di memory, bukan hit DB)
2. Baru eksekusi **1 query** (atau beberapa batch kalau datanya sangat besar) buat semua data itu sekaligus
3. Kalau butuh data referensi dari tabel lain buat banyak baris (misal ambil detail
product buat 50 order item), jangan `SELECT` 1 per item di loop — kumpulkan
semua ID-nya dulu, `SELECT ... WHERE id IN (...)` sekali, baru mapping di memory
```go
// ❌ N+1 query problem: 1 query ambil orders + N query ambil product tiap order item
for _, item := range orderItems {
var product Product
db.First(&product, item.ProductID)
// ...
}
// ✅ kumpulkan semua productID dulu, 1x query ambil semuanya, baru mapping di memory
productIDs := make([]uint, 0, len(orderItems))
for _, item := range orderItems {
productIDs = append(productIDs, item.ProductID)
}
var products []Product
db.Where("id IN ?", productIDs).Find(&products)
productMap := make(map[uint]Product, len(products))
for _, p := range products {
productMap[p.ID] = p
}
// sekarang tinggal productMap[item.ProductID] buat akses tiap item, tanpa query tambahan
```
### 3.2 Repository tetap satu-satunya lapisan yang bicara ke database
Business logic (di `service.go`) tidak boleh langsung import `gorm.io/gorm` atau
nulis query — semua akses data lewat `Repository` interface, sesuai pola yang sudah
ada. Ini memudahkan mocking di unit test dan menjaga tanggung jawab tetap terpisah.
### 3.3 Transaction untuk operasi yang harus atomik
Kalau 1 aksi bisnis butuh beberapa perubahan tabel yang harus **semua berhasil atau
semua gagal** (misal: kurangi stok + buat order), bungkus dengan `db.Transaction()`
di level repository/service, jangan biarkan tiap query jalan sendiri-sendiri.
```go
func (r *repository) CreateOrderWithStockDeduction(order *Order, productID uint, qty int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err // otomatis rollback semua perubahan di transaction ini
}
if err := tx.Model(&Product{}).Where("id = ?", productID).
Update("stock", gorm.Expr("stock - ?", qty)).Error; err != nil {
return err
}
return nil // otomatis commit kalau sampai sini tanpa error
})
}
```
### 3.4 Hindari `SELECT *` (Spesifikkan Kolom yang Dibutuhkan)
- **Hindari penggunaan `SELECT *`** dalam query database.
- Tuliskan kolom yang ingin diambil **satu per satu secara spesifik** (misal `db.Select("id", "name", "email")`) sesuai kebutuhan data yang akan digunakan.
- Memilih kolom secara spesifik membuat eksekusi query jauh lebih cepat dan efisien, serta menghemat memori dan I/O jaringan database terutama ketika mengambil data dalam jumlah banyak.
---
## 4. Error Handling & Logging
### 4.1 Error Handling
- Selalu **cek error langsung setelah pemanggilan function** yang mengembalikannya —
jangan tunda atau abaikan (`_ = err` cuma boleh kalau memang sengaja & ada alasan jelas)
- Error domain (business logic) didefinisikan sebagai **package-level `var`** pakai
`errors.New(...)`, dicek pakai `errors.Is()` — sudah dipakai konsisten di
`service.go` tiap module (`ErrUserNotFound`, `ErrEmailTaken`, dst). Ikuti pola ini
untuk error baru
- **Jangan** expose detail error asli (pesan driver database, stack trace) ke response
API — sudah ditangani otomatis oleh `response.Error()` (lihat `internal/pkg/response`),
tinggal pakai, jangan bikin cara custom kirim error langsung ke client
- Pesan error (yang dicatat ke log, bukan yang dikirim ke client) harus dalam Bahasa
Indonesia yang jelas, bukan singkatan teknis semata: `"email sudah terdaftar"`,
bukan `"dup key"`
### 4.2 Logging & Request Debugging
- **Log Request untuk Debugging**: Selalu catat log untuk request HTTP yang masuk (seperti method, URL, query params, dan payload/body request) untuk memudahkan pencarian masalah (debugging).
- **Sensor Data Kredensial & Sensitif**: **Dilarang keras** menampilkan data kredensial atau informasi sensitif di dalam log (seperti `password`, `token`, `secret`, `credit_card`, `pin`, `otp`, atau header `Authorization`).
- Pastikan field kredensial di-masking (misal menjadi `"***"`) atau dibersihkan sebelum payload request dicatat ke log.
---
## 5. Response API
- Semua response lewat `response.Success()` / `response.SuccessWithPagination()` /
`response.Error()` di `internal/pkg/response` — jangan panggil `c.JSON()` langsung
di handler, supaya format response konsisten di semua endpoint
- Field JSON pakai `snake_case`: `total_data`, `created_at` — bukan `totalData`/`camelCase`
- Untuk field opsional (misal `Meta`, `Error`), pastikan pakai `json:"...,omitempty"`
supaya tidak muncul di response kalau kosong
---
## 6. Validasi Input (DTO)
- Semua request body/query **wajib** lewat DTO struct dengan tag `binding`
jangan validasi manual pakai banyak `if` di handler
- Nama field DTO pakai tag `json` yang jelas, dan pesan error otomatis mengikuti
nama itu (lihat `internal/pkg/validator`) — jangan sampai nama field DTO ambigu
(`Val`, `Data`, `Input`)
- Tag `binding` selalu eksplisit soal wajib/opsional: pakai `required` untuk field
wajib, `omitempty` untuk field opsional — jangan biarkan ambigu
```go
// ✅ jelas mana wajib, mana opsional, dan aturan validasinya apa
type CreateUserRequest struct {
Name string `json:"name" binding:"required,min=2,max=100"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
```
---
## 7. Testing
- Unit test **wajib** ada di folder yang sama dengan kode yang di-test (keterbatasan
tooling Go, lihat `README.md`) — jangan coba pindahkan
- Nama function test deskriptif, format `Test<Subjek>_<Skenario>`:
```go
func TestService_Create_EmailSudahDipakai(t *testing.T)
func TestRateLimiter_MenolakSetelahBurstHabis(t *testing.T)
```
- Business logic (`service.go`) di-test pakai **mock repository**, bukan koneksi
database sungguhan — ikuti pola `repository_mock_test.go` yang sudah ada
- Folder `tests/` di root (kalau nanti dibuat) khusus untuk **integration/e2e test**
yang benar-benar hit endpoint HTTP + database sungguhan — ini kategori berbeda
dari unit test, dan memang boleh terpisah dari kode karena cuma manggil API
dari luar (black-box), bukan akses internal package
---
## 8. Function & Kompleksitas
- Idealnya 1 function **muat dalam 1 layar** tanpa scroll (~40-50 baris). Kalau lebih,
kemungkinan besar dia ngerjain lebih dari 1 tanggung jawab — pecah jadi beberapa function
- Hindari **nested if lebih dari 2-3 level**. Pakai **early return** (guard clause):
```go
// ❌ nested dalam-dalam, susah dibaca
func (s *service) Update(id uint, req UpdateUserRequest) (*User, error) {
u, err := s.repo.FindByID(id)
if err == nil {
if req.Name != "" {
u.Name = req.Name
if updateErr := s.repo.Update(u); updateErr == nil {
return u, nil
} else {
return nil, updateErr
}
}
return u, nil
} else {
return nil, ErrUserNotFound
}
}
// ✅ early return, alur baca dari atas ke bawah, tanpa nested
func (s *service) Update(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 err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
```
- **Magic number/string dihindari** — pakai constant yang dikasih nama:
```go
// ❌
if len(password) < 6 { ... }
// ✅
const minPasswordLength = 6
if len(password) < minPasswordLength { ... }
```
---
## 9. Comment & Dokumentasi
- **Jangan beri komentar di setiap baris atau fungsi internal** jika nama fungsi dan variabelnya sudah mendeskripsikan secara jelas (_self-descriptive_).
- Cukup berikan **overview fungsi secara keseluruhan** (doc comment di atas fungsi) untuk menjelaskan gambaran umum apa yang dikerjakan fungsi tersebut dan jangan berlebihan.
- Comment di dalam baris kode (_inline comment_) hanya dipakai untuk menjelaskan **KENAPA** (konteks bisnis/alasan keputusan teknis yang tidak terlihat langsung dari kode), bukan **APA** yang sedang dilakukan baris tersebut.
```go
// ❌ HINDARI — komentar di setiap baris yang kodenya sendiri sudah jelas
func (s *userService) CreateUser(req CreateUserRequest) error {
// hash password user
hashedPassword, _ := HashPassword(req.Password)
// simpan user ke database
return s.repo.Create(user)
}
// ✅ BENAR — cukup overview ringkas di atas fungsi, tanpa komentar per baris di dalam body
// CreateUser menangani proses registrasi dan penyimpanan data pengguna baru.
func (s *userService) CreateUser(req CreateUserRequest) error {
hashedPassword, err := HashPassword(req.Password)
if err != nil {
return err
}
return s.repo.Create(user)
}
```
- Semua **function/type exported** (huruf besar di awal) wajib punya doc comment ringkas (overview) yang diawali nama function/type itu sendiri (konvensi standar Go).
- Doc comment cukup Bahasa Indonesia, tidak perlu Bahasa Inggris kecuali project ini nantinya open source untuk audiens internasional.
---
## 10. Formatting & Linting
- Jalankan `gofmt` (atau `goimports`) sebelum commit — **wajib**, bukan opsional.
Kebanyakan editor (VS Code + ekstensi Go) sudah auto-format on save
- Disarankan pasang [`golangci-lint`](https://golangci-lint.run/) untuk nangkep
isu umum (unused variable, ineffective assignment, dst) sebelum push:
```bash
golangci-lint run ./...
```
- Import selalu dikelompokkan: stdlib dulu, baris kosong, lalu internal (`cardverse/...`),
baris kosong, lalu third-party — `goimports` otomatis ngerjain ini
---
## 11. Checklist Sebelum Pull Request
- [ ] `gofmt` sudah dijalankan, tidak ada perbedaan format
- [ ] Nama variable/function sudah deskriptif, tidak ada singkatan ambigu
- [ ] Tidak ada query database di dalam loop — sudah dikumpulkan & di-bulk
- [ ] Semua error dicek, tidak ada yang diabaikan diam-diam
- [ ] Tidak ada data sensitif (password, token) yang ikut ter-log atau ter-expose ke response
- [ ] Unit test ditambahkan/diupdate untuk logic baru, ditaruh di folder yang sama
- [ ] `go test ./...` lolos semua sebelum push
- [ ] Struktur folder module diikuti (model, dto, repository, service, handler, routes)
- [ ] Format pesan commit mengikuti konvensi `git commit -m "[commit-type]([feature]-[code]) : [message in english]"`
---
## 12. Konvensi Git (Commit & Push)
### 12.1 Commit Convention
Struktur Commit:
```bash
git commit -m "[commit-type]([feature]-[code]) : [message in english]"
```
**Contoh:**
```bash
git commit -m "feat(login-001) : add new form login"
```
Daftar `commit-type`:
- **`feat`**: Digunakan saat menambahkan fitur baru.
- **`fix`**: Digunakan saat memperbaiki bug.
- **`refactor`**: Digunakan saat mengatur ulang atau merestrukturisasi kode yang ada.
- **`docs`**: Digunakan saat membuat perubahan terkait dokumentasi atau komentar.
- **`style`**: Digunakan untuk perubahan dalam format kode, spasi, tanda baca, dll.
- **`test`**: Digunakan saat menambahkan atau memperbarui kode pengujian atau skenario pengujian.
- **`chore`**: Digunakan untuk perubahan yang terkait dengan alat bantu, berkas konfigurasi, atau organisasi proyek.
### 12.2 Push Convention & Aturan Izin
- **Wajib Izin Sebelum Push**: Aksi `git push` **harus selalu meminta izin terlebih dahulu** kepada pengguna / pemilik repositori sebelum dieksekusi.
- **Dilarang Direct Push Tanpa Konfirmasi**: Jangan pernah melakukan `git push` secara otomatis atau tanpa konfirmasi eksplisit.

41
Makefile Normal file
View File

@@ -0,0 +1,41 @@
.PHONY: dev run build test test-module test-user test-auth test-middleware test-pkg test-cover seed tidy
# jalankan server dengan hot-reload (butuh Air, lihat README bagian "Development dengan Hot-Reload")
dev:
air
# jalankan server biasa tanpa hot-reload
run:
go run ./cmd/api
# build binary production ke ./bin/api
build:
go build -o ./bin/api ./cmd/api
# jalankan SELURUH unit test (semua module + package)
test:
go test ./... -v
# jalankan test 1 module tertentu, contoh: make test-module m=user
# m bisa diisi nama folder apapun di bawah internal/modules atau internal/pkg
test-module:
go test ./internal/modules/$(m)/... -v
test-middleware:
go test ./internal/middleware/... -v
# test semua package pendukung (logger, response, utils, validator) sekaligus
test-pkg:
go test ./internal/pkg/... -v
# jalankan semua test + tampilkan persentase coverage per file
test-cover:
go test ./... -cover
# masukkan data master (lihat internal/database/seeder.go)
seed:
go run ./cmd/seed
# rapikan go.mod / go.sum
tidy:
go mod tidy

177
README.md Normal file
View File

@@ -0,0 +1,177 @@
# Cardverse API
Cardverse adalah backend API RESTful berkinerja tinggi untuk aplikasi trading card / Pokemon TCG, dibangun menggunakan **Go + Gin Framework**, PostgreSQL (GORM), JWT Authentication, Refresh Token Rotation, Google OAuth, serta manajemen aset media statis.
Disusun dengan arsitektur **per-module** (*modular monolith*) sehingga bersih, teruji (*testable*), dan mudah dikembangkan secara berkelanjutan.
> Aturan penulisan kode, konvensi penamaan, dan clean code best practice ada di [`CONVENTION.md`](./CONVENTION.md).
---
## 📁 Struktur Folder Project
```
cardverse/
├── cmd/
│ ├── api/
│ │ └── main.go # Entry point server HTTP API
│ └── seed/
│ └── main.go # Entry point seeder data master
├── config/
│ └── config.go # Memuat environment variables (.env)
├── internal/
│ ├── database/
│ │ ├── database.go # Koneksi DB PostgreSQL + AutoMigrate GORM
│ │ └── seeder.go # Seed data master (Series, Sets, Cards, Admin)
│ ├── middleware/
│ │ ├── auth.go # Auth middleware (JWT + RequireRoles)
│ │ ├── cors.go # CORS middleware
│ │ ├── logger.go # Request logger dengan auto-redact data sensitif
│ │ └── rate_limiter.go # Rate limiter per-IP (Token Bucket)
│ ├── modules/ # Modular domain features
│ │ ├── auth/ # Login, Register, Google OAuth, Refresh Token, Reset Password
│ │ ├── user/ # Profile, Avatar Upload, Admin User Management
│ │ ├── seriessetmaster/ # Data Master Series & Set Expansion
│ │ └── cardmaster/ # Data Master Kartu TCG (HP, Attacks, Battle, Element)
│ ├── router/
│ │ └── router.go # Pendaftaran route module & static file serving
│ └── pkg/ # Utility & Helper generik
│ ├── response/ # Format JSON response standar + pagination
│ ├── logger/ # Logger terpusat
│ ├── validator/ # Error translator validasi DTO per-field
│ └── utils/ # JWT, Hash Password, String Slug/Underscore, Image/File utils
├── public/ # Penyimpanan Aset Gambar & Media Statis
│ ├── images/
│ │ ├── avatars/ # Foto profil user
│ │ ├── series/ # Banner/gambar Series
│ │ ├── sets/ # Logo/gambar Expansion Set
│ │ └── elements/ # Ikon elemen kartu (Grass, Fire, Water, dst)
├── .env.example
├── CONVENTION.md # Aturan penulisan kode & standar proyek
├── Makefile # Command shortcuts (dev, test, build, seed)
├── go.mod
└── README.md
```
---
## ⚡ Cara Menjalankan
### 1. Prasyarat
- Go 1.22+
- PostgreSQL
### 2. Install Dependency
```bash
go mod tidy
```
### 3. Setup Environment Variables
```bash
cp .env.example .env
# Sesuaikan kredensial PostgreSQL, JWT Secret, dan GOOGLE_CLIENT_ID di file .env
```
### 4. Buat Database PostgreSQL
```bash
psql -U postgres -c "CREATE DATABASE cardverse;"
```
### 5. Jalankan Seeder Data Master (Opsional)
```bash
go run ./cmd/seed
# atau
make seed
```
### 6. Jalankan Server Development
```bash
go run ./cmd/api
# atau dengan Hot-Reload (Air):
make dev
```
Server akan berjalan di `http://localhost:8080`.
---
## 🛠️ Daftar Endpoint API
### 1. Authentication Module (`/api/v1/auth`)
| Method | Endpoint | Auth | Deskripsi |
| ------ | ------------------------------- | :---------: | ----------------------------------------------------------- |
| POST | `/api/v1/auth/register` | ❌ Public | Registrasi akun baru (Kirim email konfirmasi) |
| POST | `/api/v1/auth/login` | ❌ Public | Login via email & password (mengembalikan Access & Refresh) |
| POST | `/api/v1/auth/google` | ❌ Public | Login / Auto-Register via Google OAuth (ID Token) |
| POST | `/api/v1/auth/refresh-token` | ❌ Public | Minta Access Token baru & rotate Refresh Token |
| POST | `/api/v1/auth/logout` | ❌ Public | Logout & mencabut Refresh Token |
| GET | `/api/v1/auth/verify-email` | ❌ Public | Verifikasi email user via token |
| POST | `/api/v1/auth/forgot-password` | ❌ Public | Minta token reset password |
| POST | `/api/v1/auth/reset-password` | ❌ Public | Reset password akun |
### 2. User Module (`/api/v1/users`)
| Method | Endpoint | Auth | Deskripsi |
| ------ | --------------------------- | :-------------------: | ----------------------------------------------------- |
| GET | `/api/v1/users/me` | ✅ Logged In | Ambil profil user yang sedang login |
| PUT | `/api/v1/users/me` | ✅ Logged In | Update nama profil user |
| POST | `/api/v1/users/me/avatar` | ✅ Logged In | Upload file gambar avatar (`multipart/form-data`) |
| GET | `/api/v1/users` | 🔒 Admin/Superadmin | Get daftar semua user (Paginasi & Search) |
| POST | `/api/v1/users` | 🔒 Admin/Superadmin | Buat user baru |
| GET | `/api/v1/users/:id` | 🔒 Admin/Superadmin | Ambil detail user berdasarkan ID |
| PUT | `/api/v1/users/:id` | 🔒 Admin/Superadmin | Update data user |
| PUT | `/api/v1/users/:id/suspend` | 🔒 Admin/Superadmin | Suspensikan akun user |
| PUT | `/api/v1/users/:id/unsuspend`| 🔒 Admin/Superadmin | Cabut suspensi akun user |
| DELETE | `/api/v1/users/:id` | 🔒 Admin/Superadmin | Hapus user |
### 3. Series & Set Master Module (`/api/v1/series`, `/api/v1/sets`)
| Method | Endpoint | Auth | Deskripsi |
| ------ | ------------------------------- | :-------------------: | ----------------------------------------------------- |
| GET | `/api/v1/series` | ❌ Public | Get semua Series TCG |
| PUT | `/api/v1/series/:id` | 🔒 Admin/Superadmin | Update nama Series & upload file gambar (`.webp`) |
| GET | `/api/v1/sets` | ❌ Public | Get semua Expansion Set (Filter by search/parent_id) |
| GET | `/api/v1/sets/code/:code` | ❌ Public | Get detail Set berdasarkan `set_code` (misal `M-P`) |
| PUT | `/api/v1/sets/:id` | 🔒 Admin/Superadmin | Update Expansion Set & upload logo/gambar |
| GET | `/api/v1/series-set-masters/:id`| ❌ Public | Get detail Series/Set Master berdasarkan ID |
### 4. Card Master Module (`/api/v1/cards`)
| Method | Endpoint | Auth | Deskripsi |
| ------ | ---------------------- | :-------------------: | ----------------------------------------------------- |
| GET | `/api/v1/cards` | ❌ Public | Get daftar kartu TCG (Paginasi, Filter & Search) |
| GET | `/api/v1/cards/:id` | ❌ Public | Get detail kartu TCG berdasarkan ID |
| PUT | `/api/v1/cards/:id` | 🔒 Admin/Superadmin | Update data kartu master |
---
## 🔒 Otentikasi & Header
Untuk endpoint yang memerlukan otentikasi (`Auth Required`), sertakan Access Token pada HTTP Header:
```http
Authorization: Bearer <ACCESS_TOKEN>
```
---
## 🧪 Menjalankan Unit Test
Proyek ini dilengkapi dengan unit test menyeluruh (menggunakan *mock repository* tanpa memerlukan database asli saat testing):
```bash
# Jalankan semua unit test
go test ./...
# Jalankan test dengan detail & coverage
go test ./... -v -cover
# Shortcut Makefile
make test
```
---
## 📄 Lisensi
© Cardverse Development Team. Hak Cipta Dilindungi Undang-Undang.

24
cmd/api/main.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"log"
"cardverse/config"
"cardverse/internal/database"
"cardverse/internal/router"
)
func main() {
cfg := config.Load()
db := database.Connect(cfg)
database.AutoMigrate(db)
r := router.Setup(db, cfg)
addr := ":" + cfg.AppPort
log.Printf("server berjalan di http://localhost%s (env: %s)", addr, cfg.AppEnv)
if err := r.Run(addr); err != nil {
log.Fatalf("gagal menjalankan server: %v", err)
}
}

21
cmd/seed/main.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"log"
"cardverse/config"
"cardverse/internal/database"
)
func main() {
cfg := config.Load()
db := database.Connect(cfg)
database.AutoMigrate(db)
log.Println("menjalankan seeder...")
if err := database.Seed(db); err != nil {
log.Fatalf("gagal menjalankan seeder: %v", err)
}
log.Println("seeding selesai")
}

96
config/config.go Normal file
View File

@@ -0,0 +1,96 @@
package config
import (
"fmt"
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
AppEnv string
AppPort string
AppBaseURL string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBSSLMode string
JWTSecret string
JWTExpiresHours int
JWTRefreshSecret string
JWTRefreshExpiresHours int
GoogleClientID string
RateLimitRequestsPerSecond float64
RateLimitBurst int
}
var Cfg *Config
func Load() *Config {
if err := godotenv.Load(); err != nil {
log.Println("info: file .env tidak ditemukan, menggunakan environment variable sistem")
}
expiresHours, err := strconv.Atoi(getEnv("JWT_EXPIRES_HOURS", "1"))
if err != nil {
expiresHours = 1
}
refreshExpiresHours, err := strconv.Atoi(getEnv("JWT_REFRESH_EXPIRES_HOURS", "168"))
if err != nil {
refreshExpiresHours = 168
}
rateLimitRPS, err := strconv.ParseFloat(getEnv("RATE_LIMIT_RPS", "5"), 64)
if err != nil {
rateLimitRPS = 5
}
rateLimitBurst, err := strconv.Atoi(getEnv("RATE_LIMIT_BURST", "10"))
if err != nil {
rateLimitBurst = 10
}
appPort := getEnv("APP_PORT", "8080")
defaultBaseURL := fmt.Sprintf("http://localhost:%s", appPort)
Cfg = &Config{
AppEnv: getEnv("APP_ENV", "development"),
AppPort: appPort,
AppBaseURL: getEnv("APP_BASE_URL", defaultBaseURL),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "root"),
DBName: getEnv("DB_NAME", "cardverse"),
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
JWTSecret: getEnv("JWT_SECRET", "secret"),
JWTExpiresHours: expiresHours,
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "refresh_secret"),
JWTRefreshExpiresHours: refreshExpiresHours,
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
RateLimitRequestsPerSecond: rateLimitRPS,
RateLimitBurst: rateLimitBurst,
}
return Cfg
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok && value != "" {
return value
}
return fallback
}

51
go.mod Normal file
View File

@@ -0,0 +1,51 @@
module cardverse
go 1.25
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.22.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.24.0
golang.org/x/time v0.5.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.10
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/chai2010/webp v1.4.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

123
go.sum Normal file
View File

@@ -0,0 +1,123 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

BIN
public/.DS_Store vendored Normal file
View File

Binary file not shown.

BIN
public/images/.DS_Store vendored Normal file
View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

30
public/json/series.json Normal file
View File

@@ -0,0 +1,30 @@
[
{
"name": "Evolusi Mega",
"image_url": "https://cdn2.pokepedia.id/series/evolusi_mega.webp",
"image_local_path": "/images/series/evolusi_mega.webp",
"expansion_count": "9",
"card_count": "1356"
},
{
"name": "Scarlet & Violet",
"image_url": "https://cdn2.pokepedia.id/series/scarlet_violet.webp",
"image_local_path": "/images/series/scarlet_violet.webp",
"expansion_count": "33",
"card_count": "4666"
},
{
"name": "Pedang & Perisai",
"image_url": "https://cdn2.pokepedia.id/series/pedang_perisai.webp",
"image_local_path": "/images/series/pedang_perisai.webp",
"expansion_count": "35",
"card_count": "5114"
},
{
"name": "Matahari & Bulan",
"image_url": "https://cdn2.pokepedia.id/series/matahari_bulan.webp",
"image_local_path": "/images/series/matahari_bulan.webp",
"expansion_count": "16",
"card_count": "3284"
}
]