MR.D.I.Y.

API Administrative Console v1.0.0

Raw URL
Bearer Token:
MR.D.I.Y. System Admin Console Navigation
Server Online (Port 9006)

Authentication & Login

Endpoint autentikasi akun user ke sistem. Pendaftaran user dilakukan oleh Admin di User Management CRUD.

1 Endpoint
POST /api/v1/auth/login
Public ยท Login User

Autentikasi email & password. Mengembalikan JWT Token dan daftar permission codes (Mendukung `application/json` & `multipart/form-data`).

Profile Management

Kelola data profil, edit nama/email, ubah password, dan upload foto profil avatar.

4 Endpoints
GET /api/v1/auth/me
Bearer Auth

Mengambil data detail profil user terautentikasi (termasuk Full Avatar URL & list permissions).

PUT /api/v1/auth/profile
Bearer Auth

Mengedit nama dan email user (Mendukung `application/json` & `multipart/form-data`).

POST /api/v1/auth/change-password
Bearer Auth

Mengubah password user setelah verifikasi password lama (`old_password` & `new_password`).

POST /api/v1/auth/avatar
Bearer Auth

Upload file foto profil (`multipart/form-data` key: `file`). Mengembalikan **Full Avatar URL**.

Master Data Toko MR.D.I.Y.

Master gerai fisik MR.D.I.Y. (kode TKO-*) — CRUD, aksi massal, import & export .xlsx/.csv. Permission: stores.read / stores.write.

9 Endpoints
Master toko berdiri sendiri. Tidak ada relasi ke Master Gedung UTM. Kolom nearest_store pada gedung hanyalah teks apa adanya dari file vendor TapOn, bukan foreign key ke tabel ini. Bentuk data: {"code":"TKO-JKT03","name":"MR.D.I.Y. Plaza Senayan","city":"Jakarta Selatan","region":"DKI Jakarta"}
GET /api/v1/stores
Perm: stores.read

Daftar master toko, urut code menaik. Filter: search/q, is_jabodetabek, is_active, city, region, page, limit. Filter boolean menerima true/1/ya dan false/0/tidak; nilai tak dikenal diabaikan (bukan dianggap false).

GET /api/v1/stores/template & /api/v1/stores/export
Perm: stores.read

Unduh file contoh berisi header yang dikenali importer, atau export master toko saat ini. Query ?format=xlsx (default) atau ?format=csv.

POST /api/v1/stores/import
Perm: stores.write

Import daftar toko dari file .xlsx atau .csv (multipart, field file). Jalankan dry_run=true lebih dulu untuk melihat rencana perubahan tanpa menyentuh database.

POST /api/v1/stores/bulk
Perm: stores.write

Aktivasi, non-aktifkan, atau hapus banyak toko sekaligus berdasarkan daftar ids.

POST /api/v1/stores
Perm: stores.write

Tambah satu gerai baru ke master toko.

GET /api/v1/stores/{id}
Perm: stores.read

Detail satu toko beserta jumlah voucher terkait.

PUT /api/v1/stores/{id}
Perm: stores.write

Perbarui data satu toko.

DELETE /api/v1/stores/{id}
Perm: stores.write

Hapus (soft delete) satu toko.

Master Data Gedung UTM (NFC TapOn)

Master gedung apartemen/office tempat stiker NFC TapOn dipasang vendor — CRUD, aksi massal, import & export .xlsx/.csv. Permission: utm.read / utm.write.

9 Endpoints
Master gedung berdiri sendiri. Sumbernya file building list vendor TapOn, bukan gerai MR.D.I.Y. Kunci uniknya building_code (mis. B000277), dan kolom nearest_store hanya teks apa adanya dari vendor — bukan relasi ke Master Data Toko.
GET /api/v1/utm/buildings
Perm: utm.read

Daftar master gedung NFC TapOn dengan filter search, area, building_type, nearest_store, is_active, page, limit.

POST /api/v1/utm/buildings/import
Perm: utm.write

Import file building list dari vendor (.xlsx atau .csv). Header dicari otomatis walau file diawali blok judul & statistik. Baris dicocokkan berdasarkan Building ID: sudah ada = update, belum ada = create.

GET /api/v1/utm/buildings/export
Perm: utm.read

Unduh master gedung sebagai .xlsx atau .csv (memakai filter pada panel daftar gedung di atas). Tombol kedua mengunduh file template berisi header yang dikenali importer.

POST /api/v1/utm/buildings
Perm: utm.write

Tambah satu gedung TapOn secara manual.

PUT /api/v1/utm/buildings/:id
Perm: utm.write

Ubah data satu gedung. Perubahan Building ID otomatis tersalin ke seluruh link UTM milik gedung tersebut.

DELETE /api/v1/utm/buildings/:id
Perm: utm.write

Hapus gedung. Ditolak bila masih dipakai link UTM, kecuali ditambahkan query ?force=true.

POST /api/v1/utm/buildings/bulk
Perm: utm.write

Aksi massal gedung: activate, deactivate, atau delete.

Events Kampanye (CRUD)

Aturan kampanye: 1 email 1x, batasan toko Jabodetabek, & aturan expired (`events.read` / `events.write`).

8 Endpoints

Alur Integrasi End-to-End (Baca Dulu)

Event adalah sumber kebenaran semua aturan kampanye. UTM link, klaim, aktivasi kasir, dan expiry voucher semuanya membaca konfigurasi event โ€” tidak ada aturan yang ditaruh di sisi frontend.

  1. Buat event โ€” POST /api/v1/events. Set allowed_cities, valid_days_type + valid_days, live_minutes, stock_limit, landing_url, dan utm_building_ids (master gedung yang ikut kampanye ini). Event tidak menyimpan utm_source / utm_medium / utm_campaign โ€” parameter UTM diturunkan dari master gedung + kode event saat link digenerate.
  2. Generate link NFC TapOn โ€” POST /api/v1/utm/links/generate cukup dengan { "event_id": 1 }. Landing URL diambil dari event, gedung dari utm_building_ids event, dan parameter UTM diturunkan otomatis (default sistem + kode event + data gedung). Hasilnya bisa diekspor lewat GET /api/v1/utm/links/export untuk diserahkan ke vendor stiker.
  3. Pengunjung tap stiker โ€” kena short link GET /t/:slug, tap tercatat, lalu redirect ke landing page berikut parameter UTM, utm_id (ID link, dipakai saat klaim), dan b (kode gedung).
  4. Klaim voucher โ€” POST /api/v1/claim dengan event_id + data pengunjung + utm_id dari URL landing (bukan parameter utm_*). Voucher dikirim via email; response belum memuat kode voucher.
  5. Kasir aktivasi โ€” POST /api/v1/vouchers/token/:token/activate dengan store_code. Kode voucher baru muncul di response setelah kode toko valid.
  6. Kasir tandai terpakai โ€” POST /api/v1/vouchers/token/:token/use (payload opsional event_id + utm_id untuk analytics).

Aturan yang dipaksakan server

  • 1 email 1x per periode โ€” bila one_email_per_period = true, klaim kedua dengan email sama ditolak 409. Voucher yang sudah dihanguskan (void) tidak dihitung, jadi email tersebut boleh klaim ulang.
  • Batasan kota โ€” allowed_cities dicocokkan dengan city master toko saat aktivasi. Daftar kosong = semua kota boleh.
  • Kode toko wajib valid โ€” store_code selalu dicek ke master toko dan harus is_active = true, walau allowed_cities kosong. Kode salah = 400, voucher tetap UNUSED dan kasir bisa mengetik ulang.
  • Expiry voucher โ€” PLUS_DAYS: claim_expires_at = tanggal klaim + valid_days, otomatis dipotong ke end_date event bila melewatinya. FIXED_DATE: pakai expire_fixed_date, yang ditolak 400 saat disimpan bila melewati end_date.
  • Timer live โ€” setelah aktivasi, voucher hidup live_minutes menit lalu otomatis EXPIRED.
  • Stok โ€” stock_limit > 0 membatasi total klaim; habis = 400 "Kupon telah habis". 0 = unlimited (voucher digenerate on-demand). Pemakaian realtime bisa dibaca di GET /api/v1/events/:id/settings โ†’ voucher_stats.
  • Periode โ€” end_date harus lebih besar dari start_date; klaim di luar periode atau saat is_active = false ditolak 400.

Status voucher & kapan kode muncul

UNUSED โ†’ LIVE (setelah kasir aktivasi) โ†’ USED. Cabang lain: EXPIRED (lewat masa berlaku / timer live habis) dan VOID (dihanguskan admin, stok kembali ke pool).

Field code pada response publik bernilai null selama status masih UNUSED. Kode asli baru dikirim saat status LIVE atau USED โ€” jangan bangun UI yang mengharapkan kode tersedia sejak klaim.

GET /api/v1/events
Perm: events.read

Menampilkan daftar event kampanye dengan **filter query**: `search`/`q`, `code`, `building_category`, `is_active`, `page`, & `limit`.

GET /api/v1/events/:id
Perm: events.read

Menampilkan detail spesifik 1 Event Kampanye berdasarkan ID.

POST /api/v1/events
Perm: events.write

Membuat Event Kampanye promo baru. Aturan: 1 email per periode, batasan kota (allowed_cities), valid_days / live_minutes, dan daftar gedung UTM default (utm_building_ids) yang dipakai saat generate link.

Wajib: code (unik, otomatis di-uppercase) & name. Default bila kosong: valid_days_type = PLUS_DAYS, valid_days = 7, one_email_per_period = true, is_active = true, stock_limit = 0 (unlimited). Ditolak 400: end_date โ‰ค start_date, atau FIXED_DATE tanpa/melebihi expire_fixed_date di luar end_date. 409: kode event sudah dipakai.

landing_url mendukung placeholder {event_code} / {event_id}. Isi hanya bila kampanye diarahkan ke halaman di luar aplikasi klaim (microsite). Kosong = normal: tap berhenti di short link /t/{slug} dan halaman klaim menukar slug itu ke atribusinya lewat GET /api/v1/t/:slug.

PUT /api/v1/events/:id
Perm: events.write

Mengubah tanggal atau aturan event kampanye. Full replace: field yang tidak dikirim direset ke nilai kosong/default โ€” untuk ubah sebagian saja pakai PATCH /api/v1/events/:id/settings.

Pengecualian: allowed_cities dan utm_building_ids hanya berubah bila field-nya benar-benar dikirim. Bila diabaikan, daftar lama dipertahankan โ€” supaya form edit yang tidak menyertakan keduanya tidak diam-diam mengosongkan daftar gedung dan bikin POST /utm/links/generate gagal. Kirim [] secara eksplisit bila memang ingin mengosongkan.

Validasi sama dengan POST (end_date > start_date, FIXED_DATE tidak melewati end_date, kode unik). Mengubah aturan tidak mengubah voucher yang sudah terlanjur diklaim โ€” claim_expires_at dikunci saat klaim.

DELETE /api/v1/events/:id
Perm: events.write

Menghapus Event Kampanye (Soft Delete).

GET /api/v1/events/:id/vouchers
Perm: events.read

Menampilkan semua voucher yang terkait dengan event tertentu (filter: status, search, page, limit) + statistik agregat (UNUSED / LIVE / USED / EXPIRED).

GET /api/v1/events/:id/settings
Perm: events.write

Menampilkan detail pengaturan kampanye event (toggle aktif, aturan expiry, Jabodetabek only, 1 email per periode) beserta statistik voucher per status.

PATCH /api/v1/events/:id/settings PARTIAL UPDATE
Perm: events.write

Update parsial pengaturan kampanye tanpa harus mengirim ulang seluruh data event. Kirim hanya field yang ingin diubah. Fields: is_active, allowed_cities, one_email_per_period, valid_days_type, valid_days, expire_fixed_date, live_minutes, start_date, end_date, landing_url, utm_building_ids.

Field yang tidak dikirim tetap memakai nilai tersimpan. Validasi periode tetap jalan atas gabungan nilai baru + lama, jadi PATCH yang membuat expire_fixed_date melewati end_date ditolak 400. Body kosong juga 400. POST ke path yang sama adalah alias dari PATCH ini.

Response mengembalikan settings hasil simpan berikut resolved_landing_url (landing luar setelah placeholder diproses; kosong = tap ditangani aplikasi klaim sendiri) โ€” pakai nilai ini untuk memverifikasi tujuan link sebelum generate stiker.

Kupon & Voucher Public (Customer Claim & Kasir Toko)

API Publik tanpa autentikasi JWT untuk klaim customer (UTM Tracking), view voucher digital, dan aktivasi kasir toko (Live Timer 10 Min).

5 Endpoints
GET /api/v1/settings/public TANPA TOKEN
Public Endpoint

Setting yang ditandai is_public = true, dipakai halaman klaim yang tidak punya JWT. Kegunaan utamanya: membaca allowed_email_domains agar form bisa memberi tahu domain yang diterima sebelum pengunjung menekan kirim.

Hanya key, value, dan group yang dikirim โ€” description bisa memuat catatan internal, jadi sengaja tidak ikut.

POST /api/v1/claim
Public Endpoint

Customer klaim voucher dengan memasukkan data nama, email, hp, dan parameter UTM (Mendukung JSON / Form-Data).

Wajib: event_id, client_name, client_email. Semua aturan (periode aktif, 1 email 1x, stok, masa berlaku) dibaca dari event tersebut.

Atribusi dari Stiker NFC

Bila pengunjung datang dari stiker, kirim utm_id (didapat dari GET /api/v1/t/{slug}) bersama event_id. Backend mengambil sumber, medium, campaign, term, dan kode gedung dari baris link tersebut dan mengabaikan parameter utm_* yang ikut dikirim.

Parameter utm_source/utm_medium/utm_campaign kini hanya relevan untuk tautan QR booth yang memang membawanya di URL.

Filter Domain Email (Wajib Dibaca Frontend)

Klaim ditolak 400 bila domain client_email tidak ada di setting allowed_email_domains. Pesannya sudah siap tampil apa adanya ke pengunjung, contoh: "Alamat email ini belum bisa dipakai. Gunakan email berdomain gmail.com, yahoo.com, atau outlook.com ya."

Daftar domainnya dibaca frontend lebih dulu lewat GET /api/v1/settings/public supaya form bisa memvalidasi sebelum tombol kirim ditekan. Setting kosong = semua domain diizinkan.

Pencocokan persis, bukan sufiks: budi@gmail.com.attacker.net dan budi@mail.gmail.com ditolak walau gmail.com diizinkan.

Atribusi UTM: cukup teruskan utm_id (ID link hasil generate) yang sudah ada di query string landing page. Server menyalin utm_source, utm_medium, utm_campaign, utm_term, utm_content, dan kode gedung dari baris link tersebut โ€” parameter utm_* di URL tidak dibaca sama sekali, jadi atribusi tidak bisa dikarang pengunjung yang mengedit address bar.

slug tetap diterima sebagai cadangan untuk stiker lama yang dicetak sebelum utm_id ada. Tanpa keduanya (mis. pengunjung membuka landing page langsung), klaim tetap diterima dan dicatat sebagai direct / web / kode event.

Error: 409 email sudah klaim di periode ini ยท 400 event non-aktif / belum mulai / sudah berakhir / stok habis ยท 404 event tidak ada.

Response: data.link (URL voucher digital /v/:token) + data.voucher. Field code masih null di tahap ini. Email voucher dikirim async lewat rotasi SMTP; kegagalan kirim tidak menggagalkan klaim (tercatat di /api/v1/smtp/failed-logs dan bisa dikirim ulang).

GET /api/v1/vouchers/token/:token
Public Page View

Menampilkan status voucher & sisa waktu timer (jika status LIVE). Dipakai halaman voucher digital /v/:token.

Response hanya berisi field publik (tanpa PII lengkap / internal event). code = null selama status UNUSED. Pakai live_expires_at untuk hitung mundur dan claim_expires_at untuk masa berlaku voucher. Setiap request ikut menyapu voucher yang sudah lewat masa berlakunya, jadi status yang dikembalikan selalu terkini.

POST /api/v1/vouchers/token/:token/activate
Kasir Toko / Slider Swipe

Aktivasi slider voucher oleh Kasir di toko. Memulai timer expiry realtime sepanjang live_minutes event (default 10 menit).

Validasi store_code (berurutan): (1) harus ada di master toko โ€” kode salah = 400 dan kasir bisa mengetik ulang, voucher tetap UNUSED; (2) toko harus is_active = true; (3) bila event punya allowed_cities, kota toko wajib termasuk. Pengecekan ini jalan walau allowed_cities kosong.

Idempoten: voucher yang sudah LIVE mengembalikan 200 tanpa mereset timer. Status VOID / EXPIRED / USED ditolak 400.

Di sinilah kode voucher muncul: setelah sukses, response memuat code, live_expires_at, dan store_code yang tercatat.

POST /api/v1/vouchers/token/:token/use
Finalisasi Kasir

Menandai voucher sebagai USED (Telah Terpakai) oleh Kasir di POS. Voucher wajib berstatus LIVE โ€” status lain ditolak 400.

Payload opsional untuk analytics: event_id dicek silang dengan event pemilik voucher (tidak cocok = 400), dan utm_id memperbarui atribusi voucher dengan data link tersebut (source, medium, campaign, term, content, kode gedung) sehingga jejak sumber tercatat di titik redeem. Kirim body kosong bila tidak diperlukan.

Management Voucher Admin & Bulk Import/Export

API Terproteksi JWT untuk Admin: Single Voucher CRUD, Bulk CSV Generate, Import xlsx/csv dengan pemetaan kolom (status USED_POS), Export xlsx/CSV, Ringkasan Agregat, Resend Massal, Kirim/Kirim-Ulang Email Per-Voucher, Hilangkan Penerima, & Filter Laporan Voucher.

16 Endpoints
GET /api/v1/vouchers
Perm: vouchers.read

Menampilkan laporan seluruh klaim voucher terdaftar dengan **filter query**: `event_id`, `status`, `search`/`q`, `page`, & `limit`.

POST /api/v1/vouchers
Perm: vouchers.write

Menambahkan single voucher baru secara manual terikat ke ID Event / Kampanye Promo.

POST /api/v1/vouchers/bulk-csv Bulk CSV Import

Unggah file CSV atau kirimkan data CSV teks untuk membuat puluhan/ratusan voucher digital sekaligus terikat ke Event ID. Ini alur lama tanpa pemetaan kolom โ€” untuk berkas dengan header bebas + pilih kolom manual, pakai endpoint /vouchers/import-preview + /vouchers/import di bawah.

GET /api/v1/vouchers/export-csv Export CSV
Perm: vouchers.read

Export seluruh data voucher (atau terfilter per Event ID & status) langsung ke format file Excel (.xlsx, default) atau CSV.

GET /api/v1/vouchers/stats Ringkasan Agregat
Perm: vouchers.read

Ringkasan voucher dihitung dari SELURUH data yang cocok filter (opsional event_id), bukan cuma satu halaman list. Balasannya berisi hitungan per status (unused/live/used/used_pos/expired/void) plus status pengiriman email (not_sent/sent/pending/failed) dari smtp_failed_logs.

POST /api/v1/vouchers/resend-failed Resend Massal
Perm: vouchers.write

Kirim ulang email untuk SEMUA voucher yang emailnya belum sukses terkirim (log PENDING/FAILED di smtp_failed_logs) โ€” termasuk voucher yang datanya baru diubah lewat PUT /vouchers/:id (mis. client_email diganti) sehingga percobaan kirim sebelumnya sudah tidak relevan. Berbeda dari /smtp/logs/resend-all: endpoint itu generik dari sisi log SMTP, ini scoped ke voucher dan bisa difilter per event_id.

POST /api/v1/vouchers/import-preview Select Column โ€” Step 1
Perm: vouchers.write

Langkah 1 dari import .xlsx/.csv terstandarisasi: baca baris header + 5 baris contoh, tanpa menyimpan apa pun. Balasannya berisi headers, sample_rows, dan suggested_mapping (tebakan otomatis field โ†’ nama kolom) โ€” dipakai UI untuk menampilkan dropdown "pilih kolom" sebelum operator mengonfirmasi ke /vouchers/import.

POST /api/v1/vouchers/import Select Column โ€” Step 2
Perm: vouchers.write

Langkah 2: simpan pakai file + berkas yang sama, dengan mapping (JSON field โ†’ nama header terpilih, hasil koreksi dari suggested_mapping di langkah 1). Kode voucher yang sudah ada di database di-update (termasuk transisi status ke USED_POS untuk voucher yang dipakai fisik di kasir POS di luar alur app), kode yang belum ada dibuat sebagai voucher baru dan butuh event_id. Centang dry_run untuk pratinjau hasil created/updated/skipped tanpa menyimpan.

GET /api/v1/vouchers/:id
Perm: vouchers.read

Menampilkan detail lengkap data voucher berdasarkan **ID** database.

PUT /api/v1/vouchers/:id
Perm: vouchers.write

Memperbarui data DASAR voucher (kode, judul, nilai, keterangan, status, kampanye) berdasarkan ID.

PERBAIKAN: endpoint ini TIDAK LAGI menerima data penerima

Sebelumnya body ini juga menerima client_name/client_email/client_phone/store_code, dan MENIMPANYA walau tidak dikirim โ€” form edit di web tidak pernah mengirim field itu, jadi tiap kali admin menyunting voucher, data penerima yang sudah terklaim ikut kehapus diam-diam. Field itu sudah dihapus total dari endpoint ini; mengirimnya di body tidak akan berpengaruh apa pun.

Ubah/kirim penerima sekarang lewat endpoint khusus di bawah: POST /vouchers/:id/send-email (assign + kirim) dan POST /vouchers/:id/hapus-penerima (kosongkan).

DELETE /api/v1/vouchers/:id
Perm: vouchers.write

Menghapus data voucher dari sistem berdasarkan ID.

POST /api/v1/vouchers/:id/resend-email
Perm: vouchers.write

Kirim ulang email voucher ke client_email yang SUDAH tersimpan di voucher ini. Beda dari /vouchers/resend-failed: itu massal berdasar log gagal, ini satu voucher tertentu.

Gagal bila: voucher belum punya client_email (belum diklaim) atau sudah dihanguskan (voided_at terisi).

POST /api/v1/vouchers/:id/send-email
Perm: vouchers.write

Menempelkan client_name + client_email ke SATU voucher tertentu (by ID) lalu langsung mengirim email klaimnya. Berbeda dari POST /claim (publik, ambil voucher random dari stok event): di sini vouchernya sudah ditentukan operator, dan menimpa penerima lama kalau voucher itu sebelumnya sudah punya penerima.

Gagal bila: voucher sudah dihanguskan, kampanyenya tidak aktif / belum mulai / sudah berakhir (jendela tanggal sama persis dengan POST /claim), atau client_name/client_email kosong/tidak valid.

Kenapa jendela tanggal dicek: tanpa itu, claim_expires_at yang dihitung bisa jatuh di masa lalu (di-clamp ke event.end_date yang sudah lewat) โ€” voucher tersimpan UNUSED dengan expiry basi, lalu permintaan list berikutnya (yang menjalankan sweeper) langsung menghanguskannya sendiri.

POST /api/v1/vouchers/:id/hapus-penerima
Perm: vouchers.write

Mengosongkan client_name/client_email/client_phone pada voucher. Status & timer voucher tidak diubah. Data penerima yang dihapus dipindah ke last_client_name/last_client_email sebagai jejak โ€” masih terlihat di response, hanya tidak lagi jadi tujuan resend email.

Gagal bila: voucher ini memang belum punya penerima.

UTM Tracking & Analytics

Funnel kampanye NFC: tap stiker → email dibuka → klaim → aktivasi di toko, dipecah per gedung, link, toko, dan event (`utm.read`).

9 Endpoints
GET /api/v1/utm/analytics/*
Perm: utm.read

Efektivitas kampanye diukur dari data yang benar-benar dimiliki sistem ini โ€” tap stiker NFC, buka email, klaim, dan aktivasi kasir โ€” bukan dari kanal iklan digital, karena trafiknya memang tidak datang dari sana.

Satu panel = satu endpoint

Tiap panel dashboard punya endpoint sendiri, jadi response-nya kecil dan jelas isinya:

  • GET /api/v1/utm/analytics/funnel — corong + konversi (funnel, rates)
  • GET /api/v1/utm/analytics/events — by_event
  • GET /api/v1/utm/analytics/buildings — by_building
  • GET /api/v1/utm/analytics/links — by_link
  • GET /api/v1/utm/analytics/stores — by_store
  • GET /api/v1/utm/analytics/email — email

Tidak mau banyak hit? Pakai endpoint gabungan GET /api/v1/utm/analytics dengan include: ?include=funnel,by_store memuat dua panel dalam satu permintaan. Bagian yang tidak diminta tidak dikueri sama sekali. Tanpa include seluruh bagian dikirim; nilai yang tidak dikenali diperlakukan sebagai "semua" supaya salah ketik tidak menghasilkan response kosong.

Semua endpoint di atas menerima filter yang sama (event_id, start_date, end_date, limit) dan mengembalikan filter hasil parsing, plus notes yang hanya memuat catatan relevan untuk bagian tersebut.

Tahapan funnel

taps tap stiker NFC (termasuk tap berulang) → claims voucher diklaim → emails_sent email terkirim → emails_opened email dibuka (tracking pixel) → activated dibuka kasir di toko → used dipakai di kasir. Pelengkap: unique_links, expired, void, still_claimable.

rates berisi konversi antar tahap dalam persen: tap_to_claim, claim_to_email_open, claim_to_activate, activate_to_used, claim_to_used.

Pecahan per master data

  • by_building โ€” per gedung NFC, diperkaya nama / area / tipe dari master gedung. Kolom: taps, claims, used.
  • by_link โ€” per link stiker (utm_id), lengkap dengan slug, kode gedung, dan event. Dipakai menilai stiker mana yang benar-benar menghasilkan.
  • by_store โ€” per toko, diperkaya nama / kota / provinsi dari master toko. Kolom: activations, used.
  • by_event โ€” ringkasan per event, berguna saat filter event dikosongkan.

Filter query: event_id, start_date, end_date (terima YYYY-MM-DD maupun RFC3339), dan limit (jumlah baris tiap pecahan, maks 200, default 20). Rentang waktu diterapkan pada peristiwa yang relevan tiap bagian: tap pakai waktu tap, klaim pakai claimed_at, toko pakai opened_at.

Blok email โ€” menjawab pertanyaan soal email

  • Sudah dibuka? sent, opened_unique (jumlah penerima berbeda), opened_total (termasuk buka berulang), open_rate persen.
  • Kapan dibukanya? first_opened_at, last_opened_at, avg_minutes_to_open (jeda rata-rata klaim → buka), dan by_hour โ€” sebaran jam buka 00:00โ€“23:00 untuk menentukan jam kirim berikutnya.
  • Pakai perangkat apa? by_device (MOBILE / TABLET / DESKTOP / EMAIL_PROXY / UNKNOWN) dan by_email_client (Gmail, Apple Mail, Outlook, Thunderbird, Yahoo Mail).
  • IP dan user agent? recent_opens memuat kejadian terakhir apa adanya: voucher_id, email penerima, waktu, device_type, email_client, ip_address, user_agent, dan is_first_open. Riwayat penuhnya di GET /api/v1/utm/email-opens.

Baca angkanya dengan jujur: emails_opened adalah batas bawah โ€” klien email yang memblokir gambar tidak akan memuat pixel dan tidak terhitung, jadi jangan pakai angka ini sebagai jumlah pasti orang yang membaca email. Sebaliknya, sebagian klien memuat gambar lewat proxy mereka sendiri (Gmail), sehingga ip_address yang tercatat adalah IP proxy dan perangkatnya masuk kategori EMAIL_PROXY โ€” bukan perangkat penerima. Field notes pada response memuat peringatan yang sama agar konsumen API tidak salah tafsir.

GET /api/v1/utm/logs
Perm: utm.read

Daftar log mentah atribusi klaim: satu baris per voucher yang diklaim, berisi link (link_id), slug, kode gedung, parameter UTM, IP, dan user agent.

Filter query: event_id, utm_id (alias link_id), building_code, slug, source, medium, campaign, search/q (cocok ke source, medium, campaign, kode gedung, slug, IP), page, limit.

Log tap NFC mentah โ€” termasuk tap yang tidak berujung klaim โ€” ada di GET /api/v1/utm/taps.

GET /api/v1/utm/email-opens
Perm: utm.read

Riwayat buka email voucher, satu baris per pemuatan tracking pixel. Menjawab per penerima: sudah dibuka atau belum, kapan (sampai detik), dari perangkat / klien email apa, dan dari IP mana.

Tiap baris berisi voucher_id, event_id, client_email, created_at, device_type, email_client, ip_address, user_agent, dan is_first_open.

Filter query: event_id, voucher_id, email, device_type, email_client, ip_address, first_open_only, start_date, end_date, page, limit (maks 200).

Pakai first_open_only=true bila ingin menghitung orang, bukan berapa kali gambar dimuat ulang. Untuk mengecek satu penerima, saring dengan email atau voucher_id.

User Management CRUD & Suspend Status

Kelola user sistem, buat user Admin/Staff, ubah role, dan fitur **Suspend / Nonaktifkan User** (`rbac.manage`).

6 Endpoints
GET /api/v1/auth/users
Perm: rbac.manage

Daftar semua user terdaftar dengan **filter query**: `role_id`, `search`/`q`, `is_active`, `page`, & `limit`.

GET /api/v1/auth/users/:id
Perm: rbac.manage

Menampilkan detail 1 user spesifik berdasarkan ID beserta role & permissions.

POST /api/v1/auth/users
Perm: rbac.manage

Admin membuat user baru (Admin/Manager/Staff) dengan menentukan `role_id` dan `is_active`.

PUT /api/v1/auth/users/:id/status SUSPEND TOGGLE
Perm: rbac.manage

Ubah status user. Jika `is_active: false`, user di-suspend dan ditolak saat login (HTTP 403 Forbidden).

DELETE /api/v1/auth/users/:id
Perm: rbac.manage

Menghapus user dari sistem (Soft Delete).

Roles Management (RBAC)

CRUD Role relasional, menyinkronkan array ID permissions, dan merubah role user (`rbac.manage`).

6 Endpoints
GET /api/v1/rbac/roles
Perm: rbac.manage

Menampilkan daftar role sistem dengan **filter query**: `search`/`q`, `code`, `page`, & `limit` beserta array `permissions` relasional.

GET /api/v1/rbac/roles/:id
Perm: rbac.manage

Menampilkan detail spesifik 1 Role berdasarkan ID beserta relasi permissions.

POST /api/v1/rbac/roles
Perm: rbac.manage

Membuat Role baru dan mendaftarkan array `permission_ids` yang berhak diakses.

PUT /api/v1/rbac/roles/:id
Perm: rbac.manage

Mengubah nama, deskripsi, atau menyinkronkan ulang array `permission_ids` milik suatu Role.

POST /api/v1/auth/users/:id/role
Perm: rbac.manage

Mengganti `role_id` user tertentu secara langsung.

DELETE /api/v1/rbac/roles/:id
Perm: rbac.manage

Menghapus Role dari sistem (Soft Delete).

Master Permissions Sistem

Daftar lengkap master permissions sistem dengan filter modul, pencarian kata kunci, dan paginasi.

1 Endpoint
GET /api/v1/rbac/permissions
Perm: rbac.manage

Menampilkan master permissions dengan **filter query**: `module`, `search`/`q`, `code`, `page`, & `limit`.

Multi-Provider SMTP Manager (Provider Bebas)

CRUD SMTP generik untuk **provider apa pun** (Gmail, Zoho, Brevo, Mailtrap, relay internal, dst): `encryption` (none/starttls/ssl), `auth_type` (plain/login/cram-md5/none), sandbox mode, prioritas & bobot rotasi, kuota harian + bulanan, auto-failover, dan health tracking (`smtp.manage`).

15 Endpoints

Catatan Provider

Nama provider hanya **label bebas**. Yang menentukan koneksi adalah `host`, `port`, `encryption`, dan `auth_type` โ€” jadi vendor mana pun bisa dipakai, termasuk relay internal tanpa autentikasi (`auth_type: "none"`).

`provider_key` opsional: isi dengan salah satu preset dari `GET /api/v1/smtp/providers` untuk auto-isi host/port/encryption, atau `"custom"` untuk isi manual.

Password disimpan terenkripsi AES-256-GCM (`SMTP_ENCRYPTION_KEY`) dan tidak pernah dikembalikan mentah. Strategi rotasi diatur lewat env `SMTP_ROTATION_STRATEGY`: `priority`, `least_usage`, `round_robin`, `random`, `weighted`.

GET /api/v1/smtp/providers PRESET KATALOG
Perm: smtp.manage

Katalog preset provider (Gmail, Outlook 365, Yahoo, Zoho, Brevo, SendGrid, Mailgun, SES, Postmark, Mailjet, Resend, SMTP2GO, Elastic Email, Mailtrap, MailHog, relay Postfix, custom) beserta opsi `encryption`, `auth_type`, dan daftar strategi rotasi.

GET /api/v1/smtp/stats
Perm: smtp.manage

Ringkasan pool: jumlah provider aktif/eligible/sandbox/failing, total kuota harian & sisa, total pengiriman lifetime, distribusi `health`, dan strategi rotasi aktif.

GET /api/v1/smtp/configs
Perm: smtp.manage

Daftar provider SMTP dengan **filter query**: `search`/`q`, `provider`, `provider_key`, `is_active`, `health`, `sort`, `order`, `page`, & `limit`. Response memuat kuota harian/bulanan, sisa kuota, status `health`, dan `failure_count`.

GET /api/v1/smtp/configs/:id
Perm: smtp.manage

Menampilkan detail spesifik 1 Provider SMTP berdasarkan ID Path Param.

POST /api/v1/smtp/configs
Perm: smtp.manage

Menambahkan provider SMTP baru (vendor apa pun) ke pool rotasi. Wajib: `provider`, `host`, `from_email`. Kirim `provider_key` untuk auto-isi host/port/encryption dari preset, atau isi semuanya manual.

PUT /api/v1/smtp/configs/:id
Perm: smtp.manage

Mengedit konfigurasi SMTP. Semua field opsional โ€” yang tidak dikirim tetap seperti semula (`PATCH` pada endpoint yang sama berperilaku identik). Password kosong = password lama dipertahankan.

PUT /api/v1/smtp/configs/:id/reset-usage RESET LIMIT 1 PROVIDER
Perm: smtp.manage

Mereset hitungan pengiriman harian (`daily_usage = 0`) untuk 1 provider SMTP spesifik.

POST /api/v1/smtp/reset-all-usage RESET ALL SMTP
Perm: smtp.manage

Mereset hitungan pengiriman harian (`daily_usage = 0`) untuk **semua provider SMTP sekaligus**.

POST /api/v1/smtp/test-rotation
Perm: smtp.manage

Uji rotasi email ke alamat tujuan. Rotator memilih provider sesuai strategi aktif, dan bila gagal **otomatis failover ke provider berikutnya**. Counter `daily_usage` hanya bertambah pada provider yang benar-benar berhasil mengirim.

POST /api/v1/smtp/configs/:id/test-connection TANPA KIRIM EMAIL
Perm: smtp.manage

Dial + STARTTLS/SSL + autentikasi ke provider, lalu QUIT. Sukses otomatis mereset `failure_count`, gagal menaikkannya dan mengembalikan pesan error asli dari server SMTP (HTTP 502).

POST /api/v1/smtp/configs/:id/test-send 1 PROVIDER SPESIFIK
Perm: smtp.manage

Mengirim email uji lewat **1 provider tertentu tanpa rotasi** โ€” untuk memverifikasi konfigurasi baru sebelum diaktifkan. `subject` & `message` opsional (message boleh HTML).

PATCH /api/v1/smtp/configs/:id/toggle-active
Perm: smtp.manage

Membalik `is_active` tanpa mengirim ulang seluruh payload โ€” untuk menonaktifkan provider bermasalah dengan cepat.

PUT /api/v1/smtp/configs/:id/reset-failures KEMBALIKAN KE ROTASI
Perm: smtp.manage

Provider yang gagal **5 kali beruntun** otomatis dilewati rotator (`health: failing`). Endpoint ini menolkan `failure_count` & `last_error` agar provider kembali masuk rotasi setelah kredensialnya diperbaiki.

POST /api/v1/smtp/reorder BULK PRIORITAS
Perm: smtp.manage

Mengatur ulang urutan rotasi beberapa provider sekaligus. Nilai `priority` lebih kecil = lebih diprioritaskan (dipakai strategi `priority`).

DELETE /api/v1/smtp/configs/:id
Perm: smtp.manage

Menghapus provider SMTP dari pool rotasi sistem.

Monitoring Log Error Gagal Kirim SMTP & Resend (Tab Terpisah)

Daftar terpisah khusus untuk **log error gagal kirim email SMTP**, akumulasi statistik kegagalan, dan API resend manual/bulk agar **tidak mengganggu CRUD SMTP utama** (`smtp.manage`).

4 Endpoints

Proteksi Publik & Rotator Automatic Failover

Saat pengiriman email voucher gagal di semua provider SMTP, data voucher & email penerima otomatis dicatat ke tabel log ini (`status: PENDING`).

UI publik klaim voucher **tetap aman** tanpa menampilkan detail teknis error SMTP ke user. Admin dapat meresend email satu per satu atau sekaligus lewat API di bawah ini.

GET /api/v1/smtp/failed-count AKUMULASI TEKS COUNT
Perm: smtp.manage

Menampilkan jumlah akumulasi log error gagal kirim SMTP dari **seluruh akun SMTP**: `pending_failed_count` (belum terkirim), `sent_count` (sukses resend), `failed_count` (gagal permanen), dan `total_failed_records`.

GET /api/v1/smtp/logs ALIAS: /api/v1/smtp/failed-logs
Perm: smtp.manage

Daftar log gagal kirim SMTP terpaginasi dengan **filter query**: `search` (email/voucher/error), `status` (`PENDING`, `SENT`, `FAILED`), `page`, dan `limit`.

POST /api/v1/smtp/logs/:id/resend RESEND 1 LOG
Perm: smtp.manage

Mencoba kirim ulang 1 log error gagal berdasarkan ID Path Param via rotator pool SMTP. Mencegah pengiriman ganda jika status sudah `SENT`.

POST /api/v1/smtp/logs/resend-all BULK RESEND ALL
Perm: smtp.manage

Mencoba kirim ulang **seluruh log gagal berstatus PENDING** secara masal melalui rotator SMTP.

Global System Settings (Konfigurasi Domain APP_URL & Base Settings)

Kelola setting aplikasi langsung dari **Database PostgreSQL** (`system_settings` table) โ€” termasuk `app_url = https://voucher.mrdiy.co.id` agar tidak perlu merubah file `.env` berulang kali.

6 Endpoints

Hirarki Resolusi Domain Voucher Link

1. **Database Setting (`system_settings` key `app_url`)**: Prioritas Utama. Mengatur link email voucher menjadi `https://voucher.mrdiy.co.id/v/<token>`.

2. **Environment Variable (`APP_URL` / `BASE_URL`)**: Dipakai jika database belum di-set.

3. **Dynamic Client Host**: Fallback otomatis ke Host request yang masuk (`c.Request.Host`).

Key yang Benar-benar Dibaca Backend

Key di luar daftar ini boleh disimpan, tapi tidak ada kode yang membacanya โ€” jadi mengubahnya tidak berpengaruh ke perilaku sistem.

Key Dibaca di Akibat bila salah / kosong
app_url voucher_controller, utm_link_controller Link `/v/<token>` di email & short link NFC salah domain
base_url idem (cadangan) Hanya dipakai bila `app_url` kosong
email_logo_url template email voucher Logo tidak tampil di email penerima
allowed_email_domains POST /claim Kosong = semua domain boleh klaim. Diisi = domain di luar daftar ditolak 400
notify_claim_enabled services/claim_notifier.go `true`/`false`. Mati = tidak ada notifikasi Telegram/Discord dikirim saat ada klaim baru
notify_claim_channel services/claim_notifier.go `telegram` / `discord` / `both`. Menentukan channel notifikasi klaim dikirim kemana
notify_telegram_bot_token services/claim_notifier.go Kosong = notifikasi Telegram gagal terkirim (di-skip diam-diam, hanya tercatat di log server)
notify_telegram_chat_id services/claim_notifier.go Kosong = notifikasi Telegram gagal terkirim (di-skip diam-diam, hanya tercatat di log server)
notify_discord_webhook_url services/claim_notifier.go Kosong = notifikasi Discord gagal terkirim (di-skip diam-diam, hanya tercatat di log server)

allowed_email_domains di-seed gmail.com,yahoo.com,yahoo.co.id,outlook.com,hotmail.com,icloud.com dan bertanda is_public supaya terbaca GET /api/v1/settings/public. Pemisah boleh koma, titik koma, spasi, atau baris baru; awalan @ dan https:// dirapikan otomatis.

Kelima notify_* di-seed mati (`notify_claim_enabled = false`, token/webhook kosong) dan tidak bertanda is_public โ€” isi token/webhook & nyalakan lewat POST /api/v1/settings atau PUT /api/v1/settings/:key, berlaku langsung tanpa restart server. Saat klaim sukses, backend mengirim: NAMA, EMAIL, DEVICE (User-Agent), IP, UTM_CODE/BUILDING_NAME, dan WAKTU.

GET /api/v1/settings DATABASE SETTINGS
Perm: rbac.manage

Daftar seluruh setting aplikasi global dari database PostgreSQL. Opsi query filter: `group` (`general`, `voucher`, `smtp`) dan `search` (kata kunci key/value).

POST /api/v1/settings SET / UPDATE KEY
Perm: rbac.manage

Menyimpan/mengatur nilai setting global (termasuk `app_url = https://voucher.mrdiy.co.id`). Jika key belum ada maka otomatis dibuat baru.

PUT /api/v1/settings/:key
Perm: rbac.manage

Memperbarui nilai `value` spesifik dari 1 setting key (contoh `app_url`).