diff --git a/.env.example b/.env.example index 59d76f3..3f5d43f 100644 --- a/.env.example +++ b/.env.example @@ -2,25 +2,53 @@ NODE_ENV=development PORT=3000 # === KlikQRIS === -# Dapatkan di dashboard merchant kamu (https://klikqris.com) +# Dapatkan di dashboard merchant kamu (https://klikqris.com). +# Boleh dikosongkan saat dev — bot tetap jalan, tapi /buy akan gagal sampai diisi. KLIKQRIS_API_BASE=https://klikqris.com/api KLIKQRIS_API_KEY= KLIKQRIS_MERCHANT_ID= # === Telegram === -# Buat bot di @BotFather lalu paste tokennya +# Buat bot di @BotFather lalu paste tokennya. Kosongkan untuk disable. TELEGRAM_BOT_TOKEN= # === Discord === -# https://discord.com/developers/applications +# https://discord.com/developers/applications. Kosongkan untuk disable. DISCORD_BOT_TOKEN= DISCORD_CLIENT_ID= -# Optional: kalau diisi, slash commands diregister hanya di guild ini (instan). -# Kalau kosong, pakai global commands (propagasi bisa sampai 1 jam). +# Optional: kalau diisi, slash commands diregister hanya di guild ini (instan, untuk dev). +# Kalau kosong, pakai global commands (propagasi sampai 1 jam). DISCORD_GUILD_ID= +# === Owner === +# Owner = primary admin. Otomatis dapet: +# - Notifikasi saat bot online +# - Notifikasi setiap order PAID (sales) +# - Alert saat delivery gagal +# Owner OTOMATIS termasuk admin, gak perlu listing dirinya 2x di ADMIN_*_IDS. +# +# Telegram ID: chat @userinfobot untuk dapet numeric ID kamu. +# Discord ID: Settings > Advanced > Developer Mode ON, klik kanan profil > Copy User ID. +OWNER_TELEGRAM_ID= +OWNER_DISCORD_ID= + +# === Admin tambahan === +# Comma-separated user IDs admin tambahan (selain owner) yg boleh pakai /admin commands. +ADMIN_TELEGRAM_IDS= +ADMIN_DISCORD_IDS= + +# === Admin Web Dashboard === +# Login credentials untuk dashboard di /admin. +# Kosongkan ADMIN_USERNAME untuk disable dashboard sepenuhnya +# (akan auto-aktifin setup wizard di /setup). +ADMIN_USERNAME= +ADMIN_PASSWORD= +# Generate string random panjang, contoh: openssl rand -hex 32 +# Setup wizard auto-generate kalau dikosongkan. +ADMIN_SESSION_SECRET=change-me-in-production-please + # === Database === -DATABASE_URL="file:./prisma/dev.db" +DATABASE_URL="file:./dev.db" # === Webhook === # URL public yang dipasang di dashboard KlikQRIS: diff --git a/README.md b/README.md index 000324c..b30349f 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,18 @@ - [Cara Kerja](#cara-kerja) - [Tech Stack](#tech-stack) - [Quick Start](#quick-start) +- [Setup Repo GitHub](#setup-repo-github-untuk-fork) — buat preview embed yang muncul saat di-share - [Cara Dapat Kredensial](#cara-dapat-kredensial) - [Telegram Bot Token](#1-telegram-bot-token) - [Discord Bot Token](#2-discord-bot-token) - [KlikQRIS API Key](#3-klikqris-api-key) - [Konfigurasi `.env`](#konfigurasi-env) +- [Setup Wizard (`npm run setup`)](#setup-wizard-npm-run-setup) - [Webhook Public URL](#webhook-public-url) +- [Owner System](#owner-system) +- [Admin Dashboard](#admin-dashboard) +- [Analytics](#analytics) +- [Admin Commands (Bot)](#admin-commands-bot) - [Struktur Project](#struktur-project) - [Perintah Bot](#perintah-bot) - [Deploy ke Production](#deploy-ke-production) @@ -43,9 +49,18 @@ - **Multi-platform**: Telegram + Discord dari satu codebase, satu database, satu webhook. - **QRIS dinamis**: tiap order dapet QR unik via API KlikQRIS, scan pakai e-wallet apa pun (GoPay, OVO, Dana, ShopeePay, m-banking, dll). - **Auto-deliver**: stok (akun, license key, voucher, file) otomatis dikirim ke DM pembeli setelah pembayaran terkonfirmasi. +- **Inline button UX**: bot pakai tombol interaktif (gak perlu hafalin command) — Telegram InlineKeyboard, Discord ButtonBuilder + SelectMenu. +- **Setup wizard**: `npm run setup` buka browser otomatis ke form web yang ngisi `.env` untuk kamu — gak perlu edit file manual. +- **Owner-aware bots**: bot kenal siapa owner-nya. Owner otomatis admin + dapet notifikasi (bot online, order baru, delivery gagal). Slash command `/whoami` untuk cek role. +- **Admin web dashboard**: kelola produk, stok, order, plus halaman **analytics** (chart revenue 30 hari, top produk, breakdown per platform). +- **Admin bot commands**: kelola toko langsung dari Telegram/Discord — tambah produk, restok, lihat orderan, kirim ulang produk yang gagal terkirim. - **Idempotent webhook**: pengecekan status di DB mencegah kirim produk dobel kalau callback masuk berulang. - **Signature validation**: webhook divalidasi dengan signature yang disimpan saat create transaction (anti fake-callback). - **Stock management**: alokasi stok pakai DB transaction, anti race condition kalau dua orang beli barengan. +- **Retry-safe delivery**: kalau DM gagal terkirim, stok tetap teralokasi dan admin bisa kirim ulang dari dashboard tanpa double-claim stok. +- **Auto-expire**: order PENDING yang lewat waktu otomatis di-mark EXPIRED via cron job (jalan tiap 1 menit). +- **Rate limiting**: `/admin/login` di-protect dari brute force (max 10 attempt per 5 menit per IP). +- **Graceful degradation**: bot tetap jalan walau kredensial KlikQRIS belum diisi (cuma `/buy` yang gagal sampai diisi). - **Configurable**: SQLite untuk dev, tinggal ganti `DATABASE_URL` ke Postgres untuk production. ## Cara Kerja @@ -111,33 +126,92 @@ > Butuh **Node.js 20+** dan **npm** (atau pnpm/yarn). +**Cara paling cepat (recommended) — 4 command:** + ```bash -# 1. Clone repo git clone https://github.com/mocasus/botnot.git cd botnot - -# 2. Install dependencies npm install +npm run setup # bikin .env, migrate DB, buka browser otomatis ke form config +``` -# 3. Setup environment variables -cp .env.example .env -# Edit .env, isi KLIKQRIS_*, TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, DISCORD_CLIENT_ID -# (panduan ada di section "Cara Dapat Kredensial" di bawah) +### Apa yang akan kamu lihat + +`npm run setup` ngerjain ini semua otomatis: + +1. Bikin `.env` dari `.env.example` (kalau belum ada) +2. Migrate database (`prisma db push`) +3. Start dev server +4. **Auto-buka browser** ke `http://localhost:3000/setup` — form web untuk isi semua kredensial + +> **Halaman pertama yang muncul = Setup Wizard di `/setup`.** +> Bukan dashboard. Dashboard baru aktif setelah kamu isi `ADMIN_USERNAME` & `ADMIN_PASSWORD` di form ini, lalu **restart server**. + +### Setelah submit form setup + +```bash +# 1. Stop dev server yang lagi jalan +Ctrl+C -# 4. Setup database (SQLite, default ada di prisma/dev.db) +# 2. Restart untuk apply config baru +npm run dev + +# 3. Buka dashboard +# http://localhost:3000/admin +# Login pakai username/password yang barusan kamu set +``` + +Sekarang baru kamu lihat **Admin Dashboard** dengan desain modern (glass-morphism, dark theme, mobile responsive). + +**Cara manual (kalau mau edit `.env` langsung):** + +```bash +cp .env.example .env +# edit .env pakai editor favorit kamu (panduan lengkap di "Cara Dapat Kredensial") npm run db:push npm run db:seed # opsional: isi contoh produk +npm run dev +``` + +### Dashboard nggak berubah desainnya? + +Kalau habis update kode tapi UI dashboard masih kelihatan lama: + +```bash +# 1. Pastikan branch up-to-date +git pull origin main -# 5. Jalanin (webhook + Telegram bot + Discord bot, satu proses) +# 2. Reinstall deps (siapa tahu ada lockfile diff) +npm install + +# 3. Hard restart dev server (bukan cuma reload browser) +Ctrl+C npm run dev + +# 4. Hard refresh browser +# Chrome/Edge: Ctrl+Shift+R (Windows) atau Cmd+Shift+R (Mac) +# Firefox: Ctrl+F5 ``` -Output kira-kira: +Browser sering cache TailwindCSS CDN + HTML. Hard refresh wajib. +Output kira-kira (first-run, sebelum setup): + +``` +╔════════════════════════════════════════════════════════╗ +║ First-run detected — buka setup wizard di browser: ║ +║ → http://localhost:3000/setup ║ +║ Atau jalanin: npm run setup (auto-buka browser) ║ +╚════════════════════════════════════════════════════════╝ ``` -[12:34:56] INFO: Webhook server listening port=3000 + +Output setelah `.env` sudah dikonfigurasi: + +``` +[12:34:56] INFO: HTTP server listening port=3000 +[12:34:56] INFO: Admin dashboard ready url=http://localhost:3000/admin [12:34:57] INFO: Telegram bot started username=botnot_bot -[12:34:58] INFO: Discord slash commands registered scope=guild +[12:34:58] INFO: Discord slash commands registered scope=guild count=10 [12:34:58] INFO: Discord bot ready tag=botnot#1234 ``` @@ -145,6 +219,96 @@ Buka chat Telegram dengan bot kamu, ketik `/start` — siap testing! --- +## Setup Repo GitHub (untuk fork) + +Kalau kamu **fork** repo ini, default tampilan saat link repo di-share di Discord / Twitter / WhatsApp / Telegram cuma muncul nama repo + URL polos, tanpa preview gambar atau deskripsi yang menarik. Itu karena GitHub default-nya tidak meng-generate Open Graph image otomatis. + +Berikut langkah-langkah biar repo kamu punya **preview embed yang bagus** saat di-share: + +### 1. Set "About" section (deskripsi + topics + website) + +Buka `https://github.com//botnot` → klik gear icon ⚙️ di samping **About** (kanan atas, pojok header repo). + +**Description:** + +``` +Auto-order Telegram & Discord bot dengan integrasi KlikQRIS. Pelanggan scan QR, produk auto-deliver ke DM. Built with TypeScript, Fastify, Prisma. +``` + +**Website:** *(opsional, kalau ada demo / landing page)* + +``` +https://klikqris.com +``` + +**Topics** (pisahkan dengan spasi): + +``` +telegram-bot discord-bot qris klikqris indonesia auto-deliver typescript prisma fastify nodejs payment-gateway +``` + +**Centang:** +- ✅ Releases +- ✅ Packages (kalau pakai) +- (Optional) Deployments + +Klik **Save changes**. + +> Topics bantu repo kamu muncul di hasil pencarian GitHub. Description muncul sebagai subtitle saat link repo di-share. + +### 2. Upload Social Preview image + +Ini yang paling penting — **gambar 1280×640px** yang muncul sebagai card preview saat link repo di-paste di Discord / Twitter / Telegram / WhatsApp. + +1. Settings → **General** (default tab) → scroll ke section **Social preview** +2. Klik **Edit** → **Upload an image...** +3. Pilih file `assets/social-preview.png` dari repo ini *(sudah disediakan, 1280×640, design konsisten dengan dashboard)* +4. Klik **Save** + +> Kalau mau bikin custom social preview pakai branding kamu sendiri, edit `assets/social-preview.svg`, lalu convert ke PNG: +> ```bash +> # Install cairosvg +> pip install cairosvg +> +> # Convert SVG -> PNG (1280x640 sesuai standar GitHub) +> python3 -c " +> import cairosvg +> cairosvg.svg2png(url='assets/social-preview.svg', +> write_to='assets/social-preview.png', +> output_width=1280, output_height=640) +> " +> ``` + +### 3. Test preview-nya + +Setelah upload, test apakah preview-nya jalan: + +- **Discord**: paste URL repo di chat, tunggu ~5 detik +- **Twitter / X**: pakai [Card Validator](https://cards-dev.twitter.com/validator) +- **Facebook / WhatsApp**: pakai [Sharing Debugger](https://developers.facebook.com/tools/debug/) +- **Telegram**: paste URL di chat (kalau belum muncul, send `/setlinkpreview` dulu) +- **LinkedIn**: pakai [Post Inspector](https://www.linkedin.com/post-inspector/) + +### Cache + +GitHub & social media platforms cache image cukup agresif. Kalau habis update social preview tapi belum berubah: + +- **GitHub camo cache**: tunggu 5-10 menit, atau force refresh dengan rename file (`social-preview-v2.png`) lalu re-upload +- **Twitter / Facebook**: pakai validator/debugger untuk force re-fetch +- **Discord**: cache per-channel, biasanya 1-2 jam. Edit pesan atau paste di channel lain untuk test +- **Browser cache**: hard refresh (`Ctrl+Shift+R` / `Cmd+Shift+R`) + +### Hasil yang diharapkan + +Setelah setup, link `https://github.com//botnot` di-share akan muncul sebagai card berisi: + +- 🖼️ Gambar preview 1280×640 (botnot branding + tagline) +- 📝 Title: `/botnot` +- 📄 Description: `Auto-order Telegram & Discord bot dengan integrasi KlikQRIS...` +- 🔗 Link: `github.com` + +--- + ## Cara Dapat Kredensial ### 1. Telegram Bot Token @@ -207,25 +371,41 @@ Buka chat Telegram dengan bot kamu, ketik `/start` — siap testing! ## Konfigurasi `.env` +> **Tip:** pakai **[Setup Wizard](#setup-wizard-npm-run-setup)** (`npm run setup`) untuk isi semua ini lewat form web — lebih gampang daripada edit manual. + ```bash NODE_ENV=development PORT=3000 -# KlikQRIS +# KlikQRIS — boleh dikosongkan untuk dev, /buy gagal sampai diisi KLIKQRIS_API_BASE=https://klikqris.com/api KLIKQRIS_API_KEY= # x-api-key dari dashboard KLIKQRIS_MERCHANT_ID= # id_merchant -# Telegram +# Telegram — kosongkan untuk disable TELEGRAM_BOT_TOKEN= # dari @BotFather -# Discord +# Discord — kosongkan untuk disable DISCORD_BOT_TOKEN= # dari Developer Portal > Bot DISCORD_CLIENT_ID= # = Application ID DISCORD_GUILD_ID= # opsional, isi untuk dev (slash command instan) +# Owner — primary admin yg dapet notifikasi event penting (bot online, sales, delivery fail) +# Owner OTOMATIS termasuk admin, gak perlu didouble di ADMIN_*_IDS. +OWNER_TELEGRAM_ID= # numeric ID, dapet dari @userinfobot +OWNER_DISCORD_ID= # numeric ID, Developer Mode > Copy User ID + +# Admin tambahan (selain owner) untuk bot commands +ADMIN_TELEGRAM_IDS= # contoh: 12345,67890 +ADMIN_DISCORD_IDS= # contoh: 1112,3334 + +# Admin web dashboard — kosongkan ADMIN_USERNAME untuk disable + auto-aktifin /setup +ADMIN_USERNAME= +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= # generate: openssl rand -hex 32 (atau setup wizard auto-gen) + # Database -DATABASE_URL="file:./prisma/dev.db" +DATABASE_URL="file:./dev.db" # Webhook PUBLIC_BASE_URL= # contoh: https://abc123.ngrok.io @@ -233,6 +413,43 @@ PUBLIC_BASE_URL= # contoh: https://abc123.ngrok.io --- +## Setup Wizard (`npm run setup`) + +Form web di `/setup` untuk ngisi semua env vars tanpa edit `.env` manual. + +**Cara pakai:** + +```bash +npm run setup +``` + +Yang terjadi: +1. Auto-create `.env` dari `.env.example` kalau belum ada +2. Migrate database (`prisma db push`) — bikin tabel SQLite kalau belum ada +3. Start server +4. Auto-buka browser ke `http://localhost:3000/setup` (cross-platform: macOS/Linux/Windows) +5. Form punya section: Server, KlikQRIS, Telegram, Discord, Owner, Admin tambahan, Web Dashboard, Database +6. Kalau lupa generate session secret, ada tombol **Generate** yang bikin string random +7. Submit → `.env` lama di-backup (`.env.bak.`) → file baru ditulis dengan format rapi +8. Pesan konfirmasi muncul, instruksi restart (Ctrl+C → `npm run dev`) + +**Kapan setup wizard aktif:** + +- Selalu aktif di `NODE_ENV=development` (boleh re-config kapan aja) +- Aktif kalau `ADMIN_USERNAME` belum diset (first-run detection) +- **Otomatis disabled** di production yang sudah configured — `/setup` redirect ke `/admin/login` + +**Security:** + +- Hanya key di allowlist yang ditulis (anti-injection) +- Validasi minimal (password ≥ 8 karakter, username wajib) +- Backup `.env` lama sebelum overwrite +- Sensitive values (token, password, secret) dilog sebagai `` / `` aja + +**Bisa diakses langsung tanpa wizard launcher:** kalau server udah jalan via `npm run dev`, tinggal buka `http://localhost:3000/setup` di browser. + +--- + ## Webhook Public URL KlikQRIS perlu URL public untuk kirim callback. Untuk testing lokal, pakai tunnel: @@ -256,23 +473,194 @@ Untuk production: deploy ke VPS / Railway / Fly.io / Render dan pakai domain HTT --- +## Owner System + +Bot kenal siapa **owner**-nya — primary admin yang dapat perlakuan khusus. + +**Setup owner:** + +Cukup isi `OWNER_TELEGRAM_ID` dan/atau `OWNER_DISCORD_ID` di `.env` (atau via setup wizard). + +```bash +# Cara dapat numeric user ID: +# Telegram: chat @userinfobot, dia kirim ID-mu +# Discord: Settings > Advanced > Developer Mode ON, klik kanan profil > Copy User ID +OWNER_TELEGRAM_ID=123456789 +OWNER_DISCORD_ID=987654321098765432 +``` + +**Apa yang owner dapet otomatis:** + +| Event | Notifikasi ke owner | +| --- | --- | +| Bot online (saat startup) | "Bot Online — siap menerima order" | +| Order PAID + delivered | "Penjualan baru: produk X x2, Rp50.000, by @user" | +| Delivery gagal | "Delivery GAGAL — order X, error: ..., cek dashboard" | + +**Owner = Admin (otomatis):** + +Owner ID otomatis ditambahkan ke daftar admin, jadi punya akses semua admin commands & dashboard tanpa perlu listing dirinya 2x di `ADMIN_*_IDS`. + +**Cek role kamu sendiri:** + +- Telegram: `/whoami` +- Discord: `/whoami` + +Bot akan reply dengan role kamu (`Owner` / `Admin` / `Customer`) plus user ID + username. + +--- + +## Admin Dashboard + +Web UI untuk kelola toko dari browser. Auto-aktif saat `ADMIN_USERNAME` & `ADMIN_PASSWORD` diisi. + +**Setup:** + +```bash +# Edit .env +ADMIN_USERNAME=admin +ADMIN_PASSWORD=password-yang-kuat +ADMIN_SESSION_SECRET=$(openssl rand -hex 32) +``` + +**Akses:** `http://localhost:3000/admin` → login → dashboard. + +**Halaman yang tersedia:** + +| Path | Fungsi | +| --------------------------------- | ------------------------------------------------------------ | +| `/admin/login` | Form login | +| `/admin` | Stat cards (revenue today/7d/all-time, stok, count by status) + 10 order terakhir | +| `/admin/products` | List produk + form tambah produk + toggle aktif/nonaktif | +| `/admin/products/:id/stock` | Detail stok per produk + bulk add (paste banyak baris sekaligus) | +| `/admin/orders` | List order, filter status (Pending/Paid/Expired), tombol redeliver | + +**Fitur kunci:** + +- **Bulk add stok**: paste daftar akun/license/voucher di textarea, satu item per baris. +- **Redeliver**: kalau pembeli komplain produk gak nyampe (DM Discord blocked, dll), klik tombol Redeliver di halaman Orders. Sistem reuse stok yang sudah dialokasikan, jadi tidak double-claim. +- **Indikator delivery error**: order yang sudah PAID tapi DM gagal akan menampilkan jumlah percobaan + error message terakhir. +- **Session**: cookie HttpOnly + signed dengan `ADMIN_SESSION_SECRET`, expired 7 hari. + +> **Tip keamanan:** dashboard ini sekarang sudah di-rate-limit di `/admin/login` (max 10 attempt per 5 menit per IP). Untuk production, taruh tambahan reverse proxy (Caddy/Nginx) atau ekspos hanya via VPN/Tailscale. + +--- + +## Analytics + +Halaman `/admin/analytics` kasih insight 30 hari terakhir: + +| Metrik | Visualisasi | +| --- | --- | +| Revenue 30 hari | Big number + line chart per hari | +| Order 30 hari | Big number | +| Average Order Value (AOV) | Big number | +| Delivery Failure Rate | Big number (warna merah kalau >5%) | +| Top 10 produk by revenue | Ranked list dengan progress bar | +| Distribusi status order | Bar breakdown (PENDING/PAID/EXPIRED) | +| Penjualan per platform | Telegram vs Discord | + +Chart-nya pure SVG (gak butuh library) — render server-side. Hover dot di line chart untuk lihat angka per hari. + +--- + +## Admin Commands (Bot) + +Selain dashboard, admin juga bisa kelola toko langsung dari chat Telegram atau Discord. + +**Setup:** + +1. Dapatkan numeric user ID kamu: + - **Telegram**: chat `@userinfobot`, dia akan kirim ID-mu. + - **Discord**: Settings → Advanced → Developer Mode ON → klik kanan profil sendiri → Copy User ID. +2. Isi di `.env`: + ```bash + ADMIN_TELEGRAM_IDS=123456789,987654321 + ADMIN_DISCORD_IDS=111122223333,444455556666 + ``` + +### Telegram Admin Commands + +| Command | Deskripsi | +| ------------------------------------------------ | -------------------------------------------------------- | +| `/admin` | Lihat daftar admin commands | +| `/stats` | Statistik penjualan (revenue, count by status) | +| `/orders [pending\|paid\|expired]` | 10 order terakhir, filter optional | +| `/products` | Daftar semua produk + stok | +| `/addproduct \|\|\|` | Tambah produk (pisahkan dengan `\|`) | +| `/addstock ` *(multi-line)* | Tambah stok bulk (payload di baris-baris berikutnya) | +| `/toggle ` | Aktifkan/nonaktifkan produk | +| `/redeliver ` | Kirim ulang produk untuk order tertentu | + +**Contoh `/addstock` multi-line:** + +``` +/addstock NETFLIX-1B +email1@test.com|password1 +email2@test.com|password2 +email3@test.com|password3 +``` + +### Discord Admin Slash Commands + +Semua command admin pakai prefix `/admin-`. Hanya bisa diakses oleh user yang ada di `ADMIN_DISCORD_IDS`. + +| Slash Command | Deskripsi | +| ---------------------------------------------------------------- | ---------------------------------------- | +| `/admin-stats` | Statistik penjualan | +| `/admin-orders [status]` | 10 order terakhir | +| `/admin-products` | Daftar produk | +| `/admin-add-product id name price [description] [type]` | Tambah produk | +| `/admin-add-stock product_id payloads` | Tambah stok (`payloads` pisahkan `\|\|`) | +| `/admin-toggle product_id` | Aktif/nonaktif produk | +| `/admin-redeliver order_id` | Kirim ulang produk | + +**Contoh `/admin-add-stock`:** + +``` +/admin-add-stock product_id:NETFLIX-1B payloads:akun1@test.com|pw1||akun2@test.com|pw2||akun3@test.com|pw3 +``` + +(payload-nya pakai `|` internal, dan `||` sebagai separator antar item) + +--- + ## Struktur Project ``` botnot/ ├── assets/ -│ └── logo.svg +│ ├── logo.svg # Banner README +│ ├── social-preview.svg # Social preview source (edit di sini) +│ └── social-preview.png # Upload ini ke GitHub Settings ├── prisma/ │ ├── schema.prisma # Product, Stock, Order │ └── seed.ts # contoh data +├── scripts/ +│ └── setup.mjs # launcher untuk `npm run setup` (auto-buka browser) ├── src/ +│ ├── admin/ +│ │ ├── auth.ts # role helpers (owner/admin/customer) +│ │ ├── owner.ts # notifyOwner — best-effort DM ke OWNER_*_ID +│ │ ├── service.ts # stats, CRUD produk/stok, redeliver +│ │ └── dashboard/ +│ │ ├── routes.ts # Fastify routes /admin/* +│ │ ├── middleware.ts # session cookie auth +│ │ ├── layout.ts # shared HTML layout +│ │ └── pages/ # home, products, stock, orders +│ ├── setup/ +│ │ ├── routes.ts # GET/POST /setup (auto-disabled when configured) +│ │ ├── page.ts # form HTML (multi-section) +│ │ └── env-writer.ts # safe .env update + backup │ ├── bots/ -│ │ ├── telegram.ts # /start /catalog /buy -│ │ ├── discord.ts # slash commands +│ │ ├── telegram.ts # /start /catalog /buy /whoami +│ │ ├── telegram-admin.ts # /admin /stats /orders /addstock dll +│ │ ├── discord.ts # slash commands user (/catalog /buy /whoami) +│ │ ├── discord-admin.ts # /admin-* slash commands │ │ └── registry.ts # shared bot instances │ ├── orders/ │ │ ├── service.ts # createOrder, markOrderPaid -│ │ └── delivery.ts # auto-deliver stok ke pembeli +│ │ └── delivery.ts # auto-deliver stok ke pembeli (retry-safe) │ ├── payment/ │ │ ├── klikqris.ts # client API │ │ └── webhook.ts # POST /webhook/klikqris @@ -280,7 +668,7 @@ botnot/ │ ├── config.ts # zod-validated env │ ├── db.ts # prisma client │ ├── logger.ts # pino -│ └── index.ts # entrypoint +│ └── index.ts # entrypoint (HTTP + bots) ├── .env.example ├── package.json └── tsconfig.json @@ -290,26 +678,43 @@ botnot/ ## Perintah Bot -### Telegram +Bot kami pakai **inline buttons** sebagai UX utama — pelanggan tinggal klik tombol, gak perlu hafalin command. Tapi text command tetap jalan untuk power users. + +### Telegram (button-driven) + +Pelanggan ketik `/start` → muncul menu dengan tombol: + +``` + 📦 Lihat Katalog + ℹ️ Bantuan 👤 Profile +``` + +Klik **Lihat Katalog** → list produk muncul sebagai tombol (1 produk per baris dengan info harga + stok). Klik produk → detail + tombol qty (`Beli 1`, `Beli 3`, `Beli 5`). Klik beli → QR muncul + tombol `🔄 Cek Status` untuk monitor pembayaran. | Command | Deskripsi | | ----------------------- | ---------------------------------- | -| `/start` | Welcome message + daftar perintah | -| `/catalog` | Lihat semua produk + harga + stok | -| `/buy ` | Beli 1 unit | -| `/buy ` | Beli `n` unit | +| `/start` | Menu utama (button-driven) | +| `/catalog` | Buka katalog dengan tombol-tombol | +| `/buy [qty]` | Beli langsung (power user, text) | +| `/whoami` | Cek role kamu + user ID | + +### Discord (slash command + components) -### Discord +`/catalog` → embed dengan **dropdown SelectMenu** berisi semua produk. Pelanggan pilih produk → reply ephemeral dengan detail + buttons (`Beli 1`, `Beli 3`, `Beli 5`). Klik beli → QR dikirim + tombol `Cek Status`. -| Slash Command | Deskripsi | -| ---------------------------------------- | ----------------------- | -| `/catalog` | Lihat semua produk | -| `/buy product_id:` | Beli 1 unit | -| `/buy product_id: qty:` | Beli `n` unit | +| Slash Command | Deskripsi | +| ---------------------------------------- | ---------------------------------- | +| `/catalog` | Katalog dengan dropdown + buttons | +| `/buy product_id: [qty:]` | Beli langsung (power user) | +| `/whoami` | Cek role kamu + user ID | ### Tambah produk -Pakai Prisma Studio: +Cara termudah: pakai **[Admin Dashboard](#admin-dashboard)** di `/admin/products`. + +Atau dari bot pakai **[Admin Commands](#admin-commands-bot)** seperti `/addproduct` (Telegram) atau `/admin-add-product` (Discord). + +Atau via Prisma Studio: ```bash npm run db:studio @@ -344,12 +749,20 @@ Atau edit `prisma/seed.ts` lalu jalanin `npm run db:seed`. ## Roadmap -- [ ] Cron auto-expire order yang stuck `PENDING` +- [x] Admin web dashboard (login, products, stock, orders, redeliver, analytics) +- [x] Admin bot commands (Telegram + Discord) +- [x] Setup wizard (`npm run setup` + `/setup` web form) +- [x] Owner-aware bots (auto-admin, notifications, `/whoami` command) +- [x] Retry-safe delivery dengan tombol redeliver di dashboard +- [x] **Inline buttons di bot** (Telegram InlineKeyboard + Discord ButtonBuilder/SelectMenu) +- [x] **Cron auto-expire** order PENDING yang lewat waktu (jalan tiap 1 menit) +- [x] **Rate limiting** `/admin/login` (max 10 attempt/5 menit per IP) +- [x] **Dashboard analytics** — chart revenue 30 hari, top produk, breakdown per status & platform - [ ] Retry queue (BullMQ + Redis) untuk delivery yang gagal -- [ ] Admin commands: tambah produk, restok, lihat orderan, refund - [ ] Notifikasi WhatsApp via flag `notifwa` KlikQRIS -- [ ] Dashboard web (Next.js) untuk monitoring penjualan - [ ] Integrasi role Discord otomatis (untuk produk tipe `ROLE`) +- [ ] Export data CSV (orders, sales report) +- [ ] Multi-currency support - [ ] Unit & integration tests --- diff --git a/assets/logo.svg b/assets/logo.svg index 34c89b0..653e694 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,13 +1,17 @@ - - + + + + + + @@ -26,54 +30,48 @@ - - - - - - - + + - - - + - - - + - botnot - Auto-order Telegram & Discord bot — powered by KlikQRIS + botnot + Telegram & Discord auto-order bot — pelanggan scan QR, produk auto-deliver. - - - TypeScript + + Telegram + + + + Discord - - - Prisma + + + KlikQRIS - - - QRIS + + + Auto-deliver - - - Fastify + + + Dashboard diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..d96ea1d Binary files /dev/null and b/assets/social-preview.png differ diff --git a/assets/social-preview.svg b/assets/social-preview.svg new file mode 100644 index 0000000..3f550c5 --- /dev/null +++ b/assets/social-preview.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + botnot + Auto-order Telegram & Discord bot + + + + + Pelanggan scan, + produk auto-deliver. + + + + + Bot otomatis yang terintegrasi dengan KlikQRIS. + Stok akun, license, voucher dikirim langsung ke DM pembeli. + + + + + + + Telegram + + + + Discord + + + + KlikQRIS + + + + Auto-deliver + + + + Dashboard + + + + + + github.com/mocasus/botnot + MIT License · Made in Indonesia + + diff --git a/package.json b/package.json index 87502e6..02ca325 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "Auto-order Telegram & Discord bot dengan integrasi KlikQRIS", "scripts": { "dev": "tsx watch src/index.ts", + "setup": "node scripts/setup.mjs", "build": "tsc", "start": "node dist/index.js", "db:generate": "prisma generate", @@ -18,6 +19,9 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@fastify/cookie": "^11.0.1", + "@fastify/formbody": "^8.0.1", + "@fastify/rate-limit": "^10.2.1", "@prisma/client": "^5.20.0", "axios": "^1.7.7", "discord.js": "^14.16.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2399fca..8db5ed1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Stock { payload String // teks akun/license/voucher yang dikirim ke pembeli used Boolean @default(false) usedAt DateTime? + orderId String? // tracking: order mana yang men-claim stock ini createdAt DateTime @default(now()) product Product @relation(fields: [productId], references: [id]) @@ -35,26 +36,32 @@ model Stock { } model Order { - id String @id // = order_id (INV-...) - productId String - qty Int @default(1) - amount Int // sebelum unique amount - totalAmount Int? // total yg harus dibayar (dari KlikQRIS) - signature String? // dari response create - qrisUrl String? - status String @default("PENDING") // PENDING | PAID | EXPIRED | FAILED - platform String // TELEGRAM | DISCORD - chatId String // Telegram chat id atau Discord user id - username String? - expiredAt DateTime? - paidAt DateTime? - delivered Boolean @default(false) - deliveredAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id // = order_id (INV-...) + productId String + qty Int @default(1) + amount Int // sebelum unique amount + totalAmount Int? // total yg harus dibayar (dari KlikQRIS) + signature String? // dari response create + qrisUrl String? + status String @default("PENDING") // PENDING | PAID | EXPIRED | FAILED + platform String // TELEGRAM | DISCORD + chatId String // Telegram chat id atau Discord user id + username String? + expiredAt DateTime? + paidAt DateTime? + delivered Boolean @default(false) + deliveredAt DateTime? + // Retry-safe delivery: simpan payloads stok setelah dialokasikan, + // jadi DM yang gagal bisa di-retry tanpa double-claim stok. + deliveryPayload String? // JSON array of payloads + deliveryAttempts Int @default(0) + lastDeliveryError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt product Product @relation(fields: [productId], references: [id]) @@index([status]) @@index([platform, chatId]) + @@index([createdAt]) } diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..0786b20 --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * Setup launcher: bikin .env, migrate DB, start dev server, auto-open browser ke /setup. + * Cross-platform (macOS / Linux / Windows). + * + * Usage: npm run setup + * + * Yang script ini lakukan: + * 1. Bikin .env dari .env.example kalau belum ada + * 2. Jalanin `prisma db push` (idempoten — aman kalau DB sudah ada) + * 3. Start dev server (tsx watch src/index.ts) + * 4. Auto-buka browser ke http://localhost:PORT/setup + * + * Penting: .env harus ada SEBELUM prisma jalan, karena Prisma CLI baca DATABASE_URL + * langsung dari .env (bukan dari config zod aplikasi). + */ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, copyFileSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const cwd = process.cwd(); +const envPath = resolve(cwd, ".env"); +const examplePath = resolve(cwd, ".env.example"); +const isWindows = process.platform === "win32"; + +// === 1. Pastikan .env ada === +if (!existsSync(envPath)) { + if (!existsSync(examplePath)) { + console.error("\nERROR: .env.example tidak ditemukan di", cwd); + console.error("Pastikan kamu jalanin command ini dari root project (folder yang ada package.json).\n"); + process.exit(1); + } + copyFileSync(examplePath, envPath); + console.log("\n ✓ .env dibuat dari .env.example"); +} else { + console.log("\n ✓ .env sudah ada"); +} + +// === 2. Migrate DB === +console.log(" ⏳ Migrating database (prisma db push)..."); +const dbPush = spawnSync("npx", ["prisma", "db", "push", "--skip-generate", "--accept-data-loss"], { + stdio: ["ignore", "pipe", "pipe"], + shell: isWindows, + encoding: "utf-8", +}); +if (dbPush.status !== 0) { + console.error("\nERROR: prisma db push gagal."); + console.error(dbPush.stderr || dbPush.stdout); + console.error("\nCek DATABASE_URL di .env, lalu coba lagi.\n"); + process.exit(1); +} +console.log(" ✓ Database siap\n"); + +// === 3. Tentukan PORT dari .env (default 3000) === +const envContent = readFileSync(envPath, "utf-8"); +const portMatch = envContent.match(/^PORT=(\d+)/m); +const port = portMatch ? portMatch[1] : "3000"; +const setupUrl = `http://localhost:${port}/setup`; + +console.log(`Starting dev server, then opening:\n → ${setupUrl}\n`); +console.log(" ℹ Halaman pertama = Setup Wizard (form config)."); +console.log(" ℹ Setelah submit form, RESTART server (Ctrl+C → npm run dev)"); +console.log(" ℹ baru kamu bisa akses /admin dengan desain dashboard barunya.\n"); + +// === 4. Start server === +const server = spawn("npx", ["tsx", "src/index.ts"], { + stdio: "inherit", + shell: isWindows, +}); + +// === 5. Buka browser setelah server siap (2 detik delay) === +setTimeout(() => { + const opener = isWindows + ? ["cmd", ["/c", "start", "", setupUrl]] + : process.platform === "darwin" + ? ["open", [setupUrl]] + : ["xdg-open", [setupUrl]]; + + const child = spawn(opener[0], opener[1], { + stdio: "ignore", + detached: true, + shell: isWindows, + }); + child.on("error", () => { + console.log(`\n ⚠ Auto-open browser gagal. Buka manual: ${setupUrl}\n`); + }); + child.unref(); +}, 2000); + +// === 6. Forward signals ke child server === +const forward = (sig) => { + if (server && !server.killed) server.kill(sig); +}; +process.on("SIGINT", () => forward("SIGINT")); +process.on("SIGTERM", () => forward("SIGTERM")); +server.on("exit", (code) => process.exit(code ?? 0)); diff --git a/src/admin/analytics.ts b/src/admin/analytics.ts new file mode 100644 index 0000000..d7db168 --- /dev/null +++ b/src/admin/analytics.ts @@ -0,0 +1,132 @@ +import { prisma } from "../db.js"; + +/** + * Analytics queries untuk dashboard /admin/analytics. + * Pakai raw SQL kalau perlu untuk performa, tapi default Prisma groupBy/aggregate. + */ + +export interface AnalyticsData { + revenueByDay: Array<{ date: string; revenue: number; orders: number }>; + topProducts: Array<{ productId: string; name: string; revenue: number; sold: number }>; + byStatus: Array<{ status: string; count: number }>; + byPlatform: Array<{ platform: string; count: number; revenue: number }>; + totals: { + revenueAllTime: number; + revenue30d: number; + orders30d: number; + avgOrderValue: number; + deliveryFailureRate: number; // 0..1 + }; +} + +export async function getAnalytics(): Promise { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 3600 * 1000); + + // === Revenue by day (30 hari terakhir) === + const paidOrders = await prisma.order.findMany({ + where: { status: "PAID", paidAt: { gte: thirtyDaysAgo } }, + select: { paidAt: true, totalAmount: true, productId: true }, + orderBy: { paidAt: "asc" }, + }); + + const dayMap = new Map(); + // pre-fill 30 hari biar gap di chart kelihatan sebagai 0 + for (let i = 29; i >= 0; i--) { + const d = new Date(now.getTime() - i * 24 * 3600 * 1000); + const key = dateKey(d); + dayMap.set(key, { revenue: 0, orders: 0 }); + } + for (const o of paidOrders) { + if (!o.paidAt) continue; + const key = dateKey(o.paidAt); + const cur = dayMap.get(key) ?? { revenue: 0, orders: 0 }; + cur.revenue += o.totalAmount ?? 0; + cur.orders += 1; + dayMap.set(key, cur); + } + const revenueByDay = Array.from(dayMap.entries()).map(([date, v]) => ({ + date, + revenue: v.revenue, + orders: v.orders, + })); + + // === Top products (all-time, by revenue) === + const topProductsRaw = await prisma.order.groupBy({ + by: ["productId"], + where: { status: "PAID" }, + _sum: { totalAmount: true, qty: true }, + _count: { _all: true }, + orderBy: { _sum: { totalAmount: "desc" } }, + take: 10, + }); + const productMap = new Map( + ( + await prisma.product.findMany({ + where: { id: { in: topProductsRaw.map((p) => p.productId) } }, + select: { id: true, name: true }, + }) + ).map((p) => [p.id, p.name]), + ); + const topProducts = topProductsRaw.map((p) => ({ + productId: p.productId, + name: productMap.get(p.productId) ?? p.productId, + revenue: p._sum.totalAmount ?? 0, + sold: p._sum.qty ?? 0, + })); + + // === Status breakdown === + const statusGroups = await prisma.order.groupBy({ + by: ["status"], + _count: { _all: true }, + }); + const byStatus = statusGroups.map((s) => ({ + status: s.status, + count: s._count._all, + })); + + // === Platform breakdown (PAID only) === + const platformGroups = await prisma.order.groupBy({ + by: ["platform"], + where: { status: "PAID" }, + _count: { _all: true }, + _sum: { totalAmount: true }, + }); + const byPlatform = platformGroups.map((p) => ({ + platform: p.platform, + count: p._count._all, + revenue: p._sum.totalAmount ?? 0, + })); + + // === Totals === + const allTimePaid = await prisma.order.aggregate({ + where: { status: "PAID" }, + _sum: { totalAmount: true }, + _count: { _all: true }, + _avg: { totalAmount: true }, + }); + const last30d = await prisma.order.aggregate({ + where: { status: "PAID", paidAt: { gte: thirtyDaysAgo } }, + _sum: { totalAmount: true }, + _count: { _all: true }, + }); + const failedDeliveries = await prisma.order.count({ + where: { status: "PAID", delivered: false, deliveryAttempts: { gt: 0 } }, + }); + const totalPaidCount = allTimePaid._count._all || 0; + + const totals = { + revenueAllTime: allTimePaid._sum.totalAmount ?? 0, + revenue30d: last30d._sum.totalAmount ?? 0, + orders30d: last30d._count._all ?? 0, + avgOrderValue: Math.round(allTimePaid._avg.totalAmount ?? 0), + deliveryFailureRate: totalPaidCount === 0 ? 0 : failedDeliveries / totalPaidCount, + }; + + return { revenueByDay, topProducts, byStatus, byPlatform, totals }; +} + +function dateKey(d: Date): string { + // YYYY-MM-DD (UTC) — dipakai sebagai bucket key, urut alphabetically = urut by date + return d.toISOString().slice(0, 10); +} diff --git a/src/admin/auth.ts b/src/admin/auth.ts new file mode 100644 index 0000000..596645d --- /dev/null +++ b/src/admin/auth.ts @@ -0,0 +1,35 @@ +import { adminDiscordIds, adminTelegramIds, isOwnerDiscord, isOwnerTelegram } from "../config.js"; + +export function isTelegramAdmin(userId: string | number | undefined): boolean { + if (userId === undefined) return false; + return adminTelegramIds.has(String(userId)); +} + +export function isDiscordAdmin(userId: string | undefined): boolean { + if (!userId) return false; + return adminDiscordIds.has(userId); +} + +export function hasTelegramAdmins(): boolean { + return adminTelegramIds.size > 0; +} + +export function hasDiscordAdmins(): boolean { + return adminDiscordIds.size > 0; +} + +/** Cek apakah user adalah owner (primary admin), bukan admin biasa. */ +export { isOwnerTelegram, isOwnerDiscord }; + +/** Label role untuk command /whoami. */ +export function telegramRole(userId: string | number | undefined): "owner" | "admin" | "customer" { + if (isOwnerTelegram(userId)) return "owner"; + if (isTelegramAdmin(userId)) return "admin"; + return "customer"; +} + +export function discordRole(userId: string | undefined): "owner" | "admin" | "customer" { + if (isOwnerDiscord(userId)) return "owner"; + if (isDiscordAdmin(userId)) return "admin"; + return "customer"; +} diff --git a/src/admin/dashboard/layout.ts b/src/admin/dashboard/layout.ts new file mode 100644 index 0000000..93ea590 --- /dev/null +++ b/src/admin/dashboard/layout.ts @@ -0,0 +1,343 @@ +interface LayoutOptions { + title: string; + body: string; + active?: "home" | "products" | "stock" | "orders" | "analytics"; + flash?: { kind: "success" | "error" | "info"; message: string } | null; + pageTitle?: string; + pageSubtitle?: string; + breadcrumb?: Array<{ label: string; href?: string }>; +} + +export function escapeHtml(s: string | number | null | undefined): string { + if (s === null || s === undefined) return ""; + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + + +/** + * Mini icon library — inline SVG strings (Lucide-style). + */ +const ICONS: Record = { + dashboard: ``, + package: ``, + bag: ``, + trending_up: ``, + calendar: ``, + archive: ``, + clock: ``, + check: ``, + x: ``, + alert: ``, + refresh: ``, + plus: ``, + arrow_left: ``, + external: ``, + logout: ``, + bot: ``, + zap: ``, + user: ``, + database: ``, + chart: ``, + sparkles: ``, + menu: ``, + search: ``, + bell: ``, + settings: ``, + home: ``, +}; + +export function icon(name: string, cls = "w-4 h-4"): string { + const path = ICONS[name]; + if (!path) return ""; + return ``; +} + + +const FAVICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='11' width='18' height='10' rx='2' fill='%2322d3ee'/%3E%3Ccircle cx='12' cy='5' r='2' fill='%23a78bfa'/%3E%3C/svg%3E"; + +/** Shared with modern fonts, Tailwind, and enhanced theme. */ +function commonHead(title: string): string { + return ` + + + ${escapeHtml(title)} · botnot + + + + + + + + +`; +} + + +export function layout({ + title, + body, + active, + flash, + pageTitle, + pageSubtitle, + breadcrumb, +}: LayoutOptions): string { + const navItem = ( + id: NonNullable, + href: string, + label: string, + iconName: string, + ) => { + const isActive = active === id; + return ` + + ${icon(iconName, "w-[18px] h-[18px]")} + ${label} + ${isActive ? `` : ""} + `; + }; + + const mobileNavItem = ( + id: NonNullable, + href: string, + label: string, + iconName: string, + ) => { + const isActive = active === id; + return ` + + ${icon(iconName, "w-5 h-5")} + ${label} + `; + }; + + + const flashHtml = flash + ? `
+ ${icon(flash.kind === "success" ? "check" : flash.kind === "error" ? "alert" : "zap", "w-4 h-4")} + ${escapeHtml(flash.message)} +
` + : ""; + + const breadcrumbHtml = + breadcrumb && breadcrumb.length > 0 + ? `` + : ""; + + const pageHeader = pageTitle + ? `
+ ${breadcrumbHtml} +

${escapeHtml(pageTitle)}

+ ${pageSubtitle ? `

${escapeHtml(pageSubtitle)}

` : ""} +
` + : ""; + + + return ` + +${commonHead(title)} + +
+ +
+
+ + + + ${icon("bot", "w-5 h-5")} + + + + + + + + +
+ +
+ +
+ + +
+
+ + + +
+ ${mobileNavItem("home", "/admin", "Dashboard", "home")} + ${mobileNavItem("products", "/admin/products", "Produk", "package")} + ${mobileNavItem("orders", "/admin/orders", "Order", "bag")} + ${mobileNavItem("analytics", "/admin/analytics", "Analytics", "chart")} +
+
+ + +
+ ${pageHeader} + ${flashHtml} +
+ ${body} +
+
+ + +
+
+ + + botnot · auto-order bot + + v0.1.0 +
+
+
+ +`; +} + + +export function loginLayout(opts: { error?: string }): string { + return ` + +${commonHead("Login")} + +
+ +
+
+
+
+ +
+
+ + ${icon("bot", "w-8 h-8")} + +
+
+
+ botnot +
+
admin dashboard
+
+ +
+

Selamat datang

+

Masuk untuk mengelola toko kamu.

+ ${ + opts.error + ? `
${icon("alert", "w-4 h-4 shrink-0")}${escapeHtml(opts.error)}
` + : "" + } + + + + + +
+ +

+ Belum config? Buka setup wizard +

+
+ +`; +} diff --git a/src/admin/dashboard/middleware.ts b/src/admin/dashboard/middleware.ts new file mode 100644 index 0000000..6c831c1 --- /dev/null +++ b/src/admin/dashboard/middleware.ts @@ -0,0 +1,47 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { config, isDashboardConfigured } from "../../config.js"; + +export const SESSION_COOKIE = "botnot_admin"; +export const COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 hari + +/** + * Constant-time string compare untuk mencegah timing attack. + */ +export function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +export function checkCredentials(username: string, password: string): boolean { + if (!isDashboardConfigured()) return false; + return safeEqual(username, config.ADMIN_USERNAME) && safeEqual(password, config.ADMIN_PASSWORD); +} + +export function isAuthenticated(req: FastifyRequest): boolean { + const cookie = req.cookies?.[SESSION_COOKIE]; + if (!cookie) return false; + // Verifikasi signed cookie (Fastify auto-verifies via unsignCookie) + const unsigned = req.unsignCookie(cookie); + return unsigned.valid && unsigned.value === "ok"; +} + +export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise { + if (!isDashboardConfigured()) { + reply + .code(503) + .type("text/html") + .send( + "

Dashboard belum dikonfigurasi

Set ADMIN_USERNAME dan ADMIN_PASSWORD di .env

", + ); + return false; + } + if (!isAuthenticated(req)) { + reply.redirect("/admin/login"); + return false; + } + return true; +} diff --git a/src/admin/dashboard/pages/analytics.ts b/src/admin/dashboard/pages/analytics.ts new file mode 100644 index 0000000..5f8fea3 --- /dev/null +++ b/src/admin/dashboard/pages/analytics.ts @@ -0,0 +1,275 @@ +import type { AnalyticsData } from "../../analytics.js"; +import { formatIDR } from "../../service.js"; +import { escapeHtml, icon, layout } from "../layout.js"; +import { renderEmptyState } from "./home.js"; + +export function renderAnalytics(data: AnalyticsData): string { + const { revenueByDay, topProducts, byStatus, byPlatform, totals } = data; + const failurePct = (totals.deliveryFailureRate * 100).toFixed(1); + + + const statRow = ` +
+ ${statCard("Revenue 30 Hari", formatIDR(totals.revenue30d), "trending_up", "from-emerald-400 to-emerald-600", "text-emerald-400", "0.05s")} + ${statCard("Order 30 Hari", String(totals.orders30d), "bag", "from-cyan-400 to-cyan-600", "text-cyan-400", "0.1s")} + ${statCard("Avg Order Value", formatIDR(totals.avgOrderValue), "calendar", "from-violet-400 to-violet-600", "text-violet-400", "0.15s")} + ${statCard("Delivery Failure", `${failurePct}%`, "alert", "from-amber-400 to-amber-600", totals.deliveryFailureRate > 0.05 ? "text-red-400" : "text-amber-400", "0.2s")} +
`; + + const chartCard = ` +
+
+
+ + ${icon("trending_up", "w-4 h-4")} + +
+

Revenue 30 Hari Terakhir

+ Total: ${formatIDR(totals.revenue30d)} +
+
+
+
${renderRevenueChart(revenueByDay)}
+
`; + + + const topProductsCard = ` +
+
+ + ${icon("package", "w-4 h-4")} + +
+

Top Produk

+ ${topProducts.length} produk terlaris +
+
+ ${ + topProducts.length === 0 + ? renderEmptyState("Belum ada penjualan", "Top produk akan muncul setelah ada order PAID.", "package") + : `
+ ${topProducts.map((p, i) => renderTopProduct(p, i, topProducts[0]?.revenue ?? 1)).join("")} +
` + } +
`; + + const body = ` + ${statRow} + ${chartCard} +
+
${topProductsCard}
+
+
+
+ + ${icon("clock", "w-4 h-4")} + +

Status Order

+
+
${renderStatusBreakdown(byStatus)}
+
+
+
+ + ${icon("bot", "w-4 h-4")} + +

Per Platform

+
+
${renderPlatformBreakdown(byPlatform)}
+
+
+
+ `; + + return layout({ + title: "Analytics", + body, + active: "analytics", + pageTitle: "Analytics", + pageSubtitle: "Insight penjualan dan performa toko 30 hari terakhir", + }); +} + + +function statCard(label: string, value: string, iconName: string, gradient: string, textColor: string, delay: string): string { + return ` +
+
+
+ ${label} + ${icon(iconName, "w-4 h-4")} +
+
${value}
+
`; +} + +function renderRevenueChart(days: Array<{ date: string; revenue: number; orders: number }>): string { + if (days.every((d) => d.revenue === 0)) { + return `
${renderEmptyState("Belum ada data", "Chart akan muncul setelah ada order PAID dalam 30 hari terakhir.", "trending_up")}
`; + } + + const W = 800; + const H = 260; + const padX = 45; + const padTop = 20; + const padBot = 35; + const innerW = W - padX * 2; + const innerH = H - padTop - padBot; + + const max = Math.max(...days.map((d) => d.revenue), 1); + const xStep = innerW / (days.length - 1 || 1); + + const points = days.map((d, i) => { + const x = padX + i * xStep; + const y = padTop + innerH - (d.revenue / max) * innerH; + return { x, y, ...d }; + }); + + const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); + const areaPath = `${linePath} L${points[points.length - 1]!.x.toFixed(1)},${(padTop + innerH).toFixed(1)} L${points[0]!.x.toFixed(1)},${(padTop + innerH).toFixed(1)} Z`; + + const yTicks = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ + y: padTop + innerH - p * innerH, + label: formatCompact(max * p), + })); + + const xTicks = points.filter((_, i) => i % 5 === 0 || i === points.length - 1); + + + const dots = points + .map( + (p) => ` + ${escapeHtml(p.date)}: ${formatIDR(p.revenue)} (${p.orders} order) + `, + ) + .join(""); + + return ` + + + + + + + + + + + + ${yTicks + .map( + (t) => ` + + ${escapeHtml(t.label)}`, + ) + .join("")} + + + ${dots} + ${xTicks + .map( + (p) => `${escapeHtml(p.date.slice(5))}`, + ) + .join("")} + `; +} + + +function renderTopProduct( + p: { productId: string; name: string; revenue: number; sold: number }, + i: number, + maxRevenue: number, +): string { + const pct = maxRevenue > 0 ? (p.revenue / maxRevenue) * 100 : 0; + const rank = i + 1; + const rankColor = + rank === 1 + ? "text-amber-300 bg-amber-500/10 ring-1 ring-amber-500/20" + : rank === 2 + ? "text-slate-200 bg-slate-500/10 ring-1 ring-slate-500/20" + : rank === 3 + ? "text-orange-300 bg-orange-500/10 ring-1 ring-orange-500/20" + : "text-slate-400 bg-slate-700/30 ring-1 ring-slate-600/30"; + return ` +
+
+ ${rank} +
+
${escapeHtml(p.name)}
+
${escapeHtml(p.productId)}
+
+
+
${escapeHtml(formatIDR(p.revenue))}
+
${p.sold} terjual
+
+
+
+
+
+
`; +} + +function renderStatusBreakdown(items: Array<{ status: string; count: number }>): string { + if (items.length === 0) return `
Belum ada data
`; + const total = items.reduce((a, b) => a + b.count, 0); + const colors: Record = { + PENDING: { bar: "bg-amber-500", text: "text-amber-300" }, + PAID: { bar: "bg-emerald-500", text: "text-emerald-300" }, + EXPIRED: { bar: "bg-red-500", text: "text-red-300" }, + FAILED: { bar: "bg-red-500", text: "text-red-300" }, + }; + return `
+ ${items + .map((it) => { + const c = colors[it.status] ?? { bar: "bg-slate-500", text: "text-slate-300" }; + const pct = total > 0 ? (it.count / total) * 100 : 0; + return `
+
+ ${escapeHtml(it.status)} + ${it.count} (${pct.toFixed(0)}%) +
+
+
+
+
`; + }) + .join("")} +
`; +} + + +function renderPlatformBreakdown(items: Array<{ platform: string; count: number; revenue: number }>): string { + if (items.length === 0) return `
Belum ada penjualan
`; + const totalRev = items.reduce((a, b) => a + b.revenue, 0) || 1; + return `
+ ${items + .map((it) => { + const isTg = it.platform === "TELEGRAM"; + const accent = isTg ? "from-cyan-500 to-cyan-600" : "from-violet-500 to-violet-600"; + const textColor = isTg ? "text-cyan-300" : "text-violet-300"; + const bgColor = isTg ? "bg-cyan-500/10 ring-1 ring-cyan-500/20" : "bg-violet-500/10 ring-1 ring-violet-500/20"; + const pct = (it.revenue / totalRev) * 100; + return `
+
+ + ${isTg ? "TG" : "DC"} + ${escapeHtml(it.platform)} + + ${escapeHtml(formatIDR(it.revenue))} +
+
+
+
+
${it.count} order
+
`; + }) + .join("")} +
`; +} + +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}jt`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; + return Math.round(n).toString(); +} diff --git a/src/admin/dashboard/pages/home.ts b/src/admin/dashboard/pages/home.ts new file mode 100644 index 0000000..a3f8949 --- /dev/null +++ b/src/admin/dashboard/pages/home.ts @@ -0,0 +1,180 @@ +import type { AdminStats } from "../../service.js"; +import { formatIDR } from "../../service.js"; +import { escapeHtml, icon, layout } from "../layout.js"; + +interface RecentOrder { + id: string; + status: string; + totalAmount: number | null; + platform: string; + username: string | null; + createdAt: Date; + product: { name: string }; +} + +export function renderHome(opts: { stats: AdminStats; recent: RecentOrder[] }): string { + const { stats, recent } = opts; + + + const statCard = ( + label: string, + value: string, + iconName: string, + gradient: string, + textColor: string, + delay: string, + ) => ` +
+
+
+ ${label} + ${icon(iconName, "w-4 h-4")} +
+
${value}
+
`; + + const miniStat = (label: string, value: string, dotColor: string, valueColor: string) => ` +
+ +
+
${label}
+
${value}
+
+
`; + + + const body = ` + +
+ ${statCard("Hari Ini", formatIDR(stats.revenueToday), "trending_up", "from-emerald-400 to-emerald-600", "text-emerald-400", "0.05s")} + ${statCard("7 Hari", formatIDR(stats.revenue7d), "calendar", "from-cyan-400 to-cyan-600", "text-cyan-400", "0.1s")} + ${statCard("Total Revenue", formatIDR(stats.revenueAllTime), "bag", "from-violet-400 to-violet-600", "text-violet-400", "0.15s")} + ${statCard("Stok Tersedia", String(stats.stockAvailable), "archive", "from-amber-400 to-amber-600", "text-amber-400", "0.2s")} +
+ + +
+ ${miniStat("Total Order", String(stats.totalOrders), "bg-slate-400", "text-slate-100")} + ${miniStat("Pending", String(stats.pending), "bg-amber-400 shadow-sm shadow-amber-400/30", "text-amber-200")} + ${miniStat("Paid", String(stats.paid), "bg-emerald-400 shadow-sm shadow-emerald-400/30", "text-emerald-200")} + ${miniStat("Expired", String(stats.expired), "bg-red-400 shadow-sm shadow-red-400/30", "text-red-200")} +
+ + +
+
+
+ + ${icon("clock", "w-4 h-4")} + +
+

Order Terbaru

+ ${recent.length > 0 ? `${recent.length} terakhir` : ""} +
+
+ + Lihat semua ${icon("external", "w-3 h-3")} + +
+ ${ + recent.length === 0 + ? renderEmptyState("Belum ada order", "Order pertama akan muncul setelah pembeli scan QRIS.", "bag") + : `
+ + + + + + + + + + + + ${recent.map((o) => orderRow(o)).join("")} + +
Order IDProdukTotalStatusPlatformWaktu
` + } +
+ `; + + return layout({ + title: "Dashboard", + body, + active: "home", + pageTitle: "Dashboard", + pageSubtitle: "Ringkasan penjualan dan aktivitas terbaru", + }); +} + + +function orderRow(o: RecentOrder): string { + const statusBadge = renderStatusBadge(o.status); + const platformBadge = o.platform === "TELEGRAM" + ? ` + TG + Telegram + ` + : ` + DC + Discord + `; + return ` + + ${escapeHtml(o.id)} + ${escapeHtml(o.product.name)} + ${escapeHtml(formatIDR(o.totalAmount))} + ${statusBadge} + ${platformBadge} + ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} + `; +} + +export function renderStatusBadge(status: string): string { + const map: Record = { + PENDING: { + dot: "bg-amber-400 shadow-sm shadow-amber-400/40", + cls: "bg-amber-500/10 text-amber-200 border-amber-500/20", + label: "Pending", + }, + PAID: { + dot: "bg-emerald-400 shadow-sm shadow-emerald-400/40", + cls: "bg-emerald-500/10 text-emerald-200 border-emerald-500/20", + label: "Paid", + }, + SUCCESS: { + dot: "bg-emerald-400 shadow-sm shadow-emerald-400/40", + cls: "bg-emerald-500/10 text-emerald-200 border-emerald-500/20", + label: "Success", + }, + EXPIRED: { + dot: "bg-red-400 shadow-sm shadow-red-400/40", + cls: "bg-red-500/10 text-red-200 border-red-500/20", + label: "Expired", + }, + FAILED: { + dot: "bg-red-400 shadow-sm shadow-red-400/40", + cls: "bg-red-500/10 text-red-200 border-red-500/20", + label: "Failed", + }, + }; + const s = map[status] ?? { + dot: "bg-slate-400", + cls: "bg-slate-700/50 text-slate-300 border-slate-600/50", + label: status, + }; + return ` + + ${escapeHtml(s.label)} + `; +} + +export function renderEmptyState(title: string, subtitle: string, iconName: string): string { + return `
+
+ ${icon(iconName, "w-6 h-6")} +
+
${escapeHtml(title)}
+
${escapeHtml(subtitle)}
+
`; +} diff --git a/src/admin/dashboard/pages/orders.ts b/src/admin/dashboard/pages/orders.ts new file mode 100644 index 0000000..f70ef5b --- /dev/null +++ b/src/admin/dashboard/pages/orders.ts @@ -0,0 +1,167 @@ +import { escapeHtml, icon, layout } from "../layout.js"; +import { formatIDR } from "../../service.js"; +import { renderEmptyState, renderStatusBadge } from "./home.js"; + +interface OrderRow { + id: string; + status: string; + totalAmount: number | null; + amount: number; + qty: number; + platform: string; + username: string | null; + chatId: string; + delivered: boolean; + deliveryAttempts: number; + lastDeliveryError: string | null; + paidAt: Date | null; + createdAt: Date; + product: { name: string; id: string }; +} + +export function renderOrders(opts: { + orders: OrderRow[]; + filterStatus?: string; + flash?: { kind: "success" | "error" | "info"; message: string } | null; +}): string { + const { orders, filterStatus, flash } = opts; + + + const filterPill = ( + status: string | undefined, + label: string, + iconName: string, + accent: string, + count?: number, + ) => { + const active = (filterStatus ?? "") === (status ?? ""); + const href = status ? `?status=${status}` : "/admin/orders"; + return ` + ${icon(iconName, "w-3.5 h-3.5")} + ${label} + ${count !== undefined ? `${count}` : ""} + `; + }; + + const platformBadge = (platform: string) => { + if (platform === "TELEGRAM") { + return ` + TG + Telegram + `; + } + return ` + DC + Discord + `; + }; + + + const rows = orders + .map((o) => { + const deliveryCell = o.delivered + ? ` + ${icon("check", "w-3.5 h-3.5")} Terkirim + ` + : o.status === "PAID" + ? `
+
+ +
+ ${ + o.lastDeliveryError + ? `
+ ${icon("alert", "w-3 h-3")} ${o.deliveryAttempts}x gagal +
` + : "" + } +
` + : ``; + + return ` + + ${escapeHtml(o.id)} + +
${escapeHtml(o.product.name)}
+
x${o.qty}
+ + ${escapeHtml(formatIDR(o.totalAmount))} + ${renderStatusBadge(o.status)} + +
${platformBadge(o.platform)}
+
${escapeHtml(o.username ?? o.chatId)}
+ + ${deliveryCell} + ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} + `; + }) + .join(""); + + + const filterLabel = filterStatus ? ` dengan status ${filterStatus.toLowerCase()}` : ""; + + const body = ` +
+ +
+
+
+ + ${icon("bag", "w-4 h-4")} + +
+

Daftar Order

+ ${orders.length > 0 ? `${orders.length} order${filterLabel}` : ""} +
+
+
+ ${filterPill(undefined, "Semua", "package", "text-slate-500")} + ${filterPill("PENDING", "Pending", "clock", "text-amber-400")} + ${filterPill("PAID", "Paid", "check", "text-emerald-400")} + ${filterPill("EXPIRED", "Expired", "x", "text-red-400")} +
+
+
+ + + ${ + orders.length === 0 + ? renderEmptyState( + `Tidak ada order${filterLabel}`, + "Order baru akan muncul setelah pembeli scan QRIS dan bayar.", + "bag", + ) + : `
+ + + + + + + + + + + + ${rows} +
Order IDProdukTotalStatusPembeliDeliveryWaktu
` + } +
+ `; + + return layout({ + title: "Order", + body, + active: "orders", + flash, + pageTitle: "Order", + pageSubtitle: "Lihat dan kelola semua order yang masuk", + }); +} diff --git a/src/admin/dashboard/pages/products.ts b/src/admin/dashboard/pages/products.ts new file mode 100644 index 0000000..ad99ba5 --- /dev/null +++ b/src/admin/dashboard/pages/products.ts @@ -0,0 +1,185 @@ +import { escapeHtml, icon, layout } from "../layout.js"; +import { formatIDR } from "../../service.js"; +import { renderEmptyState } from "./home.js"; + +interface ProductRow { + id: string; + name: string; + description: string | null; + priceIDR: number; + type: string; + active: boolean; + available: number; + sold: number; +} + +export function renderProducts(opts: { + products: ProductRow[]; + flash?: { kind: "success" | "error" | "info"; message: string } | null; +}): string { + const { products, flash } = opts; + + + const stockBar = (available: number, sold: number) => { + const total = available + sold; + if (total === 0) { + return `
No stock
`; + } + const pct = Math.round((available / total) * 100); + const barColor = + pct === 0 ? "bg-red-500" : pct < 25 ? "bg-amber-500" : "bg-gradient-to-r from-emerald-500 to-cyan-500"; + return ` +
+
+
+
+
+ ${available} + / + ${total} +
+
`; + }; + + const rows = products + .map( + (p) => ` + + ${escapeHtml(p.id)} + +
${escapeHtml(p.name)}
+ ${p.description ? `
${escapeHtml(p.description)}
` : ""} + + ${escapeHtml(formatIDR(p.priceIDR))} + + ${escapeHtml(p.type)} + + ${stockBar(p.available, p.sold)} + + ${ + p.active + ? ` + Aktif + ` + : ` + Nonaktif + ` + } + + +
+ + ${icon("archive", "w-3.5 h-3.5")} Stok + +
+ +
+
+ + `, + ) + .join(""); + + + const inputCls = + "w-full bg-slate-950/60 border border-slate-700/50 rounded-xl px-4 py-2.5 focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/10 focus:outline-none transition-all placeholder:text-slate-600"; + const labelCls = "block text-xs uppercase tracking-wider text-slate-500 mb-1.5 font-medium"; + + const body = ` + +
+
+ + ${icon("plus", "w-4 h-4")} + +
+

Tambah Produk Baru

+

Isi form untuk menambahkan produk ke katalog

+
+
+
+
+ + +

Hanya huruf, angka, _, dan -

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + +
+
+ + ${icon("package", "w-4 h-4")} + +
+

Daftar Produk

+
+ ${products.length} +
+ ${ + products.length === 0 + ? renderEmptyState("Belum ada produk", "Tambah produk pertama lewat form di atas.", "package") + : `
+ + + + + + + + + + + + ${rows} +
IDNamaHargaTipeStokStatusAksi
` + } +
+ `; + + return layout({ + title: "Produk", + body, + active: "products", + flash, + pageTitle: "Produk", + pageSubtitle: "Kelola katalog produk yang dijual oleh bot", + }); +} diff --git a/src/admin/dashboard/pages/stock.ts b/src/admin/dashboard/pages/stock.ts new file mode 100644 index 0000000..fb9adec --- /dev/null +++ b/src/admin/dashboard/pages/stock.ts @@ -0,0 +1,168 @@ +import { escapeHtml, icon, layout } from "../layout.js"; +import { formatIDR } from "../../service.js"; +import { renderEmptyState } from "./home.js"; + +interface StockItem { + id: number; + payload: string; + used: boolean; + usedAt: Date | null; + orderId: string | null; + createdAt: Date; +} + +interface ProductInfo { + id: string; + name: string; + priceIDR: number; +} + +export function renderStock(opts: { + product: ProductInfo; + stocks: StockItem[]; + available: number; + sold: number; + flash?: { kind: "success" | "error" | "info"; message: string } | null; +}): string { + const { product, stocks, available, sold, flash } = opts; + const total = available + sold; + const pct = total > 0 ? Math.round((available / total) * 100) : 0; + + + const rows = stocks + .map( + (s) => ` + + #${s.id} + + ${escapeHtml(s.payload)} + + + ${ + s.used + ? ` + Used + ` + : ` + Available + ` + } + + ${escapeHtml(s.orderId ?? "—")} + ${escapeHtml(s.createdAt.toISOString().slice(0, 19).replace("T", " "))} + `, + ) + .join(""); + + + const summaryCard = ` +
+
+
+
+ ${escapeHtml(product.id)} + · + ${escapeHtml(formatIDR(product.priceIDR))} +
+

${escapeHtml(product.name)}

+
+
+
+
${available}
+
Available
+
+
+
${sold}
+
Sold
+
+
+
${total}
+
Total
+
+
+
+ ${ + total > 0 + ? `
+
+ Stok tersedia + ${pct}% +
+
+
+
+
` + : "" + } +
`; + + + const body = ` + ${summaryCard} + +
+ +
+
+ + ${icon("plus", "w-4 h-4")} + +
+

Tambah Stok

+

Satu item per baris

+
+
+
+

Cocok untuk paste daftar akun/license/voucher sekaligus.

+ + +
+
+ + +
+
+ + ${icon("archive", "w-4 h-4")} + +
+

Daftar Stok

+
+ ${stocks.length} terakhir +
+ ${ + stocks.length === 0 + ? renderEmptyState("Belum ada stok", "Tambahkan stok pertama lewat form di sebelah.", "archive") + : `
+ + + + + + + + + + ${rows} +
#PayloadStatusOrderDibuat
` + } +
+
+ `; + + return layout({ + title: `Stok ${product.id}`, + body, + active: "products", + flash, + pageTitle: "Kelola Stok", + breadcrumb: [ + { label: "Produk", href: "/admin/products" }, + { label: product.name }, + ], + }); +} diff --git a/src/admin/dashboard/routes.ts b/src/admin/dashboard/routes.ts new file mode 100644 index 0000000..2fc53bf --- /dev/null +++ b/src/admin/dashboard/routes.ts @@ -0,0 +1,208 @@ +import type { FastifyInstance } from "fastify"; +import { logger } from "../../logger.js"; +import { + addStock, + createProduct, + getStats, + getStockForProduct, + listProductsWithStock, + listRecentOrders, + redeliverOrder, + toggleProductActive, +} from "../service.js"; +import { getAnalytics } from "../analytics.js"; +import { prisma } from "../../db.js"; +import { + COOKIE_MAX_AGE_SEC, + SESSION_COOKIE, + checkCredentials, + requireAuth, +} from "./middleware.js"; +import { loginLayout } from "./layout.js"; +import { renderHome } from "./pages/home.js"; +import { renderProducts } from "./pages/products.js"; +import { renderStock } from "./pages/stock.js"; +import { renderOrders } from "./pages/orders.js"; +import { renderAnalytics } from "./pages/analytics.js"; + +type Flash = { kind: "success" | "error" | "info"; message: string }; + +const FLASH_COOKIE = "botnot_flash"; + +function setFlash(reply: import("fastify").FastifyReply, flash: Flash) { + reply.setCookie(FLASH_COOKIE, JSON.stringify(flash), { + path: "/admin", + httpOnly: true, + sameSite: "lax", + maxAge: 30, + }); +} + +function popFlash( + req: import("fastify").FastifyRequest, + reply: import("fastify").FastifyReply, +): Flash | null { + const raw = req.cookies?.[FLASH_COOKIE]; + if (!raw) return null; + reply.clearCookie(FLASH_COOKIE, { path: "/admin" }); + try { + return JSON.parse(raw) as Flash; + } catch { + return null; + } +} + +export function registerAdminDashboardRoutes(app: FastifyInstance): void { + // === Login === + app.get("/admin/login", async (req, reply) => { + return reply.type("text/html").send(loginLayout({})); + }); + + app.post<{ Body: { username?: string; password?: string } }>( + "/admin/login", + { + // Rate limit per IP — 10 percobaan / 5 menit untuk mitigasi brute force. + // Pakai any-cast karena type-augmentation @fastify/rate-limit di v10 bermasalah dengan v5. + // Aman karena value sudah divalidasi oleh plugin. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: { rateLimit: { max: 10, timeWindow: "5 minutes" } } as any, + }, + async (req, reply) => { + const { username = "", password = "" } = req.body ?? {}; + if (!checkCredentials(username, password)) { + logger.warn({ ip: req.ip, username }, "Failed admin login attempt"); + return reply + .code(401) + .type("text/html") + .send(loginLayout({ error: "Username atau password salah." })); + } + reply.setCookie(SESSION_COOKIE, "ok", { + path: "/", + httpOnly: true, + sameSite: "lax", + signed: true, + maxAge: COOKIE_MAX_AGE_SEC, + }); + return reply.redirect("/admin"); + }, + ); + + app.post("/admin/logout", async (req, reply) => { + reply.clearCookie(SESSION_COOKIE, { path: "/" }); + return reply.redirect("/admin/login"); + }); + + // === Dashboard home === + app.get("/admin", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + const [stats, recent] = await Promise.all([getStats(), listRecentOrders({ take: 10 })]); + return reply.type("text/html").send(renderHome({ stats, recent })); + }); + + // === Analytics === + app.get("/admin/analytics", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + const data = await getAnalytics(); + return reply.type("text/html").send(renderAnalytics(data)); + }); + + // === Products list + create === + app.get("/admin/products", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + const products = await listProductsWithStock(); + const flash = popFlash(req, reply); + return reply.type("text/html").send(renderProducts({ products, flash })); + }); + + app.post<{ + Body: { id?: string; name?: string; priceIDR?: string; description?: string; type?: string }; + }>("/admin/products", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + try { + const { id, name, priceIDR, description, type } = req.body ?? {}; + if (!id || !name || !priceIDR) throw new Error("Field id, name, priceIDR wajib diisi"); + await createProduct({ + id: id.trim(), + name: name.trim(), + priceIDR: parseInt(priceIDR, 10), + description: description?.trim() || undefined, + type: type?.trim() || undefined, + }); + setFlash(reply, { kind: "success", message: `Produk "${id}" berhasil ditambahkan` }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + setFlash(reply, { kind: "error", message: msg }); + } + return reply.redirect("/admin/products"); + }); + + app.post<{ Params: { id: string } }>("/admin/products/:id/toggle", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + try { + const p = await toggleProductActive(req.params.id); + setFlash(reply, { + kind: "success", + message: `Produk "${p.id}" ${p.active ? "diaktifkan" : "dinonaktifkan"}`, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + setFlash(reply, { kind: "error", message: msg }); + } + return reply.redirect("/admin/products"); + }); + + // === Stock per product === + app.get<{ Params: { id: string } }>("/admin/products/:id/stock", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + const product = await prisma.product.findUnique({ where: { id: req.params.id } }); + if (!product) { + return reply.code(404).type("text/html").send("

Produk tidak ditemukan

"); + } + const stocks = await getStockForProduct(product.id, { take: 100 }); + const available = stocks.filter((s) => !s.used).length; + const sold = stocks.filter((s) => s.used).length; + const flash = popFlash(req, reply); + return reply.type("text/html").send(renderStock({ product, stocks, available, sold, flash })); + }); + + app.post<{ Params: { id: string }; Body: { payloads?: string } }>( + "/admin/products/:id/stock", + async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + try { + const lines = (req.body?.payloads ?? "").split(/\r?\n/); + const added = await addStock(req.params.id, lines); + setFlash(reply, { kind: "success", message: `${added} item stok ditambahkan` }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + setFlash(reply, { kind: "error", message: msg }); + } + return reply.redirect(`/admin/products/${encodeURIComponent(req.params.id)}/stock`); + }, + ); + + // === Orders === + app.get<{ Querystring: { status?: string } }>("/admin/orders", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + const status = req.query.status?.toUpperCase(); + const orders = await listRecentOrders({ status, take: 100 }); + const flash = popFlash(req, reply); + return reply.type("text/html").send(renderOrders({ orders, filterStatus: status, flash })); + }); + + app.post<{ Params: { id: string } }>("/admin/orders/:id/redeliver", async (req, reply) => { + if (!(await requireAuth(req, reply))) return; + try { + await redeliverOrder(req.params.id); + setFlash(reply, { + kind: "success", + message: `Order ${req.params.id} berhasil dikirim ulang`, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err, orderId: req.params.id }, "Redeliver failed"); + setFlash(reply, { kind: "error", message: `Redeliver gagal: ${msg}` }); + } + return reply.redirect("/admin/orders"); + }); +} diff --git a/src/admin/owner.ts b/src/admin/owner.ts new file mode 100644 index 0000000..e4e42cf --- /dev/null +++ b/src/admin/owner.ts @@ -0,0 +1,39 @@ +import { config } from "../config.js"; +import { logger } from "../logger.js"; +import { registry } from "../bots/registry.js"; + +/** + * Best-effort kirim notifikasi ke owner via Telegram & Discord. + * Tidak throw kalau gagal — biar event lain (delivery, dll) tetap jalan. + */ +export async function notifyOwner(message: string): Promise { + const tasks: Promise[] = []; + + if (config.OWNER_TELEGRAM_ID && registry.telegram) { + tasks.push( + registry.telegram.api + .sendMessage(config.OWNER_TELEGRAM_ID, message, { parse_mode: "Markdown" }) + .catch((err) => logger.warn({ err }, "notifyOwner Telegram failed")), + ); + } + + if (config.OWNER_DISCORD_ID && registry.discord) { + tasks.push( + registry.discord.users + .fetch(config.OWNER_DISCORD_ID) + .then((user) => user.send(message)) + .catch((err) => logger.warn({ err }, "notifyOwner Discord failed")), + ); + } + + if (tasks.length === 0) { + logger.debug("notifyOwner skipped: no owner ID configured or bots not running"); + return; + } + + await Promise.allSettled(tasks); +} + +export function hasOwner(): boolean { + return Boolean(config.OWNER_TELEGRAM_ID || config.OWNER_DISCORD_ID); +} diff --git a/src/admin/service.ts b/src/admin/service.ts new file mode 100644 index 0000000..5d6f440 --- /dev/null +++ b/src/admin/service.ts @@ -0,0 +1,163 @@ +import { prisma } from "../db.js"; +import { logger } from "../logger.js"; +import { deliverOrder } from "../orders/delivery.js"; + +export interface AdminStats { + totalOrders: number; + pending: number; + paid: number; + expired: number; + revenueToday: number; + revenue7d: number; + revenueAllTime: number; + productsActive: number; + stockAvailable: number; +} + +export async function getStats(): Promise { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 3600 * 1000); + + const [byStatus, todayAgg, weekAgg, allTimeAgg, productsActive, stockAvailable] = + await Promise.all([ + prisma.order.groupBy({ by: ["status"], _count: { _all: true } }), + prisma.order.aggregate({ + _sum: { totalAmount: true }, + where: { status: "PAID", paidAt: { gte: startOfToday } }, + }), + prisma.order.aggregate({ + _sum: { totalAmount: true }, + where: { status: "PAID", paidAt: { gte: sevenDaysAgo } }, + }), + prisma.order.aggregate({ + _sum: { totalAmount: true }, + where: { status: "PAID" }, + }), + prisma.product.count({ where: { active: true } }), + prisma.stock.count({ where: { used: false } }), + ]); + + const counts = Object.fromEntries(byStatus.map((r) => [r.status, r._count._all])); + const totalOrders = byStatus.reduce((acc, r) => acc + r._count._all, 0); + + return { + totalOrders, + pending: counts.PENDING ?? 0, + paid: counts.PAID ?? 0, + expired: counts.EXPIRED ?? 0, + revenueToday: todayAgg._sum.totalAmount ?? 0, + revenue7d: weekAgg._sum.totalAmount ?? 0, + revenueAllTime: allTimeAgg._sum.totalAmount ?? 0, + productsActive, + stockAvailable, + }; +} + +export async function listRecentOrders(opts?: { status?: string; take?: number }) { + return prisma.order.findMany({ + where: opts?.status ? { status: opts.status } : undefined, + include: { product: true }, + orderBy: { createdAt: "desc" }, + take: opts?.take ?? 20, + }); +} + +export async function listProductsWithStock() { + const products = await prisma.product.findMany({ orderBy: { createdAt: "desc" } }); + const stockCounts = await prisma.stock.groupBy({ + by: ["productId", "used"], + _count: { _all: true }, + }); + const map = new Map(); + for (const r of stockCounts) { + const cur = map.get(r.productId) ?? { available: 0, sold: 0 }; + if (r.used) cur.sold += r._count._all; + else cur.available += r._count._all; + map.set(r.productId, cur); + } + return products.map((p) => ({ + ...p, + available: map.get(p.id)?.available ?? 0, + sold: map.get(p.id)?.sold ?? 0, + })); +} + +export async function createProduct(input: { + id: string; + name: string; + priceIDR: number; + description?: string; + type?: string; +}) { + if (!/^[A-Z0-9_-]+$/i.test(input.id)) { + throw new Error("Product ID hanya boleh huruf, angka, underscore, dan tanda minus"); + } + if (input.priceIDR <= 0) throw new Error("Harga harus > 0"); + return prisma.product.create({ + data: { + id: input.id, + name: input.name, + priceIDR: input.priceIDR, + description: input.description, + type: input.type ?? "ACCOUNT", + }, + }); +} + +export async function toggleProductActive(productId: string) { + const p = await prisma.product.findUnique({ where: { id: productId } }); + if (!p) throw new Error("Produk tidak ditemukan"); + return prisma.product.update({ + where: { id: productId }, + data: { active: !p.active }, + }); +} + +/** + * Tambah stok dalam batch. Skip baris kosong. + * Return jumlah item yang berhasil ditambahkan. + */ +export async function addStock(productId: string, payloads: string[]): Promise { + const product = await prisma.product.findUnique({ where: { id: productId } }); + if (!product) throw new Error(`Produk "${productId}" tidak ditemukan`); + const cleaned = payloads.map((p) => p.trim()).filter(Boolean); + if (cleaned.length === 0) return 0; + await prisma.stock.createMany({ + data: cleaned.map((payload) => ({ productId, payload })), + }); + logger.info({ productId, added: cleaned.length }, "Stock added"); + return cleaned.length; +} + +export async function getStockForProduct(productId: string, opts?: { onlyAvailable?: boolean; take?: number }) { + return prisma.stock.findMany({ + where: { + productId, + ...(opts?.onlyAvailable ? { used: false } : {}), + }, + orderBy: { id: "desc" }, + take: opts?.take ?? 100, + }); +} + +export async function redeliverOrder(orderId: string): Promise { + const order = await prisma.order.findUnique({ where: { id: orderId } }); + if (!order) throw new Error("Order tidak ditemukan"); + if (order.status !== "PAID") { + throw new Error(`Tidak bisa redeliver — status order: ${order.status}`); + } + // Kalau sudah delivered, reset dulu supaya bisa kirim ulang. + if (order.delivered) { + await prisma.order.update({ + where: { id: orderId }, + data: { delivered: false }, + }); + } + await deliverOrder(orderId); +} + +export function formatIDR(amount: number | null | undefined): string { + if (amount == null) return "-"; + return "Rp" + amount.toLocaleString("id-ID"); +} diff --git a/src/bots/discord-admin.ts b/src/bots/discord-admin.ts new file mode 100644 index 0000000..f4c6997 --- /dev/null +++ b/src/bots/discord-admin.ts @@ -0,0 +1,252 @@ +import { + EmbedBuilder, + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { hasDiscordAdmins, isDiscordAdmin } from "../admin/auth.js"; +import { + addStock, + createProduct, + formatIDR, + getStats, + listProductsWithStock, + listRecentOrders, + redeliverOrder, + toggleProductActive, +} from "../admin/service.js"; +import { logger } from "../logger.js"; + +/** + * Slash command definitions untuk admin commands. + * Pakai prefix `admin-` supaya gampang dibedakan dari user commands. + */ +export const adminCommandDefinitions = [ + new SlashCommandBuilder() + .setName("admin-stats") + .setDescription("[Admin] Statistik penjualan") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .toJSON(), + + new SlashCommandBuilder() + .setName("admin-orders") + .setDescription("[Admin] 10 order terakhir") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption((o) => + o + .setName("status") + .setDescription("Filter status") + .addChoices( + { name: "Pending", value: "PENDING" }, + { name: "Paid", value: "PAID" }, + { name: "Expired", value: "EXPIRED" }, + ), + ) + .toJSON(), + + new SlashCommandBuilder() + .setName("admin-products") + .setDescription("[Admin] Daftar produk + stok") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .toJSON(), + + new SlashCommandBuilder() + .setName("admin-add-product") + .setDescription("[Admin] Tambah produk baru") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption((o) => + o.setName("id").setDescription("ID produk (contoh: NETFLIX-1B)").setRequired(true), + ) + .addStringOption((o) => + o.setName("name").setDescription("Nama produk").setRequired(true), + ) + .addIntegerOption((o) => + o.setName("price").setDescription("Harga IDR").setRequired(true).setMinValue(1), + ) + .addStringOption((o) => + o.setName("description").setDescription("Deskripsi (opsional)"), + ) + .addStringOption((o) => + o + .setName("type") + .setDescription("Tipe produk") + .addChoices( + { name: "Account", value: "ACCOUNT" }, + { name: "License", value: "LICENSE" }, + { name: "Voucher", value: "VOUCHER" }, + { name: "File", value: "FILE" }, + { name: "Other", value: "OTHER" }, + ), + ) + .toJSON(), + + new SlashCommandBuilder() + .setName("admin-add-stock") + .setDescription("[Admin] Tambah stok untuk produk") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption((o) => + o.setName("product_id").setDescription("ID produk").setRequired(true), + ) + .addStringOption((o) => + o + .setName("payloads") + .setDescription("Payloads, pisahkan dengan || (contoh: a||b||c)") + .setRequired(true), + ) + .toJSON(), + + new SlashCommandBuilder() + .setName("admin-toggle") + .setDescription("[Admin] Aktifkan/nonaktifkan produk") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption((o) => + o.setName("product_id").setDescription("ID produk").setRequired(true), + ) + .toJSON(), + + new SlashCommandBuilder() + .setName("admin-redeliver") + .setDescription("[Admin] Kirim ulang produk untuk order tertentu") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption((o) => + o.setName("order_id").setDescription("ID order").setRequired(true), + ) + .toJSON(), +]; + +const ADMIN_COMMAND_NAMES = new Set(adminCommandDefinitions.map((c) => c.name)); + +export function isAdminCommand(name: string): boolean { + return ADMIN_COMMAND_NAMES.has(name); +} + +export async function handleAdminCommand(i: ChatInputCommandInteraction): Promise { + // Layer-1: cek di-CSV ADMIN_DISCORD_IDS — independen dari role server. + if (!hasDiscordAdmins() || !isDiscordAdmin(i.user.id)) { + await i.reply({ + content: "Akses ditolak. Kamu bukan admin yang terdaftar.", + ephemeral: true, + }); + return; + } + + await i.deferReply({ ephemeral: true }); + try { + switch (i.commandName) { + case "admin-stats": + await handleStats(i); + return; + case "admin-orders": + await handleOrders(i); + return; + case "admin-products": + await handleProducts(i); + return; + case "admin-add-product": + await handleAddProduct(i); + return; + case "admin-add-stock": + await handleAddStock(i); + return; + case "admin-toggle": + await handleToggle(i); + return; + case "admin-redeliver": + await handleRedeliver(i); + return; + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err, cmd: i.commandName }, "Discord admin command error"); + await i.editReply({ content: `Error: ${msg}` }); + } +} + +async function handleStats(i: ChatInputCommandInteraction) { + const s = await getStats(); + const embed = new EmbedBuilder() + .setTitle("Statistik Toko") + .setColor(0x22d3ee) + .addFields( + { name: "Revenue Hari Ini", value: formatIDR(s.revenueToday), inline: true }, + { name: "Revenue 7 Hari", value: formatIDR(s.revenue7d), inline: true }, + { name: "Revenue Total", value: formatIDR(s.revenueAllTime), inline: true }, + { name: "Pending", value: String(s.pending), inline: true }, + { name: "Paid", value: String(s.paid), inline: true }, + { name: "Expired", value: String(s.expired), inline: true }, + { name: "Produk Aktif", value: String(s.productsActive), inline: true }, + { name: "Stok Tersedia", value: String(s.stockAvailable), inline: true }, + { name: "Total Order", value: String(s.totalOrders), inline: true }, + ); + await i.editReply({ embeds: [embed] }); +} + +async function handleOrders(i: ChatInputCommandInteraction) { + const status = i.options.getString("status") ?? undefined; + const orders = await listRecentOrders({ status, take: 10 }); + if (orders.length === 0) { + await i.editReply(`Belum ada order${status ? ` dengan status ${status}` : ""}.`); + return; + } + const lines = orders.map((o) => { + const flag = o.delivered ? "✓" : o.status === "PAID" ? "⚠" : " "; + const time = o.createdAt.toISOString().slice(5, 16).replace("T", " "); + return `${flag} \`${o.id}\` · ${o.product.name} x${o.qty} · ${formatIDR(o.totalAmount)} · ${o.status} · ${o.platform} · ${time}`; + }); + await i.editReply({ + content: `**Order Terbaru${status ? ` (${status})` : ""}**\n\n${lines.join("\n")}`, + }); +} + +async function handleProducts(i: ChatInputCommandInteraction) { + const products = await listProductsWithStock(); + if (products.length === 0) { + await i.editReply("Belum ada produk."); + return; + } + const embed = new EmbedBuilder().setTitle("Daftar Produk").setColor(0xa78bfa); + for (const p of products) { + embed.addFields({ + name: `${p.name} (${p.id}) ${p.active ? "" : "[NONAKTIF]"}`, + value: `${formatIDR(p.priceIDR)} · stok: ${p.available}/${p.available + p.sold}`, + }); + } + await i.editReply({ embeds: [embed] }); +} + +async function handleAddProduct(i: ChatInputCommandInteraction) { + const id = i.options.getString("id", true); + const name = i.options.getString("name", true); + const priceIDR = i.options.getInteger("price", true); + const description = i.options.getString("description") ?? undefined; + const type = i.options.getString("type") ?? undefined; + const product = await createProduct({ id, name, priceIDR, description, type }); + await i.editReply( + `Produk **${product.name}** (\`${product.id}\`) berhasil dibuat.\nTambah stok dengan \`/admin-add-stock product_id:${product.id} payloads:...\``, + ); +} + +async function handleAddStock(i: ChatInputCommandInteraction) { + const productId = i.options.getString("product_id", true); + const raw = i.options.getString("payloads", true); + // Discord slash input single-line; pisahkan dengan || + const payloads = raw.split("||").map((s) => s.trim()).filter(Boolean); + if (payloads.length === 0) { + await i.editReply("Tidak ada payload yang valid. Pisahkan dengan `||`."); + return; + } + const added = await addStock(productId, payloads); + await i.editReply(`**${added}** item stok berhasil ditambahkan untuk \`${productId}\`.`); +} + +async function handleToggle(i: ChatInputCommandInteraction) { + const productId = i.options.getString("product_id", true); + const p = await toggleProductActive(productId); + await i.editReply(`Produk **${p.name}** sekarang ${p.active ? "AKTIF" : "NONAKTIF"}.`); +} + +async function handleRedeliver(i: ChatInputCommandInteraction) { + const orderId = i.options.getString("order_id", true); + await redeliverOrder(orderId); + await i.editReply(`Order \`${orderId}\` berhasil dikirim ulang.`); +} diff --git a/src/bots/discord.ts b/src/bots/discord.ts index 7c146d6..810f59c 100644 --- a/src/bots/discord.ts +++ b/src/bots/discord.ts @@ -1,19 +1,43 @@ import { + ActionRowBuilder, AttachmentBuilder, + ButtonBuilder, + ButtonStyle, Client, + ComponentType, EmbedBuilder, Events, GatewayIntentBits, REST, Routes, SlashCommandBuilder, + StringSelectMenuBuilder, + type ButtonInteraction, type ChatInputCommandInteraction, + type Interaction, + type StringSelectMenuInteraction, } from "discord.js"; import { config } from "../config.js"; import { logger } from "../logger.js"; +import { prisma } from "../db.js"; import { listProducts } from "../products/catalog.js"; import { createOrder } from "../orders/service.js"; import { registry } from "./registry.js"; +import { + adminCommandDefinitions, + handleAdminCommand, + isAdminCommand, +} from "./discord-admin.js"; +import { discordRole } from "../admin/auth.js"; +import { notifyOwner } from "../admin/owner.js"; + +/** + * Component customId formats: + * prod-select → string select menu pilih produk + * buy:: → tombol beli + * status: → cek status order + * menu:catalog → buka katalog + */ export async function startDiscordBot(): Promise { if (!config.DISCORD_BOT_TOKEN || !config.DISCORD_CLIENT_ID) { @@ -25,15 +49,14 @@ export async function startDiscordBot(): Promise { intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages], }); - // Definisi slash commands - const commands = [ + const userCommands = [ new SlashCommandBuilder() .setName("catalog") - .setDescription("Lihat daftar produk") + .setDescription("Lihat daftar produk dan beli") .toJSON(), new SlashCommandBuilder() .setName("buy") - .setDescription("Beli produk") + .setDescription("Beli produk langsung (power user)") .addStringOption((o) => o.setName("product_id").setDescription("ID produk").setRequired(true), ) @@ -41,110 +64,386 @@ export async function startDiscordBot(): Promise { o.setName("qty").setDescription("Jumlah (default 1)").setMinValue(1), ) .toJSON(), + new SlashCommandBuilder() + .setName("whoami") + .setDescription("Cek role kamu (owner / admin / customer)") + .toJSON(), ]; - // Register commands (guild = instan, global = bisa sampai 1 jam propagasi) + const commands = [...userCommands, ...adminCommandDefinitions]; const rest = new REST({ version: "10" }).setToken(config.DISCORD_BOT_TOKEN); const route = config.DISCORD_GUILD_ID ? Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, config.DISCORD_GUILD_ID) : Routes.applicationCommands(config.DISCORD_CLIENT_ID); await rest.put(route, { body: commands }); logger.info( - { scope: config.DISCORD_GUILD_ID ? "guild" : "global" }, + { scope: config.DISCORD_GUILD_ID ? "guild" : "global", count: commands.length }, "Discord slash commands registered", ); client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isChatInputCommand()) return; try { - if (interaction.commandName === "catalog") { - await handleCatalog(interaction); - } else if (interaction.commandName === "buy") { - await handleBuy(interaction); + if (interaction.isChatInputCommand()) { + await handleSlashCommand(interaction); + } else if (interaction.isStringSelectMenu()) { + await handleSelectMenu(interaction); + } else if (interaction.isButton()) { + await handleButton(interaction); } } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - logger.error({ err, cmd: interaction.commandName }, "Discord command error"); - const reply = { content: `Error: ${msg}`, ephemeral: true }; - if (interaction.deferred || interaction.replied) { - await interaction.followUp(reply).catch(() => {}); - } else { - await interaction.reply(reply).catch(() => {}); - } + await replyError(interaction, err); } }); - client.once(Events.ClientReady, (c) => - logger.info({ tag: c.user.tag }, "Discord bot ready"), - ); + client.once(Events.ClientReady, (c) => { + logger.info({ tag: c.user.tag }, "Discord bot ready"); + if (config.OWNER_DISCORD_ID) { + void notifyOwner( + [ + `**Bot Online**`, + ``, + `Discord bot ${c.user.tag} siap menerima order.`, + ``, + `Cek \`/catalog\` untuk lihat produk, atau \`/admin-stats\` untuk admin overview.`, + ].join("\n"), + ); + } + }); registry.discord = client; await client.login(config.DISCORD_BOT_TOKEN); } -async function handleCatalog(i: ChatInputCommandInteraction): Promise { - await i.deferReply({ ephemeral: true }); +// ============ Routers ============ + +async function handleSlashCommand(i: ChatInputCommandInteraction): Promise { + if (isAdminCommand(i.commandName)) { + await handleAdminCommand(i); + return; + } + if (i.commandName === "catalog") return showCatalog(i); + if (i.commandName === "buy") return handleBuyCommand(i); + if (i.commandName === "whoami") return handleWhoami(i); +} + +async function handleSelectMenu(i: StringSelectMenuInteraction): Promise { + if (i.customId === "prod-select") { + const productId = i.values[0]; + if (productId) await showProductDetail(i, productId); + } +} + +async function handleButton(i: ButtonInteraction): Promise { + const id = i.customId; + if (id === "menu:catalog") return showCatalog(i); + if (id.startsWith("buy:")) { + const [, productId, qtyStr] = id.split(":"); + if (productId && qtyStr) await executeBuy(i, productId, parseInt(qtyStr, 10) || 1); + return; + } + if (id.startsWith("status:")) { + const orderId = id.slice("status:".length); + return showStatus(i, orderId); + } +} + +// ============ Renderers ============ + +type AnyInteraction = + | ChatInputCommandInteraction + | ButtonInteraction + | StringSelectMenuInteraction; + +async function showCatalog(i: AnyInteraction): Promise { + if (i.isChatInputCommand() || i.isButton()) { + if (!i.deferred && !i.replied) await i.deferReply({ ephemeral: true }); + } const products = await listProducts(); + if (products.length === 0) { - await i.editReply("Belum ada produk tersedia."); + await replyOrEdit(i, { content: "Belum ada produk tersedia.", embeds: [], components: [] }); return; } - const embed = new EmbedBuilder().setTitle("Katalog Produk").setColor(0x5865f2); - for (const p of products) { + + const embed = new EmbedBuilder() + .setTitle("📦 Katalog Produk") + .setColor(0x22d3ee) + .setDescription(`Pilih produk dari menu di bawah untuk lihat detail dan beli.\n\n${products.length} produk tersedia.`); + + for (const p of products.slice(0, 25)) { embed.addFields({ - name: `${p.name} (${p.id})`, - value: [ - p.description ?? "", - `Harga: **Rp${p.priceIDR.toLocaleString("id-ID")}**`, - `Stok: ${p.availableStock}`, - ] - .filter(Boolean) - .join("\n"), + name: `${p.name} · Rp${p.priceIDR.toLocaleString("id-ID")}`, + value: `\`${p.id}\` · Stok: ${p.availableStock}${p.description ? ` · ${p.description.slice(0, 60)}` : ""}`, }); } - await i.editReply({ - content: "Beli dengan `/buy product_id:`", - embeds: [embed], + + // Discord select menu max 25 options + const select = new StringSelectMenuBuilder() + .setCustomId("prod-select") + .setPlaceholder("Pilih produk untuk beli…") + .addOptions( + products.slice(0, 25).map((p) => ({ + label: `${p.name}`.slice(0, 100), + description: `Rp${p.priceIDR.toLocaleString("id-ID")} · Stok ${p.availableStock}`.slice(0, 100), + value: p.id, + })), + ); + + const row = new ActionRowBuilder().addComponents(select); + + await replyOrEdit(i, { content: "", embeds: [embed], components: [row] }); +} + +async function showProductDetail(i: StringSelectMenuInteraction, productId: string): Promise { + await i.deferUpdate().catch(() => {}); + + const product = await prisma.product.findUnique({ where: { id: productId } }); + if (!product) { + await i.followUp({ content: "Produk tidak ditemukan.", ephemeral: true }); + return; + } + const available = await prisma.stock.count({ where: { productId, used: false } }); + + const embed = new EmbedBuilder() + .setTitle(product.name) + .setColor(0xa78bfa) + .setDescription(product.description ?? "") + .addFields( + { name: "Harga", value: `**Rp${product.priceIDR.toLocaleString("id-ID")}**`, inline: true }, + { name: "Stok", value: String(available), inline: true }, + { name: "Tipe", value: product.type, inline: true }, + { name: "ID", value: `\`${product.id}\``, inline: false }, + ); + + const row = new ActionRowBuilder(); + if (available > 0) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`buy:${product.id}:1`) + .setLabel("Beli 1") + .setStyle(ButtonStyle.Primary) + .setEmoji("🛒"), + ); + if (available >= 3) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`buy:${product.id}:3`) + .setLabel("Beli 3") + .setStyle(ButtonStyle.Primary), + ); + } + if (available >= 5) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`buy:${product.id}:5`) + .setLabel("Beli 5") + .setStyle(ButtonStyle.Primary), + ); + } + } else { + row.addComponents( + new ButtonBuilder() + .setCustomId(`buy:${product.id}:0`) + .setLabel("Stok habis") + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + ); + } + row.addComponents( + new ButtonBuilder().setCustomId("menu:catalog").setLabel("← Katalog").setStyle(ButtonStyle.Secondary), + ); + + await i.editReply({ content: "", embeds: [embed], components: [row] }); +} + +async function executeBuy(i: ButtonInteraction, productId: string, qty: number): Promise { + if (qty <= 0) return; + await i.deferReply({ ephemeral: true }); + + try { + const { order, tx } = await createOrder({ + productId, + qty, + platform: "DISCORD", + chatId: i.user.id, + username: i.user.username, + }); + + const buffer = decodeBase64Image(tx.qris_image); + const fileName = `${order.id}.png`; + const attachment = new AttachmentBuilder(buffer, { name: fileName }); + + const embed = new EmbedBuilder() + .setTitle(`Order ${order.id}`) + .setColor(0xfaa61a) + .setDescription( + "Scan QRIS di lampiran untuk bayar.\nProduk dikirim otomatis ke DM kamu setelah pembayaran terkonfirmasi.", + ) + .addFields( + { name: "Total", value: `**Rp${order.totalAmount?.toLocaleString("id-ID")}**`, inline: true }, + { name: "Berlaku sampai", value: `${tx.expired_at} WIB`, inline: true }, + ) + .setImage(`attachment://${fileName}`); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`status:${order.id}`) + .setLabel("Cek Status") + .setStyle(ButtonStyle.Success) + .setEmoji("🔄"), + new ButtonBuilder() + .setCustomId("menu:catalog") + .setLabel("← Katalog") + .setStyle(ButtonStyle.Secondary), + ); + + await i.editReply({ embeds: [embed], files: [attachment], components: [row] }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err, productId, qty }, "Discord buy failed"); + await i.editReply({ content: `❌ Gagal membuat order: ${msg}` }); + } +} + +async function showStatus(i: ButtonInteraction, orderId: string): Promise { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { product: true }, }); + if (!order) { + await i.reply({ content: "Order tidak ditemukan.", ephemeral: true }); + return; + } + const colors: Record = { + PENDING: 0xfaa61a, + PAID: 0x57f287, + EXPIRED: 0xed4245, + }; + const msg = + order.status === "PAID" && order.delivered + ? "✓ Order sudah dibayar dan produk sudah terkirim." + : order.status === "PAID" + ? "Order sudah dibayar, sedang dikirim…" + : order.status === "EXPIRED" + ? "Order sudah expired. Silakan order baru." + : "Order masih PENDING. Silakan scan QRIS untuk bayar."; + + const embed = new EmbedBuilder() + .setTitle(`Status Order`) + .setColor(colors[order.status] ?? 0x5865f2) + .setDescription(msg) + .addFields( + { name: "Order ID", value: `\`${order.id}\``, inline: false }, + { name: "Status", value: order.status, inline: true }, + { name: "Total", value: `Rp${order.totalAmount?.toLocaleString("id-ID")}`, inline: true }, + ); + await i.reply({ embeds: [embed], ephemeral: true }); } -async function handleBuy(i: ChatInputCommandInteraction): Promise { +async function handleBuyCommand(i: ChatInputCommandInteraction): Promise { await i.deferReply({ ephemeral: true }); const productId = i.options.getString("product_id", true); const qty = i.options.getInteger("qty") ?? 1; - const { order, tx } = await createOrder({ - productId, - qty, - platform: "DISCORD", - chatId: i.user.id, - username: i.user.username, - }); + try { + const { order, tx } = await createOrder({ + productId, + qty, + platform: "DISCORD", + chatId: i.user.id, + username: i.user.username, + }); + + const buffer = decodeBase64Image(tx.qris_image); + const fileName = `${order.id}.png`; + const attachment = new AttachmentBuilder(buffer, { name: fileName }); + + const embed = new EmbedBuilder() + .setTitle(`Order ${order.id}`) + .setColor(0xfaa61a) + .setDescription("Scan QRIS untuk bayar. Produk dikirim otomatis ke DM setelah lunas.") + .addFields( + { name: "Total", value: `**Rp${order.totalAmount?.toLocaleString("id-ID")}**`, inline: true }, + { name: "Berlaku sampai", value: `${tx.expired_at} WIB`, inline: true }, + ) + .setImage(`attachment://${fileName}`); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`status:${order.id}`) + .setLabel("Cek Status") + .setStyle(ButtonStyle.Success) + .setEmoji("🔄"), + ); + await i.editReply({ embeds: [embed], files: [attachment], components: [row] }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + await i.editReply({ content: `❌ Gagal membuat order: ${msg}` }); + } +} - const buffer = decodeBase64Image(tx.qris_image); - const fileName = `${order.id}.png`; - const attachment = new AttachmentBuilder(buffer, { name: fileName }); +async function handleWhoami(i: ChatInputCommandInteraction): Promise { + const role = discordRole(i.user.id); + const labels: Record = { + owner: "👑 Owner", + admin: "🛡️ Admin", + customer: "👤 Customer", + }; + const colors: Record = { + owner: 0xfaa61a, + admin: 0xa78bfa, + customer: 0x5865f2, + }; + const extra = + role === "owner" + ? "Kamu primary admin. Otomatis dapat notifikasi event penting." + : role === "admin" + ? "Kamu admin. Pakai `/admin-stats` untuk overview." + : "Klik `/catalog` untuk lihat produk yang dijual."; const embed = new EmbedBuilder() - .setTitle(`Order ${order.id}`) - .setColor(0xfaa61a) - .setDescription( - "Scan QRIS di lampiran untuk bayar.\nProduk akan dikirim otomatis ke DM kamu setelah lunas.", - ) + .setTitle(`Role: ${labels[role]}`) + .setColor(colors[role]) + .setDescription(extra) .addFields( - { - name: "Total", - value: `Rp${order.totalAmount?.toLocaleString("id-ID")}`, - inline: true, - }, - { name: "Berlaku sampai", value: `${tx.expired_at} WIB`, inline: true }, - ) - .setImage(`attachment://${fileName}`); - - await i.editReply({ embeds: [embed], files: [attachment] }); + { name: "User ID", value: `\`${i.user.id}\``, inline: true }, + { name: "Username", value: i.user.username, inline: true }, + ); + await i.reply({ embeds: [embed], ephemeral: true }); +} + +// ============ Helpers ============ + +async function replyOrEdit( + i: AnyInteraction, + payload: { content?: string; embeds?: EmbedBuilder[]; components?: ActionRowBuilder[] }, +): Promise { + // Cast karena tipe ActionRowBuilder generik + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = payload as any; + if (i.deferred || i.replied) { + await i.editReply(p); + } else { + await i.reply({ ...p, ephemeral: true }); + } +} + +async function replyError(interaction: Interaction, err: unknown): Promise { + const msg = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err }, "Discord interaction error"); + if (!interaction.isRepliable()) return; + const reply = { content: `❌ Error: ${msg}`, ephemeral: true } as const; + if (interaction.deferred || interaction.replied) { + await interaction.followUp(reply).catch(() => {}); + } else { + await interaction.reply(reply).catch(() => {}); + } } function decodeBase64Image(dataUrl: string): Buffer { const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ""); return Buffer.from(base64, "base64"); } + +// ComponentType import marker (untuk satisfy lint kalau gak terpakai langsung) +void ComponentType; diff --git a/src/bots/telegram-admin.ts b/src/bots/telegram-admin.ts new file mode 100644 index 0000000..e3254d6 --- /dev/null +++ b/src/bots/telegram-admin.ts @@ -0,0 +1,189 @@ +import type { Bot, Context } from "grammy"; +import { hasTelegramAdmins, isTelegramAdmin } from "../admin/auth.js"; +import { + addStock, + createProduct, + formatIDR, + getStats, + listProductsWithStock, + listRecentOrders, + redeliverOrder, + toggleProductActive, +} from "../admin/service.js"; +import { logger } from "../logger.js"; + +const ADMIN_HELP = [ + "*Admin Commands*", + "", + "`/stats` — Statistik penjualan", + "`/orders [status]` — 10 order terakhir (status: pending, paid, expired)", + "`/products` — Daftar produk + stok", + "`/addproduct |||` — Tambah produk (pisahkan dengan |)", + "`/addstock ` — Tambah stok (kirim payload di baris berikutnya)", + "`/toggle ` — Aktifkan/nonaktifkan produk", + "`/redeliver ` — Kirim ulang produk ke pembeli", +].join("\n"); + +function escapeMd(s: string): string { + return s.replace(/([_*`\[\]])/g, "\\$1"); +} + +/** + * Wrap handler dengan admin check. Reply tegas kalau bukan admin. + */ +function adminOnly(handler: (ctx: Context) => Promise | unknown) { + return async (ctx: Context) => { + if (!isTelegramAdmin(ctx.from?.id)) { + // Diam saja kalau bukan admin (tidak konfirmasi command admin exist) + return; + } + try { + await handler(ctx); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err, userId: ctx.from?.id }, "Telegram admin command error"); + await ctx.reply(`Error: ${msg}`); + } + }; +} + +export function registerTelegramAdminCommands(bot: Bot): void { + if (!hasTelegramAdmins()) { + logger.warn("ADMIN_TELEGRAM_IDS kosong — admin commands Telegram dinonaktifkan"); + return; + } + + bot.command("admin", adminOnly(async (ctx) => { + await ctx.reply(ADMIN_HELP, { parse_mode: "Markdown" }); + })); + + bot.command("stats", adminOnly(async (ctx) => { + const s = await getStats(); + const text = [ + "*Statistik Toko*", + "", + `Revenue Hari Ini: *${formatIDR(s.revenueToday)}*`, + `Revenue 7 Hari: *${formatIDR(s.revenue7d)}*`, + `Revenue Total: *${formatIDR(s.revenueAllTime)}*`, + "", + `Total Order: ${s.totalOrders}`, + ` Pending: ${s.pending}`, + ` Paid: ${s.paid}`, + ` Expired: ${s.expired}`, + "", + `Produk Aktif: ${s.productsActive}`, + `Stok Tersedia: ${s.stockAvailable}`, + ].join("\n"); + await ctx.reply(text, { parse_mode: "Markdown" }); + })); + + bot.command("orders", adminOnly(async (ctx) => { + const filter = ctx.match?.toString().trim().toUpperCase(); + const status = filter && ["PENDING", "PAID", "EXPIRED", "FAILED"].includes(filter) ? filter : undefined; + const orders = await listRecentOrders({ status, take: 10 }); + if (orders.length === 0) { + return ctx.reply(`Belum ada order${status ? ` dengan status ${status}` : ""}.`); + } + const lines = orders.map((o) => { + const time = o.createdAt.toISOString().slice(5, 16).replace("T", " "); + const deliveryFlag = o.delivered ? "✓" : o.status === "PAID" ? "⚠" : " "; + return [ + `${deliveryFlag} \`${o.id}\``, + ` ${escapeMd(o.product.name)} x${o.qty} — ${formatIDR(o.totalAmount)}`, + ` ${o.status} · ${o.platform} · ${time}`, + ].join("\n"); + }); + await ctx.reply( + [`*Order Terbaru*${status ? ` (${status})` : ""}`, "", lines.join("\n\n")].join("\n"), + { parse_mode: "Markdown" }, + ); + })); + + bot.command("products", adminOnly(async (ctx) => { + const products = await listProductsWithStock(); + if (products.length === 0) { + return ctx.reply("Belum ada produk."); + } + const lines = products.map((p) => { + const status = p.active ? "Active" : "Inactive"; + return [ + `*${escapeMd(p.name)}* (\`${p.id}\`) — ${status}`, + ` ${formatIDR(p.priceIDR)} · stok: ${p.available}/${p.available + p.sold}`, + ].join("\n"); + }); + await ctx.reply(["*Daftar Produk*", "", lines.join("\n\n")].join("\n"), { + parse_mode: "Markdown", + }); + })); + + bot.command("addproduct", adminOnly(async (ctx) => { + const text = ctx.match?.toString().trim() ?? ""; + if (!text) { + return ctx.reply( + "Format: `/addproduct ID|Nama|Harga|Deskripsi`\n" + + "Contoh: `/addproduct NETFLIX-1B|Netflix Premium 1 Bulan|25000|Akun sharing 1 profil`", + { parse_mode: "Markdown" }, + ); + } + const parts = text.split("|").map((s) => s.trim()); + const [id, name, priceStr, description] = parts; + if (!id || !name || !priceStr) { + return ctx.reply("Field id, nama, harga wajib diisi (pisahkan dengan `|`)"); + } + const priceIDR = parseInt(priceStr, 10); + if (!Number.isFinite(priceIDR) || priceIDR <= 0) { + return ctx.reply("Harga harus angka > 0"); + } + const product = await createProduct({ id, name, priceIDR, description }); + await ctx.reply( + `Produk *${escapeMd(product.name)}* (\`${product.id}\`) berhasil dibuat.\n` + + `Tambah stok dengan: \`/addstock ${product.id}\``, + { parse_mode: "Markdown" }, + ); + })); + + bot.command("addstock", adminOnly(async (ctx) => { + // Format: first line "/addstock " + // Subsequent lines = payloads (one per line) + const fullText = ctx.message?.text ?? ""; + const lines = fullText.split(/\r?\n/); + const firstLineParts = (lines[0] ?? "").split(/\s+/); + const productId = firstLineParts[1]?.trim(); + if (!productId) { + return ctx.reply( + "Format:\n```\n/addstock \npayload1\npayload2\n...\n```", + { parse_mode: "Markdown" }, + ); + } + const payloads = lines.slice(1); + if (payloads.filter((p) => p.trim()).length === 0) { + return ctx.reply("Tambahkan minimal 1 payload di baris berikutnya."); + } + const added = await addStock(productId, payloads); + await ctx.reply(`*${added}* item stok berhasil ditambahkan untuk \`${productId}\`.`, { + parse_mode: "Markdown", + }); + })); + + bot.command("toggle", adminOnly(async (ctx) => { + const productId = ctx.match?.toString().trim(); + if (!productId) { + return ctx.reply("Format: `/toggle `", { parse_mode: "Markdown" }); + } + const p = await toggleProductActive(productId); + await ctx.reply(`Produk *${escapeMd(p.name)}* sekarang ${p.active ? "AKTIF" : "NONAKTIF"}.`, { + parse_mode: "Markdown", + }); + })); + + bot.command("redeliver", adminOnly(async (ctx) => { + const orderId = ctx.match?.toString().trim(); + if (!orderId) { + return ctx.reply("Format: `/redeliver `", { parse_mode: "Markdown" }); + } + await redeliverOrder(orderId); + await ctx.reply(`Order \`${orderId}\` berhasil dikirim ulang.`, { parse_mode: "Markdown" }); + })); + + logger.info("Telegram admin commands registered"); +} diff --git a/src/bots/telegram.ts b/src/bots/telegram.ts index 2076222..947db00 100644 --- a/src/bots/telegram.ts +++ b/src/bots/telegram.ts @@ -1,9 +1,21 @@ -import { Bot, InputFile } from "grammy"; +import { Bot, type Context, InlineKeyboard, InputFile } from "grammy"; import { config } from "../config.js"; import { logger } from "../logger.js"; +import { prisma } from "../db.js"; import { listProducts } from "../products/catalog.js"; import { createOrder } from "../orders/service.js"; import { registry } from "./registry.js"; +import { registerTelegramAdminCommands } from "./telegram-admin.js"; +import { telegramRole } from "../admin/auth.js"; +import { notifyOwner } from "../admin/owner.js"; + +/** + * Callback data formats (Telegram limit: 64 bytes): + * menu: → action = catalog | help | whoami | start + * prod: → buka detail produk + * buy:: → eksekusi pembelian + * status: → cek status order + */ export async function startTelegramBot(): Promise { if (!config.TELEGRAM_BOT_TOKEN) { @@ -13,104 +25,289 @@ export async function startTelegramBot(): Promise { const bot = new Bot(config.TELEGRAM_BOT_TOKEN); - bot.command("start", (ctx) => - ctx.reply( - [ - "Halo! Saya bot toko otomatis.", - "", - "Perintah:", - "/catalog - Lihat daftar produk", - "/buy [qty] - Beli produk", - "", - "Setelah bayar QRIS, produk dikirim otomatis di chat ini.", - ].join("\n"), - ), - ); - - bot.command("catalog", async (ctx) => { - const products = await listProducts(); - if (products.length === 0) { - return ctx.reply("Belum ada produk tersedia."); - } - const lines = products.map((p) => - [ - `*${escapeMd(p.name)}* (\`${p.id}\`)`, - p.description ? `_${escapeMd(p.description)}_` : null, - `Harga: Rp${p.priceIDR.toLocaleString("id-ID")}`, - `Stok: ${p.availableStock}`, - ] - .filter(Boolean) - .join("\n"), - ); - await ctx.reply( - [ - "*Katalog Produk*", - "", - lines.join("\n\n"), - "", - "Beli dengan: `/buy [qty]`", - ].join("\n"), - { parse_mode: "Markdown" }, - ); - }); + bot.command("start", async (ctx) => sendMainMenu(ctx, "reply")); + bot.command("catalog", async (ctx) => sendCatalog(ctx, "reply")); + bot.command("whoami", async (ctx) => sendWhoami(ctx)); + // Backward-compat: text command /buy [qty] tetap jalan untuk power user bot.command("buy", async (ctx) => { - const argText = ctx.match?.toString().trim() ?? ""; - const parts = argText.split(/\s+/).filter(Boolean); + const parts = (ctx.match?.toString() ?? "").trim().split(/\s+/).filter(Boolean); const productId = parts[0]; const qty = Math.max(1, parseInt(parts[1] ?? "1", 10) || 1); - if (!productId) { - return ctx.reply("Cara pakai: `/buy [qty]`", { parse_mode: "Markdown" }); + return ctx.reply("Cara pakai: `/buy [qty]`\nAtau pakai /catalog buat lihat tombol-tombol.", { + parse_mode: "Markdown", + }); } + await executeBuy(ctx, productId, qty); + }); - try { - const { order, tx } = await createOrder({ - productId, - qty, - platform: "TELEGRAM", - chatId: String(ctx.chat.id), - username: ctx.from?.username, - }); + // === Callback query router === + bot.callbackQuery(/^menu:(.+)$/, async (ctx) => { + const action = ctx.match[1]; + if (action === "catalog") await sendCatalog(ctx, "edit"); + else if (action === "start") await sendMainMenu(ctx, "edit"); + else if (action === "help") await sendHelp(ctx); + else if (action === "whoami") await sendWhoami(ctx); + await ctx.answerCallbackQuery().catch(() => {}); + }); - const caption = [ - `*Order ${order.id}*`, - "", - `Total: *Rp${order.totalAmount?.toLocaleString("id-ID")}*`, - `Berlaku sampai: ${tx.expired_at} WIB`, - "", - "Scan QRIS di atas untuk bayar.", - "Produk akan otomatis dikirim setelah pembayaran terkonfirmasi.", - ].join("\n"); - - const buffer = decodeBase64Image(tx.qris_image); - await ctx.replyWithPhoto(new InputFile(buffer, `${order.id}.png`), { - caption, - parse_mode: "Markdown", - }); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - logger.error({ err }, "Telegram /buy gagal"); - await ctx.reply(`Gagal membuat order: ${message}`); + bot.callbackQuery(/^prod:(.+)$/, async (ctx) => { + const id = ctx.match[1]; + await showProductDetail(ctx, id); + await ctx.answerCallbackQuery().catch(() => {}); + }); + + bot.callbackQuery(/^buy:([^:]+):(\d+)$/, async (ctx) => { + const id = ctx.match[1]; + const qty = parseInt(ctx.match[2], 10); + await ctx.answerCallbackQuery({ text: "Membuat order…" }).catch(() => {}); + await executeBuy(ctx, id, qty); + }); + + bot.callbackQuery(/^status:(.+)$/, async (ctx) => { + const orderId = ctx.match[1]; + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { product: true }, + }); + if (!order) { + await ctx.answerCallbackQuery({ text: "Order tidak ditemukan", show_alert: true }); + return; } + const txt = + order.status === "PAID" && order.delivered + ? `✓ Order *${order.id}* sudah dibayar dan terkirim.` + : order.status === "PAID" + ? `Order *${order.id}* sudah dibayar, sedang dikirim…` + : order.status === "EXPIRED" + ? `Order *${order.id}* sudah expired.` + : `Order *${order.id}* masih PENDING. Silakan bayar QRIS.`; + await ctx.answerCallbackQuery({ text: order.status, show_alert: false }); + await ctx.reply(txt, { parse_mode: "Markdown" }); }); + // Admin commands tetap text-based + registerTelegramAdminCommands(bot); + bot.catch((err) => logger.error({ err }, "Telegram bot error")); registry.telegram = bot; - // Long polling start (non-blocking) bot.start({ - onStart: (info) => logger.info({ username: info.username }, "Telegram bot started"), + onStart: async (info) => { + logger.info({ username: info.username }, "Telegram bot started"); + // Set bot commands menu (muncul sebagai tombol "/" di Telegram) + await bot.api + .setMyCommands([ + { command: "start", description: "Mulai" }, + { command: "catalog", description: "Lihat produk" }, + { command: "whoami", description: "Cek role kamu" }, + ]) + .catch((err) => logger.warn({ err }, "Failed to set bot commands")); + + if (config.OWNER_TELEGRAM_ID) { + void notifyOwner( + [`*Bot Online*`, ``, `Telegram bot @${info.username} siap menerima order.`].join("\n"), + ); + } + }, }); } +// ============ Renderers ============ + +async function sendMainMenu(ctx: Context, mode: "reply" | "edit"): Promise { + const role = telegramRole(ctx.from?.id); + const greeting = + role === "owner" + ? `Halo *Owner* ${escapeMd(ctx.from?.first_name ?? "")}!\nBot kamu sudah online dan siap melayani pembeli.` + : role === "admin" + ? `Halo *Admin*!\nBot toko siap digunakan.` + : `Halo *${escapeMd(ctx.from?.first_name ?? "kamu")}*!\nSelamat datang di toko otomatis kami.`; + + const kb = new InlineKeyboard() + .text("📦 Lihat Katalog", "menu:catalog") + .row() + .text("ℹ️ Bantuan", "menu:help") + .text("👤 Profile", "menu:whoami"); + + if (role === "owner" || role === "admin") { + kb.row().text("⚙️ Admin Commands", "menu:help"); + } + + const text = [greeting, "", "Pilih menu di bawah:"].join("\n"); + + if (mode === "edit" && ctx.callbackQuery?.message) { + await ctx.editMessageText(text, { parse_mode: "Markdown", reply_markup: kb }).catch(() => {}); + } else { + await ctx.reply(text, { parse_mode: "Markdown", reply_markup: kb }); + } +} + +async function sendCatalog(ctx: Context, mode: "reply" | "edit"): Promise { + const products = await listProducts(); + const kb = new InlineKeyboard(); + + if (products.length === 0) { + const text = "_Belum ada produk tersedia._"; + if (mode === "edit" && ctx.callbackQuery?.message) { + await ctx.editMessageText(text, { parse_mode: "Markdown" }).catch(() => {}); + } else { + await ctx.reply(text, { parse_mode: "Markdown" }); + } + return; + } + + // 1 produk per row supaya nama panjang gak ketabrak + for (const p of products) { + const stock = p.availableStock > 0 ? `${p.availableStock} stok` : "stok habis"; + const label = `${p.name} · Rp${p.priceIDR.toLocaleString("id-ID")} · ${stock}`; + // Telegram label limit ~64 chars + const trimmed = label.length > 60 ? label.slice(0, 57) + "…" : label; + kb.text(trimmed, `prod:${p.id}`).row(); + } + kb.text("← Kembali", "menu:start"); + + const text = [ + "*Katalog Produk*", + "", + "Klik produk untuk lihat detail dan beli.", + "", + `_${products.length} produk tersedia_`, + ].join("\n"); + + if (mode === "edit" && ctx.callbackQuery?.message) { + await ctx.editMessageText(text, { parse_mode: "Markdown", reply_markup: kb }).catch(() => {}); + } else { + await ctx.reply(text, { parse_mode: "Markdown", reply_markup: kb }); + } +} + +async function showProductDetail(ctx: Context, productId: string): Promise { + const product = await prisma.product.findUnique({ where: { id: productId } }); + if (!product) { + await ctx.reply("Produk tidak ditemukan."); + return; + } + const available = await prisma.stock.count({ where: { productId, used: false } }); + + const lines = [ + `*${escapeMd(product.name)}*`, + `\`${product.id}\` · ${escapeMd(product.type)}`, + "", + product.description ? `_${escapeMd(product.description)}_\n` : "", + `💰 *Harga:* Rp${product.priceIDR.toLocaleString("id-ID")}`, + `📦 *Stok:* ${available}`, + ].filter(Boolean); + + const kb = new InlineKeyboard(); + if (available > 0) { + kb.text("Beli 1", `buy:${product.id}:1`); + if (available >= 3) kb.text("Beli 3", `buy:${product.id}:3`); + if (available >= 5) kb.text("Beli 5", `buy:${product.id}:5`); + kb.row(); + } + kb.text("← Katalog", "menu:catalog"); + + if (ctx.callbackQuery?.message) { + await ctx.editMessageText(lines.join("\n"), { parse_mode: "Markdown", reply_markup: kb }).catch(() => {}); + } else { + await ctx.reply(lines.join("\n"), { parse_mode: "Markdown", reply_markup: kb }); + } +} + +async function executeBuy(ctx: Context, productId: string, qty: number): Promise { + if (!ctx.chat?.id) return; + try { + const { order, tx } = await createOrder({ + productId, + qty, + platform: "TELEGRAM", + chatId: String(ctx.chat.id), + username: ctx.from?.username, + }); + + const caption = [ + `*Order ${order.id}*`, + "", + `*${escapeMd(tx.nama_toko)}*`, + `${escapeMd(tx.keterangan ?? "")}`, + "", + `Total: *Rp${order.totalAmount?.toLocaleString("id-ID")}*`, + `Berlaku sampai: ${tx.expired_at} WIB`, + "", + "Scan QRIS di atas untuk bayar.", + "Produk akan otomatis dikirim setelah pembayaran terkonfirmasi.", + ].join("\n"); + + const kb = new InlineKeyboard() + .text("🔄 Cek Status", `status:${order.id}`) + .url("💬 Bantuan", "https://t.me/") + .row() + .text("← Katalog", "menu:catalog"); + + const buffer = decodeBase64Image(tx.qris_image); + await ctx.replyWithPhoto(new InputFile(buffer, `${order.id}.png`), { + caption, + parse_mode: "Markdown", + reply_markup: kb, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err }, "Telegram buy failed"); + await ctx.reply(`❌ Gagal membuat order: ${message}`); + } +} + +async function sendHelp(ctx: Context): Promise { + const role = telegramRole(ctx.from?.id); + const lines = [ + "*Bantuan*", + "", + "Cara beli:", + "1. Klik *📦 Lihat Katalog*", + "2. Pilih produk", + "3. Klik tombol *Beli*", + "4. Scan QRIS yang dikirim bot", + "5. Produk dikirim otomatis ke chat ini", + "", + "Command:", + "`/start` — menu utama", + "`/catalog` — lihat produk", + "`/buy [qty]` — beli langsung (power user)", + "`/whoami` — cek role kamu", + ]; + if (role === "owner" || role === "admin") { + lines.push("", "*Admin commands:*", "`/admin` — daftar admin commands"); + } + await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" }); +} + +async function sendWhoami(ctx: Context): Promise { + const role = telegramRole(ctx.from?.id); + const labels: Record = { + owner: "👑 Owner", + admin: "🛡️ Admin", + customer: "👤 Customer", + }; + await ctx.reply( + [ + `*Role:* ${labels[role]}`, + `*User ID:* \`${ctx.from?.id}\``, + `*Username:* @${ctx.from?.username ?? "(tidak ada)"}`, + ].join("\n"), + { parse_mode: "Markdown" }, + ); +} + +// ============ Helpers ============ + function decodeBase64Image(dataUrl: string): Buffer { const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ""); return Buffer.from(base64, "base64"); } function escapeMd(s: string): string { - // basic Markdown V1 escape supaya nama produk yg punya _ atau * tidak rusak return s.replace(/([_*`\[\]])/g, "\\$1"); } diff --git a/src/config.ts b/src/config.ts index 14de906..84fc0e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,18 +5,82 @@ const schema = z.object({ NODE_ENV: z.string().default("development"), PORT: z.coerce.number().default(3000), + // KlikQRIS — optional supaya bot/dashboard bisa start dulu, validasi runtime saat create transaksi. KLIKQRIS_API_BASE: z.string().default("https://klikqris.com/api"), - KLIKQRIS_API_KEY: z.string().min(1, "KLIKQRIS_API_KEY wajib diisi"), - KLIKQRIS_MERCHANT_ID: z.string().min(1, "KLIKQRIS_MERCHANT_ID wajib diisi"), + KLIKQRIS_API_KEY: z.string().default(""), + KLIKQRIS_MERCHANT_ID: z.string().default(""), + // Telegram TELEGRAM_BOT_TOKEN: z.string().optional(), + // Discord DISCORD_BOT_TOKEN: z.string().optional(), DISCORD_CLIENT_ID: z.string().optional(), DISCORD_GUILD_ID: z.string().optional(), - DATABASE_URL: z.string().default("file:./prisma/dev.db"), + // Owner — primary admin yg dapet notifikasi event penting (bot online, delivery gagal, dll) + // Otomatis termasuk dalam ADMIN_*_IDS, tidak perlu didouble. + OWNER_TELEGRAM_ID: z.string().default(""), + OWNER_DISCORD_ID: z.string().default(""), + + // Admin tambahan (CSV list of user IDs yg boleh pakai /admin di bot) + ADMIN_TELEGRAM_IDS: z.string().default(""), + ADMIN_DISCORD_IDS: z.string().default(""), + + // Web dashboard + ADMIN_USERNAME: z.string().default(""), + ADMIN_PASSWORD: z.string().default(""), + ADMIN_SESSION_SECRET: z.string().default("change-me-in-production-please"), + + // Database + DATABASE_URL: z.string().default("file:./dev.db"), PUBLIC_BASE_URL: z.string().optional(), }); export const config = schema.parse(process.env); + +function parseIdList(csv: string): Set { + return new Set( + csv + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +// Owner OTOMATIS termasuk admin (jadi user gak perlu listing dirinya 2x). +export const adminTelegramIds = (() => { + const set = parseIdList(config.ADMIN_TELEGRAM_IDS); + if (config.OWNER_TELEGRAM_ID) set.add(config.OWNER_TELEGRAM_ID); + return set; +})(); + +export const adminDiscordIds = (() => { + const set = parseIdList(config.ADMIN_DISCORD_IDS); + if (config.OWNER_DISCORD_ID) set.add(config.OWNER_DISCORD_ID); + return set; +})(); + +export function isKlikqrisConfigured(): boolean { + return Boolean(config.KLIKQRIS_API_KEY && config.KLIKQRIS_MERCHANT_ID); +} + +export function isDashboardConfigured(): boolean { + return Boolean(config.ADMIN_USERNAME && config.ADMIN_PASSWORD); +} + +export function isFirstRun(): boolean { + // First-run = belum ada admin password sama sekali. + // Setup wizard akan dibuka dalam kondisi ini. + return !isDashboardConfigured(); +} + +export function isOwnerTelegram(userId: string | number | undefined): boolean { + if (userId === undefined || !config.OWNER_TELEGRAM_ID) return false; + return String(userId) === config.OWNER_TELEGRAM_ID; +} + +export function isOwnerDiscord(userId: string | undefined): boolean { + if (!userId || !config.OWNER_DISCORD_ID) return false; + return userId === config.OWNER_DISCORD_ID; +} diff --git a/src/cron/expire-orders.ts b/src/cron/expire-orders.ts new file mode 100644 index 0000000..292e83f --- /dev/null +++ b/src/cron/expire-orders.ts @@ -0,0 +1,44 @@ +import { prisma } from "../db.js"; +import { logger } from "../logger.js"; + +/** + * Background job: tandai order PENDING yang sudah lewat expiredAt jadi EXPIRED. + * Jalan tiap 60 detik (interval cukup karena KlikQRIS QRIS expiry default 60 menit). + * Idempoten — kalau gak ada order yang expired, no-op. + */ +const INTERVAL_MS = 60_000; // 1 menit + +let timer: NodeJS.Timeout | null = null; + +export function startExpireOrdersCron(): void { + if (timer) return; // sudah jalan + // Run sekali saat startup, lalu interval + void tick(); + timer = setInterval(() => void tick(), INTERVAL_MS); + logger.info({ intervalMs: INTERVAL_MS }, "Expire-orders cron started"); +} + +export function stopExpireOrdersCron(): void { + if (timer) { + clearInterval(timer); + timer = null; + } +} + +async function tick(): Promise { + try { + const now = new Date(); + const result = await prisma.order.updateMany({ + where: { + status: "PENDING", + expiredAt: { lt: now, not: null }, + }, + data: { status: "EXPIRED" }, + }); + if (result.count > 0) { + logger.info({ count: result.count }, "Auto-expired stale PENDING orders"); + } + } catch (err) { + logger.error({ err }, "Expire-orders cron tick failed"); + } +} diff --git a/src/index.ts b/src/index.ts index 1a09ead..ad15864 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,62 @@ import Fastify from "fastify"; -import { config } from "./config.js"; +import cookie from "@fastify/cookie"; +import formbody from "@fastify/formbody"; +import rateLimit from "@fastify/rate-limit"; +import { + config, + isDashboardConfigured, + isFirstRun, + isKlikqrisConfigured, +} from "./config.js"; import { logger } from "./logger.js"; import { registerWebhookRoutes } from "./payment/webhook.js"; +import { registerAdminDashboardRoutes } from "./admin/dashboard/routes.js"; +import { registerSetupRoutes } from "./setup/routes.js"; import { startTelegramBot } from "./bots/telegram.js"; import { startDiscordBot } from "./bots/discord.js"; +import { startExpireOrdersCron, stopExpireOrdersCron } from "./cron/expire-orders.js"; import { prisma } from "./db.js"; async function main() { const app = Fastify({ - // pakai pino instance kita biar log seragam - loggerInstance: logger, + // Disable Fastify HTTP logging — kita pakai pino logger langsung di route handlers. + logger: false, + trustProxy: true, // supaya rate-limit pakai IP asli di belakang reverse proxy }); + // Plugin: parse application/x-www-form-urlencoded (untuk admin dashboard + setup forms) + await app.register(formbody); + + // Plugin: signed cookies untuk session admin dashboard + await app.register(cookie, { + secret: config.ADMIN_SESSION_SECRET, + }); + + // Plugin: rate limiter — global default loose, route-specific tight di /admin/login + await app.register(rateLimit, { + global: false, // hanya route yang explicit-in via { config: { rateLimit: ... } } + keyGenerator: (req) => req.ip, + }); + + // Setup wizard (auto-disabled di production yg sudah configured) + registerSetupRoutes(app); + registerWebhookRoutes(app); + if (isDashboardConfigured()) { + registerAdminDashboardRoutes(app); + logger.info("Admin dashboard enabled at /admin"); + } else { + logger.warn("Admin dashboard disabled (ADMIN_USERNAME/ADMIN_PASSWORD belum diset)"); + } + await app.listen({ host: "0.0.0.0", port: config.PORT }); - logger.info({ port: config.PORT }, "Webhook server listening"); + logger.info({ port: config.PORT }, "HTTP server listening"); + + printStartupBanner(); + + // Start background cron untuk auto-expire order PENDING yang sudah lewat waktu + startExpireOrdersCron(); // Start kedua bot paralel; masing-masing kerja sendiri. await Promise.all([startTelegramBot(), startDiscordBot()]); @@ -23,6 +64,7 @@ async function main() { const shutdown = async (signal: string) => { logger.info({ signal }, "Shutting down..."); try { + stopExpireOrdersCron(); await app.close(); await prisma.$disconnect(); } catch (err) { @@ -35,6 +77,50 @@ async function main() { process.on("SIGTERM", () => void shutdown("SIGTERM")); } +function printStartupBanner() { + const baseUrl = `http://localhost:${config.PORT}`; + + if (isFirstRun()) { + // Banner besar untuk first-run user — direct ke setup wizard. + // eslint-disable-next-line no-console + console.log( + [ + "", + "╔════════════════════════════════════════════════════════╗", + "║ ║", + "║ First-run detected — buka setup wizard di browser: ║", + "║ ║", + `║ → ${pad(`${baseUrl}/setup`, 50)}║`, + "║ ║", + "║ Atau jalanin: npm run setup (auto-buka browser) ║", + "║ ║", + "╚════════════════════════════════════════════════════════╝", + "", + ].join("\n"), + ); + } else { + if (!isKlikqrisConfigured()) { + logger.warn( + "KlikQRIS belum dikonfigurasi (KLIKQRIS_API_KEY / KLIKQRIS_MERCHANT_ID kosong). " + + "Bot tetap jalan, tapi /buy akan gagal sampai diisi. Edit /setup untuk update.", + ); + } + if ( + config.ADMIN_SESSION_SECRET === "change-me-in-production-please" && + config.NODE_ENV === "production" + ) { + logger.warn( + "ADMIN_SESSION_SECRET masih default. Generate yang random untuk production: openssl rand -hex 32", + ); + } + logger.info({ url: `${baseUrl}/admin` }, "Admin dashboard ready"); + } +} + +function pad(s: string, width: number): string { + return s + " ".repeat(Math.max(0, width - s.length)); +} + main().catch((err) => { logger.error({ err }, "Fatal error, exit"); process.exit(1); diff --git a/src/orders/delivery.ts b/src/orders/delivery.ts index a0e0dd1..5cdc15e 100644 --- a/src/orders/delivery.ts +++ b/src/orders/delivery.ts @@ -1,10 +1,18 @@ import { prisma } from "../db.js"; import { logger } from "../logger.js"; import { registry } from "../bots/registry.js"; +import { notifyOwner } from "../admin/owner.js"; /** * Allocate stok untuk order dan kirim ke pembeli via Telegram/Discord DM. - * Idempoten: kalau order.delivered=true, langsung skip. + * + * Retry-safe flow: + * 1. Stok dialokasikan dalam DB transaction → payloads disimpan di order.deliveryPayload + * sehingga retry tidak men-double-claim stok. + * 2. DM dikirim. Kalau gagal, lastDeliveryError diset; admin bisa retry via dashboard + * (atau webhook ulang) — kita ambil dari deliveryPayload yang sudah ada. + * 3. Hanya setelah DM sukses, order.delivered diset true. + * 4. Owner di-notify untuk: order sukses (sales notif) ATAU delivery gagal (alert). */ export async function deliverOrder(orderId: string): Promise { const order = await prisma.order.findUnique({ @@ -17,46 +25,105 @@ export async function deliverOrder(orderId: string): Promise { return; } - // Allocate stok dalam transaction supaya tidak race condition - const payloads: string[] = []; - await prisma.$transaction(async (db) => { - for (let i = 0; i < order.qty; i++) { + // Step 1: pastikan stok sudah ter-allocate untuk order ini + let payloads: string[]; + if (order.deliveryPayload) { + payloads = JSON.parse(order.deliveryPayload) as string[]; + logger.info({ orderId, attempts: order.deliveryAttempts }, "Retrying delivery with existing payloads"); + } else { + payloads = await allocateStock(orderId, order.productId, order.qty); + } + + // Step 2: kirim DM + const message = formatDeliveryMessage(order.product.name, payloads); + try { + await sendToUser(order.platform, order.chatId, message); + await prisma.order.update({ + where: { id: orderId }, + data: { + delivered: true, + deliveredAt: new Date(), + lastDeliveryError: null, + deliveryAttempts: { increment: 1 }, + }, + }); + logger.info({ orderId, qty: order.qty, platform: order.platform }, "Order delivered"); + + // Sales notif untuk owner — best-effort, tidak block kalau gagal + void notifyOwner( + [ + `*Penjualan baru!*`, + `Order: \`${order.id}\``, + `Produk: ${order.product.name} x${order.qty}`, + `Total: Rp${order.totalAmount?.toLocaleString("id-ID")}`, + `Pembeli: ${order.username ?? order.chatId} (${order.platform})`, + ].join("\n"), + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const updated = await prisma.order.update({ + where: { id: orderId }, + data: { + deliveryAttempts: { increment: 1 }, + lastDeliveryError: msg.slice(0, 500), + }, + }); + logger.error({ err, orderId }, "Gagal kirim DM ke user (stok sudah di-allocate, bisa retry)"); + + // Alert owner — pakai attempt baru + void notifyOwner( + [ + `*Delivery GAGAL — order ${order.id}*`, + `Produk: ${order.product.name} x${order.qty}`, + `Pembeli: ${order.username ?? order.chatId} (${order.platform})`, + `Percobaan ke-${updated.deliveryAttempts}`, + `Error: \`${msg.slice(0, 200)}\``, + ``, + `Stok sudah ter-allocate. Cek dashboard /admin/orders untuk redeliver.`, + ].join("\n"), + ); + + throw err; + } +} + +async function allocateStock(orderId: string, productId: string, qty: number): Promise { + return prisma.$transaction(async (db) => { + const payloads: string[] = []; + for (let i = 0; i < qty; i++) { const stock = await db.stock.findFirst({ - where: { productId: order.productId, used: false }, + where: { productId, used: false }, orderBy: { id: "asc" }, }); if (!stock) { - throw new Error(`Stok ${order.productId} habis saat delivery (order ${orderId})`); + throw new Error(`Stok ${productId} habis saat delivery (order ${orderId})`); } await db.stock.update({ where: { id: stock.id }, - data: { used: true, usedAt: new Date() }, + data: { used: true, usedAt: new Date(), orderId }, }); payloads.push(stock.payload); } await db.order.update({ where: { id: orderId }, - data: { delivered: true, deliveredAt: new Date() }, + data: { deliveryPayload: JSON.stringify(payloads) }, }); + return payloads; }); +} - const message = formatDeliveryMessage(order.product.name, payloads); - - try { - if (order.platform === "TELEGRAM") { - const bot = registry.telegram; - if (!bot) throw new Error("Telegram bot not running"); - await bot.api.sendMessage(order.chatId, message, { parse_mode: "Markdown" }); - } else if (order.platform === "DISCORD") { - const client = registry.discord; - if (!client) throw new Error("Discord bot not running"); - const user = await client.users.fetch(order.chatId); - await user.send(message); - } - logger.info({ orderId, qty: order.qty, platform: order.platform }, "Order delivered"); - } catch (err) { - logger.error({ err, orderId }, "Gagal kirim DM ke user (produk sudah di-allocate)"); - throw err; +async function sendToUser(platform: string, chatId: string, message: string): Promise { + if (platform === "TELEGRAM") { + const bot = registry.telegram; + if (!bot) throw new Error("Telegram bot tidak running"); + await bot.api.sendMessage(chatId, message, { parse_mode: "Markdown" }); + } else if (platform === "DISCORD") { + const client = registry.discord; + if (!client) throw new Error("Discord bot tidak running"); + const user = await client.users.fetch(chatId); + await user.send(message); + } else { + throw new Error(`Platform ${platform} tidak didukung`); } } diff --git a/src/payment/klikqris.ts b/src/payment/klikqris.ts index 72c5e08..b4bea0e 100644 --- a/src/payment/klikqris.ts +++ b/src/payment/klikqris.ts @@ -1,5 +1,5 @@ import axios, { type AxiosInstance } from "axios"; -import { config } from "../config.js"; +import { config, isKlikqrisConfigured } from "../config.js"; import { logger } from "../logger.js"; /** @@ -48,6 +48,11 @@ export interface StatusResponse { let client: AxiosInstance | null = null; function http(): AxiosInstance { + if (!isKlikqrisConfigured()) { + throw new Error( + "KlikQRIS belum dikonfigurasi. Set KLIKQRIS_API_KEY dan KLIKQRIS_MERCHANT_ID di .env terlebih dahulu.", + ); + } if (client) return client; client = axios.create({ baseURL: config.KLIKQRIS_API_BASE, diff --git a/src/setup/env-writer.ts b/src/setup/env-writer.ts new file mode 100644 index 0000000..6db4a58 --- /dev/null +++ b/src/setup/env-writer.ts @@ -0,0 +1,128 @@ +import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import { logger } from "../logger.js"; + +/** + * Allowlist key yang boleh ditulis oleh setup wizard. + * Penting untuk security — mencegah injection key arbitrary. + */ +export const ALLOWED_ENV_KEYS = [ + "NODE_ENV", + "PORT", + "KLIKQRIS_API_BASE", + "KLIKQRIS_API_KEY", + "KLIKQRIS_MERCHANT_ID", + "TELEGRAM_BOT_TOKEN", + "DISCORD_BOT_TOKEN", + "DISCORD_CLIENT_ID", + "DISCORD_GUILD_ID", + "OWNER_TELEGRAM_ID", + "OWNER_DISCORD_ID", + "ADMIN_TELEGRAM_IDS", + "ADMIN_DISCORD_IDS", + "ADMIN_USERNAME", + "ADMIN_PASSWORD", + "ADMIN_SESSION_SECRET", + "DATABASE_URL", + "PUBLIC_BASE_URL", +] as const; + +export type EnvKey = (typeof ALLOWED_ENV_KEYS)[number]; + +const ENV_PATH = resolve(process.cwd(), ".env"); + +/** + * Baca .env saat ini dan return Map key→value. + * Hanya parse baris valid `KEY=VALUE`. Comments & blank lines diabaikan. + */ +export function readEnvFile(): Map { + const map = new Map(); + if (!existsSync(ENV_PATH)) return map; + + const content = readFileSync(ENV_PATH, "utf-8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + map.set(key, value); + } + return map; +} + +/** + * Tulis .env dengan format rapi, group by section. + * Backup .env lama ke .env.bak. kalau ada. + */ +export function writeEnvFile(values: Map): { backupPath: string | null } { + let backupPath: string | null = null; + if (existsSync(ENV_PATH)) { + backupPath = `${ENV_PATH}.bak.${Date.now()}`; + copyFileSync(ENV_PATH, backupPath); + } + + const get = (k: EnvKey) => values.get(k) ?? ""; + + const content = `# Generated by botnot setup wizard at ${new Date().toISOString()} +# Edit manually anytime — wizard akan preserve format ini. + +NODE_ENV=${get("NODE_ENV") || "development"} +PORT=${get("PORT") || "3000"} + +# === KlikQRIS === +KLIKQRIS_API_BASE=${get("KLIKQRIS_API_BASE") || "https://klikqris.com/api"} +KLIKQRIS_API_KEY=${get("KLIKQRIS_API_KEY")} +KLIKQRIS_MERCHANT_ID=${get("KLIKQRIS_MERCHANT_ID")} + +# === Telegram === +TELEGRAM_BOT_TOKEN=${get("TELEGRAM_BOT_TOKEN")} + +# === Discord === +DISCORD_BOT_TOKEN=${get("DISCORD_BOT_TOKEN")} +DISCORD_CLIENT_ID=${get("DISCORD_CLIENT_ID")} +DISCORD_GUILD_ID=${get("DISCORD_GUILD_ID")} + +# === Owner (primary admin yg dapet notifikasi event penting) === +OWNER_TELEGRAM_ID=${get("OWNER_TELEGRAM_ID")} +OWNER_DISCORD_ID=${get("OWNER_DISCORD_ID")} + +# === Admin tambahan (CSV user IDs) === +ADMIN_TELEGRAM_IDS=${get("ADMIN_TELEGRAM_IDS")} +ADMIN_DISCORD_IDS=${get("ADMIN_DISCORD_IDS")} + +# === Web Dashboard === +ADMIN_USERNAME=${get("ADMIN_USERNAME")} +ADMIN_PASSWORD=${get("ADMIN_PASSWORD")} +ADMIN_SESSION_SECRET=${get("ADMIN_SESSION_SECRET") || generateSecret()} + +# === Database === +DATABASE_URL=${quoteIfNeeded(get("DATABASE_URL") || "file:./dev.db")} + +# === Webhook === +PUBLIC_BASE_URL=${get("PUBLIC_BASE_URL")} +`; + + writeFileSync(ENV_PATH, content, "utf-8"); + logger.info({ envPath: ENV_PATH, backupPath }, ".env updated by setup wizard"); + return { backupPath }; +} + +export function generateSecret(): string { + return randomBytes(32).toString("hex"); +} + +function quoteIfNeeded(value: string): string { + // SQLite URL pakai colon → quote untuk safety + if (value.includes(":") || value.includes(" ")) return `"${value}"`; + return value; +} diff --git a/src/setup/page.ts b/src/setup/page.ts new file mode 100644 index 0000000..453c1f5 --- /dev/null +++ b/src/setup/page.ts @@ -0,0 +1,385 @@ +import { escapeHtml } from "../admin/dashboard/layout.js"; + +interface SetupFormData { + current: Map; + flash?: { kind: "success" | "error" | "info"; message: string } | null; + saved?: boolean; + backupPath?: string | null; +} + +const FAVICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='11' width='18' height='10' rx='2' fill='%2322d3ee'/%3E%3Ccircle cx='12' cy='5' r='2' fill='%23a78bfa'/%3E%3C/svg%3E"; + +export function renderSetupPage(data: SetupFormData): string { + const v = (key: string) => escapeHtml(data.current.get(key) ?? ""); + + const flashHtml = data.flash + ? `
+ ${flashIcon(data.flash.kind)} + ${escapeHtml(data.flash.message)} +
` + : ""; + + const savedBanner = data.saved + ? `
+
+
+ ${checkIcon} +
+
+

Konfigurasi tersimpan

+

+ File .env berhasil ditulis.${ + data.backupPath + ? ` Backup lama disimpan sebagai ${escapeHtml(data.backupPath.split("/").pop() ?? "")}.` + : "" + } +

+

+ Penting: restart server (Ctrl+C lalu npm run dev) supaya konfigurasi baru aktif. +

+ + Lanjut ke Admin Login ${arrowRightIcon} + +
+
+
` + : ""; + + const sections = ` + ${section({ + title: "Server", + subtitle: "Port dan public URL untuk webhook", + icon: serverIcon, + iconColor: "text-cyan-400", + iconBg: "bg-cyan-500/10", + content: ` + ${field({ name: "PORT", label: "Port HTTP", value: v("PORT") || "3000", type: "number", placeholder: "3000" })} + ${field({ name: "PUBLIC_BASE_URL", label: "Public Base URL", value: v("PUBLIC_BASE_URL"), type: "url", placeholder: "https://abc123.ngrok.io", help: "Untuk webhook KlikQRIS. Pakai ngrok untuk dev." })} + `, + })} + + ${section({ + title: "KlikQRIS", + subtitle: "Payment gateway QRIS", + icon: qrIcon, + iconColor: "text-emerald-400", + iconBg: "bg-emerald-500/10", + content: ` +

Dapatkan dari dashboard merchant klikqris.com. Boleh dikosongkan dulu — bot tetap jalan, tapi /buy gagal sampai diisi.

+ ${field({ name: "KLIKQRIS_API_KEY", label: "API Key (x-api-key)", value: v("KLIKQRIS_API_KEY"), type: "password", placeholder: "Y4DSZg..." })} + ${field({ name: "KLIKQRIS_MERCHANT_ID", label: "Merchant ID", value: v("KLIKQRIS_MERCHANT_ID"), type: "text", placeholder: "177301847754" })} + `, + })} + + ${section({ + title: "Telegram Bot", + subtitle: "Opsional", + icon: telegramIcon, + iconColor: "text-cyan-400", + iconBg: "bg-cyan-500/10", + content: ` +

Dapatkan token dari @BotFather dengan command /newbot.

+ ${field({ name: "TELEGRAM_BOT_TOKEN", label: "Bot Token", value: v("TELEGRAM_BOT_TOKEN"), type: "password", placeholder: "123456:ABC-DEF..." })} + `, + })} + + ${section({ + title: "Discord Bot", + subtitle: "Opsional", + icon: discordIcon, + iconColor: "text-violet-400", + iconBg: "bg-violet-500/10", + content: ` +

Buat di Discord Developer Portal.

+ ${field({ name: "DISCORD_BOT_TOKEN", label: "Bot Token", value: v("DISCORD_BOT_TOKEN"), type: "password", placeholder: "MTI...AAA" })} + ${field({ name: "DISCORD_CLIENT_ID", label: "Application ID (Client ID)", value: v("DISCORD_CLIENT_ID"), type: "text", placeholder: "1234567890" })} + ${field({ name: "DISCORD_GUILD_ID", label: "Guild ID", value: v("DISCORD_GUILD_ID"), type: "text", placeholder: "9876543210", help: "Opsional, untuk dev (slash command instan)." })} + `, + })} + + ${section({ + title: "Owner", + subtitle: "Primary admin yang dapat notifikasi", + icon: crownIcon, + iconColor: "text-amber-400", + iconBg: "bg-amber-500/10", + content: ` +

Owner = primary admin. Dapat notifikasi otomatis (bot online, delivery gagal) dan otomatis termasuk admin.

+
    +
  • Telegram: chat @userinfobot untuk dapet ID
  • +
  • Discord: Settings → Advanced → Developer Mode ON, klik kanan profil → Copy User ID
  • +
+ ${field({ name: "OWNER_TELEGRAM_ID", label: "Owner Telegram User ID", value: v("OWNER_TELEGRAM_ID"), type: "text", placeholder: "123456789" })} + ${field({ name: "OWNER_DISCORD_ID", label: "Owner Discord User ID", value: v("OWNER_DISCORD_ID"), type: "text", placeholder: "111122223333" })} + `, + })} + + ${section({ + title: "Admin tambahan", + subtitle: "Selain owner", + icon: userIcon, + iconColor: "text-violet-400", + iconBg: "bg-violet-500/10", + content: ` +

User ID lain yg boleh pakai admin commands di bot. Pisahkan dengan koma.

+ ${field({ name: "ADMIN_TELEGRAM_IDS", label: "Telegram Admin IDs", value: v("ADMIN_TELEGRAM_IDS"), type: "text", placeholder: "123,456" })} + ${field({ name: "ADMIN_DISCORD_IDS", label: "Discord Admin IDs", value: v("ADMIN_DISCORD_IDS"), type: "text", placeholder: "111,222" })} + `, + })} + + ${section({ + title: "Web Dashboard", + subtitle: "Wajib untuk akses /admin", + icon: lockIcon, + iconColor: "text-cyan-400", + iconBg: "bg-cyan-500/10", + required: true, + content: ` +
+ Wajib diisi. Kalau dikosongkan, dashboard di /admin tidak aktif. Pakai password kuat (min. 8 karakter). +
+ ${field({ name: "ADMIN_USERNAME", label: "Username", value: v("ADMIN_USERNAME"), type: "text", placeholder: "admin", required: true })} + ${field({ name: "ADMIN_PASSWORD", label: "Password", value: v("ADMIN_PASSWORD"), type: "password", placeholder: "min 12 karakter", required: true })} +
+ +
+ + +
+

String random untuk sign cookies. Kosongkan untuk auto-generate.

+
+ `, + })} + + ${section({ + title: "Database", + subtitle: "SQLite (dev) atau PostgreSQL (prod)", + icon: databaseIcon, + iconColor: "text-emerald-400", + iconBg: "bg-emerald-500/10", + content: ` + ${field({ name: "DATABASE_URL", label: "DATABASE_URL", value: v("DATABASE_URL") || "file:./dev.db", type: "text", placeholder: "file:./dev.db", help: "Default SQLite di prisma/dev.db. Untuk Postgres: postgresql://..." })} + `, + })} + `; + + return ` + + + + + Setup Wizard · botnot + + + + + + + + + + +
+
+
+
+ + +
+
+ + + ${botIcon} + + + botnot + + + + +
+
+ +
+ ${savedBanner} + ${flashHtml} + + ${ + !data.saved + ? `
+
+ ${sparkleIcon} First-run setup +
+

Selamat datang di botnot

+

Atur kredensial bot kamu di sini. Semua field opsional kecuali Web Dashboard. Bisa diisi sebagian dulu, ditambah belakangan.

+
` + : "" + } + +
+ ${sections} + + +
+ + Skip untuk sekarang + + +
+
+ + +
+

+ Setelah simpan, restart server (Ctrl+Cnpm run dev) untuk apply config. +

+
+
+ + + +`; +} + + +// === Helpers === + +interface SectionOpts { + title: string; + subtitle?: string; + icon: string; + iconColor: string; + iconBg: string; + content: string; + required?: boolean; +} + +function section(opts: SectionOpts): string { + return `
+
+ ${opts.icon} +
+

+ ${escapeHtml(opts.title)} + ${opts.required ? 'Required' : ""} +

+ ${opts.subtitle ? `

${escapeHtml(opts.subtitle)}

` : ""} +
+
+
+ ${opts.content} +
+
`; +} + +interface FieldOpts { + name: string; + label: string; + value: string; + type?: string; + placeholder?: string; + required?: boolean; + help?: string; +} + +function field(opts: FieldOpts): string { + const type = opts.type ?? "text"; + const isMonoFont = + type === "password" || + opts.name.includes("ID") || + opts.name === "DATABASE_URL" || + opts.name === "PUBLIC_BASE_URL"; + const inputCls = isMonoFont ? "font-mono text-sm" : "text-sm"; + return `
+ + + ${opts.help ? `

${escapeHtml(opts.help)}

` : ""} +
`; +} + +// === Inline SVG icons === + +function svg(path: string, size = "w-4 h-4"): string { + return ``; +} + +const botIcon = svg( + ``, + "w-5 h-5", +); +const serverIcon = svg(``, "w-[18px] h-[18px]"); +const qrIcon = svg(``, "w-[18px] h-[18px]"); +const telegramIcon = svg(``, "w-[18px] h-[18px]"); +const discordIcon = svg(``, "w-[18px] h-[18px]"); +const crownIcon = svg(``, "w-[18px] h-[18px]"); +const userIcon = svg(``, "w-[18px] h-[18px]"); +const lockIcon = svg(``, "w-[18px] h-[18px]"); +const databaseIcon = svg(``, "w-[18px] h-[18px]"); +const checkIcon = svg(``, "w-5 h-5"); +const arrowRightIcon = svg(``, "w-3.5 h-3.5"); +const saveIcon = svg(``); +const sparkleIcon = svg(``, "w-3.5 h-3.5"); + +function flashIcon(kind: "success" | "error" | "info"): string { + if (kind === "success") return checkIcon; + if (kind === "error") + return svg( + ``, + ); + return svg(``); +} diff --git a/src/setup/routes.ts b/src/setup/routes.ts new file mode 100644 index 0000000..e5cd9f5 --- /dev/null +++ b/src/setup/routes.ts @@ -0,0 +1,108 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { config, isDashboardConfigured } from "../config.js"; +import { logger } from "../logger.js"; +import { ALLOWED_ENV_KEYS, readEnvFile, writeEnvFile, generateSecret } from "./env-writer.js"; +import { renderSetupPage } from "./page.js"; + +/** + * Cek apakah setup wizard available di request ini. + * + * Boleh diakses kalau: + * - Belum dikonfigurasi (first-run), ATAU + * - NODE_ENV=development (untuk reconfigure) + * + * Di production yg sudah configured, redirect ke /admin/login. + */ +function isSetupAllowed(): boolean { + if (!isDashboardConfigured()) return true; + if (config.NODE_ENV === "development") return true; + return false; +} + +export function registerSetupRoutes(app: FastifyInstance): void { + app.get("/setup", async (req, reply) => { + if (!isSetupAllowed()) { + return reply.redirect("/admin/login"); + } + const current = readEnvFile(); + return reply.type("text/html").send(renderSetupPage({ current })); + }); + + app.post<{ Body: Record }>("/setup", async (req, reply) => { + if (!isSetupAllowed()) { + return reply + .code(403) + .type("text/html") + .send("

Setup wizard disabled

Sudah configured. Hapus ADMIN_USERNAME di .env untuk re-enable, atau set NODE_ENV=development.

"); + } + + const body = req.body ?? {}; + const merged = readEnvFile(); + + // Hanya update key yg ada di allowlist + for (const key of ALLOWED_ENV_KEYS) { + const incoming = body[key]; + if (incoming !== undefined) { + merged.set(key, incoming.trim()); + } + } + + // Auto-generate session secret kalau dikosongkan + if (!merged.get("ADMIN_SESSION_SECRET")) { + merged.set("ADMIN_SESSION_SECRET", generateSecret()); + } + + // Validasi minimal: dashboard wajib punya username + password + const errors: string[] = []; + if (!merged.get("ADMIN_USERNAME")) errors.push("Admin Dashboard Username wajib diisi"); + if (!merged.get("ADMIN_PASSWORD")) errors.push("Admin Dashboard Password wajib diisi"); + if ((merged.get("ADMIN_PASSWORD") ?? "").length < 8) { + errors.push("Admin Dashboard Password minimal 8 karakter"); + } + + if (errors.length > 0) { + return reply + .type("text/html") + .send( + renderSetupPage({ + current: merged, + flash: { kind: "error", message: errors.join(" · ") }, + }), + ); + } + + try { + const { backupPath } = writeEnvFile(merged); + logSetupChanges(merged, req); + return reply.type("text/html").send( + renderSetupPage({ + current: merged, + saved: true, + backupPath, + }), + ); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + logger.error({ err }, "Setup wizard write failed"); + return reply.type("text/html").send( + renderSetupPage({ + current: merged, + flash: { kind: "error", message: `Gagal tulis .env: ${msg}` }, + }), + ); + } + }); +} + +function logSetupChanges(values: Map, req: FastifyRequest) { + const summary: Record = {}; + for (const key of ALLOWED_ENV_KEYS) { + const v = values.get(key) ?? ""; + summary[key] = isSensitive(key) ? (v ? "" : "") : v || ""; + } + logger.info({ ip: req.ip, summary }, "Setup wizard saved configuration"); +} + +function isSensitive(key: string): boolean { + return /KEY|TOKEN|PASSWORD|SECRET/.test(key); +}