From e3aca176b7d5dd4606d39398e366f7013dc27dfb Mon Sep 17 00:00:00 2001 From: RezalSultan Date: Thu, 30 Jul 2026 09:51:25 +0700 Subject: [PATCH] first commit --- .DS_Store | Bin 0 -> 8196 bytes .air.toml | 39 ++ .env.example | 25 + .gitignore | 15 + CONVENTION.md | 478 +++++++++++++++ Makefile | 41 ++ README.md | 177 ++++++ cmd/api/main.go | 24 + cmd/seed/main.go | 21 + config/config.go | 96 +++ go.mod | 51 ++ go.sum | 123 ++++ internal/database/database.go | 56 ++ internal/database/seeder.go | 484 +++++++++++++++ internal/middleware/auth.go | 69 +++ internal/middleware/cors.go | 23 + internal/middleware/logger.go | 78 +++ internal/middleware/rate_limitter.go | 94 +++ internal/middleware/rate_limitter_test.go | 78 +++ internal/modules/auth/dto.go | 58 ++ internal/modules/auth/handler.go | 182 ++++++ internal/modules/auth/model.go | 15 + internal/modules/auth/repository.go | 59 ++ internal/modules/auth/routes.go | 28 + internal/modules/auth/service.go | 348 +++++++++++ internal/modules/auth/service_test.go | 280 +++++++++ .../modules/auth/user_repository_mock_test.go | 137 +++++ internal/modules/cardmaster/dto.go | 169 ++++++ internal/modules/cardmaster/handler.go | 314 ++++++++++ internal/modules/cardmaster/model.go | 161 +++++ internal/modules/cardmaster/repository.go | 158 +++++ .../cardmaster/repository_mock_test.go | 44 ++ internal/modules/cardmaster/routes.go | 27 + internal/modules/cardmaster/service.go | 291 +++++++++ internal/modules/cardmaster/service_test.go | 179 ++++++ internal/modules/seriessetmaster/dto.go | 97 +++ internal/modules/seriessetmaster/handler.go | 550 ++++++++++++++++++ internal/modules/seriessetmaster/model.go | 60 ++ .../modules/seriessetmaster/repository.go | 228 ++++++++ .../seriessetmaster/repository_mock_test.go | 100 ++++ internal/modules/seriessetmaster/routes.go | 36 ++ internal/modules/seriessetmaster/service.go | 206 +++++++ .../modules/seriessetmaster/service_test.go | 226 +++++++ internal/modules/user/dto.go | 72 +++ internal/modules/user/handler.go | 315 ++++++++++ internal/modules/user/model.go | 36 ++ internal/modules/user/repository.go | 125 ++++ internal/modules/user/repository_mock_test.go | 84 +++ internal/modules/user/routes.go | 37 ++ internal/modules/user/service.go | 233 ++++++++ internal/modules/user/service_test.go | 264 +++++++++ internal/pkg/logger/logger.go | 105 ++++ internal/pkg/response/response.go | 131 +++++ internal/pkg/utils/file.go | 28 + internal/pkg/utils/jwt.go | 95 +++ internal/pkg/utils/jwt_test.go | 67 +++ internal/pkg/utils/password.go | 15 + internal/pkg/utils/password_test.go | 34 ++ internal/pkg/utils/string.go | 31 + internal/pkg/utils/url.go | 77 +++ internal/pkg/validator/validator.go | 96 +++ internal/router/router.go | 47 ++ public/.DS_Store | Bin 0 -> 6148 bytes public/images/.DS_Store | Bin 0 -> 8196 bytes .../avatars/avatar_1_1785230686084953000.png | Bin 0 -> 868961 bytes .../avatars/avatar_1_1785231461279633000.png | Bin 0 -> 868961 bytes public/images/elements/Colorless.png | Bin 0 -> 138120 bytes public/images/elements/Darkness.png | Bin 0 -> 278661 bytes public/images/elements/Dragon.png | Bin 0 -> 225723 bytes public/images/elements/Fairy.png | Bin 0 -> 261934 bytes public/images/elements/Fighting.png | Bin 0 -> 131998 bytes public/images/elements/Fire.png | Bin 0 -> 171621 bytes public/images/elements/Grass.png | Bin 0 -> 164539 bytes public/images/elements/Lightning.png | Bin 0 -> 170820 bytes public/images/elements/Metal.png | Bin 0 -> 371856 bytes public/images/elements/Psychic.png | Bin 0 -> 182431 bytes public/images/elements/Water.png | Bin 0 -> 161226 bytes public/images/series/evolusi_mega.webp | Bin 0 -> 134186 bytes public/images/series/matahari_bulan.webp | Bin 0 -> 267396 bytes public/images/series/pedang_perisai.webp | Bin 0 -> 177492 bytes public/images/series/scarlet_violet.webp | Bin 0 -> 105926 bytes public/json/series.json | 30 + 82 files changed, 7847 insertions(+) create mode 100644 .DS_Store create mode 100644 .air.toml create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONVENTION.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/api/main.go create mode 100644 cmd/seed/main.go create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/database/database.go create mode 100644 internal/database/seeder.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/logger.go create mode 100644 internal/middleware/rate_limitter.go create mode 100644 internal/middleware/rate_limitter_test.go create mode 100644 internal/modules/auth/dto.go create mode 100644 internal/modules/auth/handler.go create mode 100644 internal/modules/auth/model.go create mode 100644 internal/modules/auth/repository.go create mode 100644 internal/modules/auth/routes.go create mode 100644 internal/modules/auth/service.go create mode 100644 internal/modules/auth/service_test.go create mode 100644 internal/modules/auth/user_repository_mock_test.go create mode 100644 internal/modules/cardmaster/dto.go create mode 100644 internal/modules/cardmaster/handler.go create mode 100644 internal/modules/cardmaster/model.go create mode 100644 internal/modules/cardmaster/repository.go create mode 100644 internal/modules/cardmaster/repository_mock_test.go create mode 100644 internal/modules/cardmaster/routes.go create mode 100644 internal/modules/cardmaster/service.go create mode 100644 internal/modules/cardmaster/service_test.go create mode 100644 internal/modules/seriessetmaster/dto.go create mode 100644 internal/modules/seriessetmaster/handler.go create mode 100644 internal/modules/seriessetmaster/model.go create mode 100644 internal/modules/seriessetmaster/repository.go create mode 100644 internal/modules/seriessetmaster/repository_mock_test.go create mode 100644 internal/modules/seriessetmaster/routes.go create mode 100644 internal/modules/seriessetmaster/service.go create mode 100644 internal/modules/seriessetmaster/service_test.go create mode 100644 internal/modules/user/dto.go create mode 100644 internal/modules/user/handler.go create mode 100644 internal/modules/user/model.go create mode 100644 internal/modules/user/repository.go create mode 100644 internal/modules/user/repository_mock_test.go create mode 100644 internal/modules/user/routes.go create mode 100644 internal/modules/user/service.go create mode 100644 internal/modules/user/service_test.go create mode 100644 internal/pkg/logger/logger.go create mode 100644 internal/pkg/response/response.go create mode 100644 internal/pkg/utils/file.go create mode 100644 internal/pkg/utils/jwt.go create mode 100644 internal/pkg/utils/jwt_test.go create mode 100644 internal/pkg/utils/password.go create mode 100644 internal/pkg/utils/password_test.go create mode 100644 internal/pkg/utils/string.go create mode 100644 internal/pkg/utils/url.go create mode 100644 internal/pkg/validator/validator.go create mode 100644 internal/router/router.go create mode 100644 public/.DS_Store create mode 100644 public/images/.DS_Store create mode 100644 public/images/avatars/avatar_1_1785230686084953000.png create mode 100644 public/images/avatars/avatar_1_1785231461279633000.png create mode 100644 public/images/elements/Colorless.png create mode 100644 public/images/elements/Darkness.png create mode 100644 public/images/elements/Dragon.png create mode 100644 public/images/elements/Fairy.png create mode 100644 public/images/elements/Fighting.png create mode 100644 public/images/elements/Fire.png create mode 100644 public/images/elements/Grass.png create mode 100644 public/images/elements/Lightning.png create mode 100644 public/images/elements/Metal.png create mode 100644 public/images/elements/Psychic.png create mode 100644 public/images/elements/Water.png create mode 100644 public/images/series/evolusi_mega.webp create mode 100644 public/images/series/matahari_bulan.webp create mode 100644 public/images/series/pedang_perisai.webp create mode 100644 public/images/series/scarlet_violet.webp create mode 100644 public/json/series.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..821097e811cd2e790d077b21d432c5fd7de678d7 GIT binary patch literal 8196 zcmeHM&2Jk;6o2D{WZe+DX_Jx$kXH2tsZmHM1R;d#I*Gubk|>TsLR-x4dg3fu&se+b zm^O;!I|vDmNN_~r$_d1U3lbNu9FQvb1CZc^#0lPfINkLo9CC;#%t$-$dER^T@!S3F zn^^+@sdO3(0Mh_q;U%O!hSf1e*u|AA1>G`%l3+YQ08L=PAp)B7HVp;=gMdN6AYc$M z2>crmz??0Lbwb`(R#O`U3 zr=$$UKGo@g=uo1M7(|7m-4X66%Ubr7RN+8WI1oKD(I*rlN5?ovx&tXIX=;OjK_HKS zIJ=*NIXt%qkfhJ=)sXvM$UU?prN|YOpZz6^>|_XhXh9?E1z)0VF6A|XUL3y&?1-MT zzOu)(?>H>MD!c)ku!8jlROPR^V@9duMrqhhATIz0H7j>-OXlXNemy8p(j5LDA**n*nR^ZiRPPBbKo>Ww44?v2I^JIC%EO z#aUei&cVX`wS`${aq;=X!=g1+dgi6|`d-*#{C!*m7NXy)Fs^xGdY`V0Hnw);yfo*R2~{IT=)#D$9!lM_=@QsB{A&)9y zhi(NvXSLuS-Ad?tiBRhHg#o{H+Iagx)5hI4`b<7(kbS)pQOa&LqoA?6vlG)~E2VGO z?eno8@i1KDagYR@ZQqU&?P@|>Rkp)%#C=41wJl8XCXHg0xtOhOH;P==q?NmOLf50= zDy#4$;Vt1Qdc%dRMO5@8$F8+qKVZ$$_1_horSe)|d0Htpl$3%XPxdt}N$K107pZSB z&SlJ@EX)$7rO8vd;*`tSMVMD1i!)`WSCqcSIAZJ9C?{sC$Ew!d5idD zmjvWp6<_Gz?5A{pT96o(+|AyvMJ(j3i8c{u!A<1C98%`(I&CW>KeN&J#1oGbjC!J-p;O;(wk+Ddy-$zf?X2bB=gd#HUv<=O$*E zJ-7!S!Kd&Ad;{OZefSOjB%{PWfjN<)$UibCbE5}XQ-pQQYd2sS$6k@SY9MzO#0cqp zIE0y}1?>UGhLC?2J%)1S)b>c1PlTX1p;Jvxwb4ezx{2&;HDQJ%i}o8qdyE$ceJgnWlu@8U^(*-0eX{scDv_HS}^%Pw|O@C LU!1VdI{*I-?g-1v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4b994ea0adce7152582cf6a74b1015585faeecd6 GIT binary patch literal 6148 zcmeHKPfrs;6n_H+wji=VtAH`t*oz57sVK%6LoFC>jDc8!MZmh-4rOIK)9h}kKuCJm ziyy!b;MJS);?av&Z$`g?UOnlXKMmCC%@{-WC2xLj-n=)n^J`{a2LMR6UQPp;0AQdB zjP+yl8xi9|JETi{dJu`6gF=w=c**g&(-9pxA_gJ`{x=51+3f`vs$fF}+Rv}J6tbun zen^p@-WKaLf|@?-Z=9Ov2I2JdN7B_D>*?(?`iuc%BfrHP`OpsQLDu%yL~ogM&uQ7V zv*OZ5(M-&97TPYOzD#h-6hqb@xU9?@Szc%5K<4JvfDt$1MRRxo8D+dR0BbmB*{eE%Vt1|vfel`%iprj0HY{7Fh+BW5ucJ!vmht{S(n`UcU z@&17mgC~c~;gQkd#Beg1Jau|(eB#VR(HwQ`(ssocpF7Vz?q|I^U90$RIbX9&E~^wm z`ZUbDd$eed{1SSfmLDCumUn9ylQ?w9>2CX!GCAMk=4L>{!V!D5XbuG>pL^aC58Tk@ zY;`?AvU4G==GZ336?c&6#hMrjR;V8!cH|URZQr+9g=Tj*Jlmn(BFpkHvGtOu{m040FaV#~Ha1-vq z3T(q3ynvVR3f{td_yk|!JLw}PIY%bRWpabekvk+u9+DE-A};aO1YNBk`mE}=S`whu z_CxRFedcjiLBGg}!4n9813OZ|#0hwfdh`Tf_n3%g${08%`-!#@F%U8E_ZSfOgM}(E zqp_(_ULDxTCjep$-BQpNm!`5U|{gw;|l2I775d#qe?F@9Qt|{vOtv|p2+moVR#6ZNrv0{M47V-wNM|U3K4Nlg)#*jU5=$8O7R$~6dbdJftb1P0Hv<4HS1pYJ3;{q2 zGfArli|-`rr`(o0;+7pK8R7vP2q2vBWzqH9tWASqz%XDKFbo(53t0hD1`GrLB?GiS*qBLXV_DZyZ5>#s5CCN-Hmig>#sLar$1)qsx|RxxW9sOE#ZVT9 z7)-;l-zIaE*;v-K)No)L4lJIT#Sscqvm?${=D@OAn$j>}7-(mJV)t&0+iRGk#`s;j z*h9{g!+hkcN~qDN01iIpWy}IGalF57|DwfyJKEJiBBlxlq>njiK=1F32sttMoQmJbjO|b_)5VV zxFAL3c!CEC;FUO<+`8_Gl3dBjswjmDo7WwwbSho2_O7lTJ3T&PkDWTcHe#=ijUFEx zvB$?xuC1k0gPEgerVC5{vXBpnDcptP-3o~2#`Fj)n-b#{Yw-fv+hl4|IS_g1O0=8gS+<(?cKM3U%}etI>n`OK$m_&`ZCD*RerM^c%^*BDSDzj7x8;h z-h03c*3K5wXpD$Q_VKI=tKP5vi@}!u* z6(aEy5nrAV3le9nA?cG9>I!Fh5Tb0u%*{JN;D|EM-M{TSF83!zPDYVjp7V-Q=qvA2 z!(w$R;520B+-J>fCVMqe4r;}Mj8^ce9Z0lTvdnA$qta)D6g-{DPU)UBKH-PpJY0iW zSb_)e7@op&cm;3ZJ$!;MteaVEm>ps#*jaXtU11aK1}m~f=CMEz(2?Bq1G=7+EJSV7 zn_kNY!k40qHiYj*FVP;ksJxQ=#zhbEu_jtP=Ly<3dT1|H(C12I31!4lEkMr;vBhh~ zcF-}}j18fvW+Wev5M4tgc`w#lu(aMrY!}X;XX0(U0_`Ht8g`K;+keHQn-*of_}}&7 zrW@Vjx%i;}C25Pk&LN8bFKzz)|1VjMxlF@=Vc=&NV4WB97wLdbUR+uSp_c?3^Igo8 z$gZxXf?z==)ULvDlIwfdi*M=I Bi6H<0 literal 0 HcmV?d00001 diff --git a/public/images/avatars/avatar_1_1785230686084953000.png b/public/images/avatars/avatar_1_1785230686084953000.png new file mode 100644 index 0000000000000000000000000000000000000000..c0abbe3ef8f72c379c3be60c8d1b75744715c729 GIT binary patch literal 868961 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

bn&OVz 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

%b+mknHWP2Ma=DuYN!OO;Sr!# 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%0Dwi}i*`R< zo&uaMH2zA@^`h7Fpxf~N?aN=?ed*z0Smh9*?wxyxP7waez~9vu^lq*LS`XD-VZ(?_ zt%o5(hMfwODbhceM?~fu9aD5cwWTNtK?cr`TMs}CuwU?%2s6)i0-+BWj16+&mM#0? zDDZQAQ5N^_E_K2dW5&4BPmeSuX03G~89v6uKLw>yf!Ko(X(0t>+^xlSFPm@VkGBfB zXp3^lbIvB!EaEg$W#t=A_d*mNeQs{<clfLB%^ zhGdEhUMk=H!9%K~OpEP`E+wHmgeC_ynLszA zJ1=av2U>LYe=}lyiGM_&kj|z%B?d2G4Wd>gt6gFaR~qcD7zmXG0isq+5B^O~j0q4O zX9g{V-t@J+-oti$tpf1f+iwe7C6JJacw8?36yX)?@?q7&9IF`+E;2(vP+w>^#An#W z_E{B!3kKYSw*0$?oZ1{{{hj>xgRroAll%io{3V{Qw}Gy@gWecAZFYM|0HTzu{}A93 zBo6vY^X4RgCbf`*%mz#ks1c~_;uY&j&5cc?nh|#N_TW)vLkEy##uJ~QFoK1~9G6PL z2$0@TLWCf;{+<;$v;edUgm19wXVV^xj#y>>^?Wrf4JyoH6(!SIJNv9XV+*&cHuo)| zKvNb88JFMEYI|K9BHmh49+{y01 z0d#rA)&uz`*Q1fF*G&Kvf&0xvNmKw5d?#MEQ)FwmsBUC#sqOSiOx>q^!eml%rSEe3 zzp<_gwW_ra+$o85VcjsG6XnE5e)HIi89Vwb@3R<bO(2yThn+HoQ`EsjEcR((ROCo+Mu?V-CqU@#|+5xjn;3ux0kbR9kj zYmswENQjv^vgi3mxkJs@N(^4a>3o*54Ujmu%=&v;LqlMWkEJrQxX?GMPobB_w@iC) z`s(uCqHP~(Ic5CpGp8pR2yf+=)%`|34$qLF$f1slZP`vnO2BY-C}=+8EId$_qRhrJ=MkcAZs z|FlVwzUP)0<;e>9vW8=lkTslM-2UpSHa1k0&|>~|eE4#1c*veH3E2S1>cmQsoxM1X zj*EjLH&$Cdw