Authentication & Login
Endpoint autentikasi akun user ke sistem. Pendaftaran user dilakukan oleh Admin di User Management CRUD.
/api/v1/auth/login
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.
/api/v1/auth/me
Mengambil data detail profil user terautentikasi (termasuk Full Avatar URL & list permissions).
/api/v1/auth/profile
Mengedit nama dan email user (Mendukung `application/json` & `multipart/form-data`).
/api/v1/auth/change-password
Mengubah password user setelah verifikasi password lama (`old_password` & `new_password`).
/api/v1/auth/avatar
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.
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"}
/api/v1/stores
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).
/api/v1/stores/template & /api/v1/stores/export
Unduh file contoh berisi header yang dikenali importer, atau export master toko saat ini. Query ?format=xlsx (default) atau ?format=csv.
Export mengikuti filter yang sedang diisi pada GET /api/v1/stores di atas, jadi isi filter dulu bila ingin sebagian data saja.
/api/v1/stores/import
Import daftar toko dari file .xlsx atau .csv (multipart, field file). Jalankan dry_run=true lebih dulu untuk melihat rencana perubahan tanpa menyentuh database.
Kolom yang dikenali: Code/Kode, Name/Nama, City/Kota, Region/Provinsi, Address/Alamat, Is Jabodetabek, Status/Aktif. Baris dicocokkan lewat kode toko: sudah ada = update, belum ada = create. Kode duplikat di dalam satu file dilewati dan dilaporkan per baris.
/api/v1/stores/bulk
Aktivasi, non-aktifkan, atau hapus banyak toko sekaligus berdasarkan daftar ids.
action: activate | deactivate | delete. Alternatif, kirim is_active boolean sebagai ganti action.
/api/v1/stores
Tambah satu gerai baru ke master toko.
Wajib: code, name. Kode otomatis di-uppercase dan dirapikan spasinya. is_jabodetabek dan is_active default true bila tidak dikirim; kirim eksplisit false bila memang ingin non-aktif. Kode ganda ditolak 409.
/api/v1/stores/{id}
Detail satu toko beserta jumlah voucher terkait.
Response menyertakan vouchers_count: jumlah voucher yang memakai kode toko ini.
/api/v1/stores/{id}
Perbarui data satu toko.
Full replace: field yang tidak dikirim akan dikosongkan, kecuali is_jabodetabek / is_active yang dipertahankan bila diabaikan. Ganti kode ke kode milik toko lain ditolak 409.
/api/v1/stores/{id}
Hapus (soft delete) satu toko.
409. Lebih aman nonaktifkan (is_active=false) daripada menghapus, karena kode toko sudah tersalin ke voucher dan dipakai laporan. Paksa hapus dengan ?force=true.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.
building_code (mis. B000277), dan kolom nearest_store hanya teks apa adanya dari vendor — bukan relasi ke Master Data Toko.
/api/v1/utm/buildings
Daftar master gedung NFC TapOn dengan filter search, area, building_type, nearest_store, is_active, page, limit.
/api/v1/utm/buildings/import
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.
/api/v1/utm/buildings/export
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.
/api/v1/utm/buildings
Tambah satu gedung TapOn secara manual.
/api/v1/utm/buildings/:id
Ubah data satu gedung. Perubahan Building ID otomatis tersalin ke seluruh link UTM milik gedung tersebut.
/api/v1/utm/buildings/:id
Hapus gedung. Ditolak bila masih dipakai link UTM, kecuali ditambahkan query ?force=true.
/api/v1/utm/buildings/bulk
Aksi massal gedung: activate, deactivate, atau delete.
Events Kampanye (CRUD)
Aturan kampanye: 1 email 1x, batasan toko Jabodetabek, & aturan expired (`events.read` / `events.write`).
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.
- Buat event โ
POST /api/v1/events. Setallowed_cities,valid_days_type+valid_days,live_minutes,stock_limit,landing_url, danutm_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. - Generate link NFC TapOn โ
POST /api/v1/utm/links/generatecukup dengan{ "event_id": 1 }. Landing URL diambil dari event, gedung dariutm_building_idsevent, dan parameter UTM diturunkan otomatis (default sistem + kode event + data gedung). Hasilnya bisa diekspor lewatGET /api/v1/utm/links/exportuntuk diserahkan ke vendor stiker. - 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), danb(kode gedung). - Klaim voucher โ
POST /api/v1/claimdenganevent_id+ data pengunjung +utm_iddari URL landing (bukan parameterutm_*). Voucher dikirim via email; response belum memuat kode voucher. - Kasir aktivasi โ
POST /api/v1/vouchers/token/:token/activatedenganstore_code. Kode voucher baru muncul di response setelah kode toko valid. - Kasir tandai terpakai โ
POST /api/v1/vouchers/token/:token/use(payload opsionalevent_id+utm_iduntuk analytics).
Aturan yang dipaksakan server
- 1 email 1x per periode โ bila
one_email_per_period = true, klaim kedua dengan email sama ditolak409. Voucher yang sudah dihanguskan (void) tidak dihitung, jadi email tersebut boleh klaim ulang. - Batasan kota โ
allowed_citiesdicocokkan dengancitymaster toko saat aktivasi. Daftar kosong = semua kota boleh. - Kode toko wajib valid โ
store_codeselalu dicek ke master toko dan harusis_active = true, walauallowed_citieskosong. Kode salah =400, voucher tetap UNUSED dan kasir bisa mengetik ulang. - Expiry voucher โ
PLUS_DAYS:claim_expires_at= tanggal klaim +valid_days, otomatis dipotong keend_dateevent bila melewatinya.FIXED_DATE: pakaiexpire_fixed_date, yang ditolak400saat disimpan bila melewatiend_date. - Timer live โ setelah aktivasi, voucher hidup
live_minutesmenit lalu otomatis EXPIRED. - Stok โ
stock_limit > 0membatasi total klaim; habis =400"Kupon telah habis".0= unlimited (voucher digenerate on-demand). Pemakaian realtime bisa dibaca diGET /api/v1/events/:id/settingsโvoucher_stats. - Periode โ
end_dateharus lebih besar daristart_date; klaim di luar periode atau saatis_active = falseditolak400.
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.
/api/v1/events
Menampilkan daftar event kampanye dengan **filter query**: `search`/`q`, `code`, `building_category`, `is_active`, `page`, & `limit`.
/api/v1/events/:id
Menampilkan detail spesifik 1 Event Kampanye berdasarkan ID.
/api/v1/events
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.
/api/v1/events/:id
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.
/api/v1/events/:id
Menghapus Event Kampanye (Soft Delete).
/api/v1/events/:id/vouchers
Menampilkan semua voucher yang terkait dengan event tertentu (filter: status, search, page, limit) + statistik agregat (UNUSED / LIVE / USED / EXPIRED).
/api/v1/events/:id/settings
Menampilkan detail pengaturan kampanye event (toggle aktif, aturan expiry, Jabodetabek only, 1 email per periode) beserta statistik voucher per status.
/api/v1/events/:id/settings
PARTIAL UPDATE
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).
/api/v1/settings/public
TANPA TOKEN
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.
/api/v1/claim
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).
/api/v1/vouchers/token/:token
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.
/api/v1/vouchers/token/:token/activate
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.
/api/v1/vouchers/token/:token/use
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.
/api/v1/vouchers
Menampilkan laporan seluruh klaim voucher terdaftar dengan **filter query**: `event_id`, `status`, `search`/`q`, `page`, & `limit`.
/api/v1/vouchers
Menambahkan single voucher baru secara manual terikat ke ID Event / Kampanye Promo.
/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.
/api/v1/vouchers/export-csv
Export CSV
Export seluruh data voucher (atau terfilter per Event ID & status) langsung ke format file Excel (.xlsx, default) atau CSV.
/api/v1/vouchers/stats
Ringkasan Agregat
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.
/api/v1/vouchers/resend-failed
Resend Massal
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.
Ini SUNGGUHAN mengirim email ke pelanggan, tidak ada mode pratinjau/dry-run. Isi Event ID dulu kalau cuma mau resend satu kampanye tertentu.
/api/v1/vouchers/import-preview
Select Column โ Step 1
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.
/api/v1/vouchers/import
Select Column โ Step 2
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.
File dipakai dari input voucher-import-file di panel "Select Column โ Step 1" di atas โ jalankan Baca Kolom dulu, textarea mapping di bawah terisi otomatis dari suggested_mapping, lalu koreksi sebelum Simpan Import.
Field yang dikenali: code (wajib), event_id, title, discount_value, description, client_name, client_email, client_phone, store_code, status (nilai: UNUSED / LIVE / USED / USED_POS / EXPIRED / VOID). Field yang dikosongkan jatuh ke tebakan alias otomatis di backend.
/api/v1/vouchers/:id
Menampilkan detail lengkap data voucher berdasarkan **ID** database.
/api/v1/vouchers/:id
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).
/api/v1/vouchers/:id
Menghapus data voucher dari sistem berdasarkan ID.
/api/v1/vouchers/:id/resend-email
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).
/api/v1/vouchers/:id/send-email
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.
/api/v1/vouchers/:id/hapus-penerima
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 Link Builder & Log Tap NFC
Generate link unik per event × gedung, export file Link Builder untuk vendor UTM, dan log tap NFC mentah. Permission: utm.read / utm.write.
PERUBAHAN: Stiker NFC Sekarang Hanya Memuat Short Link
Yang dicetak di stiker & dikirim ke vendor hanya short_url = {app_url}/t/{slug}. Field full_url sudah DIHAPUS dari seluruh response, file export, dan hasil generate โ bukan sekadar tidak dipakai.
Alasannya: seluruh atribusi dulu terpampang di address bar (b=, event_id=, utm_*) sehingga siapa pun bisa mengganti kode gedung sebelum klaim โ laporan per gedung yang justru dibayar pihak UTM jadi tidak bisa dipercaya. Slug acak tidak memuat arti apa pun, jadi tujuan stiker juga bisa diubah tanpa cetak ulang.
Alur baru: tap /t/{slug} → halaman klaim menukar slug lewat GET /api/v1/t/{slug} → kirim utm_id + event_id ke POST /api/v1/claim. Parameter utm_source, utm_medium, utm_term tidak perlu dikirim frontend โ backend membacanya dari baris link.
Kolom Full URL sudah dihapus dari file export Link Builder. Kolom UTM di belakang tetap ada sebagai catatan internal, bukan sesuatu yang perlu dipasang vendor. Selama field itu masih dikembalikan, ia akan dipakai orang โ dan begitu dipakai, atribusi kembali terpampang di address bar tempat siapa pun bisa menyuntingnya.
/api/v1/t/:slug
TANPA TOKEN
Menukar slug stiker dengan atribusinya. Dipanggil halaman klaim tepat setelah tap.
Response: utm_id, event_id, event_code, event_name, building_code, building_name, building_area, landing_url (opsional, bila kampanye diarahkan ke microsite di luar aplikasi klaim).
Endpoint ini MENCATAT tap. Panggil sekali per kunjungan dan lakukan di sisi server โ memanggilnya dari effect komponen yang bisa jalan dua kali akan menggandakan angka tap.
Status: 200 aktif · 410 link ada tapi kampanye/link sudah nonaktif (tap tetap dicatat, berguna untuk memutuskan penarikan stiker) · 404 slug tidak dikenal.
Alur Kerja
- Import file building list vendor (.xlsx / .csv) ke master gedung.
- Atur
landing_url+utm_building_idspada pengaturan event (PATCH /events/:id/settings). - Generate link: tiap gedung terpilih dapat slug unik
/t/{slug}. - Export Link Builder (.xlsx / .csv) lalu kirim ke pihak UTM untuk ditanam di stiker NFC.
- Orang tap NFC → tap tercatat → redirect ke landing page event lengkap dengan parameter UTM & kode gedung.
/api/v1/utm/links/generate
Inti sistem: signing event ke kode gedung. Setiap gedung terpilih mendapat satu link unik.
Cara pilih gedung (salah satu):
• utm_building_ids sudah diset di event → cukup kirim { "event_id": 1 } โ gedung diambil otomatis dari event
• building_ref_ids / building_codes โ override eksplisit per request
• all_buildings: true + filter โ seluruh gedung aktif (bisa dipersempit dengan area, building_type, dll)
Pakai dry_run: true untuk simulasi tanpa simpan. Pakai overwrite: true untuk memperbarui parameter link yang sudah ada (slug tidak berubah, stiker lama tetap hidup).
Bila tidak dikirim di body, nilai UTM diturunkan otomatis: utm_source = tmn, utm_medium = nfc, utm_campaign = kode event, utm_term = tipe gedung, dan utm_content = kode gedung dari master. Target stiker selalu short link /t/{slug}; landing_url event hanya dipakai bila kampanye diarahkan ke halaman luar. Event tidak menyimpan utm_source / utm_medium / utm_campaign sendiri โ sumber UTM adalah master gedung + kode event, jadi tidak ada dua tempat yang mendefinisikan hal yang sama.
Response berisi baris per gedung dengan status: created / updated / skipped (link sudah ada & overwrite false), lengkap dengan slug, short_url, dan full_url. Serahkan hasilnya ke vendor stiker lewat GET /api/v1/utm/links/export. Tanpa pilihan gedung apa pun (event juga tidak punya utm_building_ids) request ditolak 400.
{
"event_id": 1,
"building_ref_ids": [1, 2, 5],
"utm_source": "tapon",
"utm_medium": "nfc",
"utm_campaign": "",
"overwrite": false,
"dry_run": true
}
/api/v1/utm/links
Daftar link UTM beserta short_url โ satu-satunya URL yang dipakai, dicetak di stiker. Field full_url sudah dihapus dari response.
/api/v1/utm/links/export
Unduh file TMN Link Builder siap kirim ke pihak UTM: kolom asli vendor + kolom Link (short link), slug, parameter UTM, dan jumlah tap. Kolom Full URL sudah dihapus โ vendor cukup memasang short link.
/api/v1/utm/links
Buat satu link manual untuk pasangan event + gedung. Parameter UTM yang dikosongkan diisi otomatis dari pengaturan event dan data gedung.
/api/v1/utm/links/:id
Detail satu link berikut 20 tap terakhir.
/api/v1/utm/links/:id
Ubah parameter UTM, landing override, catatan, atau status aktif sebuah link. Field yang tidak dikirim tidak diubah.
/api/v1/utm/links/:id
Hapus link. Short link yang sudah tercetak di stiker akan mati (tap diarahkan ke 404).
/api/v1/utm/links/bulk
Aksi massal link: activate, deactivate, delete, atau regenerate_slug (ganti slug bila stiker dicetak ulang).
/api/v1/utm/links/stats
Ringkasan performa per gedung: jumlah tap NFC versus jumlah voucher yang benar-benar diklaim, plus persentase konversi.
/api/v1/utm/taps
Log mentah setiap tap NFC: slug, kode gedung, IP, user agent, dan waktu tap.
/t/:slug
Endpoint publik yang ditanam pada stiker NFC. Tap dicatat ke log, penghitung tap_count bertambah, lalu pengunjung di-redirect (302) ke landing page event lengkap dengan utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id (ID link), event_code, event_id, dan b (kode gedung).
Link non-aktif tetap dicatat tapi diarahkan ke halaman utama, sehingga stiker lama yang masih ditap tetap terlihat pada log.
Halaman claim cukup meneruskan utm_id ke POST /api/v1/claim agar voucher yang diklaim tersambung ke link & gedung asalnya. Parameter utm_* lain di URL hanya untuk konsumsi tool analytics pihak ketiga โ server tidak membacanya saat klaim.
/px/:token.gif
Tracking pixel email. Gambar 1x1 ini ditanam otomatis pada email voucher; saat klien email memuatnya, pembukaan pertama mengisi email_opened_at dan tiap pemuatan menambah email_open_count pada voucher. Dari sinilah angka emails_opened di GET /api/v1/utm/analytics berasal.
Selalu membalas 200 dengan GIF 1x1 โ termasuk saat token tidak dikenal. Respons yang berbeda-beda akan membocorkan token mana yang valid ke siapa pun yang menebak-nebak, dan klien email tidak punya cara menampilkan pesan error.
Base URL pixel mengikuti setting app_url (lalu base_url, lalu host permintaan), sama seperti tautan voucher di email. Tidak perlu dipanggil frontend โ endpoint ini murni dikonsumsi klien email penerima.
UTM Tracking & Analytics
Funnel kampanye NFC: tap stiker → email dibuka → klaim → aktivasi di toko, dipecah per gedung, link, toko, dan event (`utm.read`).
/api/v1/utm/analytics/*
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_eventGET /api/v1/utm/analytics/buildings—by_buildingGET /api/v1/utm/analytics/links—by_linkGET /api/v1/utm/analytics/stores—by_storeGET /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_ratepersen. - Kapan dibukanya?
first_opened_at,last_opened_at,avg_minutes_to_open(jeda rata-rata klaim → buka), danby_hourโ sebaran jam buka00:00โ23:00untuk menentukan jam kirim berikutnya. - Pakai perangkat apa?
by_device(MOBILE / TABLET / DESKTOP / EMAIL_PROXY / UNKNOWN) danby_email_client(Gmail, Apple Mail, Outlook, Thunderbird, Yahoo Mail). - IP dan user agent?
recent_opensmemuat kejadian terakhir apa adanya: voucher_id, email penerima, waktu, device_type, email_client, ip_address, user_agent, dan is_first_open. Riwayat penuhnya diGET /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.
/api/v1/utm/logs
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.
/api/v1/utm/email-opens
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`).
/api/v1/auth/users
Daftar semua user terdaftar dengan **filter query**: `role_id`, `search`/`q`, `is_active`, `page`, & `limit`.
/api/v1/auth/users/:id
Menampilkan detail 1 user spesifik berdasarkan ID beserta role & permissions.
/api/v1/auth/users
Admin membuat user baru (Admin/Manager/Staff) dengan menentukan `role_id` dan `is_active`.
/api/v1/auth/users/:id/status
SUSPEND TOGGLE
Ubah status user. Jika `is_active: false`, user di-suspend dan ditolak saat login (HTTP 403 Forbidden).
/api/v1/auth/users/:id
Menghapus user dari sistem (Soft Delete).
Roles Management (RBAC)
CRUD Role relasional, menyinkronkan array ID permissions, dan merubah role user (`rbac.manage`).
/api/v1/rbac/roles
Menampilkan daftar role sistem dengan **filter query**: `search`/`q`, `code`, `page`, & `limit` beserta array `permissions` relasional.
/api/v1/rbac/roles/:id
Menampilkan detail spesifik 1 Role berdasarkan ID beserta relasi permissions.
/api/v1/rbac/roles
Membuat Role baru dan mendaftarkan array `permission_ids` yang berhak diakses.
/api/v1/rbac/roles/:id
Mengubah nama, deskripsi, atau menyinkronkan ulang array `permission_ids` milik suatu Role.
/api/v1/auth/users/:id/role
Mengganti `role_id` user tertentu secara langsung.
/api/v1/rbac/roles/:id
Menghapus Role dari sistem (Soft Delete).
Master Permissions Sistem
Daftar lengkap master permissions sistem dengan filter modul, pencarian kata kunci, dan paginasi.
/api/v1/rbac/permissions
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`).
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`.
/api/v1/smtp/providers
PRESET KATALOG
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.
/api/v1/smtp/stats
Ringkasan pool: jumlah provider aktif/eligible/sandbox/failing, total kuota harian & sisa, total pengiriman lifetime, distribusi `health`, dan strategi rotasi aktif.
/api/v1/smtp/configs
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`.
/api/v1/smtp/configs/:id
Menampilkan detail spesifik 1 Provider SMTP berdasarkan ID Path Param.
/api/v1/smtp/configs
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.
/api/v1/smtp/configs/:id
Mengedit konfigurasi SMTP. Semua field opsional โ yang tidak dikirim tetap seperti semula (`PATCH` pada endpoint yang sama berperilaku identik). Password kosong = password lama dipertahankan.
/api/v1/smtp/configs/:id/reset-usage
RESET LIMIT 1 PROVIDER
Mereset hitungan pengiriman harian (`daily_usage = 0`) untuk 1 provider SMTP spesifik.
/api/v1/smtp/reset-all-usage
RESET ALL SMTP
Mereset hitungan pengiriman harian (`daily_usage = 0`) untuk **semua provider SMTP sekaligus**.
/api/v1/smtp/test-rotation
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.
/api/v1/smtp/configs/:id/test-connection
TANPA KIRIM EMAIL
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).
/api/v1/smtp/configs/:id/test-send
1 PROVIDER SPESIFIK
Mengirim email uji lewat **1 provider tertentu tanpa rotasi** โ untuk memverifikasi konfigurasi baru sebelum diaktifkan. `subject` & `message` opsional (message boleh HTML).
/api/v1/smtp/configs/:id/toggle-active
Membalik `is_active` tanpa mengirim ulang seluruh payload โ untuk menonaktifkan provider bermasalah dengan cepat.
/api/v1/smtp/configs/:id/reset-failures
KEMBALIKAN KE ROTASI
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.
/api/v1/smtp/reorder
BULK PRIORITAS
Mengatur ulang urutan rotasi beberapa provider sekaligus. Nilai `priority` lebih kecil = lebih diprioritaskan (dipakai strategi `priority`).
/api/v1/smtp/configs/:id
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`).
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.
/api/v1/smtp/failed-count
AKUMULASI TEKS COUNT
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`.
/api/v1/smtp/logs
ALIAS: /api/v1/smtp/failed-logs
Daftar log gagal kirim SMTP terpaginasi dengan **filter query**: `search` (email/voucher/error), `status` (`PENDING`, `SENT`, `FAILED`), `page`, dan `limit`.
/api/v1/smtp/logs/:id/resend
RESEND 1 LOG
Mencoba kirim ulang 1 log error gagal berdasarkan ID Path Param via rotator pool SMTP. Mencegah pengiriman ganda jika status sudah `SENT`.
/api/v1/smtp/logs/resend-all
BULK RESEND ALL
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.
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.
/api/v1/settings
DATABASE SETTINGS
Daftar seluruh setting aplikasi global dari database PostgreSQL. Opsi query filter: `group` (`general`, `voucher`, `smtp`) dan `search` (kata kunci key/value).
/api/v1/settings
SET / UPDATE KEY
Menyimpan/mengatur nilai setting global (termasuk `app_url = https://voucher.mrdiy.co.id`). Jika key belum ada maka otomatis dibuat baru.
/api/v1/settings/:key
Memperbarui nilai `value` spesifik dari 1 setting key (contoh `app_url`).