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