commit e3aca176b7d5dd4606d39398e366f7013dc27dfb Author: RezalSultan Date: Thu Jul 30 09:51:25 2026 +0700 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..821097e Binary files /dev/null and b/.DS_Store differ diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..2664dd8 --- /dev/null +++ b/.air.toml @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2a1c853 --- /dev/null +++ b/.env.example @@ -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" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa355a8 --- /dev/null +++ b/.gitignore @@ -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/* \ No newline at end of file diff --git a/CONVENTION.md b/CONVENTION.md new file mode 100644 index 0000000..3cabae5 --- /dev/null +++ b/CONVENTION.md @@ -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_`: + ```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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0833957 --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e05d6e5 --- /dev/null +++ b/README.md @@ -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 +``` + +--- + +## 🧪 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. diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..914fadd --- /dev/null +++ b/cmd/api/main.go @@ -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) + } +} \ No newline at end of file diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..aeb401c --- /dev/null +++ b/cmd/seed/main.go @@ -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") +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0282028 --- /dev/null +++ b/config/config.go @@ -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 +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90396bd --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cd3b6ee --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..a4085f2 --- /dev/null +++ b/internal/database/database.go @@ -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") +} diff --git a/internal/database/seeder.go b/internal/database/seeder.go new file mode 100644 index 0000000..959f281 --- /dev/null +++ b/internal/database/seeder.go @@ -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 +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..a43f266 --- /dev/null +++ b/internal/middleware/auth.go @@ -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() + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..27e6535 --- /dev/null +++ b/internal/middleware/cors.go @@ -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() + } +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..e636da6 --- /dev/null +++ b/internal/middleware/logger.go @@ -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 +} \ No newline at end of file diff --git a/internal/middleware/rate_limitter.go b/internal/middleware/rate_limitter.go new file mode 100644 index 0000000..843a573 --- /dev/null +++ b/internal/middleware/rate_limitter.go @@ -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() + } +} \ No newline at end of file diff --git a/internal/middleware/rate_limitter_test.go b/internal/middleware/rate_limitter_test.go new file mode 100644 index 0000000..91852cc --- /dev/null +++ b/internal/middleware/rate_limitter_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/modules/auth/dto.go b/internal/modules/auth/dto.go new file mode 100644 index 0000000..8661140 --- /dev/null +++ b/internal/modules/auth/dto.go @@ -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"` +} diff --git a/internal/modules/auth/handler.go b/internal/modules/auth/handler.go new file mode 100644 index 0000000..ac36655 --- /dev/null +++ b/internal/modules/auth/handler.go @@ -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) +} diff --git a/internal/modules/auth/model.go b/internal/modules/auth/model.go new file mode 100644 index 0000000..00f3ef3 --- /dev/null +++ b/internal/modules/auth/model.go @@ -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" +} diff --git a/internal/modules/auth/repository.go b/internal/modules/auth/repository.go new file mode 100644 index 0000000..bed278f --- /dev/null +++ b/internal/modules/auth/repository.go @@ -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 + }) +} diff --git a/internal/modules/auth/routes.go b/internal/modules/auth/routes.go new file mode 100644 index 0000000..f20d624 --- /dev/null +++ b/internal/modules/auth/routes.go @@ -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) + } +} diff --git a/internal/modules/auth/service.go b/internal/modules/auth/service.go new file mode 100644 index 0000000..92cb38e --- /dev/null +++ b/internal/modules/auth/service.go @@ -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) +} diff --git a/internal/modules/auth/service_test.go b/internal/modules/auth/service_test.go new file mode 100644 index 0000000..dd6d791 --- /dev/null +++ b/internal/modules/auth/service_test.go @@ -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) + } +} diff --git a/internal/modules/auth/user_repository_mock_test.go b/internal/modules/auth/user_repository_mock_test.go new file mode 100644 index 0000000..2ea9e9d --- /dev/null +++ b/internal/modules/auth/user_repository_mock_test.go @@ -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 +} diff --git a/internal/modules/cardmaster/dto.go b/internal/modules/cardmaster/dto.go new file mode 100644 index 0000000..35578ae --- /dev/null +++ b/internal/modules/cardmaster/dto.go @@ -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 +} diff --git a/internal/modules/cardmaster/handler.go b/internal/modules/cardmaster/handler.go new file mode 100644 index 0000000..2d16cf1 --- /dev/null +++ b/internal/modules/cardmaster/handler.go @@ -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()) +} diff --git a/internal/modules/cardmaster/model.go b/internal/modules/cardmaster/model.go new file mode 100644 index 0000000..087580c --- /dev/null +++ b/internal/modules/cardmaster/model.go @@ -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 "" +} diff --git a/internal/modules/cardmaster/repository.go b/internal/modules/cardmaster/repository.go new file mode 100644 index 0000000..ebf5fe7 --- /dev/null +++ b/internal/modules/cardmaster/repository.go @@ -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 + } +} diff --git a/internal/modules/cardmaster/repository_mock_test.go b/internal/modules/cardmaster/repository_mock_test.go new file mode 100644 index 0000000..850afa1 --- /dev/null +++ b/internal/modules/cardmaster/repository_mock_test.go @@ -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 +} diff --git a/internal/modules/cardmaster/routes.go b/internal/modules/cardmaster/routes.go new file mode 100644 index 0000000..4f012a9 --- /dev/null +++ b/internal/modules/cardmaster/routes.go @@ -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) + } +} diff --git a/internal/modules/cardmaster/service.go b/internal/modules/cardmaster/service.go new file mode 100644 index 0000000..6f0abca --- /dev/null +++ b/internal/modules/cardmaster/service.go @@ -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) +} diff --git a/internal/modules/cardmaster/service_test.go b/internal/modules/cardmaster/service_test.go new file mode 100644 index 0000000..a77fb28 --- /dev/null +++ b/internal/modules/cardmaster/service_test.go @@ -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) + } +} diff --git a/internal/modules/seriessetmaster/dto.go b/internal/modules/seriessetmaster/dto.go new file mode 100644 index 0000000..77f7c3c --- /dev/null +++ b/internal/modules/seriessetmaster/dto.go @@ -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 +} diff --git a/internal/modules/seriessetmaster/handler.go b/internal/modules/seriessetmaster/handler.go new file mode 100644 index 0000000..8f54d50 --- /dev/null +++ b/internal/modules/seriessetmaster/handler.go @@ -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])), "`, 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])), "`, 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 +} diff --git a/internal/modules/seriessetmaster/model.go b/internal/modules/seriessetmaster/model.go new file mode 100644 index 0000000..a97133a --- /dev/null +++ b/internal/modules/seriessetmaster/model.go @@ -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"` +} diff --git a/internal/modules/seriessetmaster/repository.go b/internal/modules/seriessetmaster/repository.go new file mode 100644 index 0000000..21571a3 --- /dev/null +++ b/internal/modules/seriessetmaster/repository.go @@ -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 +} diff --git a/internal/modules/seriessetmaster/repository_mock_test.go b/internal/modules/seriessetmaster/repository_mock_test.go new file mode 100644 index 0000000..ead4665 --- /dev/null +++ b/internal/modules/seriessetmaster/repository_mock_test.go @@ -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 +} diff --git a/internal/modules/seriessetmaster/routes.go b/internal/modules/seriessetmaster/routes.go new file mode 100644 index 0000000..640592e --- /dev/null +++ b/internal/modules/seriessetmaster/routes.go @@ -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) + } +} diff --git a/internal/modules/seriessetmaster/service.go b/internal/modules/seriessetmaster/service.go new file mode 100644 index 0000000..6eb3b8a --- /dev/null +++ b/internal/modules/seriessetmaster/service.go @@ -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) +} diff --git a/internal/modules/seriessetmaster/service_test.go b/internal/modules/seriessetmaster/service_test.go new file mode 100644 index 0000000..88f43b3 --- /dev/null +++ b/internal/modules/seriessetmaster/service_test.go @@ -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") + } +} diff --git a/internal/modules/user/dto.go b/internal/modules/user/dto.go new file mode 100644 index 0000000..1251625 --- /dev/null +++ b/internal/modules/user/dto.go @@ -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 +} diff --git a/internal/modules/user/handler.go b/internal/modules/user/handler.go new file mode 100644 index 0000000..5b3051f --- /dev/null +++ b/internal/modules/user/handler.go @@ -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 +} diff --git a/internal/modules/user/model.go b/internal/modules/user/model.go new file mode 100644 index 0000000..605847a --- /dev/null +++ b/internal/modules/user/model.go @@ -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" +} diff --git a/internal/modules/user/repository.go b/internal/modules/user/repository.go new file mode 100644 index 0000000..3a0b4e7 --- /dev/null +++ b/internal/modules/user/repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/modules/user/repository_mock_test.go b/internal/modules/user/repository_mock_test.go new file mode 100644 index 0000000..c604cc6 --- /dev/null +++ b/internal/modules/user/repository_mock_test.go @@ -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 +} diff --git a/internal/modules/user/routes.go b/internal/modules/user/routes.go new file mode 100644 index 0000000..a46f1ac --- /dev/null +++ b/internal/modules/user/routes.go @@ -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) + } +} diff --git a/internal/modules/user/service.go b/internal/modules/user/service.go new file mode 100644 index 0000000..518c101 --- /dev/null +++ b/internal/modules/user/service.go @@ -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) +} \ No newline at end of file diff --git a/internal/modules/user/service_test.go b/internal/modules/user/service_test.go new file mode 100644 index 0000000..3192141 --- /dev/null +++ b/internal/modules/user/service_test.go @@ -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) + } +} diff --git a/internal/pkg/logger/logger.go b/internal/pkg/logger/logger.go new file mode 100644 index 0000000..a8760b2 --- /dev/null +++ b/internal/pkg/logger/logger.go @@ -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) } \ No newline at end of file diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go new file mode 100644 index 0000000..23d623c --- /dev/null +++ b/internal/pkg/response/response.go @@ -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, + }) +} \ No newline at end of file diff --git a/internal/pkg/utils/file.go b/internal/pkg/utils/file.go new file mode 100644 index 0000000..ad4f433 --- /dev/null +++ b/internal/pkg/utils/file.go @@ -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) + } +} diff --git a/internal/pkg/utils/jwt.go b/internal/pkg/utils/jwt.go new file mode 100644 index 0000000..5660a6b --- /dev/null +++ b/internal/pkg/utils/jwt.go @@ -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 +} diff --git a/internal/pkg/utils/jwt_test.go b/internal/pkg/utils/jwt_test.go new file mode 100644 index 0000000..0d29728 --- /dev/null +++ b/internal/pkg/utils/jwt_test.go @@ -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") + } +} diff --git a/internal/pkg/utils/password.go b/internal/pkg/utils/password.go new file mode 100644 index 0000000..272dc25 --- /dev/null +++ b/internal/pkg/utils/password.go @@ -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 +} diff --git a/internal/pkg/utils/password_test.go b/internal/pkg/utils/password_test.go new file mode 100644 index 0000000..aa9887e --- /dev/null +++ b/internal/pkg/utils/password_test.go @@ -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") + } +} diff --git a/internal/pkg/utils/string.go b/internal/pkg/utils/string.go new file mode 100644 index 0000000..831dd4e --- /dev/null +++ b/internal/pkg/utils/string.go @@ -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 +} diff --git a/internal/pkg/utils/url.go b/internal/pkg/utils/url.go new file mode 100644 index 0000000..2b6ef6b --- /dev/null +++ b/internal/pkg/utils/url.go @@ -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) +} diff --git a/internal/pkg/validator/validator.go b/internal/pkg/validator/validator.go new file mode 100644 index 0000000..3d1e4b2 --- /dev/null +++ b/internal/pkg/validator/validator.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..fc15adb --- /dev/null +++ b/internal/router/router.go @@ -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 +} \ No newline at end of file diff --git a/public/.DS_Store b/public/.DS_Store new file mode 100644 index 0000000..4b994ea Binary files /dev/null and b/public/.DS_Store differ diff --git a/public/images/.DS_Store b/public/images/.DS_Store new file mode 100644 index 0000000..f4a8b15 Binary files /dev/null and b/public/images/.DS_Store differ diff --git a/public/images/avatars/avatar_1_1785230686084953000.png b/public/images/avatars/avatar_1_1785230686084953000.png new file mode 100644 index 0000000..c0abbe3 Binary files /dev/null and b/public/images/avatars/avatar_1_1785230686084953000.png differ diff --git a/public/images/avatars/avatar_1_1785231461279633000.png b/public/images/avatars/avatar_1_1785231461279633000.png new file mode 100644 index 0000000..c0abbe3 Binary files /dev/null and b/public/images/avatars/avatar_1_1785231461279633000.png differ diff --git a/public/images/elements/Colorless.png b/public/images/elements/Colorless.png new file mode 100644 index 0000000..b5f6981 Binary files /dev/null and b/public/images/elements/Colorless.png differ diff --git a/public/images/elements/Darkness.png b/public/images/elements/Darkness.png new file mode 100644 index 0000000..1bfa63a Binary files /dev/null and b/public/images/elements/Darkness.png differ diff --git a/public/images/elements/Dragon.png b/public/images/elements/Dragon.png new file mode 100644 index 0000000..c9c3e7a Binary files /dev/null and b/public/images/elements/Dragon.png differ diff --git a/public/images/elements/Fairy.png b/public/images/elements/Fairy.png new file mode 100644 index 0000000..ed3139a Binary files /dev/null and b/public/images/elements/Fairy.png differ diff --git a/public/images/elements/Fighting.png b/public/images/elements/Fighting.png new file mode 100644 index 0000000..974f907 Binary files /dev/null and b/public/images/elements/Fighting.png differ diff --git a/public/images/elements/Fire.png b/public/images/elements/Fire.png new file mode 100644 index 0000000..9a8b817 Binary files /dev/null and b/public/images/elements/Fire.png differ diff --git a/public/images/elements/Grass.png b/public/images/elements/Grass.png new file mode 100644 index 0000000..dab2f23 Binary files /dev/null and b/public/images/elements/Grass.png differ diff --git a/public/images/elements/Lightning.png b/public/images/elements/Lightning.png new file mode 100644 index 0000000..2144597 Binary files /dev/null and b/public/images/elements/Lightning.png differ diff --git a/public/images/elements/Metal.png b/public/images/elements/Metal.png new file mode 100644 index 0000000..0b2c0f3 Binary files /dev/null and b/public/images/elements/Metal.png differ diff --git a/public/images/elements/Psychic.png b/public/images/elements/Psychic.png new file mode 100644 index 0000000..3300e1d Binary files /dev/null and b/public/images/elements/Psychic.png differ diff --git a/public/images/elements/Water.png b/public/images/elements/Water.png new file mode 100644 index 0000000..efb7a31 Binary files /dev/null and b/public/images/elements/Water.png differ diff --git a/public/images/series/evolusi_mega.webp b/public/images/series/evolusi_mega.webp new file mode 100644 index 0000000..271b396 Binary files /dev/null and b/public/images/series/evolusi_mega.webp differ diff --git a/public/images/series/matahari_bulan.webp b/public/images/series/matahari_bulan.webp new file mode 100644 index 0000000..195a474 Binary files /dev/null and b/public/images/series/matahari_bulan.webp differ diff --git a/public/images/series/pedang_perisai.webp b/public/images/series/pedang_perisai.webp new file mode 100644 index 0000000..64b9218 Binary files /dev/null and b/public/images/series/pedang_perisai.webp differ diff --git a/public/images/series/scarlet_violet.webp b/public/images/series/scarlet_violet.webp new file mode 100644 index 0000000..3477338 Binary files /dev/null and b/public/images/series/scarlet_violet.webp differ diff --git a/public/json/series.json b/public/json/series.json new file mode 100644 index 0000000..6dfb77b --- /dev/null +++ b/public/json/series.json @@ -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" + } +]