diff --git a/admin-next/src/components/layout/admin-header.tsx b/admin-next/src/components/layout/admin-header.tsx index 9fd2066..c082288 100755 --- a/admin-next/src/components/layout/admin-header.tsx +++ b/admin-next/src/components/layout/admin-header.tsx @@ -22,8 +22,9 @@ import { } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; -import { Moon, Sun, LogOut, User, Search } from "lucide-react"; +import { Moon, Sun, LogOut, User, Search, Globe, Copy } from "lucide-react"; import { useTheme } from "next-themes"; +import { toast } from "sonner"; import { useAuthStore } from "@/stores/auth-store"; import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette"; import { AppBreadcrumbs } from "@/components/layout/breadcrumbs"; @@ -79,6 +80,7 @@ export function AdminHeader() { const { theme, setTheme } = useTheme(); const { role, logout } = useAuthStore(); const [logoutOpen, setLogoutOpen] = useState(false); + const [onion, setOnion] = useState(""); useEffect(() => { const update = () => setHash(window.location.hash.slice(1) || "/"); @@ -87,6 +89,35 @@ export function AdminHeader() { return () => window.removeEventListener("hashchange", update); }, []); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/system/info"); + if (!res.ok) throw new Error("Failed to fetch system info"); + const data = await res.json(); + if (cancelled) return; + setOnion(data.adminOnion || ""); + } catch { + if (cancelled) return; + setOnion(""); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const copyOnion = async () => { + if (!onion) return; + try { + await navigator.clipboard.writeText(onion); + toast.success("Onion address copied"); + } catch { + toast.error("Failed to copy onion address"); + } + }; + const title = pageTitles[hash] || (hash.startsWith("/users/") @@ -105,6 +136,18 @@ export function AdminHeader() { {/* 1. Clock (hidden on mobile) */} + {/* 1b. Onion host badge (hidden on mobile) */} + + + {onion || "onion: —"} + {onion && } + {/* 2. Command palette search button */} (null); const [revealed, setRevealed] = useState>({}); const [revealing, setRevealing] = useState(null); + const [activated, setActivated] = useState(null); + const [activationLoading, setActivationLoading] = useState(false); + const [activationDialogOpen, setActivationDialogOpen] = useState(false); + const [onion, setOnion] = useState(""); + const [lanUrl, setLanUrl] = useState(""); + + useEffect(() => { + if (!isSuperAdmin) return; + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/activation"); + if (!res.ok) throw new Error("Failed to fetch activation"); + const data = await res.json(); + if (!cancelled) setActivated(!!data.activated); + } catch { + if (!cancelled) setActivated(null); + } + })(); + return () => { + cancelled = true; + }; + }, [isSuperAdmin]); + + useEffect(() => { + if (!isSuperAdmin) return; + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/system/info"); + if (!res.ok) throw new Error("Failed to fetch system info"); + const data = await res.json(); + if (cancelled) return; + setOnion(data.adminOnion || ""); + setLanUrl(data.lanUrl || ""); + } catch { + if (cancelled) return; + setOnion(""); + setLanUrl(""); + } + })(); + return () => { + cancelled = true; + }; + }, [isSuperAdmin]); + + const copyText = async (text: string, label: string) => { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + } catch { + toast.error(`Failed to copy ${label}`); + } + }; + + const handleToggleActivation = async () => { + if (activated === null) return; + const next = !activated; + setActivationLoading(true); + try { + const res = await fetch("/api/activation", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activated: next }), + }); + if (!res.ok) throw new Error("Failed to update activation"); + const data = await res.json(); + setActivated(!!data.activated); + toast.success(next ? "Shop activated" : "Shop blocked"); + } catch { + toast.error("Failed to update shop activation"); + } finally { + setActivationLoading(false); + setActivationDialogOpen(false); + } + }; useEffect(() => { fetch("/api/settings") @@ -431,6 +509,110 @@ export function SettingsPage() { + + {/* Shop Activation (super admin only) */} + {isSuperAdmin && ( + + + + + Shop Activation + + + Control whether the shop is active and accepting orders. Blocking the shop + immediately disables purchasing for all users. + + + + + {activated === null ? ( + Checking... + ) : activated ? ( + Activated + ) : ( + Blocked + )} + + {activated === null + ? "Loading activation status..." + : activated + ? "The shop is currently active and accepting orders." + : "The shop is currently blocked. Users cannot place orders."} + + + + + + + Onion: + copyText(onion, "Onion address")} + disabled={!onion} + title={onion ? "Copy onion address" : "Onion address not ready yet"} + className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50" + > + {onion || "—"} + {onion && } + + + + + LAN: + copyText(lanUrl, "LAN URL")} + disabled={!lanUrl} + title={lanUrl ? "Copy LAN URL" : "LAN URL not ready yet"} + className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50" + > + {lanUrl || "—"} + {lanUrl && } + + + + + + + + + {activated ? "Block Shop" : "Activate Shop"} + + + + + + {activated ? "Block Shop" : "Activate Shop"} + + + {activated + ? "Blocking the shop will immediately prevent all users from placing orders. This action can be reversed at any time." + : "Activating the shop will allow users to place orders again. This action can be reversed at any time."} + + + + Cancel + + {activationLoading + ? "Updating..." + : activated + ? "Block Shop" + : "Activate Shop"} + + + + + + + )} ); } diff --git a/admin-next/src/lib/commission-wallets.ts b/admin-next/src/lib/commission-wallets.ts new file mode 100644 index 0000000..8e9a0c1 --- /dev/null +++ b/admin-next/src/lib/commission-wallets.ts @@ -0,0 +1,27 @@ +import { db } from '@/lib/db'; + +const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const; + +const ENV_KEYS: Record = { + BTC: 'COMMISSION_WALLET_BTC', + LTC: 'COMMISSION_WALLET_LTC', + USDT: 'COMMISSION_WALLET_USDT', + USDC: 'COMMISSION_WALLET_USDC', + ETH: 'COMMISSION_WALLET_ETH', +}; + +export async function getCommissionWallets(): Promise> { + const keys = WALLET_TYPES.map((t) => `commission_wallet_${t}`); + const rows = await db.siteSetting.findMany({ + where: { key: { in: keys } }, + }); + + const wallets: Record = {}; + for (const type of WALLET_TYPES) { + const dbKey = `commission_wallet_${type}`; + const row = rows.find((r) => r.key === dbKey); + wallets[type] = row?.value || process.env[ENV_KEYS[type]] || ''; + } + + return wallets; +} diff --git a/src/__tests__/commissionService.test.js b/src/__tests__/commissionService.test.js new file mode 100644 index 0000000..dee3012 --- /dev/null +++ b/src/__tests__/commissionService.test.js @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock config +vi.mock('../config/config.js', () => ({ + __esModule: true, + default: { + BOT_TOKEN: 'test-token', + ADMIN_IDS: ['123456789'], + SUPER_ADMIN_IDS: ['123456789'], + SUPPORT_LINK: 'https://t.me/support', + DEFAULT_LANGUAGE: 'en', + ENCRYPTION_KEY: 'x'.repeat(64), + COMMISSION_ENABLED: true, + COMMISSION_PERCENT: 5, + COMMISSION_WALLETS: { + BTC: 'btc-env-address', + LTC: 'ltc-env-address', + USDT: 'usdt-env-address', + USDC: 'usdc-env-address', + ETH: 'eth-env-address' + } + } +})); + +// Mock logger +vi.mock('../utils/logger.js', () => ({ + __esModule: true, + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn() + } +})); + +// Track DB calls +const dbCalls = { + allAsync: [], + getAsync: [], + runAsync: [] +}; + +// Mock database +vi.mock('../config/database.js', () => { + const db = { + allAsync: vi.fn(async (sql, params = []) => { + dbCalls.allAsync.push({ sql, params }); + return []; + }), + getAsync: vi.fn(async (sql, params = []) => { + dbCalls.getAsync.push({ sql, params }); + return undefined; + }), + runAsync: vi.fn(async (sql, params = []) => { + dbCalls.runAsync.push({ sql, params }); + return {}; + }) + }; + return { __esModule: true, default: db }; +}); + +// We need to import after mocks are set up +let CommissionService; + +describe('CommissionService', () => { + beforeEach(async () => { + dbCalls.allAsync = []; + dbCalls.getAsync = []; + dbCalls.runAsync = []; + vi.clearAllMocks(); + + // Dynamic import to get fresh module with mocks + CommissionService = (await import('../services/commissionService.js')).default; + + // Invalidate cache before each test + CommissionService.invalidateCache(); + }); + + describe('getCommissionWallets', () => { + it('returns wallets from DB when all are set', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.allAsync.mockResolvedValueOnce([ + { key: 'commission_wallet_BTC', value: 'btc-db-address' }, + { key: 'commission_wallet_LTC', value: 'ltc-db-address' }, + { key: 'commission_wallet_USDT', value: 'usdt-db-address' }, + { key: 'commission_wallet_USDC', value: 'usdc-db-address' }, + { key: 'commission_wallet_ETH', value: 'eth-db-address' } + ]); + + const wallets = await CommissionService.getCommissionWallets(); + + expect(wallets).toEqual({ + BTC: 'btc-db-address', + LTC: 'ltc-db-address', + USDT: 'usdt-db-address', + USDC: 'usdc-db-address', + ETH: 'eth-db-address' + }); + }); + + it('falls back to env when DB value is empty string', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.allAsync.mockResolvedValueOnce([ + { key: 'commission_wallet_BTC', value: '' }, + { key: 'commission_wallet_LTC', value: 'ltc-db-address' }, + { key: 'commission_wallet_USDT', value: '' }, + { key: 'commission_wallet_USDC', value: 'usdc-db-address' }, + { key: 'commission_wallet_ETH', value: '' } + ]); + + const wallets = await CommissionService.getCommissionWallets(); + + expect(wallets.BTC).toBe('btc-env-address'); + expect(wallets.LTC).toBe('ltc-db-address'); + expect(wallets.USDT).toBe('usdt-env-address'); + expect(wallets.USDC).toBe('usdc-db-address'); + expect(wallets.ETH).toBe('eth-env-address'); + }); + + it('falls back to env when DB key is missing', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.allAsync.mockResolvedValueOnce([ + { key: 'commission_wallet_BTC', value: 'btc-db-address' } + ]); + + const wallets = await CommissionService.getCommissionWallets(); + + expect(wallets.BTC).toBe('btc-db-address'); + expect(wallets.LTC).toBe('ltc-env-address'); + expect(wallets.USDT).toBe('usdt-env-address'); + expect(wallets.USDC).toBe('usdc-env-address'); + expect(wallets.ETH).toBe('eth-env-address'); + }); + + it('caches result: second call does not hit DB', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.allAsync.mockResolvedValueOnce([ + { key: 'commission_wallet_BTC', value: 'btc-db-address' }, + { key: 'commission_wallet_LTC', value: 'ltc-db-address' }, + { key: 'commission_wallet_USDT', value: 'usdt-db-address' }, + { key: 'commission_wallet_USDC', value: 'usdc-db-address' }, + { key: 'commission_wallet_ETH', value: 'eth-db-address' } + ]); + + await CommissionService.getCommissionWallets(); + const callCount = dbCalls.allAsync.length; + + await CommissionService.getCommissionWallets(); + expect(dbCalls.allAsync.length).toBe(callCount); + }); + }); + + describe('getShopActivated', () => { + it('returns false when DB has "false"', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'false' }); + + const result = await CommissionService.getShopActivated(); + expect(result).toBe(false); + }); + + it('returns true when DB has "true"', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'true' }); + + const result = await CommissionService.getShopActivated(); + expect(result).toBe(true); + }); + + it('returns false when DB key missing and env SHOP_ACTIVATED is "false"', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.getAsync.mockResolvedValueOnce(undefined); + + const original = process.env.SHOP_ACTIVATED; + process.env.SHOP_ACTIVATED = 'false'; + try { + const result = await CommissionService.getShopActivated(); + expect(result).toBe(false); + } finally { + process.env.SHOP_ACTIVATED = original; + } + }); + + it('returns true when DB key missing and env SHOP_ACTIVATED is not set', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.getAsync.mockResolvedValueOnce(undefined); + + const original = process.env.SHOP_ACTIVATED; + delete process.env.SHOP_ACTIVATED; + try { + const result = await CommissionService.getShopActivated(); + expect(result).toBe(true); + } finally { + if (original !== undefined) { + process.env.SHOP_ACTIVATED = original; + } + } + }); + + it('caches result: second call does not hit DB', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'true' }); + + await CommissionService.getShopActivated(); + const callCount = dbCalls.getAsync.length; + + await CommissionService.getShopActivated(); + expect(dbCalls.getAsync.length).toBe(callCount); + }); + }); + + describe('invalidateCache', () => { + it('forces re-fetch after invalidation', async () => { + const { default: dbMock } = await import('../config/database.js'); + dbMock.allAsync.mockResolvedValueOnce([ + { key: 'commission_wallet_BTC', value: 'btc-db-address' }, + { key: 'commission_wallet_LTC', value: 'ltc-db-address' }, + { key: 'commission_wallet_USDT', value: 'usdt-db-address' }, + { key: 'commission_wallet_USDC', value: 'usdc-db-address' }, + { key: 'commission_wallet_ETH', value: 'eth-db-address' } + ]); + dbMock.allAsync.mockResolvedValueOnce([ + { key: 'commission_wallet_BTC', value: 'btc-db-address-v2' }, + { key: 'commission_wallet_LTC', value: 'ltc-db-address' }, + { key: 'commission_wallet_USDT', value: 'usdt-db-address' }, + { key: 'commission_wallet_USDC', value: 'usdc-db-address' }, + { key: 'commission_wallet_ETH', value: 'eth-db-address' } + ]); + + const first = await CommissionService.getCommissionWallets(); + expect(first.BTC).toBe('btc-db-address'); + + CommissionService.invalidateCache(); + + const second = await CommissionService.getCommissionWallets(); + expect(second.BTC).toBe('btc-db-address-v2'); + }); + }); +}); diff --git a/src/handlers/adminHandlers/adminWalletsHandler.js b/src/handlers/adminHandlers/adminWalletsHandler.js index 2c348f6..b1e4730 100644 --- a/src/handlers/adminHandlers/adminWalletsHandler.js +++ b/src/handlers/adminHandlers/adminWalletsHandler.js @@ -14,20 +14,11 @@ import WalletUtils from '../../utils/walletUtils.js'; import Validators from '../../utils/validators.js'; import logger from '../../utils/logger.js'; import { logAudit } from '../../services/auditService.js'; +import CommissionService from '../../services/commissionService.js'; import fs from 'fs'; import csvWriter from 'csv-writer'; export default class AdminWalletsHandler { - static { - if (config.COMMISSION_ENABLED) { - const requiredWallets = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH']; - const missingWallets = requiredWallets.filter(wallet => !config.COMMISSION_WALLETS[wallet]); - - if (missingWallets.length > 0) { - logger.warn({ missingWallets }, `Commission enabled but wallet addresses missing for: ${missingWallets.join(', ')}. Commission features will be limited.`); - } - } - } // Метод для проверки, является ли пользователь администратором // (используется общая функция из middleware/auth.js) @@ -218,7 +209,8 @@ export default class AdminWalletsHandler { try { logger.info({ walletType, requiredAmount: requiredAmount.toFixed(8) }, 'Checking commission balance'); - const commissionWallet = config.COMMISSION_WALLETS[walletType]; + const wallets = await CommissionService.getCommissionWallets(); + const commissionWallet = wallets[walletType]; if (!commissionWallet) { throw new Error(`Commission wallet not configured for ${walletType}`); } @@ -396,8 +388,9 @@ export default class AdminWalletsHandler { logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance'); if (commissionCheck.difference < 0) { + const commissionWallets = await CommissionService.getCommissionWallets(); const message = `⚠️ Insufficient balance in commission wallet!\n` + - `Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` + + `Wallet: ${commissionWallets[walletType]}\n` + `Required: ${commissionAmount.toFixed(8)} ${walletType}\n` + `Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` + `Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`; @@ -534,8 +527,9 @@ export default class AdminWalletsHandler { logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance'); if (commissionCheck.difference < 0) { + const commissionWallets = await CommissionService.getCommissionWallets(); const message = `⚠️ Insufficient balance in commission wallet!\n` + - `Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` + + `Wallet: ${commissionWallets[walletType]}\n` + `Required: ${commissionAmount.toFixed(8)} ${walletType}\n` + `Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` + `Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`; diff --git a/src/handlers/userHandlers/userHandler.js b/src/handlers/userHandlers/userHandler.js index 184e91c..3bfef42 100644 --- a/src/handlers/userHandlers/userHandler.js +++ b/src/handlers/userHandlers/userHandler.js @@ -8,6 +8,7 @@ import logger from "../../utils/logger.js"; import { resetUserContext } from "../../utils/messageUtils.js"; import userStates from "../../context/userStates.js"; import chatbotService from "../../services/chatbotService.js"; +import CommissionService from "../../services/commissionService.js"; import leadService from "../../services/leadService.js"; import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js'; @@ -19,6 +20,11 @@ export default class UserHandler { const lang = user?.language || 'en'; const t = tForUser(lang); + if (!(await CommissionService.getShopActivated())) { + await bot.sendMessage(telegramId, '⛔ Shop is not activated yet. Please contact support.'); + return false; + } + const keyboard = { inline_keyboard: [ [{text: t('bot.contact_support'), url: config.SUPPORT_LINK}] diff --git a/src/migrations/runner.js b/src/migrations/runner.js index 4d1da79..fd1b4c8 100644 --- a/src/migrations/runner.js +++ b/src/migrations/runner.js @@ -49,6 +49,7 @@ export async function runMigrations() { (await import('./012_fix_typos.js')).default, (await import('./013_ai_features.js')).default, (await import('./014_user_notes.js')).default, + (await import('./015_shop_activation.js')).default, ]; for (let i = currentVersion; i < migrations.length; i++) { diff --git a/src/services/commissionService.js b/src/services/commissionService.js new file mode 100644 index 0000000..69d877c --- /dev/null +++ b/src/services/commissionService.js @@ -0,0 +1,102 @@ +import db from '../config/database.js'; +import config from '../config/config.js'; +import logger from '../utils/logger.js'; + +const CACHE_TTL_MS = 30 * 1000; // 30 sec + +let commissionCache = null; +let commissionCachedAt = 0; + +let shopActivatedCache = null; +let shopActivatedCachedAt = 0; + +const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH']; + +function isCacheValid(cachedAt) { + return Date.now() - cachedAt < CACHE_TTL_MS; +} + +/** + * Get commission wallet addresses from site_settings with env fallback. + * @returns {Promise<{BTC: string, LTC: string, USDT: string, USDC: string, ETH: string}>} + */ +export async function getCommissionWallets() { + const now = Date.now(); + if (commissionCache && isCacheValid(commissionCachedAt)) { + return commissionCache; + } + + try { + const rows = await db.allAsync( + `SELECT key, value FROM site_settings WHERE key IN (${WALLET_TYPES.map(() => '?').join(',')})`, + WALLET_TYPES.map(t => `commission_wallet_${t}`) + ); + + const dbValues = {}; + for (const row of rows) { + const type = row.key.replace('commission_wallet_', ''); + dbValues[type] = row.value; + } + + const wallets = {}; + for (const type of WALLET_TYPES) { + const dbVal = dbValues[type]; + wallets[type] = (dbVal && dbVal !== '') ? dbVal : (config.COMMISSION_WALLETS[type] || ''); + } + + commissionCache = wallets; + commissionCachedAt = now; + return wallets; + } catch (err) { + logger.error({ err }, 'Failed to load commission wallets from site_settings'); + return { ...config.COMMISSION_WALLETS }; + } +} + +/** + * Check if the shop is activated. + * Reads site_settings.shop_activated; falls back to env SHOP_ACTIVATED !== 'false' (default true). + * @returns {Promise} + */ +export async function getShopActivated() { + const now = Date.now(); + if (shopActivatedCache !== null && isCacheValid(shopActivatedCachedAt)) { + return shopActivatedCache; + } + + try { + const row = await db.getAsync( + "SELECT value FROM site_settings WHERE key = 'shop_activated'" + ); + + let activated; + if (row) { + activated = row.value !== 'false'; + } else { + activated = process.env.SHOP_ACTIVATED !== 'false'; + } + + shopActivatedCache = activated; + shopActivatedCachedAt = now; + return activated; + } catch (err) { + logger.error({ err }, 'Failed to read shop_activated from site_settings'); + return process.env.SHOP_ACTIVATED !== 'false'; + } +} + +/** + * Invalidate all caches (useful after settings update). + */ +export function invalidateCache() { + commissionCache = null; + commissionCachedAt = 0; + shopActivatedCache = null; + shopActivatedCachedAt = 0; +} + +export default { + getCommissionWallets, + getShopActivated, + invalidateCache, +};