From c850e10543b31ee286ca017a0fb5395c2347b9ac Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 21 May 2026 19:07:19 +0000 Subject: [PATCH 01/11] feat: admin commands (Telegram + Discord) and admin web dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major additions: - Admin web dashboard at /admin (Fastify + Tailwind CDN + HTMX) - Login with username/password, signed cookie session (7 days) - Stats: revenue today/7d/all-time, counts by status, stock available - Products: list, create, toggle active - Stock: bulk add via textarea (one per line), per-product view - Orders: filter by status, redeliver button for failed deliveries - Admin Telegram commands: /admin /stats /orders /products /addproduct /addstock (multi-line) /toggle /redeliver - Admin Discord slash commands: /admin-stats /admin-orders /admin-products /admin-add-product /admin-add-stock /admin-toggle /admin-redeliver Bug fixes & UX improvements: - KLIKQRIS_API_KEY/MERCHANT_ID now optional at startup (graceful degradation) — bot/dashboard run, /buy throws clear error if missing - Retry-safe delivery: stock allocated separately from DM send so failed DMs can be retried without double-claiming stock (deliveryPayload JSON on Order, deliveryAttempts counter, lastDeliveryError) - Schema: track orderId on Stock for audit trail - DATABASE_URL default fixed: file:./dev.db (was file:./prisma/dev.db which Prisma resolved relative to schema → prisma/prisma/dev.db) - Discord admin: PermissionFlagsBits.Administrator default + runtime ADMIN_DISCORD_IDS check (defense in depth) - Telegram admin: silently ignores non-admins (doesn't reveal commands) Verified: tsc --noEmit clean, end-to-end runtime test (login, dashboard, products CRUD) --- .env.example | 28 ++- README.md | 149 ++++++++++++++- package.json | 2 + prisma/schema.prisma | 41 +++-- src/admin/auth.ts | 19 ++ src/admin/dashboard/layout.ts | 103 +++++++++++ src/admin/dashboard/middleware.ts | 47 +++++ src/admin/dashboard/pages/home.ts | 93 ++++++++++ src/admin/dashboard/pages/orders.ts | 107 +++++++++++ src/admin/dashboard/pages/products.ts | 124 +++++++++++++ src/admin/dashboard/pages/stock.ts | 94 ++++++++++ src/admin/dashboard/routes.ts | 185 +++++++++++++++++++ src/admin/service.ts | 163 +++++++++++++++++ src/bots/discord-admin.ts | 252 ++++++++++++++++++++++++++ src/bots/discord.ts | 17 +- src/bots/telegram-admin.ts | 189 +++++++++++++++++++ src/bots/telegram.ts | 4 + src/config.ts | 39 +++- src/index.ts | 45 ++++- src/orders/delivery.ts | 92 +++++++--- src/payment/klikqris.ts | 7 +- 21 files changed, 1730 insertions(+), 70 deletions(-) create mode 100644 src/admin/auth.ts create mode 100644 src/admin/dashboard/layout.ts create mode 100644 src/admin/dashboard/middleware.ts create mode 100644 src/admin/dashboard/pages/home.ts create mode 100644 src/admin/dashboard/pages/orders.ts create mode 100644 src/admin/dashboard/pages/products.ts create mode 100644 src/admin/dashboard/pages/stock.ts create mode 100644 src/admin/dashboard/routes.ts create mode 100644 src/admin/service.ts create mode 100644 src/bots/discord-admin.ts create mode 100644 src/bots/telegram-admin.ts diff --git a/.env.example b/.env.example index 59d76f3..927e121 100644 --- a/.env.example +++ b/.env.example @@ -2,25 +2,41 @@ 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= +# === Admin Bot Commands === +# Comma-separated user IDs yang diizinkan pakai /admin commands di bot. +# Telegram: cara dapat ID — chat @userinfobot, dia akan kirim numeric ID kamu. +# Discord: enable Developer Mode, lalu klik kanan profil kamu > Copy User ID. +ADMIN_TELEGRAM_IDS= +ADMIN_DISCORD_IDS= + +# === Admin Web Dashboard === +# Login credentials untuk dashboard di /admin. +# Kosongkan ADMIN_USERNAME untuk disable dashboard sepenuhnya. +ADMIN_USERNAME= +ADMIN_PASSWORD= +# Generate string random panjang, contoh: openssl rand -hex 32 +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..62aaccc 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ - [KlikQRIS API Key](#3-klikqris-api-key) - [Konfigurasi `.env`](#konfigurasi-env) - [Webhook Public URL](#webhook-public-url) +- [Admin Dashboard](#admin-dashboard) +- [Admin Commands (Bot)](#admin-commands-bot) - [Struktur Project](#struktur-project) - [Perintah Bot](#perintah-bot) - [Deploy ke Production](#deploy-ke-production) @@ -43,9 +45,13 @@ - **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. +- **Admin web dashboard**: kelola produk, stok, dan order dari browser. Login dengan username/password, signed cookie session. +- **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. +- **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 @@ -211,21 +217,30 @@ Buka chat Telegram dengan bot kamu, ketik `/start` — siap testing! 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) +# Admin bot commands (CSV user IDs) +ADMIN_TELEGRAM_IDS= # contoh: 12345,67890 (lihat @userinfobot) +ADMIN_DISCORD_IDS= # contoh: 1112,3334 (Developer Mode > Copy User ID) + +# Admin web dashboard — kosongkan ADMIN_USERNAME untuk disable +ADMIN_USERNAME= +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= # generate: openssl rand -hex 32 + # Database -DATABASE_URL="file:./prisma/dev.db" +DATABASE_URL="file:./dev.db" # Webhook PUBLIC_BASE_URL= # contoh: https://abc123.ngrok.io @@ -256,6 +271,103 @@ Untuk production: deploy ke VPS / Railway / Fly.io / Render dan pakai domain HTT --- +## 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 tidak di-rate-limit. Untuk production, taruh di belakang reverse proxy (Caddy/Nginx) yang bisa rate-limit `/admin/login`, atau ekspos hanya via VPN/Tailscale. + +--- + +## 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 ``` @@ -266,13 +378,23 @@ botnot/ │ ├── schema.prisma # Product, Stock, Order │ └── seed.ts # contoh data ├── src/ +│ ├── admin/ +│ │ ├── auth.ts # admin ID checks +│ │ ├── 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 │ ├── bots/ │ │ ├── telegram.ts # /start /catalog /buy -│ │ ├── discord.ts # slash commands +│ │ ├── telegram-admin.ts # /admin /stats /orders /addstock dll +│ │ ├── discord.ts # slash commands user +│ │ ├── 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 +402,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 @@ -309,7 +431,11 @@ botnot/ ### 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 +470,15 @@ Atau edit `prisma/seed.ts` lalu jalanin `npm run db:seed`. ## Roadmap +- [x] Admin web dashboard (login, products, stock, orders, redeliver) +- [x] Admin bot commands (Telegram + Discord) +- [x] Retry-safe delivery dengan tombol redeliver di dashboard - [ ] Cron auto-expire order yang stuck `PENDING` - [ ] 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 +- [ ] Dashboard analytics (chart penjualan, top produk) - [ ] Integrasi role Discord otomatis (untuk produk tipe `ROLE`) +- [ ] Rate limiting di `/admin/login` - [ ] Unit & integration tests --- diff --git a/package.json b/package.json index 87502e6..3d41fd3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@fastify/cookie": "^11.0.1", + "@fastify/formbody": "^8.0.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/src/admin/auth.ts b/src/admin/auth.ts new file mode 100644 index 0000000..994d6da --- /dev/null +++ b/src/admin/auth.ts @@ -0,0 +1,19 @@ +import { adminDiscordIds, adminTelegramIds } 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; +} diff --git a/src/admin/dashboard/layout.ts b/src/admin/dashboard/layout.ts new file mode 100644 index 0000000..221f48d --- /dev/null +++ b/src/admin/dashboard/layout.ts @@ -0,0 +1,103 @@ +interface LayoutOptions { + title: string; + body: string; + active?: "home" | "products" | "stock" | "orders"; + flash?: { kind: "success" | "error" | "info"; message: string } | null; +} + +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, "'"); +} + +export function layout({ title, body, active, flash }: LayoutOptions): string { + const navItem = (id: NonNullable, href: string, label: string) => ` + ${label} + `; + + const flashHtml = flash + ? `
${escapeHtml(flash.message)}
` + : ""; + + return ` + + + + + ${escapeHtml(title)} · botnot admin + + + + + +
+
+ botnot + / + admin + +
+ +
+
+
+
+ ${flashHtml} + ${body} +
+ +`; +} + +export function loginLayout(opts: { error?: string }): string { + return ` + + + + + Login · botnot admin + + + + +
+

botnot admin

+

Masuk untuk mengelola toko kamu.

+ ${ + opts.error + ? `
${escapeHtml(opts.error)}
` + : "" + } + + + + + +
+ +`; +} 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/home.ts b/src/admin/dashboard/pages/home.ts new file mode 100644 index 0000000..4a42430 --- /dev/null +++ b/src/admin/dashboard/pages/home.ts @@ -0,0 +1,93 @@ +import type { AdminStats } from "../../service.js"; +import { formatIDR } from "../../service.js"; +import { escapeHtml, 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, color: string) => ` +
+
${label}
+
${value}
+
`; + + const body = ` +

Dashboard

+ +
+ ${statCard("Revenue Hari Ini", formatIDR(stats.revenueToday), "text-emerald-400")} + ${statCard("Revenue 7 Hari", formatIDR(stats.revenue7d), "text-cyan-400")} + ${statCard("Total Revenue", formatIDR(stats.revenueAllTime), "text-violet-400")} + ${statCard("Stok Tersedia", String(stats.stockAvailable), "text-amber-400")} +
+ +
+ ${statCard("Total Order", String(stats.totalOrders), "text-slate-100")} + ${statCard("Pending", String(stats.pending), "text-amber-300")} + ${statCard("Paid", String(stats.paid), "text-emerald-300")} + ${statCard("Expired", String(stats.expired), "text-red-300")} +
+ +
+
+

Order Terbaru

+ Lihat semua → +
+ ${ + recent.length === 0 + ? `
Belum ada order.
` + : ` + + + + + + + + + + + + ${recent.map((o) => orderRow(o)).join("")} + +
Order IDProdukTotalStatusPlatformWaktu
` + } +
+ `; + + return layout({ title: "Dashboard", body, active: "home" }); +} + +function orderRow(o: RecentOrder): string { + const statusBadge = renderStatusBadge(o.status); + return ` + + ${escapeHtml(o.id)} + ${escapeHtml(o.product.name)} + ${escapeHtml(formatIDR(o.totalAmount))} + ${statusBadge} + ${escapeHtml(o.platform)} + ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} + `; +} + +export function renderStatusBadge(status: string): string { + const map: Record = { + PENDING: "bg-amber-500/10 text-amber-300 border-amber-500/30", + PAID: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30", + EXPIRED: "bg-red-500/10 text-red-300 border-red-500/30", + FAILED: "bg-red-500/10 text-red-300 border-red-500/30", + }; + const cls = map[status] ?? "bg-slate-700 text-slate-300"; + return `${escapeHtml(status)}`; +} diff --git a/src/admin/dashboard/pages/orders.ts b/src/admin/dashboard/pages/orders.ts new file mode 100644 index 0000000..fc5024b --- /dev/null +++ b/src/admin/dashboard/pages/orders.ts @@ -0,0 +1,107 @@ +import { escapeHtml, layout } from "../layout.js"; +import { formatIDR } from "../../service.js"; +import { 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 filterButton = (status: string | undefined, label: string) => { + const active = (filterStatus ?? "") === (status ?? ""); + const href = status ? `?status=${status}` : "/admin/orders"; + return `${label}`; + }; + + const rows = orders + .map((o) => { + const deliveryCell = o.delivered + ? `✓ Terkirim` + : o.status === "PAID" + ? `
+ +
${ + o.lastDeliveryError + ? `
⚠ ${o.deliveryAttempts}× gagal
` + : "" + }` + : `-`; + + return ` + + ${escapeHtml(o.id)} + +
${escapeHtml(o.product.name)}
+
x${o.qty}
+ + ${escapeHtml(formatIDR(o.totalAmount))} + ${renderStatusBadge(o.status)} + +
${escapeHtml(o.platform)}
+
${escapeHtml(o.username ?? o.chatId)}
+ + ${deliveryCell} + ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} + `; + }) + .join(""); + + const body = ` +
+

Order

+
+ ${filterButton(undefined, "Semua")} + ${filterButton("PENDING", "Pending")} + ${filterButton("PAID", "Paid")} + ${filterButton("EXPIRED", "Expired")} +
+
+ +
+ ${ + orders.length === 0 + ? `
Tidak ada order ${filterStatus ? `dengan status ${filterStatus}` : ""}.
` + : ` + + + + + + + + + + + + ${rows} +
Order IDProdukTotalStatusPembeliDeliveryWaktu
` + } +
+ `; + + return layout({ title: "Order", body, active: "orders", flash }); +} diff --git a/src/admin/dashboard/pages/products.ts b/src/admin/dashboard/pages/products.ts new file mode 100644 index 0000000..5d8c9c4 --- /dev/null +++ b/src/admin/dashboard/pages/products.ts @@ -0,0 +1,124 @@ +import { escapeHtml, layout } from "../layout.js"; +import { formatIDR } from "../../service.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 rows = products + .map( + (p) => ` + + ${escapeHtml(p.id)} + +
${escapeHtml(p.name)}
+ ${p.description ? `
${escapeHtml(p.description)}
` : ""} + + ${escapeHtml(formatIDR(p.priceIDR))} + ${escapeHtml(p.type)} + + ${p.available} + / ${p.available + p.sold} + + + ${ + p.active + ? `Active` + : `Inactive` + } + + + Kelola Stok +
+ +
+ + `, + ) + .join(""); + + const body = ` +
+

Produk

+
+ +
+

Tambah Produk Baru

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+

Daftar Produk (${products.length})

+
+ ${ + products.length === 0 + ? `
Belum ada produk.
` + : ` + + + + + + + + + + + + ${rows} +
IDNamaHargaTipeStokStatusAksi
` + } +
+ `; + + return layout({ title: "Produk", body, active: "products", flash }); +} diff --git a/src/admin/dashboard/pages/stock.ts b/src/admin/dashboard/pages/stock.ts new file mode 100644 index 0000000..7585f4a --- /dev/null +++ b/src/admin/dashboard/pages/stock.ts @@ -0,0 +1,94 @@ +import { escapeHtml, layout } from "../layout.js"; +import { formatIDR } from "../../service.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 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 body = ` +
+ ← Kembali ke produk +

Stok: ${escapeHtml(product.name)}

+

+ ${escapeHtml(product.id)} · + ${escapeHtml(formatIDR(product.priceIDR))} · + ${available} available, + ${sold} sold +

+
+ +
+

Tambah Stok (Bulk)

+

Satu item per baris. Cocok untuk paste daftar akun/license/voucher sekaligus.

+
+ + +
+
+ +
+
+

Daftar Stok (${stocks.length} terakhir)

+
+ ${ + stocks.length === 0 + ? `
Belum ada stok.
` + : ` + + + + + + + + + + ${rows} +
#PayloadStatusOrderDibuat
` + } +
+ `; + + return layout({ title: `Stok ${product.id}`, body, active: "products", flash }); +} diff --git a/src/admin/dashboard/routes.ts b/src/admin/dashboard/routes.ts new file mode 100644 index 0000000..cbb3693 --- /dev/null +++ b/src/admin/dashboard/routes.ts @@ -0,0 +1,185 @@ +import type { FastifyInstance } from "fastify"; +import { logger } from "../../logger.js"; +import { + addStock, + createProduct, + getStats, + getStockForProduct, + listProductsWithStock, + listRecentOrders, + redeliverOrder, + toggleProductActive, +} from "../service.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"; + +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", + async (req, reply) => { + const { username = "", password = "" } = req.body ?? {}; + if (!checkCredentials(username, password)) { + 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 })); + }); + + // === 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/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..88dd056 100644 --- a/src/bots/discord.ts +++ b/src/bots/discord.ts @@ -14,6 +14,11 @@ import { logger } from "../logger.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"; export async function startDiscordBot(): Promise { if (!config.DISCORD_BOT_TOKEN || !config.DISCORD_CLIENT_ID) { @@ -25,8 +30,8 @@ export async function startDiscordBot(): Promise { intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages], }); - // Definisi slash commands - const commands = [ + // User commands + const userCommands = [ new SlashCommandBuilder() .setName("catalog") .setDescription("Lihat daftar produk") @@ -43,6 +48,8 @@ export async function startDiscordBot(): Promise { .toJSON(), ]; + const commands = [...userCommands, ...adminCommandDefinitions]; + // Register commands (guild = instan, global = bisa sampai 1 jam propagasi) const rest = new REST({ version: "10" }).setToken(config.DISCORD_BOT_TOKEN); const route = config.DISCORD_GUILD_ID @@ -50,13 +57,17 @@ export async function startDiscordBot(): Promise { : 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 (isAdminCommand(interaction.commandName)) { + await handleAdminCommand(interaction); + return; + } if (interaction.commandName === "catalog") { await handleCatalog(interaction); } else if (interaction.commandName === "buy") { 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..981d3f2 100644 --- a/src/bots/telegram.ts +++ b/src/bots/telegram.ts @@ -4,6 +4,7 @@ import { logger } from "../logger.js"; import { listProducts } from "../products/catalog.js"; import { createOrder } from "../orders/service.js"; import { registry } from "./registry.js"; +import { registerTelegramAdminCommands } from "./telegram-admin.js"; export async function startTelegramBot(): Promise { if (!config.TELEGRAM_BOT_TOKEN) { @@ -95,6 +96,9 @@ export async function startTelegramBot(): Promise { } }); + // Admin commands (otomatis di-skip kalau ADMIN_TELEGRAM_IDS kosong) + registerTelegramAdminCommands(bot); + bot.catch((err) => logger.error({ err }, "Telegram bot error")); registry.telegram = bot; diff --git a/src/config.ts b/src/config.ts index 14de906..35cea45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,18 +5,51 @@ 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"), + // Admin (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); + +export function parseIdList(csv: string): Set { + return new Set( + csv + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +export const adminTelegramIds = parseIdList(config.ADMIN_TELEGRAM_IDS); +export const adminDiscordIds = parseIdList(config.ADMIN_DISCORD_IDS); + +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); +} diff --git a/src/index.ts b/src/index.ts index 1a09ead..98f0eaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,41 @@ import Fastify from "fastify"; -import { config } from "./config.js"; +import cookie from "@fastify/cookie"; +import formbody from "@fastify/formbody"; +import { config, isDashboardConfigured, isKlikqrisConfigured } from "./config.js"; import { logger } from "./logger.js"; import { registerWebhookRoutes } from "./payment/webhook.js"; +import { registerAdminDashboardRoutes } from "./admin/dashboard/routes.js"; import { startTelegramBot } from "./bots/telegram.js"; import { startDiscordBot } from "./bots/discord.js"; import { prisma } from "./db.js"; async function main() { + printStartupWarnings(); + 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, + }); + + // Plugin: parse application/x-www-form-urlencoded (untuk admin dashboard forms) + await app.register(formbody); + + // Plugin: signed cookies untuk session admin dashboard + await app.register(cookie, { + secret: config.ADMIN_SESSION_SECRET, }); 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"); // Start kedua bot paralel; masing-masing kerja sendiri. await Promise.all([startTelegramBot(), startDiscordBot()]); @@ -35,6 +55,23 @@ async function main() { process.on("SIGTERM", () => void shutdown("SIGTERM")); } +function printStartupWarnings() { + if (!isKlikqrisConfigured()) { + logger.warn( + "KlikQRIS belum dikonfigurasi (KLIKQRIS_API_KEY / KLIKQRIS_MERCHANT_ID kosong). " + + "Bot tetap jalan, tapi /buy akan gagal sampai diisi.", + ); + } + 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", + ); + } +} + 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..b18ba06 100644 --- a/src/orders/delivery.ts +++ b/src/orders/delivery.ts @@ -4,7 +4,13 @@ import { registry } from "../bots/registry.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. */ export async function deliverOrder(orderId: string): Promise { const order = await prisma.order.findUnique({ @@ -17,46 +23,80 @@ 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"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + 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)"); + 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, From 5b155e217b528614873f48e6fb8acc0b0dadbde0 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 21 May 2026 19:19:07 +0000 Subject: [PATCH 02/11] feat: owner system + setup wizard with auto-browser launch Owner system: - New env vars: OWNER_TELEGRAM_ID, OWNER_DISCORD_ID - Owner ID auto-merged into admin set (no need to list twice) - /whoami command (Telegram + Discord) shows role: owner/admin/customer - /start greeting differentiated per role - notifyOwner() helper sends best-effort DMs via both platforms - Owner notifications wired into: - Bot online (on startup ready) - Order PAID + delivered (sales notification) - Delivery failure (alert with retry instructions) Setup wizard (/setup): - Multi-section form: Server, KlikQRIS, Telegram, Discord, Owner, Admins, Dashboard, Database - Auto-generate session secret button - Validates input (admin password >= 8 chars) - Backs up old .env to .env.bak. before overwriting - Allowlist of writable keys prevents env injection - Sensitive values logged as / only - Auto-disabled in production once configured (redirects to /admin/login) - Always available in NODE_ENV=development for re-config CLI launcher (npm run setup): - scripts/setup.mjs: cross-platform (macOS/Linux/Windows) - Creates .env from .env.example if missing - Spawns dev server - Auto-opens browser to http://localhost:PORT/setup after 1.5s - Forwards SIGINT/SIGTERM to child server Index startup: - First-run banner with prominent setup URL (when .env missing/empty) - Standard log line with admin URL when configured - Restructured warnings (more helpful pointers) Verified: tsc clean, end-to-end runtime tests: - First-run: GET /setup serves form (11KB), all sections present - POST /setup: writes .env (formatted), shows confirmation page - POST /setup with weak password: rejected with validation error - After restart: dashboard available at /admin, login works, all pages render - /setup still accessible in dev mode for re-config --- .env.example | 22 +++-- README.md | 155 +++++++++++++++++++++++++++----- package.json | 1 + scripts/setup.mjs | 63 +++++++++++++ src/admin/auth.ts | 18 +++- src/admin/owner.ts | 39 +++++++++ src/bots/discord.ts | 54 +++++++++++- src/bots/telegram.ts | 70 +++++++++++++-- src/config.ts | 39 ++++++++- src/index.ts | 70 +++++++++++---- src/orders/delivery.ts | 29 +++++- src/setup/env-writer.ts | 128 +++++++++++++++++++++++++++ src/setup/page.ts | 190 ++++++++++++++++++++++++++++++++++++++++ src/setup/routes.ts | 108 +++++++++++++++++++++++ 14 files changed, 924 insertions(+), 62 deletions(-) create mode 100644 scripts/setup.mjs create mode 100644 src/admin/owner.ts create mode 100644 src/setup/env-writer.ts create mode 100644 src/setup/page.ts create mode 100644 src/setup/routes.ts diff --git a/.env.example b/.env.example index 927e121..3f5d43f 100644 --- a/.env.example +++ b/.env.example @@ -20,19 +20,31 @@ DISCORD_CLIENT_ID= # Kalau kosong, pakai global commands (propagasi sampai 1 jam). DISCORD_GUILD_ID= -# === Admin Bot Commands === -# Comma-separated user IDs yang diizinkan pakai /admin commands di bot. -# Telegram: cara dapat ID — chat @userinfobot, dia akan kirim numeric ID kamu. -# Discord: enable Developer Mode, lalu klik kanan profil kamu > Copy User 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. +# 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 === diff --git a/README.md b/README.md index 62aaccc..643ca14 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ - [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) - [Admin Commands (Bot)](#admin-commands-bot) - [Struktur Project](#struktur-project) @@ -45,6 +47,8 @@ - **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. +- **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, dan order dari browser. Login dengan username/password, signed cookie session. - **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. @@ -117,33 +121,49 @@ > Butuh **Node.js 20+** dan **npm** (atau pnpm/yarn). +**Cara paling cepat (recommended):** + ```bash -# 1. Clone repo git clone https://github.com/mocasus/botnot.git cd botnot - -# 2. Install dependencies npm install +npm run db:push +npm run setup # 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) +`npm run setup` akan: +1. Bikin `.env` dari `.env.example` (kalau belum ada) +2. Start dev server +3. **Auto-buka browser** ke `http://localhost:3000/setup` — form web untuk isi semua kredensial (KlikQRIS, bot tokens, owner ID, admin login) +4. Setelah submit, restart server dengan Ctrl+C → `npm run dev` + +**Cara manual (kalau mau edit `.env` langsung):** -# 4. Setup database (SQLite, default ada di prisma/dev.db) +```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 - -# 5. Jalanin (webhook + Telegram bot + Discord bot, satu proses) npm run dev ``` -Output kira-kira: +Output kira-kira (first-run, sebelum setup): ``` -[12:34:56] INFO: Webhook server listening port=3000 +╔════════════════════════════════════════════════════════╗ +║ First-run detected — buka setup wizard di browser: ║ +║ → http://localhost:3000/setup ║ +║ Atau jalanin: npm run setup (auto-buka browser) ║ +╚════════════════════════════════════════════════════════╝ +``` + +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 ``` @@ -213,6 +233,8 @@ 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 @@ -230,14 +252,19 @@ DISCORD_BOT_TOKEN= # dari Developer Portal > Bot DISCORD_CLIENT_ID= # = Application ID DISCORD_GUILD_ID= # opsional, isi untuk dev (slash command instan) -# Admin bot commands (CSV user IDs) -ADMIN_TELEGRAM_IDS= # contoh: 12345,67890 (lihat @userinfobot) -ADMIN_DISCORD_IDS= # contoh: 1112,3334 (Developer Mode > Copy User ID) +# 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 +# Admin web dashboard — kosongkan ADMIN_USERNAME untuk disable + auto-aktifin /setup ADMIN_USERNAME= ADMIN_PASSWORD= -ADMIN_SESSION_SECRET= # generate: openssl rand -hex 32 +ADMIN_SESSION_SECRET= # generate: openssl rand -hex 32 (atau setup wizard auto-gen) # Database DATABASE_URL="file:./dev.db" @@ -248,6 +275,42 @@ 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. Start server +3. Auto-buka browser ke `http://localhost:3000/setup` (cross-platform: macOS/Linux/Windows) +4. Form punya section: Server, KlikQRIS, Telegram, Discord, Owner, Admin tambahan, Web Dashboard, Database +5. Kalau lupa generate session secret, ada tombol **Generate** yang bikin string random +6. Submit → `.env` lama di-backup (`.env.bak.`) → file baru ditulis dengan format rapi +7. 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: @@ -271,6 +334,43 @@ 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. @@ -377,19 +477,26 @@ botnot/ ├── 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 # admin ID checks +│ │ ├── 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 +│ │ ├── telegram.ts # /start /catalog /buy /whoami │ │ ├── telegram-admin.ts # /admin /stats /orders /addstock dll -│ │ ├── discord.ts # slash commands user +│ │ ├── discord.ts # slash commands user (/catalog /buy /whoami) │ │ ├── discord-admin.ts # /admin-* slash commands │ │ └── registry.ts # shared bot instances │ ├── orders/ @@ -416,10 +523,11 @@ botnot/ | Command | Deskripsi | | ----------------------- | ---------------------------------- | -| `/start` | Welcome message + daftar perintah | +| `/start` | Welcome message (greeting beda untuk owner/admin/customer) | | `/catalog` | Lihat semua produk + harga + stok | | `/buy ` | Beli 1 unit | | `/buy ` | Beli `n` unit | +| `/whoami` | Cek role kamu (owner/admin/customer) + user ID | ### Discord @@ -428,6 +536,7 @@ botnot/ | `/catalog` | Lihat semua produk | | `/buy product_id:` | Beli 1 unit | | `/buy product_id: qty:` | Beli `n` unit | +| `/whoami` | Cek role kamu + user ID | ### Tambah produk @@ -472,6 +581,8 @@ Atau edit `prisma/seed.ts` lalu jalanin `npm run db:seed`. - [x] Admin web dashboard (login, products, stock, orders, redeliver) - [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 - [ ] Cron auto-expire order yang stuck `PENDING` - [ ] Retry queue (BullMQ + Redis) untuk delivery yang gagal diff --git a/package.json b/package.json index 3d41fd3..3e2fbfe 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", diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..d36ad8e --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Setup launcher: start dev server + auto-open browser ke /setup. + * Cross-platform (macOS / Linux / Windows). + * + * Usage: npm run setup + */ +import { spawn } 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"); + +// 1. Pastikan .env ada +if (!existsSync(envPath)) { + if (existsSync(examplePath)) { + copyFileSync(examplePath, envPath); + console.log("Created .env from .env.example"); + } else { + console.error("ERROR: .env.example not found in", cwd); + process.exit(1); + } +} + +// 2. 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("\nStarting botnot dev server, then opening", setupUrl, "...\n"); + +// 3. Start server +const server = spawn("npx", ["tsx", "src/index.ts"], { + stdio: "inherit", + shell: process.platform === "win32", +}); + +// 4. Open browser setelah server siap (1.5 detik delay) +setTimeout(() => { + const opener = + process.platform === "darwin" + ? ["open", [setupUrl]] + : process.platform === "win32" + ? ["cmd", ["/c", "start", "", setupUrl]] + : ["xdg-open", [setupUrl]]; + + spawn(opener[0], opener[1], { stdio: "ignore", detached: true }) + .on("error", () => { + console.log(`\n Buka manual: ${setupUrl}\n`); + }) + .unref(); +}, 1500); + +// 5. Forward signals +const forward = (sig) => { + if (!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/auth.ts b/src/admin/auth.ts index 994d6da..596645d 100644 --- a/src/admin/auth.ts +++ b/src/admin/auth.ts @@ -1,4 +1,4 @@ -import { adminDiscordIds, adminTelegramIds } from "../config.js"; +import { adminDiscordIds, adminTelegramIds, isOwnerDiscord, isOwnerTelegram } from "../config.js"; export function isTelegramAdmin(userId: string | number | undefined): boolean { if (userId === undefined) return false; @@ -17,3 +17,19 @@ export function hasTelegramAdmins(): boolean { 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/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/bots/discord.ts b/src/bots/discord.ts index 88dd056..7df04a2 100644 --- a/src/bots/discord.ts +++ b/src/bots/discord.ts @@ -19,6 +19,8 @@ import { handleAdminCommand, isAdminCommand, } from "./discord-admin.js"; +import { discordRole } from "../admin/auth.js"; +import { notifyOwner } from "../admin/owner.js"; export async function startDiscordBot(): Promise { if (!config.DISCORD_BOT_TOKEN || !config.DISCORD_CLIENT_ID) { @@ -46,6 +48,10 @@ 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(), ]; const commands = [...userCommands, ...adminCommandDefinitions]; @@ -72,6 +78,8 @@ export async function startDiscordBot(): Promise { await handleCatalog(interaction); } else if (interaction.commandName === "buy") { await handleBuy(interaction); + } else if (interaction.commandName === "whoami") { + await handleWhoami(interaction); } } catch (err) { const msg = err instanceof Error ? err.message : "Unknown error"; @@ -85,9 +93,20 @@ export async function startDiscordBot(): Promise { } }); - 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 /admin-stats atau /whoami untuk cek status.`, + ].join("\n"), + ); + } + }); registry.discord = client; await client.login(config.DISCORD_BOT_TOKEN); @@ -155,6 +174,35 @@ async function handleBuy(i: ChatInputCommandInteraction): Promise { await i.editReply({ embeds: [embed], files: [attachment] }); } +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" + ? "\nKamu primary admin. Otomatis dapat notifikasi event penting." + : role === "admin" + ? "\nKamu admin. Pakai `/admin-stats` untuk overview." + : ""; + const embed = new EmbedBuilder() + .setTitle(`Role: ${labels[role]}`) + .setColor(colors[role]) + .addFields( + { name: "User ID", value: `\`${i.user.id}\``, inline: true }, + { name: "Username", value: i.user.username, inline: true }, + ) + .setDescription(extra || null); + await i.reply({ embeds: [embed], ephemeral: true }); +} + function decodeBase64Image(dataUrl: string): Buffer { const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ""); return Buffer.from(base64, "base64"); diff --git a/src/bots/telegram.ts b/src/bots/telegram.ts index 981d3f2..3697794 100644 --- a/src/bots/telegram.ts +++ b/src/bots/telegram.ts @@ -1,10 +1,12 @@ import { Bot, InputFile } from "grammy"; -import { config } from "../config.js"; +import { config, isOwnerTelegram } from "../config.js"; import { logger } from "../logger.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"; export async function startTelegramBot(): Promise { if (!config.TELEGRAM_BOT_TOKEN) { @@ -14,19 +16,52 @@ export async function startTelegramBot(): Promise { const bot = new Bot(config.TELEGRAM_BOT_TOKEN); - bot.command("start", (ctx) => - ctx.reply( + bot.command("start", (ctx) => { + const role = telegramRole(ctx.from?.id); + const greeting = + role === "owner" + ? `Halo *Owner*! Selamat datang kembali. Bot kamu sudah online.` + : role === "admin" + ? `Halo Admin! Bot toko siap digunakan.` + : `Halo! Saya bot toko otomatis.`; + + return ctx.reply( [ - "Halo! Saya bot toko otomatis.", + greeting, "", "Perintah:", "/catalog - Lihat daftar produk", "/buy [qty] - Beli produk", + "/whoami - Cek role kamu", + ...(role === "owner" || role === "admin" ? ["/admin - Daftar admin commands"] : []), "", "Setelah bayar QRIS, produk dikirim otomatis di chat ini.", ].join("\n"), - ), - ); + { parse_mode: "Markdown" }, + ); + }); + + bot.command("whoami", (ctx) => { + const role = telegramRole(ctx.from?.id); + const labels: Record = { + owner: "Owner", + admin: "Admin", + customer: "Customer", + }; + return ctx.reply( + [ + `*Role:* ${labels[role]}`, + `*User ID:* \`${ctx.from?.id}\``, + `*Username:* @${ctx.from?.username ?? "(tidak ada)"}`, + ...(role === "owner" + ? ["", "Kamu primary admin. Otomatis dapat notifikasi event penting."] + : role === "admin" + ? ["", "Kamu admin. Pakai /admin untuk lihat command admin."] + : []), + ].join("\n"), + { parse_mode: "Markdown" }, + ); + }); bot.command("catalog", async (ctx) => { const products = await listProducts(); @@ -96,16 +131,30 @@ export async function startTelegramBot(): Promise { } }); - // Admin commands (otomatis di-skip kalau ADMIN_TELEGRAM_IDS kosong) + // Admin commands (otomatis di-skip kalau ADMIN_TELEGRAM_IDS & OWNER_TELEGRAM_ID kosong) registerTelegramAdminCommands(bot); bot.catch((err) => logger.error({ err }, "Telegram bot error")); registry.telegram = bot; - // Long polling start (non-blocking) + // Long polling start (non-blocking) + greet owner saat bot online bot.start({ - onStart: (info) => logger.info({ username: info.username }, "Telegram bot started"), + onStart: (info) => { + logger.info({ username: info.username }, "Telegram bot started"); + // Best-effort owner greeting (skip silently kalau OWNER_TELEGRAM_ID tidak set) + if (config.OWNER_TELEGRAM_ID) { + void notifyOwner( + [ + `*Bot Online*`, + ``, + `Telegram bot @${info.username} siap menerima order.`, + ``, + `Cek /admin untuk daftar admin commands, atau /whoami untuk cek status.`, + ].join("\n"), + ); + } + }, }); } @@ -118,3 +167,6 @@ function escapeMd(s: string): string { // basic Markdown V1 escape supaya nama produk yg punya _ atau * tidak rusak return s.replace(/([_*`\[\]])/g, "\\$1"); } + +// Marker import biar isOwnerTelegram di-mark dipakai (untuk future use) +void isOwnerTelegram; diff --git a/src/config.ts b/src/config.ts index 35cea45..84fc0e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,12 @@ const schema = z.object({ DISCORD_CLIENT_ID: z.string().optional(), DISCORD_GUILD_ID: z.string().optional(), - // Admin (CSV list of user IDs yg boleh pakai /admin di bot) + // 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(""), @@ -34,7 +39,7 @@ const schema = z.object({ export const config = schema.parse(process.env); -export function parseIdList(csv: string): Set { +function parseIdList(csv: string): Set { return new Set( csv .split(",") @@ -43,8 +48,18 @@ export function parseIdList(csv: string): Set { ); } -export const adminTelegramIds = parseIdList(config.ADMIN_TELEGRAM_IDS); -export const adminDiscordIds = parseIdList(config.ADMIN_DISCORD_IDS); +// 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); @@ -53,3 +68,19 @@ export function isKlikqrisConfigured(): boolean { 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/index.ts b/src/index.ts index 98f0eaf..920a10d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,27 @@ import Fastify from "fastify"; import cookie from "@fastify/cookie"; import formbody from "@fastify/formbody"; -import { config, isDashboardConfigured, isKlikqrisConfigured } from "./config.js"; +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 { prisma } from "./db.js"; async function main() { - printStartupWarnings(); - const app = Fastify({ // Disable Fastify HTTP logging — kita pakai pino logger langsung di route handlers. logger: false, }); - // Plugin: parse application/x-www-form-urlencoded (untuk admin dashboard forms) + // Plugin: parse application/x-www-form-urlencoded (untuk admin dashboard + setup forms) await app.register(formbody); // Plugin: signed cookies untuk session admin dashboard @@ -25,6 +29,9 @@ async function main() { secret: config.ADMIN_SESSION_SECRET, }); + // Setup wizard (auto-disabled di production yg sudah configured) + registerSetupRoutes(app); + registerWebhookRoutes(app); if (isDashboardConfigured()) { @@ -37,6 +44,8 @@ async function main() { await app.listen({ host: "0.0.0.0", port: config.PORT }); logger.info({ port: config.PORT }, "HTTP server listening"); + printStartupBanner(); + // Start kedua bot paralel; masing-masing kerja sendiri. await Promise.all([startTelegramBot(), startDiscordBot()]); @@ -55,23 +64,50 @@ async function main() { process.on("SIGTERM", () => void shutdown("SIGTERM")); } -function printStartupWarnings() { - if (!isKlikqrisConfigured()) { - logger.warn( - "KlikQRIS belum dikonfigurasi (KLIKQRIS_API_KEY / KLIKQRIS_MERCHANT_ID kosong). " + - "Bot tetap jalan, tapi /buy akan gagal sampai diisi.", - ); - } - 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", +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 b18ba06..5cdc15e 100644 --- a/src/orders/delivery.ts +++ b/src/orders/delivery.ts @@ -1,6 +1,7 @@ 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. @@ -11,6 +12,7 @@ import { registry } from "../bots/registry.js"; * 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({ @@ -46,9 +48,20 @@ export async function deliverOrder(orderId: string): Promise { }, }); 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); - await prisma.order.update({ + const updated = await prisma.order.update({ where: { id: orderId }, data: { deliveryAttempts: { increment: 1 }, @@ -56,6 +69,20 @@ export async function deliverOrder(orderId: string): Promise { }, }); 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; } } 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..d51878a --- /dev/null +++ b/src/setup/page.ts @@ -0,0 +1,190 @@ +import { escapeHtml } from "../admin/dashboard/layout.js"; + +interface SetupFormData { + current: Map; + flash?: { kind: "success" | "error" | "info"; message: string } | null; + saved?: boolean; + backupPath?: string | null; +} + +export function renderSetupPage(data: SetupFormData): string { + const v = (key: string) => escapeHtml(data.current.get(key) ?? ""); + + const flashHtml = data.flash + ? `
${escapeHtml(data.flash.message)}
` + : ""; + + const savedBanner = data.saved + ? `
+

Konfigurasi tersimpan

+

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

+

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

+ Lanjut ke Admin Login → +
` + : ""; + + return ` + + + + + Setup Wizard · botnot + + + + +
+
+ botnot + / + setup wizard +
+
+ +
+ ${savedBanner} + ${flashHtml} + + ${ + !data.saved + ? `
+

Selamat datang!

+

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

+
` + : "" + } + +
+ + ${section( + "Server", + ` + ${field("PORT", "Port HTTP", v("PORT") || "3000", "number", "3000")} + ${field("PUBLIC_BASE_URL", "Public Base URL (untuk webhook KlikQRIS)", v("PUBLIC_BASE_URL"), "url", "https://abc123.ngrok.io")} + `, + )} + + ${section( + "KlikQRIS", + ` +

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

+ ${field("KLIKQRIS_API_KEY", "API Key (x-api-key)", v("KLIKQRIS_API_KEY"), "password", "Y4DSZg...")} + ${field("KLIKQRIS_MERCHANT_ID", "Merchant ID", v("KLIKQRIS_MERCHANT_ID"), "text", "177301847754")} + `, + )} + + ${section( + "Telegram Bot", + ` +

Dapatkan token dari @BotFather dengan command /newbot.

+ ${field("TELEGRAM_BOT_TOKEN", "Bot Token", v("TELEGRAM_BOT_TOKEN"), "password", "123456:ABC-DEF...")} + `, + )} + + ${section( + "Discord Bot", + ` +

Buat di Discord Developer Portal.

+ ${field("DISCORD_BOT_TOKEN", "Bot Token", v("DISCORD_BOT_TOKEN"), "password", "MTI...AAA")} + ${field("DISCORD_CLIENT_ID", "Application ID (Client ID)", v("DISCORD_CLIENT_ID"), "text", "1234567890")} + ${field("DISCORD_GUILD_ID", "Guild ID (opsional, dev only)", v("DISCORD_GUILD_ID"), "text", "9876543210")} + `, + )} + + ${section( + "Owner", + ` +

Owner = primary admin. Dapat notifikasi otomatis (bot online, delivery gagal). Otomatis termasuk admin, gak perlu didouble di field admin di bawah.
+ Telegram: chat @userinfobot untuk dapet ID.
+ Discord: Settings → Advanced → Developer Mode ON, klik kanan profil → Copy User ID.

+ ${field("OWNER_TELEGRAM_ID", "Owner Telegram User ID", v("OWNER_TELEGRAM_ID"), "text", "123456789")} + ${field("OWNER_DISCORD_ID", "Owner Discord User ID", v("OWNER_DISCORD_ID"), "text", "111122223333")} + `, + )} + + ${section( + "Admin tambahan", + ` +

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

+ ${field("ADMIN_TELEGRAM_IDS", "Telegram Admin IDs (CSV)", v("ADMIN_TELEGRAM_IDS"), "text", "123,456")} + ${field("ADMIN_DISCORD_IDS", "Discord Admin IDs (CSV)", v("ADMIN_DISCORD_IDS"), "text", "111,222")} + `, + )} + + ${section( + "Web Dashboard", + ` +

Wajib untuk akses /admin. Pakai password yang kuat.

+ ${field("ADMIN_USERNAME", "Username", v("ADMIN_USERNAME"), "text", "admin", true)} + ${field("ADMIN_PASSWORD", "Password", v("ADMIN_PASSWORD"), "password", "min 12 karakter", true)} +
+ +
+ + +
+

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

+
+ `, + )} + + ${section( + "Database", + ` + ${field("DATABASE_URL", "DATABASE_URL", v("DATABASE_URL") || "file:./dev.db", "text", "file:./dev.db")} +

Default SQLite di prisma/dev.db. Untuk Postgres: postgresql://...

+ `, + )} + +
+ Skip + +
+
+
+ + + +`; +} + +function section(title: string, content: string): string { + return `
+ ${escapeHtml(title)} + ${content} +
`; +} + +function field( + name: string, + label: string, + value: string, + type: string = "text", + placeholder = "", + required = false, +): string { + return `
+ + +
`; +} 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); +} From a38b66f303db971fc29daa6ac44e73cd68d6a7a2 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 21 May 2026 19:38:26 +0000 Subject: [PATCH 03/11] fix(setup): wizard auto-runs prisma db push, simpler quickstart Mengikuti README lama (db:push lalu setup) gagal di first-run karena prisma butuh DATABASE_URL di .env, padahal .env baru dibuat oleh wizard. Fix: scripts/setup.mjs sekarang satu command yang: 1. Bikin .env dari .env.example kalau belum ada 2. Jalanin prisma db push (idempoten) 3. Start dev process 4. Auto-buka browser ke /setup Quickstart jadi: npm install && npm run setup (cuma 2 command). Output juga lebih informatif dengan check-mark per langkah. README sudah diupdate. --- README.md | 27 ++++++++------- scripts/setup.mjs | 86 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 72 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 643ca14..db7ccb7 100644 --- a/README.md +++ b/README.md @@ -121,21 +121,21 @@ > Butuh **Node.js 20+** dan **npm** (atau pnpm/yarn). -**Cara paling cepat (recommended):** +**Cara paling cepat (recommended) — cuma 3 command:** ```bash git clone https://github.com/mocasus/botnot.git cd botnot npm install -npm run db:push -npm run setup # buka browser otomatis ke form config +npm run setup # bikin .env, migrate DB, buka browser otomatis ke form config ``` -`npm run setup` akan: +`npm run setup` ngerjain ini semua otomatis: 1. Bikin `.env` dari `.env.example` (kalau belum ada) -2. Start dev server -3. **Auto-buka browser** ke `http://localhost:3000/setup` — form web untuk isi semua kredensial (KlikQRIS, bot tokens, owner ID, admin login) -4. Setelah submit, restart server dengan Ctrl+C → `npm run dev` +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 (KlikQRIS, bot tokens, owner ID, admin login) +5. Setelah submit form, restart server dengan Ctrl+C → `npm run dev` **Cara manual (kalau mau edit `.env` langsung):** @@ -287,12 +287,13 @@ npm run setup Yang terjadi: 1. Auto-create `.env` dari `.env.example` kalau belum ada -2. Start server -3. Auto-buka browser ke `http://localhost:3000/setup` (cross-platform: macOS/Linux/Windows) -4. Form punya section: Server, KlikQRIS, Telegram, Discord, Owner, Admin tambahan, Web Dashboard, Database -5. Kalau lupa generate session secret, ada tombol **Generate** yang bikin string random -6. Submit → `.env` lama di-backup (`.env.bak.`) → file baru ditulis dengan format rapi -7. Pesan konfirmasi muncul, instruksi restart (Ctrl+C → `npm run dev`) +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:** diff --git a/scripts/setup.mjs b/scripts/setup.mjs index d36ad8e..bd2c079 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -1,62 +1,92 @@ #!/usr/bin/env node /** - * Setup launcher: start dev server + auto-open browser ke /setup. + * 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 } from "node:child_process"; +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 +// === 1. Pastikan .env ada === if (!existsSync(envPath)) { - if (existsSync(examplePath)) { - copyFileSync(examplePath, envPath); - console.log("Created .env from .env.example"); - } else { - console.error("ERROR: .env.example not found in", cwd); + 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. Tentukan PORT dari .env (default 3000) +// === 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("\nStarting botnot dev server, then opening", setupUrl, "...\n"); +console.log(`Starting dev server, then opening:\n → ${setupUrl}\n`); -// 3. Start server +// === 4. Start server === const server = spawn("npx", ["tsx", "src/index.ts"], { stdio: "inherit", - shell: process.platform === "win32", + shell: isWindows, }); -// 4. Open browser setelah server siap (1.5 detik delay) +// === 5. Buka browser setelah server siap (2 detik delay) === setTimeout(() => { - const opener = - process.platform === "darwin" + const opener = isWindows + ? ["cmd", ["/c", "start", "", setupUrl]] + : process.platform === "darwin" ? ["open", [setupUrl]] - : process.platform === "win32" - ? ["cmd", ["/c", "start", "", setupUrl]] - : ["xdg-open", [setupUrl]]; - - spawn(opener[0], opener[1], { stdio: "ignore", detached: true }) - .on("error", () => { - console.log(`\n Buka manual: ${setupUrl}\n`); - }) - .unref(); -}, 1500); - -// 5. Forward signals + : ["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.killed) server.kill(sig); + if (server && !server.killed) server.kill(sig); }; process.on("SIGINT", () => forward("SIGINT")); process.on("SIGTERM", () => forward("SIGTERM")); From 9b40b082b834bb2fd9ecc06669b0f95808a3fe02 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 21 May 2026 19:47:13 +0000 Subject: [PATCH 04/11] feat(dashboard): polish UI with icons, gradients, and better visual hierarchy Visual improvements across the admin dashboard: Icon library - Inline SVG icons (Lucide-style) defined as map in layout.ts - 22 icons covering common actions (dashboard, package, bag, clock, check, x, alert, refresh, plus, archive, etc) - Helper: icon("name", "w-4 h-4") Layout & nav - Top nav with bot icon logo on gradient pill - Active nav item with gradient background + ring - Each nav item has its own icon - Subtle dot-grid background pattern (radial gradient) - Page header pattern: title with gradient text + subtitle + breadcrumb - Logout button with red hover state - Setup link in nav for quick re-config Login page - Centered logo + brand badge above form - Glassmorphism card (backdrop-blur) - Radial gradient ambient lighting in corners - Better focus states (ring-2) - Link to /setup at bottom for first-time users Home (dashboard) - Primary stat cards with icon + glow halo + gradient accent - Mini stat cards with colored dot indicators (orders by status) - Status badges now have colored dots + semantic colors - Platform indicator: "TG" / "DC" colored mini-badges - Empty state component with icon + helpful subtitle Products page - Form section with icon-prefixed heading + helper texts - Stock progress bar (color-coded: red/amber/emerald based on %) - Action buttons with icons (kelola stok, enable/disable) - Active/inactive badges with colored dots - Type badge as subtle mono pill Stock page - Big summary card at top: ID + name + price + 3-column metrics - Stock progress bar with gradient fill - 2-column layout (form + table) using lg:grid-cols-5 - Payload displayed as bordered code block - Status badges with dots - Breadcrumb back to products Orders page - Filter pills with icons (clock for pending, check for paid, x for expired) - Active filter has gradient ring - Platform badges as colored mini-pills (TG cyan, DC violet) - Delivery cell improved: success with checkmark, redeliver with refresh icon - Order count chip in header Misc - Browser tab favicon (inline SVG bot icon) - Inter font fallback - Custom scrollbar styling - Smooth transitions on hover/active states All pages typecheck clean (tsc --noEmit). Runtime smoke-tested: all 5 dashboard pages return HTTP 200 with expected content markers. HTML sizes increased ~3x reflecting richer visual content. --- src/admin/dashboard/layout.ts | 233 +++++++++++++++++++++----- src/admin/dashboard/pages/home.ts | 148 +++++++++++----- src/admin/dashboard/pages/orders.ts | 124 +++++++++----- src/admin/dashboard/pages/products.ts | 148 ++++++++++------ src/admin/dashboard/pages/stock.ts | 156 +++++++++++------ 5 files changed, 588 insertions(+), 221 deletions(-) diff --git a/src/admin/dashboard/layout.ts b/src/admin/dashboard/layout.ts index 221f48d..1408d83 100644 --- a/src/admin/dashboard/layout.ts +++ b/src/admin/dashboard/layout.ts @@ -3,6 +3,9 @@ interface LayoutOptions { body: string; active?: "home" | "products" | "stock" | "orders"; 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 { @@ -15,23 +18,99 @@ export function escapeHtml(s: string | number | null | undefined): string { .replace(/'/g, "'"); } -export function layout({ title, body, active, flash }: LayoutOptions): string { - const navItem = (id: NonNullable, href: string, label: string) => ` - ${label} - `; +/** + * Mini icon library — inline SVG strings (Lucide-style). + * Pakai: icon("name", "w-5 h-5") + */ +const ICONS: Record = { + dashboard: ``, + package: ``, + bag: ``, + trending_up: ``, + calendar: ``, + archive: ``, + clock: ``, + check: ``, + x: ``, + alert: ``, + refresh: ``, + plus: ``, + arrow_left: ``, + external: ``, + logout: ``, + bot: ``, + zap: ``, + user: ``, + database: ``, +}; + +export function icon(name: string, cls = "w-4 h-4"): string { + const path = ICONS[name]; + if (!path) return ""; + return ``; +} + +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-4 h-4")} + ${label} + `; + }; const flashHtml = flash - ? `
${escapeHtml(flash.message)}
` + ? "border-red-500/40 bg-red-500/10 text-red-200" + : "border-cyan-500/40 bg-cyan-500/10 text-cyan-200" + }"> + ${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 ` @@ -39,31 +118,59 @@ export function layout({ title, body, active, flash }: LayoutOptions): string { - ${escapeHtml(title)} · botnot admin + ${escapeHtml(title)} · botnot + -
-
- botnot - / - admin +
+
+ + + ${icon("bot", "w-5 h-5")} + + botnot + -
- -
+
+ +
+ +
+
-
+
+ ${pageHeader} ${flashHtml} ${body}
@@ -79,25 +186,61 @@ export function loginLayout(opts: { error?: string }): string { Login · botnot admin - - - -
-

botnot admin

-

Masuk untuk mengelola toko kamu.

- ${ - opts.error - ? `
${escapeHtml(opts.error)}
` - : "" + + + + +
+
+ + ${icon("bot", "w-7 h-7")} + +
+
botnot
+
admin dashboard
+
+
+ + +

Selamat datang kembali

+

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/pages/home.ts b/src/admin/dashboard/pages/home.ts index 4a42430..2f4b094 100644 --- a/src/admin/dashboard/pages/home.ts +++ b/src/admin/dashboard/pages/home.ts @@ -1,6 +1,6 @@ import type { AdminStats } from "../../service.js"; import { formatIDR } from "../../service.js"; -import { escapeHtml, layout } from "../layout.js"; +import { escapeHtml, icon, layout } from "../layout.js"; interface RecentOrder { id: string; @@ -15,39 +15,59 @@ interface RecentOrder { export function renderHome(opts: { stats: AdminStats; recent: RecentOrder[] }): string { const { stats, recent } = opts; - const statCard = (label: string, value: string, color: string) => ` -
-
${label}
-
${value}
+ const primaryStat = ( + label: string, + value: string, + iconName: string, + accent: { from: string; to: string; text: string }, + ) => ` +
+
+
+ ${label} + ${icon(iconName, "w-4 h-4")} +
+
${value}
`; - const body = ` -

Dashboard

+ const miniStat = (label: string, value: string, dotColor: string, valueColor: string) => ` +
+ +
+
${label}
+
${value}
+
+
`; -
- ${statCard("Revenue Hari Ini", formatIDR(stats.revenueToday), "text-emerald-400")} - ${statCard("Revenue 7 Hari", formatIDR(stats.revenue7d), "text-cyan-400")} - ${statCard("Total Revenue", formatIDR(stats.revenueAllTime), "text-violet-400")} - ${statCard("Stok Tersedia", String(stats.stockAvailable), "text-amber-400")} + const body = ` +
+ ${primaryStat("Hari Ini", formatIDR(stats.revenueToday), "trending_up", { from: "from-emerald-400", to: "to-emerald-600", text: "text-emerald-400" })} + ${primaryStat("7 Hari", formatIDR(stats.revenue7d), "calendar", { from: "from-cyan-400", to: "to-cyan-600", text: "text-cyan-400" })} + ${primaryStat("Total Revenue", formatIDR(stats.revenueAllTime), "bag", { from: "from-violet-400", to: "to-violet-600", text: "text-violet-400" })} + ${primaryStat("Stok Tersedia", String(stats.stockAvailable), "archive", { from: "from-amber-400", to: "to-amber-600", text: "text-amber-400" })}
-
- ${statCard("Total Order", String(stats.totalOrders), "text-slate-100")} - ${statCard("Pending", String(stats.pending), "text-amber-300")} - ${statCard("Paid", String(stats.paid), "text-emerald-300")} - ${statCard("Expired", String(stats.expired), "text-red-300")} +
+ ${miniStat("Total Order", String(stats.totalOrders), "bg-slate-400", "text-slate-100")} + ${miniStat("Pending", String(stats.pending), "bg-amber-400 ring-2 ring-amber-400/20", "text-amber-200")} + ${miniStat("Paid", String(stats.paid), "bg-emerald-400 ring-2 ring-emerald-400/20", "text-emerald-200")} + ${miniStat("Expired", String(stats.expired), "bg-red-400 ring-2 ring-red-400/20", "text-red-200")}
-
-

Order Terbaru

- Lihat semua → +
+
+ ${icon("clock", "w-4 h-4")} +

Order Terbaru

+ ${recent.length > 0 ? `${recent.length}` : ""} +
+ Lihat semua ${icon("external", "w-3 h-3")}
${ recent.length === 0 - ? `
Belum ada order.
` - : ` - + ? renderEmptyState("Belum ada order", "Order pertama akan muncul di sini setelah pembeli scan QRIS.", "bag") + : `
+ @@ -60,34 +80,86 @@ export function renderHome(opts: { stats: AdminStats; recent: RecentOrder[] }): ${recent.map((o) => orderRow(o)).join("")} -
Order ID Produk
` +
` }
`; - return layout({ title: "Dashboard", body, active: "home" }); + 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 platformIcon = o.platform === "TELEGRAM" + ? `TG` + : `DC`; return ` - - ${escapeHtml(o.id)} - ${escapeHtml(o.product.name)} - ${escapeHtml(formatIDR(o.totalAmount))} + + ${escapeHtml(o.id)} + ${escapeHtml(o.product.name)} + ${escapeHtml(formatIDR(o.totalAmount))} ${statusBadge} - ${escapeHtml(o.platform)} - ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} + + + ${platformIcon} + ${escapeHtml(o.platform)} + + + ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} `; } export function renderStatusBadge(status: string): string { - const map: Record = { - PENDING: "bg-amber-500/10 text-amber-300 border-amber-500/30", - PAID: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30", - EXPIRED: "bg-red-500/10 text-red-300 border-red-500/30", - FAILED: "bg-red-500/10 text-red-300 border-red-500/30", + const map: Record = { + PENDING: { + dot: "bg-amber-400", + cls: "bg-amber-500/10 text-amber-200 border-amber-500/30", + label: "Pending", + }, + PAID: { + dot: "bg-emerald-400", + cls: "bg-emerald-500/10 text-emerald-200 border-emerald-500/30", + label: "Paid", + }, + SUCCESS: { + dot: "bg-emerald-400", + cls: "bg-emerald-500/10 text-emerald-200 border-emerald-500/30", + label: "Success", + }, + EXPIRED: { + dot: "bg-red-400", + cls: "bg-red-500/10 text-red-200 border-red-500/30", + label: "Expired", + }, + FAILED: { + dot: "bg-red-400", + cls: "bg-red-500/10 text-red-200 border-red-500/30", + label: "Failed", + }, }; - const cls = map[status] ?? "bg-slate-700 text-slate-300"; - return `${escapeHtml(status)}`; + const s = map[status] ?? { + dot: "bg-slate-400", + cls: "bg-slate-700 text-slate-300 border-slate-600", + 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 index fc5024b..01b8622 100644 --- a/src/admin/dashboard/pages/orders.ts +++ b/src/admin/dashboard/pages/orders.ts @@ -1,6 +1,6 @@ -import { escapeHtml, layout } from "../layout.js"; +import { escapeHtml, icon, layout } from "../layout.js"; import { formatIDR } from "../../service.js"; -import { renderStatusBadge } from "./home.js"; +import { renderEmptyState, renderStatusBadge } from "./home.js"; interface OrderRow { id: string; @@ -26,43 +26,73 @@ export function renderOrders(opts: { }): string { const { orders, filterStatus, flash } = opts; - const filterButton = (status: string | undefined, label: string) => { + const filterPill = ( + status: string | undefined, + label: string, + iconName: string, + accent: string, + ) => { const active = (filterStatus ?? "") === (status ?? ""); const href = status ? `?status=${status}` : "/admin/orders"; - return `${label}`; + ? `bg-gradient-to-r from-cyan-500/15 to-violet-500/10 text-cyan-200 ring-1 ring-cyan-500/30` + : "text-slate-400 hover:text-slate-100 ring-1 ring-transparent hover:ring-slate-700 hover:bg-slate-800/40" + }"> + ${icon(iconName, "w-3.5 h-3.5")} + ${label} + `; + }; + + const platformBadge = (platform: string) => { + if (platform === "TELEGRAM") { + return ` + TG + Telegram + `; + } + return ` + DC + Discord + `; }; const rows = orders .map((o) => { const deliveryCell = o.delivered - ? `✓ Terkirim` + ? ` + ${icon("check", "w-3.5 h-3.5")} Terkirim + ` : o.status === "PAID" - ? `
- -
${ - o.lastDeliveryError - ? `
⚠ ${o.deliveryAttempts}× gagal
` - : "" - }` - : `-`; + ? `
+
+ +
+ ${ + o.lastDeliveryError + ? `
+ ${icon("alert", "w-3 h-3")} ${o.deliveryAttempts}× gagal +
` + : "" + } +
` + : ``; return ` - - ${escapeHtml(o.id)} + + ${escapeHtml(o.id)} -
${escapeHtml(o.product.name)}
-
x${o.qty}
+
${escapeHtml(o.product.name)}
+
x${o.qty}
- ${escapeHtml(formatIDR(o.totalAmount))} + ${escapeHtml(formatIDR(o.totalAmount))} ${renderStatusBadge(o.status)} -
${escapeHtml(o.platform)}
-
${escapeHtml(o.username ?? o.chatId)}
+
${platformBadge(o.platform)}
+
${escapeHtml(o.username ?? o.chatId)}
${deliveryCell} ${escapeHtml(o.createdAt.toISOString().slice(0, 19).replace("T", " "))} @@ -70,23 +100,32 @@ export function renderOrders(opts: { }) .join(""); - const body = ` -
-

Order

-
- ${filterButton(undefined, "Semua")} - ${filterButton("PENDING", "Pending")} - ${filterButton("PAID", "Paid")} - ${filterButton("EXPIRED", "Expired")} -
-
+ const filterLabel = filterStatus ? ` dengan status ${filterStatus.toLowerCase()}` : ""; + const body = `
+
+
+ ${icon("bag", "w-4 h-4")} +

Daftar Order

+ ${orders.length > 0 ? `${orders.length}` : ""} +
+
+ ${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 - ? `
Tidak ada order ${filterStatus ? `dengan status ${filterStatus}` : ""}.
` - : ` - + ? renderEmptyState( + `Tidak ada order${filterLabel}`, + "Order baru akan muncul di sini setelah pembeli scan QRIS dan bayar.", + "bag", + ) + : `
+ @@ -98,10 +137,17 @@ export function renderOrders(opts: { ${rows} -
Order ID Produk
` +
` }
`; - return layout({ title: "Order", body, active: "orders", flash }); + 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 index 5d8c9c4..e1d7602 100644 --- a/src/admin/dashboard/pages/products.ts +++ b/src/admin/dashboard/pages/products.ts @@ -1,5 +1,6 @@ -import { escapeHtml, layout } from "../layout.js"; +import { escapeHtml, icon, layout } from "../layout.js"; import { formatIDR } from "../../service.js"; +import { renderEmptyState } from "./home.js"; interface ProductRow { id: string; @@ -18,92 +19,130 @@ export function renderProducts(opts: { }): 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-emerald-500"; + return ` +
+
+
+
+
+ ${available} + /${total} +
+
`; + }; + const rows = products .map( (p) => ` - - ${escapeHtml(p.id)} - -
${escapeHtml(p.name)}
- ${p.description ? `
${escapeHtml(p.description)}
` : ""} + + ${escapeHtml(p.id)} + +
${escapeHtml(p.name)}
+ ${p.description ? `
${escapeHtml(p.description)}
` : ""} - ${escapeHtml(formatIDR(p.priceIDR))} - ${escapeHtml(p.type)} - - ${p.available} - / ${p.available + p.sold} + ${escapeHtml(formatIDR(p.priceIDR))} + + ${escapeHtml(p.type)} - + ${stockBar(p.available, p.sold)} + ${ p.active - ? `Active` - : `Inactive` + ? ` + Aktif + ` + : ` + Nonaktif + ` } - - Kelola Stok -
- -
+ +
+ + ${icon("archive", "w-3.5 h-3.5")} Kelola Stok + +
+ +
+
`, ) .join(""); - const body = ` -
-

Produk

-
+ const inputCls = + "w-full bg-slate-950 border border-slate-700 rounded-lg px-3.5 py-2.5 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20 focus:outline-none transition"; + const labelCls = "block text-xs uppercase tracking-wider text-slate-500 mb-1.5 font-medium"; -
-

Tambah Produk Baru

-
+ const body = ` +
+
+ ${icon("plus", "w-4 h-4")} +

Tambah Produk Baru

+
+
- + + class="${inputCls} font-mono text-sm" /> +

Hanya huruf, angka, _, dan -

- + + class="${inputCls}" />
- + + class="${inputCls}" />
- - + + + + +
- + + class="${inputCls}" />
-
- +
+
-
-

Daftar Produk (${products.length})

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

Daftar Produk

+ ${products.length}
${ products.length === 0 - ? `
Belum ada produk.
` - : ` - + ? renderEmptyState("Belum ada produk", "Tambah produk pertama lewat form di atas.", "package") + : `
+ @@ -115,10 +154,17 @@ export function renderProducts(opts: { ${rows} -
ID Nama
` +
` }
`; - return layout({ title: "Produk", body, active: "products", flash }); + 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 index 7585f4a..da17dae 100644 --- a/src/admin/dashboard/pages/stock.ts +++ b/src/admin/dashboard/pages/stock.ts @@ -1,5 +1,6 @@ -import { escapeHtml, layout } from "../layout.js"; +import { escapeHtml, icon, layout } from "../layout.js"; import { formatIDR } from "../../service.js"; +import { renderEmptyState } from "./home.js"; interface StockItem { id: number; @@ -24,71 +25,130 @@ export function renderStock(opts: { 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.id} + + ${escapeHtml(s.payload)} + ${ s.used - ? `Used` - : `Available` + ? ` + Used + ` + : ` + Available + ` } - ${escapeHtml(s.orderId ?? "")} - ${escapeHtml(s.createdAt.toISOString().slice(0, 19).replace("T", " "))} + ${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 = ` -
- ← Kembali ke produk -

Stok: ${escapeHtml(product.name)}

-

- ${escapeHtml(product.id)} · - ${escapeHtml(formatIDR(product.priceIDR))} · - ${available} available, - ${sold} sold -

-
+ ${summaryCard} -
-

Tambah Stok (Bulk)

-

Satu item per baris. Cocok untuk paste daftar akun/license/voucher sekaligus.

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

Tambah Stok

+
+
+

Satu item per baris. Cocok untuk paste daftar akun/license/voucher sekaligus.

+ + +
+
-
-
-

Daftar Stok (${stocks.length} terakhir)

+
+
+ ${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
` + }
- ${ - stocks.length === 0 - ? `
Belum ada stok.
` - : ` - - - - - - - - - - ${rows} -
#PayloadStatusOrderDibuat
` - }
`; - return layout({ title: `Stok ${product.id}`, body, active: "products", flash }); + return layout({ + title: `Stok ${product.id}`, + body, + active: "products", + flash, + pageTitle: "Kelola Stok", + breadcrumb: [ + { label: "Produk", href: "/admin/products" }, + { label: product.name }, + ], + }); } From 0e22f69fee0a9fcfa086daea900e487cd1402952 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 21 May 2026 20:05:00 +0000 Subject: [PATCH 05/11] feat: inline buttons, analytics page, auto-expire cron, rate limiting, font polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major UX upgrade dan 3 roadmap features sekaligus. Bot inline buttons (replaces text-only flow) - Telegram: InlineKeyboard menu → catalog list → product detail → qty buttons → QR + cek status - Discord: SelectMenu dropdown → product detail → buy buttons → QR + status button - Callback query routing dengan custom IDs (menu/prod/buy/status) - /start sets bot commands menu via setMyCommands - /buy text command dipertahankan untuk power user Three roadmap features - Cron auto-expire (src/cron/expire-orders.ts): jalan tiap 60 detik, mark PENDING orders yang lewat expiredAt jadi EXPIRED. Idempoten. - Rate limiting di /admin/login: max 10 attempt per 5 menit per IP via @fastify/rate-limit. Gagal login di-log dengan IP. - Analytics page (/admin/analytics): - Stat cards: Revenue 30d, Orders 30d, AOV, Delivery Failure % - SVG line chart revenue per hari (gradient area + hover dots) - Top 10 produk by revenue dengan ranked progress bars - Distribusi status order (PENDING/PAID/EXPIRED) - Penjualan per platform (Telegram vs Discord) - Pure SVG, gak butuh chart library Dashboard polish - Font: Inter dari Google Fonts (with JetBrains Mono untuk code/mono) - font-feature-settings untuk numeric tabular alignment - Subtle dot-grid background pattern - Refined card hover states (translateY + shadow) - Form focus glow (3px ring) - Refined nav: active item dengan gradient underline (subtler) - Custom selection color (cyan tint) - Footer dengan version - New icons: chart, sparkles - Polished scrollbar Verified runtime: - Login page: 5KB with Inter font + tailwind config - Analytics: 16KB, semua 7 sections render - Rate limit: 10 OK then HTTP 429 (rate-limited) - Cron: started log appears - TypeScript: 0 errors --- README.md | 74 +++-- package.json | 1 + src/admin/analytics.ts | 132 +++++++++ src/admin/dashboard/layout.ts | 240 ++++++++------- src/admin/dashboard/pages/analytics.ts | 274 +++++++++++++++++ src/admin/dashboard/routes.ts | 27 +- src/bots/discord.ts | 394 ++++++++++++++++++++----- src/bots/telegram.ts | 383 ++++++++++++++++-------- src/cron/expire-orders.ts | 44 +++ src/index.ts | 13 + 10 files changed, 1255 insertions(+), 327 deletions(-) create mode 100644 src/admin/analytics.ts create mode 100644 src/admin/dashboard/pages/analytics.ts create mode 100644 src/cron/expire-orders.ts diff --git a/README.md b/README.md index db7ccb7..951ffac 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - [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) @@ -47,14 +48,17 @@ - **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, dan order dari browser. Login dengan username/password, signed cookie session. +- **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. @@ -404,7 +408,25 @@ ADMIN_SESSION_SECRET=$(openssl rand -hex 32) - **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 tidak di-rate-limit. Untuk production, taruh di belakang reverse proxy (Caddy/Nginx) yang bisa rate-limit `/admin/login`, atau ekspos hanya via VPN/Tailscale. +> **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. --- @@ -520,24 +542,35 @@ 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 (greeting beda untuk owner/admin/customer) | -| `/catalog` | Lihat semua produk + harga + stok | -| `/buy ` | Beli 1 unit | -| `/buy ` | Beli `n` unit | -| `/whoami` | Cek role kamu (owner/admin/customer) + user ID | +| `/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 | -| `/whoami` | Cek role kamu + user ID | +| Slash Command | Deskripsi | +| ---------------------------------------- | ---------------------------------- | +| `/catalog` | Katalog dengan dropdown + buttons | +| `/buy product_id: [qty:]` | Beli langsung (power user) | +| `/whoami` | Cek role kamu + user ID | ### Tambah produk @@ -580,17 +613,20 @@ Atau edit `prisma/seed.ts` lalu jalanin `npm run db:seed`. ## Roadmap -- [x] Admin web dashboard (login, products, stock, orders, redeliver) +- [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 -- [ ] Cron auto-expire order yang stuck `PENDING` +- [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 - [ ] Notifikasi WhatsApp via flag `notifwa` KlikQRIS -- [ ] Dashboard analytics (chart penjualan, top produk) - [ ] Integrasi role Discord otomatis (untuk produk tipe `ROLE`) -- [ ] Rate limiting di `/admin/login` +- [ ] Export data CSV (orders, sales report) +- [ ] Multi-currency support - [ ] Unit & integration tests --- diff --git a/package.json b/package.json index 3e2fbfe..02ca325 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "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/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/dashboard/layout.ts b/src/admin/dashboard/layout.ts index 1408d83..efc24fe 100644 --- a/src/admin/dashboard/layout.ts +++ b/src/admin/dashboard/layout.ts @@ -1,7 +1,7 @@ interface LayoutOptions { title: string; body: string; - active?: "home" | "products" | "stock" | "orders"; + active?: "home" | "products" | "stock" | "orders" | "analytics"; flash?: { kind: "success" | "error" | "info"; message: string } | null; pageTitle?: string; pageSubtitle?: string; @@ -20,10 +20,9 @@ export function escapeHtml(s: string | number | null | undefined): string { /** * Mini icon library — inline SVG strings (Lucide-style). - * Pakai: icon("name", "w-5 h-5") */ const ICONS: Record = { - dashboard: ``, + dashboard: ``, package: ``, bag: ``, trending_up: ``, @@ -42,6 +41,8 @@ const ICONS: Record = { zap: ``, user: ``, database: ``, + chart: ``, + sparkles: ``, }; export function icon(name: string, cls = "w-4 h-4"): string { @@ -50,6 +51,59 @@ export function icon(name: string, cls = "w-4 h-4"): string { 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 dengan font, Tailwind, theme variables. */ +function commonHead(title: string): string { + return ` + + + ${escapeHtml(title)} · botnot + + + + + + + + +`; +} + export function layout({ title, body, @@ -67,13 +121,14 @@ export function layout({ ) => { const isActive = active === id; return ` - + ${isActive ? `` : ""} ${icon(iconName, "w-4 h-4")} - ${label} + ${label} `; }; @@ -90,90 +145,76 @@ export function layout({
` : ""; - const breadcrumbHtml = breadcrumb && breadcrumb.length > 0 - ? `
+ +
+ ${pageHeader} + ${flashHtml} + ${body} +
+ +
+
+ botnot · auto-order Telegram & Discord + v0.1.0 +
+
+ `; } @@ -181,60 +222,43 @@ export function layout({ export function loginLayout(opts: { error?: string }): string { return ` - - - - Login · botnot admin - - - - - +${commonHead("Login")} + +
+
+
+
- + ${icon("bot", "w-7 h-7")}
-
botnot
+
botnot
admin dashboard
-
-

Selamat datang kembali

+ +

Selamat datang kembali

Masuk untuk mengelola toko kamu.

${ opts.error ? `
${icon("alert", "w-4 h-4 shrink-0")}${escapeHtml(opts.error)}
` : "" } - + - + class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3.5 py-2.5 mb-4 focus:border-cyan-500 focus:outline-none transition" /> + - + class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3.5 py-2.5 mb-6 focus:border-cyan-500 focus:outline-none transition" /> +

diff --git a/src/admin/dashboard/pages/analytics.ts b/src/admin/dashboard/pages/analytics.ts new file mode 100644 index 0000000..49ce0a5 --- /dev/null +++ b/src/admin/dashboard/pages/analytics.ts @@ -0,0 +1,274 @@ +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-300")} + ${statCard("Order 30 Hari", String(totals.orders30d), "bag", "from-cyan-400 to-cyan-600", "text-cyan-300")} + ${statCard("Avg Order Value", formatIDR(totals.avgOrderValue), "calendar", "from-violet-400 to-violet-600", "text-violet-300")} + ${statCard("Delivery Failure", `${failurePct}%`, "alert", "from-amber-400 to-amber-600", totals.deliveryFailureRate > 0.05 ? "text-red-300" : "text-amber-300")} +
`; + + 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} +
+ ${ + 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 breakdownCard = ` +
+
+
+ ${icon("clock", "w-4 h-4")} +

Distribusi Status

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

Penjualan per Platform

+
+
${renderPlatformBreakdown(byPlatform)}
+
+
`; + + 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): 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 = 240; + const padX = 40; + const padTop = 20; + const padBot = 30; + 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`; + + // Y-axis labels (4 ticks: 0, 25%, 50%, 75%, 100%) + const yTicks = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ + y: padTop + innerH - p * innerH, + label: formatCompact(max * p), + })); + + // X-axis labels (every 5th day) + 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/15 border-amber-500/30" + : rank === 2 + ? "text-slate-200 bg-slate-500/15 border-slate-500/30" + : rank === 3 + ? "text-orange-300 bg-orange-500/15 border-orange-500/30" + : "text-slate-400 bg-slate-700/40 border-slate-600"; + 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/15" : "bg-violet-500/15"; + 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/routes.ts b/src/admin/dashboard/routes.ts index cbb3693..2fc53bf 100644 --- a/src/admin/dashboard/routes.ts +++ b/src/admin/dashboard/routes.ts @@ -10,6 +10,7 @@ import { redeliverOrder, toggleProductActive, } from "../service.js"; +import { getAnalytics } from "../analytics.js"; import { prisma } from "../../db.js"; import { COOKIE_MAX_AGE_SEC, @@ -22,6 +23,7 @@ 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 }; @@ -36,7 +38,10 @@ function setFlash(reply: import("fastify").FastifyReply, flash: Flash) { }); } -function popFlash(req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply): Flash | null { +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" }); @@ -55,9 +60,17 @@ export function registerAdminDashboardRoutes(app: FastifyInstance): void { 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") @@ -86,6 +99,13 @@ export function registerAdminDashboardRoutes(app: FastifyInstance): void { 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; @@ -174,7 +194,10 @@ export function registerAdminDashboardRoutes(app: FastifyInstance): void { if (!(await requireAuth(req, reply))) return; try { await redeliverOrder(req.params.id); - setFlash(reply, { kind: "success", message: `Order ${req.params.id} berhasil dikirim ulang` }); + 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"); diff --git a/src/bots/discord.ts b/src/bots/discord.ts index 7df04a2..810f59c 100644 --- a/src/bots/discord.ts +++ b/src/bots/discord.ts @@ -1,16 +1,25 @@ 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"; @@ -22,6 +31,14 @@ import { 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) { logger.warn("DISCORD_BOT_TOKEN/CLIENT_ID tidak diset, skip Discord bot"); @@ -32,15 +49,14 @@ export async function startDiscordBot(): Promise { intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages], }); - // User 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), ) @@ -55,8 +71,6 @@ export async function startDiscordBot(): Promise { ]; const commands = [...userCommands, ...adminCommandDefinitions]; - - // Register commands (guild = instan, global = bisa sampai 1 jam propagasi) 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) @@ -68,28 +82,16 @@ export async function startDiscordBot(): Promise { ); client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isChatInputCommand()) return; try { - if (isAdminCommand(interaction.commandName)) { - await handleAdminCommand(interaction); - return; - } - if (interaction.commandName === "catalog") { - await handleCatalog(interaction); - } else if (interaction.commandName === "buy") { - await handleBuy(interaction); - } else if (interaction.commandName === "whoami") { - await handleWhoami(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); } }); @@ -102,7 +104,7 @@ export async function startDiscordBot(): Promise { ``, `Discord bot ${c.user.tag} siap menerima order.`, ``, - `Cek /admin-stats atau /whoami untuk cek status.`, + `Cek \`/catalog\` untuk lihat produk, atau \`/admin-stats\` untuk admin overview.`, ].join("\n"), ); } @@ -112,74 +114,280 @@ export async function startDiscordBot(): Promise { 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 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 akan dikirim otomatis ke DM kamu 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}`); - - await i.editReply({ embeds: [embed], files: [attachment] }); + 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}` }); + } } async function handleWhoami(i: ChatInputCommandInteraction): Promise { const role = discordRole(i.user.id); const labels: Record = { - owner: "Owner", - admin: "Admin", - customer: "Customer", + owner: "👑 Owner", + admin: "🛡️ Admin", + customer: "👤 Customer", }; const colors: Record = { owner: 0xfaa61a, @@ -188,22 +396,54 @@ async function handleWhoami(i: ChatInputCommandInteraction): Promise { }; const extra = role === "owner" - ? "\nKamu primary admin. Otomatis dapat notifikasi event penting." + ? "Kamu primary admin. Otomatis dapat notifikasi event penting." : role === "admin" - ? "\nKamu admin. Pakai `/admin-stats` untuk overview." - : ""; + ? "Kamu admin. Pakai `/admin-stats` untuk overview." + : "Klik `/catalog` untuk lihat produk yang dijual."; + const embed = new EmbedBuilder() .setTitle(`Role: ${labels[role]}`) .setColor(colors[role]) + .setDescription(extra) .addFields( { name: "User ID", value: `\`${i.user.id}\``, inline: true }, { name: "Username", value: i.user.username, inline: true }, - ) - .setDescription(extra || null); + ); 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.ts b/src/bots/telegram.ts index 3697794..947db00 100644 --- a/src/bots/telegram.ts +++ b/src/bots/telegram.ts @@ -1,6 +1,7 @@ -import { Bot, InputFile } from "grammy"; -import { config, isOwnerTelegram } from "../config.js"; +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"; @@ -8,6 +9,14 @@ 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) { logger.warn("TELEGRAM_BOT_TOKEN tidak diset, skip Telegram bot"); @@ -16,157 +25,289 @@ export async function startTelegramBot(): Promise { const bot = new Bot(config.TELEGRAM_BOT_TOKEN); - bot.command("start", (ctx) => { - const role = telegramRole(ctx.from?.id); - const greeting = - role === "owner" - ? `Halo *Owner*! Selamat datang kembali. Bot kamu sudah online.` - : role === "admin" - ? `Halo Admin! Bot toko siap digunakan.` - : `Halo! Saya bot toko otomatis.`; - - return ctx.reply( - [ - greeting, - "", - "Perintah:", - "/catalog - Lihat daftar produk", - "/buy [qty] - Beli produk", - "/whoami - Cek role kamu", - ...(role === "owner" || role === "admin" ? ["/admin - Daftar admin commands"] : []), - "", - "Setelah bayar QRIS, produk dikirim otomatis di chat ini.", - ].join("\n"), - { parse_mode: "Markdown" }, - ); - }); - - bot.command("whoami", (ctx) => { - const role = telegramRole(ctx.from?.id); - const labels: Record = { - owner: "Owner", - admin: "Admin", - customer: "Customer", - }; - return ctx.reply( - [ - `*Role:* ${labels[role]}`, - `*User ID:* \`${ctx.from?.id}\``, - `*Username:* @${ctx.from?.username ?? "(tidak ada)"}`, - ...(role === "owner" - ? ["", "Kamu primary admin. Otomatis dapat notifikasi event penting."] - : role === "admin" - ? ["", "Kamu admin. Pakai /admin untuk lihat command admin."] - : []), - ].join("\n"), - { parse_mode: "Markdown" }, - ); - }); - - 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 (otomatis di-skip kalau ADMIN_TELEGRAM_IDS & OWNER_TELEGRAM_ID kosong) + // Admin commands tetap text-based registerTelegramAdminCommands(bot); bot.catch((err) => logger.error({ err }, "Telegram bot error")); registry.telegram = bot; - // Long polling start (non-blocking) + greet owner saat bot online bot.start({ - onStart: (info) => { + onStart: async (info) => { logger.info({ username: info.username }, "Telegram bot started"); - // Best-effort owner greeting (skip silently kalau OWNER_TELEGRAM_ID tidak set) + // 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.`, - ``, - `Cek /admin untuk daftar admin commands, atau /whoami untuk cek status.`, - ].join("\n"), + [`*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"); } - -// Marker import biar isOwnerTelegram di-mark dipakai (untuk future use) -void isOwnerTelegram; 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 920a10d..ad15864 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import Fastify from "fastify"; import cookie from "@fastify/cookie"; import formbody from "@fastify/formbody"; +import rateLimit from "@fastify/rate-limit"; import { config, isDashboardConfigured, @@ -13,12 +14,14 @@ 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({ // 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) @@ -29,6 +32,12 @@ async function main() { 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); @@ -46,12 +55,16 @@ async function main() { 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()]); const shutdown = async (signal: string) => { logger.info({ signal }, "Shutting down..."); try { + stopExpireOrdersCron(); await app.close(); await prisma.$disconnect(); } catch (err) { From c99cf1b2d119d241cc2d5f01c3d8d73a8040fc98 Mon Sep 17 00:00:00 2001 From: mocasus <137678581+mocasus@users.noreply.github.com> Date: Fri, 22 May 2026 12:24:17 +0700 Subject: [PATCH 06/11] feat(dashboard): refresh UI/UX with modern glass-morphism design (#9) - Update layout with glass-morphism header, mobile responsive nav, ambient glow effects, noise texture, and smooth animations - Upgrade to TailwindCSS v3.4.17 and htmx v2.0.4 - Redesign all dashboard pages with card-glass containers - Improve tables with divide-y pattern and subtle hover states - Add staggered slide-up animations for stat cards - Enhance status badges with ring borders and glowing dots - Improve charts with gradient line (emerald to cyan) - Add mobile hamburger menu for responsive navigation - Update login page with modern centered card design - Use consistent rounded-2xl containers throughout - Add tabular-nums for better numeric alignment Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- src/admin/dashboard/layout.ts | 211 +++++++++++++++++-------- src/admin/dashboard/pages/analytics.ts | 179 ++++++++++----------- src/admin/dashboard/pages/home.ts | 145 +++++++++-------- src/admin/dashboard/pages/orders.ts | 96 ++++++----- src/admin/dashboard/pages/products.ts | 113 +++++++------ src/admin/dashboard/pages/stock.ts | 108 +++++++------ 6 files changed, 492 insertions(+), 360 deletions(-) diff --git a/src/admin/dashboard/layout.ts b/src/admin/dashboard/layout.ts index efc24fe..b750f03 100644 --- a/src/admin/dashboard/layout.ts +++ b/src/admin/dashboard/layout.ts @@ -18,6 +18,7 @@ export function escapeHtml(s: string | number | null | undefined): string { .replace(/'/g, "'"); } + /** * Mini icon library — inline SVG strings (Lucide-style). */ @@ -43,6 +44,11 @@ const ICONS: Record = { database: ``, chart: ``, sparkles: ``, + menu: ``, + search: ``, + bell: ``, + settings: ``, + home: ``, }; export function icon(name: string, cls = "w-4 h-4"): string { @@ -51,10 +57,11 @@ export function icon(name: string, cls = "w-4 h-4"): string { 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 dengan font, Tailwind, theme variables. */ +/** Shared with modern fonts, Tailwind, and enhanced theme. */ function commonHead(title: string): string { return ` @@ -63,8 +70,8 @@ function commonHead(title: string): string { - - + + - + + + + + + + - -
-
- botnot - / - setup wizard + + +
+
+
+
+ + +
+
+ + ${botIcon} + +
+ + botnot + + / + setup wizard +
-
+
${savedBanner} ${flashHtml} ${ !data.saved - ? `
-

Selamat datang!

-

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

+ ? `
+

Selamat datang!

+

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

` : "" } -
- - ${section( - "Server", - ` + + ${section("Server", serverIcon, "0.05s", ` ${field("PORT", "Port HTTP", v("PORT") || "3000", "number", "3000")} ${field("PUBLIC_BASE_URL", "Public Base URL (untuk webhook KlikQRIS)", v("PUBLIC_BASE_URL"), "url", "https://abc123.ngrok.io")} - `, - )} + `)} - ${section( - "KlikQRIS", - ` -

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

+ ${section("KlikQRIS", qrIcon, "0.1s", ` +

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

${field("KLIKQRIS_API_KEY", "API Key (x-api-key)", v("KLIKQRIS_API_KEY"), "password", "Y4DSZg...")} ${field("KLIKQRIS_MERCHANT_ID", "Merchant ID", v("KLIKQRIS_MERCHANT_ID"), "text", "177301847754")} - `, - )} + `)} - ${section( - "Telegram Bot", - ` -

Dapatkan token dari @BotFather dengan command /newbot.

+ ${section("Telegram Bot", telegramIcon, "0.15s", ` +

Dapatkan token dari @BotFather dengan command /newbot.

${field("TELEGRAM_BOT_TOKEN", "Bot Token", v("TELEGRAM_BOT_TOKEN"), "password", "123456:ABC-DEF...")} - `, - )} + `)} - ${section( - "Discord Bot", - ` -

Buat di Discord Developer Portal.

+ ${section("Discord Bot", discordIcon, "0.2s", ` +

Buat di Discord Developer Portal.

${field("DISCORD_BOT_TOKEN", "Bot Token", v("DISCORD_BOT_TOKEN"), "password", "MTI...AAA")} ${field("DISCORD_CLIENT_ID", "Application ID (Client ID)", v("DISCORD_CLIENT_ID"), "text", "1234567890")} ${field("DISCORD_GUILD_ID", "Guild ID (opsional, dev only)", v("DISCORD_GUILD_ID"), "text", "9876543210")} - `, - )} - - ${section( - "Owner", - ` -

Owner = primary admin. Dapat notifikasi otomatis (bot online, delivery gagal). Otomatis termasuk admin, gak perlu didouble di field admin di bawah.
- Telegram: chat @userinfobot untuk dapet ID.
+ `)} + + ${section("Owner", crownIcon, "0.25s", ` +

Owner = primary admin. Dapat notifikasi otomatis (bot online, delivery gagal). Otomatis termasuk admin, gak perlu didouble di field admin di bawah.
+ Telegram: chat @userinfobot untuk dapet ID.
Discord: Settings → Advanced → Developer Mode ON, klik kanan profil → Copy User ID.

${field("OWNER_TELEGRAM_ID", "Owner Telegram User ID", v("OWNER_TELEGRAM_ID"), "text", "123456789")} ${field("OWNER_DISCORD_ID", "Owner Discord User ID", v("OWNER_DISCORD_ID"), "text", "111122223333")} - `, - )} + `)} - ${section( - "Admin tambahan", - ` -

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

+ ${section("Admin tambahan", userIcon, "0.3s", ` +

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

${field("ADMIN_TELEGRAM_IDS", "Telegram Admin IDs (CSV)", v("ADMIN_TELEGRAM_IDS"), "text", "123,456")} ${field("ADMIN_DISCORD_IDS", "Discord Admin IDs (CSV)", v("ADMIN_DISCORD_IDS"), "text", "111,222")} - `, - )} + `)} - ${section( - "Web Dashboard", - ` -

Wajib untuk akses /admin. Pakai password yang kuat.

+ ${section("Web Dashboard", lockIcon, "0.35s", ` +
+ Wajib untuk akses /admin. Pakai password yang kuat (min. 8 karakter). +
${field("ADMIN_USERNAME", "Username", v("ADMIN_USERNAME"), "text", "admin", true)} ${field("ADMIN_PASSWORD", "Password", v("ADMIN_PASSWORD"), "password", "min 12 karakter", true)} -
- +
+
+ class="flex-1 bg-slate-950/60 border border-slate-700/50 rounded-xl px-4 py-2.5 font-mono text-xs focus:border-cyan-500/50 focus:outline-none transition-all placeholder:text-slate-600" /> + class="bg-slate-800/40 hover:bg-slate-700/50 border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm transition-colors whitespace-nowrap">Generate
-

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

+

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

- `, - )} + `)} - ${section( - "Database", - ` + ${section("Database", databaseIcon, "0.4s", ` ${field("DATABASE_URL", "DATABASE_URL", v("DATABASE_URL") || "file:./dev.db", "text", "file:./dev.db")} -

Default SQLite di prisma/dev.db. Untuk Postgres: postgresql://...

- `, - )} +

Default SQLite di prisma/dev.db. Untuk Postgres: postgresql://...

+ `)} -
- Skip - + +
+ Skip +
+ +

+ Setelah simpan, restart server (Ctrl+C → npm run dev) untuk apply config. +

+ - + + -
-
-
+
+
+
-
+
- - ${botIcon} - - + + +
-
+
${savedBanner} ${flashHtml} ${ !data.saved - ? `
-

Selamat datang!

-

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

+ ? `
+
+ ${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.

` : "" }
- ${section("Server", serverIcon, "0.05s", ` - ${field("PORT", "Port HTTP", v("PORT") || "3000", "number", "3000")} - ${field("PUBLIC_BASE_URL", "Public Base URL (untuk webhook KlikQRIS)", v("PUBLIC_BASE_URL"), "url", "https://abc123.ngrok.io")} - `)} - - ${section("KlikQRIS", qrIcon, "0.1s", ` -

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

- ${field("KLIKQRIS_API_KEY", "API Key (x-api-key)", v("KLIKQRIS_API_KEY"), "password", "Y4DSZg...")} - ${field("KLIKQRIS_MERCHANT_ID", "Merchant ID", v("KLIKQRIS_MERCHANT_ID"), "text", "177301847754")} - `)} - - ${section("Telegram Bot", telegramIcon, "0.15s", ` -

Dapatkan token dari @BotFather dengan command /newbot.

- ${field("TELEGRAM_BOT_TOKEN", "Bot Token", v("TELEGRAM_BOT_TOKEN"), "password", "123456:ABC-DEF...")} - `)} - - ${section("Discord Bot", discordIcon, "0.2s", ` -

Buat di Discord Developer Portal.

- ${field("DISCORD_BOT_TOKEN", "Bot Token", v("DISCORD_BOT_TOKEN"), "password", "MTI...AAA")} - ${field("DISCORD_CLIENT_ID", "Application ID (Client ID)", v("DISCORD_CLIENT_ID"), "text", "1234567890")} - ${field("DISCORD_GUILD_ID", "Guild ID (opsional, dev only)", v("DISCORD_GUILD_ID"), "text", "9876543210")} - `)} - - ${section("Owner", crownIcon, "0.25s", ` -

Owner = primary admin. Dapat notifikasi otomatis (bot online, delivery gagal). Otomatis termasuk admin, gak perlu didouble di field admin di bawah.
- Telegram: chat @userinfobot untuk dapet ID.
- Discord: Settings → Advanced → Developer Mode ON, klik kanan profil → Copy User ID.

- ${field("OWNER_TELEGRAM_ID", "Owner Telegram User ID", v("OWNER_TELEGRAM_ID"), "text", "123456789")} - ${field("OWNER_DISCORD_ID", "Owner Discord User ID", v("OWNER_DISCORD_ID"), "text", "111122223333")} - `)} - - ${section("Admin tambahan", userIcon, "0.3s", ` -

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

- ${field("ADMIN_TELEGRAM_IDS", "Telegram Admin IDs (CSV)", v("ADMIN_TELEGRAM_IDS"), "text", "123,456")} - ${field("ADMIN_DISCORD_IDS", "Discord Admin IDs (CSV)", v("ADMIN_DISCORD_IDS"), "text", "111,222")} - `)} - - ${section("Web Dashboard", lockIcon, "0.35s", ` -
- Wajib untuk akses /admin. Pakai password yang kuat (min. 8 karakter). -
- ${field("ADMIN_USERNAME", "Username", v("ADMIN_USERNAME"), "text", "admin", true)} - ${field("ADMIN_PASSWORD", "Password", v("ADMIN_PASSWORD"), "password", "min 12 karakter", true)} -
- -
- - -
-

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

-
- `)} - - ${section("Database", databaseIcon, "0.4s", ` - ${field("DATABASE_URL", "DATABASE_URL", v("DATABASE_URL") || "file:./dev.db", "text", "file:./dev.db")} -

Default SQLite di prisma/dev.db. Untuk Postgres: postgresql://...

- `)} + ${sections} -
- Skip -
-

- Setelah simpan, restart server (Ctrl+C → npm run dev) untuk apply config. -

+ +
+

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

+
`; } -function section(title: string, iconSvg: string, delay: string, content: string): string { - return `
- - ${iconSvg} - ${escapeHtml(title)} - -
${content}
-
`; + +// === Helpers === + +interface SectionOpts { + title: string; + subtitle?: string; + icon: string; + iconColor: string; + iconBg: string; + content: string; + required?: boolean; } -function field( - name: string, - label: string, - value: string, - type: string = "text", - placeholder = "", - required = false, -): string { - const inputCls = type === "password" || name.includes("ID") || name === "DATABASE_URL" - ? "font-mono text-sm" - : ""; +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 === -const svgWrap = (path: string, size = "w-4 h-4") => - ``; -const botIcon = svgWrap( +function svg(path: string, size = "w-4 h-4"): string { + return ``; +} + +const botIcon = svg( ``, "w-5 h-5", ); -const serverIcon = svgWrap(``); -const qrIcon = svgWrap(``); -const telegramIcon = svgWrap(``); -const discordIcon = svgWrap(``); -const crownIcon = svgWrap(``); -const userIcon = svgWrap(``); -const lockIcon = svgWrap(``); -const databaseIcon = svgWrap(``); -const checkIcon = svgWrap(``, "w-5 h-5"); -const arrowRightIcon = svgWrap(``, "w-3.5 h-3.5"); -const saveIcon = svgWrap(``); +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 svgWrap( + return svg( ``, ); - return svgWrap(``); + return svg(``); } From caff5dfdb65a0ac1897d5cf549a87105435cd0fc Mon Sep 17 00:00:00 2001 From: mocasus <137678581+mocasus@users.noreply.github.com> Date: Fri, 22 May 2026 17:12:47 +0700 Subject: [PATCH 09/11] feat(branding): refresh README banner with modern dashboard aesthetic (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banner sekarang match dengan desain dashboard baru: Visual changes: - Background ganti ke #0a0e1a dengan grid pattern subtle (sama seperti dashboard) plus ambient glow cyan + violet di kiri/kanan - Bot icon: rounded square frame dengan brand gradient + Lucide-style bot icon line art (sama seperti header dashboard di /admin) - Wordmark split: 'bot' putih-slate gradient + 'not' cyan-violet gradient (sama dengan header dashboard 'bot' + 'not' split) - Eyebrow tag dengan animated cyan dot — 'Auto-order · QRIS · ID' - Tagline lebih ringkas: 'Telegram + Discord bot — pelanggan scan QR, produk auto-deliver.' - 5 feature pills dengan icon mini Lucide-style: - Telegram (cyan) - Discord (violet) - KlikQRIS (emerald, QR icon) - Auto-deliver (amber, lightning) - Dashboard (pink, bar chart) - Dekorasi sparkles random di sudut-sudut Technical: - Inter font family (sama dengan UI) - Width 1280, tinggi 360 (sebelumnya 320, +40px untuk akomodasi pills) - Border 1px white/6 untuk subtle frame - Halo glow di belakang icon untuk depth Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- assets/logo.svg | 225 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 169 insertions(+), 56 deletions(-) diff --git a/assets/logo.svg b/assets/logo.svg index 34c89b0..b89f74e 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,80 +1,193 @@ - + - - - + + + + - + + + + + + + - - + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + - - - botnot - Auto-order Telegram & Discord bot — powered by KlikQRIS + + + + + + + + + Auto-order · QRIS · ID + + + + + + botnot + + + + + + + Telegram + Discord bot — pelanggan scan QR, produk auto-deliver. + + + + + + + + + + + + + Telegram + - - - - - TypeScript + + + + + + + + + + Discord - - - Prisma + + + + + + + + + + + + + + + KlikQRIS - - - QRIS + + + + + + + + Auto-deliver - - - Fastify + + + + + + + + + + + + Dashboard + + + + + + + + + + + + + + From 19b392aa16c6e4aae8bf611f21f9e0cbd8e179ad Mon Sep 17 00:00:00 2001 From: mocasus <137678581+mocasus@users.noreply.github.com> Date: Mon, 25 May 2026 13:01:28 +0700 Subject: [PATCH 10/11] fix(banner): apply final clean SVG (was lost in merge conflict resolution) (#14) When PR #12 was merged, conflict resolution kept the older problematic version of logo.svg that contains: - aria-label with HTML entity & - Multiple linearGradients structure that differs from the proven original - 1280x360 viewBox (taller, wasted space) - More complex path structure This applies the FINAL clean version that mirrors the original SVG structure (which was proven to render on GitHub): - 1280x320 viewBox (matches original aspect ratio) - Same defs structure as original: bg + grad + dots pattern + glow filter - Mascot bot icon (cute style with eyes + QR mouth) - Wordmark split: 'bot' white-slate + 'not' cyan-violet gradient - Indonesian tagline - 5 feature chips: Telegram, Discord, KlikQRIS, Auto-deliver, Dashboard - aria-label uses plain text (no entities) This should render properly on GitHub README. Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- assets/logo.svg | 211 +++++++++++------------------------------------- 1 file changed, 48 insertions(+), 163 deletions(-) diff --git a/assets/logo.svg b/assets/logo.svg index b89f74e..653e694 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,193 +1,78 @@ - + - - + - + - - - - - - - + - - - - - - - - - - - + + - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - Auto-order · QRIS · ID - - - - - - botnot - - + + + botnot + Telegram & Discord auto-order bot — pelanggan scan QR, produk auto-deliver. - - - - Telegram + Discord bot — pelanggan scan QR, produk auto-deliver. - - - - - - - - - - - - - Telegram + + + + Telegram - - - - - - - - - - - Discord + + + Discord - - - - - - - - - - - - - - - KlikQRIS + + + KlikQRIS - - - - - - - - Auto-deliver + + + Auto-deliver - - - - - - - - - - - - Dashboard + + + Dashboard - - - - - - - - - - - - - - From 8bdd7bc900254d62173a71f4c261409ef63ed7cb Mon Sep 17 00:00:00 2001 From: mocasus <137678581+mocasus@users.noreply.github.com> Date: Mon, 25 May 2026 16:15:26 +0700 Subject: [PATCH 11/11] feat(branding): add GitHub social preview + setup repo guide (#15) Untuk repo yang di-fork, default GitHub tidak generate Open Graph preview yang bagus saat link di-share di Discord / Twitter / WhatsApp. Hasilnya: cuma muncul nama repo polos, tanpa gambar atau deskripsi. Solusi: provide social-preview image (1280x640) yang user tinggal upload ke GitHub repo Settings, plus dokumentasi lengkap cara setup. Files added: - assets/social-preview.svg - source design (Inter font, dark theme, cyan-violet gradient matching dashboard, mascot bot icon, headline 'Pelanggan scan, produk auto-deliver.', 5 feature chips) - assets/social-preview.png - generated 1280x640 PNG (117KB) ready to upload to GitHub Settings -> Social preview README updates: - New section 'Setup Repo GitHub (untuk fork)' between Quick Start and Cara Dapat Kredensial, with 3 steps: 1. Set About section (description + topics + website) 2. Upload social preview image 3. Test preview di Discord / Twitter / Facebook / Telegram - Recommended description text untuk About section - 11 topics untuk discoverability (telegram-bot, discord-bot, qris, klikqris, indonesia, auto-deliver, typescript, prisma, fastify, nodejs, payment-gateway) - Validator URLs untuk test preview di tiap platform - Cache busting tips untuk camo / Twitter / Facebook / Discord - Bonus: cara regenerate PNG dari SVG pakai cairosvg Updated TOC dan struktur project tree Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- README.md | 95 ++++++++++++++++++++++++++++++++++++- assets/social-preview.png | Bin 0 -> 117484 bytes assets/social-preview.svg | 97 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 assets/social-preview.png create mode 100644 assets/social-preview.svg diff --git a/README.md b/README.md index 15e1279..b30349f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [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) @@ -218,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 @@ -539,7 +630,9 @@ Semua command admin pakai prefix `/admin-`. Hanya bisa diakses oleh user yang ad ``` 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 diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..d96ea1da777f39e7e8529eedb466697b27329506 GIT binary patch literal 117484 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%0D;)-KT_Mn4ceRe{i%x2+EHntToap@;x}=7x_x` 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%0D;0mb~j{TP4 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�KpUiDawrwFd7;3OInHBEKZdZr zV5ig(lAY(u z3VOHvK~XFwkpl)^u$Xwq_En|*2|o{SFDe?BRHi#5TH*PedEZyb?7Zjp{XW+}BZZhn zC6*eI=YC8{nb*H{rJI-ISQTB^ZnL*G?iw)La|`d_;*`szZmPf@iwj+-M45U-G^Byy zAi{yn{Wfdhu?dL9cong(&6*4U>ulf0_l@Ufng$OP&P?&4F2|dF(v!mi@s2Tb=N$Ks zlUC%^)CbeH{f7d~9ejE`&4&M~p%&Kttr_$krB+@j{ZiMN!)MWLJ05C=DPtD`28d(R zhFj_M-Vq$6JHT&jt_n~A1(C4llwUak+}q1gNj8HWsoWGIZszr$osn=9Bvzn2>fRK$iBYywC4Yv(I%Nfx`z^Sh?K5r}?7D=L;8>HI?hI z0=gm}4a+_O`ice_39Tu@YC`Gh5nt`;uXCQKO>3$BUCyke1g|^swh{wa+VOt*a+3z= zEhmO+=5C44=@KG$a6Y$)_*&N+mA}V_W&(<6@u5IUl@Z6YStKm%=HqD}PByRDnKDM3 zIB0)|Y%-!-dbVtCyt5u3q`cp~H!CsL#B3w;;4IZGtFKRP*oE4ST72F=lZvy(sepJK z6@H(2_nJN{U)%X*=pe@u-SS?WT{8t=`8fIh?0->RHFG3`2`|buwTsj0aWg^eA+k6M z7dFAIB&k5Ce%&2OY#A)ON`S?~yN@u%6can2wrs5R8iL}w-@h38K_mso;n|?s2Eo{g{dCCv#kJ4Po`7&x}vG$^ynR;)h02I=E%*g4h z&yStTiM1m0pVRhj6x2`d$Wn=B<%OTxSloH{wxU1+`DO7#k7Y&n%c2{-Q#6{R<8>7C37A##a0+0|K6xaa3bKlB`K!I2s_0| zaPTZzZl^WvU9ajb1r};(6p0h^-rwyOS{kktyL_!v*y}7B1I6Zmn3`Ekdg-yf9&cVU zJq$$B`?If-VuzBsc9W~^$b_9~y~89CngNf=;|pBLA4Tp35l(!bKV$LAPcs#51JTCy z`2LxeM;S7h#}kJyEjr;J+CSCWh@K87^>49_8BjpIVjkyj=ntRf3FuR|RF$-bl(f?P zs{6i^Nhgym(U>;WX@mdJbqNFRkTLF#1X*ge(ur)YXH+eNSiXttJDOMD`&0hTfjQ&W z+4en-aYbAb6||V5=lOF%+*}uXxv9)~jW;i!AHOtd04#jVVpFtD0P)>X|_*qe7nZDO!!$3p)n-P6(|?sy(Xxy))AGIk%wgjay14muQ= zFQaVT5ggHvUvNtK{eDYkjy_LB>INoW`2CDg5(iFjb~>NAN+sS{6?CBZIl zWDUiwpHh+h^Vs#z9!P|#jY|3Cz^<@`#FFgM`tEiAHk(U+#H9RvHjq7I!(7uj$4Jcj zk0uXf9rV0MwA^lCU?wpHsr|>XL(9|ter%M#&K4k%Saqw%nP5Np6T=7MYTi>UnH2|9<1X+lF-W{<^s#mFR&`(8C@-lXxMxY6S%(Ls3ir457GQy4g{Qf)b zD3G|>A}k1e>~LqAc$GCd!2-!at?NErS2?<=_Ku%GXPNbV_$H?oUDjE)hzhqPBm3;5 z6Na=L9fKsKb@$t#RJx3hmtxw8+%P9t1O}mBlB}Fq8mF9m#;oYKx%6>n!Pz9UHc~9e9{4o8h`^8nJE6=13aoY#zzBh?9up5kD0_H&@C=+GA@*$ zH%h2-ByANWxKzhq1#8_iv_3v1WDOAI!Q@BB@$uwog{%HlXq+P9P(>6B~11z!hPt^`c7q2eO+5egI)56T)Ex?1gcj)A(2*5%A6h?zZeGwVemO=N z?|HiztVA-^XS?ys~|tFhW@|4XOK ztPCB|a4om4BSy3H4F{o+wqw()-S(O!y%~@eQAMzU5A%b%+=?q_#d2gJ5SkRmSs!3t z4=36ljaD3h7#PE>&Ud0z&}R=a&@_;jC4FIQxjQ_pi|sr_Yjric7;u@RsaZqhRpqh5e8h{GzBdTzc{HFuw@ekX4O(D;cz=Zjp+4Djj{P(d zp?zCDO38*)VXsCUYvwIbW(J{x?AQLR#sHZ&F^w7)R29HhbJ_`C*(@oiX}zmZ9J0wk zl28j9D#Wec$eN=Y=*X=Er<2*!nF^5F>Bk|>2xj?~uwBF8aialvDUOY74RAROoPfBH{KQ{~ zf&-<-5?tn)41DQ|Ed$82UE$9++OG4C^CcM=ar+@TVFa+gn-M+TVF+*;2}GZ>qx!zTqn1fXM8XnYvNA2Bqq->s?Gtj+8t{v)K~xR!xJ^0rq|_$9r4?1!qmO#*2oUNypk z<$M{1=WeZj#wGbfgv;H`al zj{jXT1(sO+v*XY&Ki}t1pQ)_cRMwf1_q6;u8e?6!otam7RpN73$R zsEak9u6~G0#espgni!nKSMa}=7Fk9L1qb)#<*RjGGQvYmhN!{{f!gkdpq5G=S!!K# zcc-ltDK~UwIFa%=fp>t~wv`SAmO`mIjQg$kCl&1dXwmYJd2VAs^I&u`~kzKM-T(M}j! zxV)FDO&1-fD0)dx&9km>Dug$Q-Y-^MlnIE`xSLF`uJ#FNwILac8Nhd*=g_^5Sz7jrPk{B;T#!bH)XS4CSt)(8$ z>)L zocbVwr%P)&)&zr)WYjmCnYfW+pxDdR`uFC&d%nm3QmiyNMG(DcpPiw z=2P+r;ZdNMlyKx!2=>^4KM;y1yjzYAaiF=s3ZI~OKy3k=$+2t6p z2;kzn6gTnK>yeo!w{dxq3(#;yLVBLQjRbrSa<&B$Qt_$~?zZR*6Zf6p6vr|?Vv>E? zFxD=#nO1$u;kxU7#Y89PCFc4n4-x?Z&bjAv#67Kw@xI0(kHg z$nP)WvvLVCm0F((RiO{~iFtH28tjYe@S~W-dk{F7NGGm9zdZ!!IBf$2A!VHdC+^aF z*fDO(MBMGRxv4n);C8s#gC0W7P6vZO*3k2H zu@E*6V!~TWv3e40LVg7hQSsGHaAYYV5ijGvW!L~(&DckZuLUUUY`U0>;S7}OaG4kQ zW`58RqXQ(d;b*H)Iqo-)d>*yjrfje7*_|tM)ifYKxjr9MeTJJzDHLy+o)RQXa}H?r zZrb#~KcFojtg7_>v$cA^sWd4Q=%Z7qX02dM9phIivfTW5k+lTv{e(;#J{Brtyqsz? z#6*B1;A*|lvl+_tXV4c@Yq=GlIovhZ*9k*|1e_HYGMNdMG0l!GQ>K&?>hs3-(Rx;I zDK@I^_4C^yS4c~xEF-4d4KpSjiT8Pt*IadSdHmfGg;w?Jo(Bc8ncl*5vQvj`Tj|lr zI~5TNr<1CGiAcuMY4?74z5@R@K?Q4XTn**gR%$HyWnT$ z3Lxm(Kxwg@o-c`Pm*uvfjgqYz&(~7EcU{mVnZQ^)XRa+iZ_sdUFjd`lzAY;Fsu-ro zxwY!gYO2y9Exd5-y>|0L#Bk2Le~>;@Y-Q-q^m-5e$6l%qF(fI#?hn$COpu`3hYa(V zSvfW8Ru`RvUVV(PW>lp4v$vZjCq;dO?gKt)Sb{dV)Z3geX8FK!uA23mMG6LUU9F}W zlYLHUyUby;;Mn@pgp@?7T82p z2G1?I6eEX+g0SnxeS2fLU)leTQ6d!0;fe}$zYDk3X8!RktXa>}HIIY;)==iFWHR$I zn*X@ooA)w^p=TUj;Bb8rzBL{$(l>gv7iSs~q~0^cNl=2K#H-}&IP6s|Jm6)XITC{l zQ5vX~4T($`;&|Zeb5}XtpHqu)B^CZ;?ll-gSdu{k;&jd(1FmWpgIk|#w?zYmdtx8> ztb`JEIRq*~_!?b&O?JUfdk#)v*Gc;G1L?4KEpd};=rZu{?b1{-cT0z`EK?0vuS>Ma z0fgp>rG98&pn95Y-N&33`5|q8*V@LLGRtnGoht>NCBAR2olElz!~k|$Q(wE|cSyQh zL&3s>@e#+h8BV58QZ~DXqeyuL&qGX_WcOR)WZC{+mLqjSf}P`h?G_3ZX1QKnt@{?r z+wDV8eLKg5!$_8eB$*efH1EZhb&{a>=2<4gHFf3>lRXQpInlz(Lfg}G4hC%KmZ~OB zaM4n$%gpkj@Ac4;qdBftXQg9b6$ttP7`FqhQir?c!uhrJn`S0=Rw8(MElM;Y?aI~s zBu{{Z3;${Sy3r1pIKe*7CpGtrwHSJAubSVyT;k!$UT+F-q0*~4=yS`9oNCSH`_CWm zJ)g9z7BV!{s@|)t1{~^wP0q0(LG!j+6l%%GZ|k7C zY6(U7iB_Onx5AJUyFB?d9bb>b(3IX(2lA{0MYt<9mVE6?MyS$b_eM^a#?>t!6otYl z2|IPoJ&X3cihJ()xJ7QW`*C`Cm5-X;qiAIX`41vN$Aug3<`T=s(lub3G=s}+&r5fu z!UJk#evkW_d5!v4i*8xglYR$rM^`)TEzJfkzlyFuAADEzLOA`{su0`kx~Ciut_@`=adU?7-N8r$`{v0|O~v24s90jNLua*4UAIGF%%_br=WY;-DGOolw&Meag5y;6XfVh2+3DfpAUSRL@oE4GcjWPGz^Ufo z9~>oOl;65Sm`R30aM1aCMg3<){hL|+f1G6IE%;?-*!OPnD3wI|sB1kDtC~j4uT~$R zqa5z>c8})Mtk<%gJ;+AL#3k}+VUF_EvA(!zncUw^|7qIF%8)Ob(c)1ur_B=+ry4?- zq8;C8YO%YpaB11Bj?QXpKBaBJY1gI2Pl9lA$W`Fh#NX_q>h!QS+`*@^a?AzIvY$?} zNdfh?QuPMvqyrC6$dmXiKQ^jjD)i7Y45Y643$-ZW9Vko1`oG*N*LnIp2uZhAQZ^c^ zHr`oiOBlxHuMY`$dO^Fj-@7y&@6sRix_-CVgtEXE7Z;C|dehF^j#YAFX|$94wqHEm zO;m=dn&g(gi|RKyK8eRq-EptvQc_*bd5XIUK`VncWTEF&SkKt%;xRdl%U}M9Rs}Y@U7ZpDR4~*%C8sH3zO!rLOnW zu)v>Flu}y;OqFv=**b0dHZFxZJ6k%qv2gY$L7kLK&5XC&-X*?&gl7V?Fcp;MSP9}T?dNm@V5@1{^6)b@~> z6&D@Xj)29_x=QlGW$Cv}?Z`+hnl`>GBPb2}9(g||G&jUyL`ly+{@}UYzj2?og4HS) za)uo}_d_!PUDbVu#dP3G60uo`VtoKDLsEjY4eW(Epo(}GQ(#Jj*8Dr?D=90_@&m|( zj*n5fqp)wBSD?pcVu`@7JPZeMd_7M{z~O0&^7}dPUrcd5L0`eqK~=ykp>%&0uj7dT z=vJ$Ux4QNS0wcna=4G?b!wR!NUGsX24LZx)y}n@ilmtdlVV$QGjI|LFiL7 zlAlU7{r&L&^9^4NjwdK!Kut18Anj*D56!^AId66vHBscE38TR78YeL`mOMz=jI zqX*i8PjH!<6=+(WDraxA1K%<^;=3x_i&xvuz5 zhAe0Ax)C({vjn5WQWT?9`_mp9kNg-rJ$dztAn)etww7@~0gGhv>1Ne&WTA4(JDe&I&Si`Gqwb;Q6X=X$OrJ) zm}#=uiEvII>Ylq*C^ccnX<+1RIUQ}krU5YxSMQ=X+P9=s9eCO~AGDDG-hx{!aD@tp zEQu3c&A=1X*k2G7-Jo3x8CcpNUGe{y6#^T(`4y~vZJx+;eai>e9k4maMF>IOI{>FZ zMnlMh;Zky+rv0;oHf*8;l)N`mHD{^s|8Y}b3$TQRZ^ebUkUA&0uA>%~Lx3hidBoyp zQtkq@!vEj%533B!?>7`npp5@6Fjx;PVCq|OBQBKm&;$Sbs{k=n9s0|#@G%2neCD0p ze+3F{1sLKYKVj0SdK>x`?0*($0v5P(G9Wrecv&|?uIRbQ-L>$73N&r&2RFp6yEZ~z zAtUX9k?;f?mGnHGFkXEdAL^wBhF1LhAm{)hZX*SWEKughl3iV&>&!}e#F z8Gs5b=xgWU{~Q9(@x%_>bxs{zDDuA*ig@~BN(c+Y3GDHNf%4*d@@uM^zXWk9p|(Na z7WcG8UoHk83I9K8AxzruE7fbtDee$_w8@I!T&=7sk(0dc+OsM&0qObYTWIzX?nq$ zevTC#h5$Rllp(&v|5r7R)^>x0lp8-1zemUm{hu_LXc-WPd7mSgdCBKgF0ZSIzbCnK z4?*7oBt$2K!Dpg*3pe<$M4_-QrbzzzTH*5#9fQPxb99sBMCU`HLS23$cLo>rUHr_> z9RSvdU)SPJ%?G)A-y6yddPD_|YF$nLCmVR=KX2dFHXw%cIxo1T!axMmHn=q8na1>2 zUjO(n_402;DyD7lE&FlmAI=eJ2dFssSy0=6tq&F0B!)r_L=`qSTFRe<|87+Z;BL0` zoBw@mn-KVR6Nt#g6gd`13or2?BW@cqT=Dgjf4D@n8CZE@jN^aUbXyL1iv53{0?q{c zhTPWqpHsDmK1KjxDmjqv3mRjf5;X$DZmi(LuK~L9lq{*r2&43g9?MeWf;V=P4;Z}H@_lI?>Bb=z+Y5RrB5QV^ZgICh9u{`vj3|5f1inhwHFwmpxL=S7j`gkarb^)aW0=RRDyO z)gi4%#KiU$0i$mzXF` z4`SD@dL+o~14f<{DfzAvSy=C}NSA4p0axdohNIJENTu9*GJ6%&-273abLAOia#!e) z;D8!E2k4+Ye#+roFN%Fayxp>%(4r|b&!VkHy9ydqwODP>%r#jaVzBI~J)FME5agLW zH3u%mm!ptbj3nRN{kUsg(`%YCQ(seGwh>mmkq{Z>#YCqa9is|Mt2sA4l*#b#S?~jk@@c*){!?95UGxpn?gS@GXv= zI~FqCvy?7vIxvKtev$#d{K-ZBUj>s3tPne9LM}~4g{6!6Z$Sb+AiS;*l#(28lw14% z(e)NkQEuJ;_yY(id6ke7RFp3121P+Zx*McHLTczi5l~X48%01G=^hXT>FyY$yPIL= zf4J|x*Zcn7?|-dX%jNLQc~0!J_h*0h-skv7wMpxO%Uo?aMK5;a2Y)^)>Fh^qcIUT=t;NA9$sv$g26JV$=k>=(AE0ehSIR={Uh(d&%0Dlab6CpTj0^ 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 du>;$ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + +