Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d8aad8c3 | ||
|
|
1c17501476 | ||
|
|
d2324db105 | ||
|
|
1d083e564c | ||
|
|
e90dcb60c9 | ||
|
|
b3d3018057 |
42
admin-next/src/app/api/activation/route.ts
Normal file
42
admin-next/src/app/api/activation/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuth, requireSuperAuth } from '@/lib/auth-middleware';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = getAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
try {
|
||||
const row = await db.siteSetting.findUnique({ where: { key: 'shop_activated' } });
|
||||
const activated = row ? row.value === 'true' : process.env.SHOP_ACTIVATED !== 'false';
|
||||
return NextResponse.json({ activated });
|
||||
} catch (error) {
|
||||
console.error('Activation GET error:', error);
|
||||
return NextResponse.json({ error: 'Failed to load activation status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const auth = requireSuperAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (typeof body.activated !== 'boolean') {
|
||||
return NextResponse.json({ error: 'activated must be a boolean' }, { status: 400 });
|
||||
}
|
||||
const activated = body.activated;
|
||||
const value = activated ? 'true' : 'false';
|
||||
|
||||
await db.siteSetting.upsert({
|
||||
where: { key: 'shop_activated' },
|
||||
update: { value, updatedAt: new Date() },
|
||||
create: { key: 'shop_activated', value },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, activated });
|
||||
} catch (error) {
|
||||
console.error('Activation PUT error:', error);
|
||||
return NextResponse.json({ error: 'Failed to save activation status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
80
admin-next/src/app/api/commission-wallets/route.ts
Normal file
80
admin-next/src/app/api/commission-wallets/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
||||
import { getCommissionWallets } from '@/lib/commission-wallets';
|
||||
|
||||
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const;
|
||||
|
||||
// Базовая валидация формата адреса по типу (не исчерпывающая, но отсекает мусор)
|
||||
function isValidWalletAddress(type: string, value: string): boolean {
|
||||
if (!value) return true; // пустое значение = очистить кошелёк
|
||||
switch (type) {
|
||||
case 'BTC':
|
||||
return /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/.test(value);
|
||||
case 'LTC':
|
||||
return /^(ltc1|[LM3])[a-zA-HJ-NP-Z0-9]{26,62}$/.test(value);
|
||||
case 'ETH':
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(value);
|
||||
case 'USDT':
|
||||
case 'USDC':
|
||||
// TRC-20 (T...) или ERC-20 (0x...)
|
||||
return /^T[a-zA-HJ-NP-Z0-9]{33}$/.test(value) || /^0x[a-fA-F0-9]{40}$/.test(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = requireSuperAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
try {
|
||||
const wallets = await getCommissionWallets();
|
||||
return NextResponse.json({ wallets });
|
||||
} catch (error) {
|
||||
console.error('Commission wallets GET error:', error);
|
||||
return NextResponse.json({ error: 'Failed to load commission wallets' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const auth = requireSuperAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const wallets: Record<string, string> = body.wallets || {};
|
||||
|
||||
// Валидация: только известные типы и корректный формат адресов
|
||||
const operations: Prisma.PrismaPromise<unknown>[] = [];
|
||||
for (const type of WALLET_TYPES) {
|
||||
if (wallets[type] === undefined) continue; // не трогаем отсутствующие типы
|
||||
const value = String(wallets[type]).trim();
|
||||
if (!isValidWalletAddress(type, value)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid ${type} wallet address format` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const key = `commission_wallet_${type}`;
|
||||
operations.push(
|
||||
db.siteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value, updatedAt: new Date() },
|
||||
create: { key, value },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operations.length === 0) {
|
||||
return NextResponse.json({ error: 'No wallet types provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.$transaction(operations);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Commission wallets PUT error:', error);
|
||||
return NextResponse.json({ error: 'Failed to save commission wallets' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
26
admin-next/src/app/api/system/info/route.ts
Normal file
26
admin-next/src/app/api/system/info/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
||||
import fs from 'fs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = requireSuperAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
let adminOnion = '';
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync('/onion-hosts/onion-hosts.txt', 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('ADMIN_ONION=')) {
|
||||
adminOnion = trimmed.slice('ADMIN_ONION='.length);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file may not exist — return empty string
|
||||
}
|
||||
|
||||
const lanUrl = process.env.ADMIN_URL || '';
|
||||
|
||||
return NextResponse.json({ adminOnion, lanUrl });
|
||||
}
|
||||
@@ -1,17 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuth } from '@/lib/auth-middleware';
|
||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
||||
import { getCommissionWallets } from '@/lib/commission-wallets';
|
||||
|
||||
// Цены в USD (как в боте src/utils/walletUtils.js): стейблкоины = 1
|
||||
const STABLECOIN_RATE = 1;
|
||||
|
||||
function getBaseWalletType(walletType: string): string {
|
||||
if (walletType.includes('ERC-20')) return 'ETH';
|
||||
if (walletType.includes('_')) return walletType.split('_')[0];
|
||||
return walletType;
|
||||
}
|
||||
|
||||
async function getCryptoPrices(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,litecoin,ethereum&vs_currencies=usd',
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
if (!res.ok) throw new Error(`prices HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
btc: data.bitcoin?.usd || 0,
|
||||
ltc: data.litecoin?.usd || 0,
|
||||
eth: data.ethereum?.usd || 0,
|
||||
usdt: STABLECOIN_RATE,
|
||||
usdc: STABLECOIN_RATE,
|
||||
};
|
||||
} catch {
|
||||
// Fallback: стейблкоины по 1 USD, остальные — 0 (не ломаем дашборд)
|
||||
return { btc: 0, ltc: 0, eth: 0, usdt: STABLECOIN_RATE, usdc: STABLECOIN_RATE };
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = getAuth(request);
|
||||
const auth = requireSuperAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
try {
|
||||
const commissionEnabled = true;
|
||||
const commissionRate = 0.05;
|
||||
|
||||
const [wallets, payments, walletTypeCounts] = await Promise.all([
|
||||
db.cryptoWallet.findMany(),
|
||||
const [wallets, paymentAgg, recentPayments, walletTypeCounts, prices] = await Promise.all([
|
||||
db.cryptoWallet.findMany({
|
||||
select: { id: true, userId: true, walletType: true, balance: true },
|
||||
}),
|
||||
db.commissionPayment.aggregate({
|
||||
_sum: { paidAmountUsd: true },
|
||||
}),
|
||||
db.commissionPayment.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
take: 20,
|
||||
@@ -20,28 +55,33 @@ export async function GET(request: NextRequest) {
|
||||
by: ['walletType'],
|
||||
_count: true,
|
||||
}),
|
||||
getCryptoPrices(),
|
||||
]);
|
||||
|
||||
const totals: Record<string, number> = { BTC: 0, LTC: 0, ETH: 0, USDT: 0, USDC: 0 };
|
||||
const walletCounts: Record<string, number> = { BTC: 0, LTC: 0, ETH: 0, USDT: 0, USDC: 0 };
|
||||
const userIdSet = new Set<number>();
|
||||
|
||||
let totalUsd = 0;
|
||||
let totalWallets = 0;
|
||||
let activeWallets = 0;
|
||||
|
||||
for (const w of wallets) {
|
||||
const t = w.walletType.toUpperCase();
|
||||
const base = getBaseWalletType(w.walletType);
|
||||
const t = base.toUpperCase();
|
||||
if (t in totals) {
|
||||
totals[t] += w.balance;
|
||||
walletCounts[t]++;
|
||||
totalWallets++;
|
||||
if (w.balance > 0) activeWallets++;
|
||||
totalUsd += w.balance * (prices[t.toLowerCase()] ?? 0);
|
||||
}
|
||||
userIdSet.add(w.userId);
|
||||
}
|
||||
|
||||
const totalUsd = wallets.reduce((sum, w) => sum + w.balance, 0);
|
||||
const totalWallets = wallets.length;
|
||||
const activeWallets = wallets.filter((w) => w.balance > 0).length;
|
||||
const totalUsers = userIdSet.size;
|
||||
|
||||
const currentCommission = totalUsd * commissionRate;
|
||||
const lastPaidAmount = payments.reduce((sum, p) => sum + p.paidAmountUsd, 0);
|
||||
const lastPaidAmount = paymentAgg._sum.paidAmountUsd ?? 0;
|
||||
const commissionDue = Math.max(0, currentCommission - lastPaidAmount);
|
||||
|
||||
const walletTypeDistribution = walletTypeCounts.map((w) => ({
|
||||
@@ -49,13 +89,7 @@ export async function GET(request: NextRequest) {
|
||||
count: w._count,
|
||||
}));
|
||||
|
||||
const commissionWallets = {
|
||||
BTC: process.env.COMMISSION_WALLET_BTC || '',
|
||||
LTC: process.env.COMMISSION_WALLET_LTC || '',
|
||||
USDT: process.env.COMMISSION_WALLET_USDT || '',
|
||||
USDC: process.env.COMMISSION_WALLET_USDC || '',
|
||||
ETH: process.env.COMMISSION_WALLET_ETH || '',
|
||||
};
|
||||
const commissionWallets = await getCommissionWallets();
|
||||
const mercuryoUrl = 'https://mercuryo.io/';
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -65,10 +99,10 @@ export async function GET(request: NextRequest) {
|
||||
totalWallets,
|
||||
activeWallets,
|
||||
totalUsers,
|
||||
commissionEnabled,
|
||||
commissionEnabled: true,
|
||||
commissionRate,
|
||||
currentCommission,
|
||||
payments,
|
||||
payments: recentPayments,
|
||||
lastPaidAmount,
|
||||
commissionDue,
|
||||
walletTypeDistribution,
|
||||
|
||||
@@ -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() {
|
||||
</h1>
|
||||
{/* 1. Clock (hidden on mobile) */}
|
||||
<RealtimeClock />
|
||||
{/* 1b. Onion host badge (hidden on mobile) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyOnion}
|
||||
disabled={!onion}
|
||||
title={onion ? "Copy onion address" : "Onion address not ready yet"}
|
||||
className="hidden md:flex items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground hover:bg-muted transition-colors shrink-0 max-w-[220px]"
|
||||
>
|
||||
<Globe className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{onion || "onion: —"}</span>
|
||||
{onion && <Copy className="size-3 shrink-0 opacity-60" />}
|
||||
</button>
|
||||
{/* 2. Command palette search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff } from "lucide-react";
|
||||
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff, ShieldCheck, Power, Copy, Globe, Link2 } from "lucide-react";
|
||||
|
||||
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
||||
|
||||
@@ -88,6 +89,83 @@ export function SettingsPage() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||
const [revealing, setRevealing] = useState<string | null>(null);
|
||||
const [activated, setActivated] = useState<boolean | null>(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() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Shop Activation (super admin only) */}
|
||||
{isSuperAdmin && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
Shop Activation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control whether the shop is active and accepting orders. Blocking the shop
|
||||
immediately disables purchasing for all users.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{activated === null ? (
|
||||
<Badge variant="secondary">Checking...</Badge>
|
||||
) : activated ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">Activated</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/15 text-red-400">Blocked</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activated === null
|
||||
? "Loading activation status..."
|
||||
: activated
|
||||
? "The shop is currently active and accepting orders."
|
||||
: "The shop is currently blocked. Users cannot place orders."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Onion:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 && <Copy className="h-3 w-3 opacity-60" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">LAN:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 && <Copy className="h-3 w-3 opacity-60" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={activationDialogOpen} onOpenChange={setActivationDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={activated ? "destructive" : "default"}
|
||||
className="gap-2"
|
||||
disabled={activated === null || activationLoading}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{activated ? "Block Shop" : "Activate Shop"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{activated ? "Block Shop" : "Activate Shop"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleToggleActivation}
|
||||
disabled={activationLoading}
|
||||
className={activated ? "bg-destructive text-white hover:bg-destructive/90" : ""}
|
||||
>
|
||||
{activationLoading
|
||||
? "Updating..."
|
||||
: activated
|
||||
? "Block Shop"
|
||||
: "Activate Shop"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ShieldCheck,
|
||||
PieChart as PieChartIcon,
|
||||
Receipt,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Pagination } from '@/components/shared/pagination';
|
||||
@@ -97,7 +98,6 @@ interface OverviewData {
|
||||
totalWallets: number;
|
||||
activeWallets: number;
|
||||
totalUsers: number;
|
||||
commissionEnabled: boolean;
|
||||
commissionRate: number;
|
||||
currentCommission: number;
|
||||
payments: {
|
||||
@@ -208,6 +208,9 @@ export function WalletsPage() {
|
||||
const [paymentAmount, setPaymentAmount] = useState('');
|
||||
const [paymentNote, setPaymentNote] = useState('');
|
||||
const [paymentSubmitting, setPaymentSubmitting] = useState(false);
|
||||
const [editingWallets, setEditingWallets] = useState(false);
|
||||
const [walletDraft, setWalletDraft] = useState<Record<string, string>>({});
|
||||
const [walletsSaving, setWalletsSaving] = useState(false);
|
||||
|
||||
// Tab 4 state
|
||||
const [txData, setTxData] = useState<TransactionRow[]>([]);
|
||||
@@ -304,6 +307,48 @@ export function WalletsPage() {
|
||||
fetchOverview();
|
||||
}, []);
|
||||
|
||||
const startEditWallets = () => {
|
||||
const current = overview?.commissionWallets || {};
|
||||
setWalletDraft({
|
||||
BTC: current.BTC || '',
|
||||
LTC: current.LTC || '',
|
||||
USDT: current.USDT || '',
|
||||
USDC: current.USDC || '',
|
||||
ETH: current.ETH || '',
|
||||
});
|
||||
setEditingWallets(true);
|
||||
};
|
||||
|
||||
const cancelEditWallets = () => {
|
||||
setEditingWallets(false);
|
||||
setWalletDraft({});
|
||||
};
|
||||
|
||||
const handleSaveWallets = async () => {
|
||||
setWalletsSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/commission-wallets', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wallets: walletDraft }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save wallets');
|
||||
toast.success('Commission wallets updated');
|
||||
setEditingWallets(false);
|
||||
setWalletDraft({});
|
||||
// Refetch overview
|
||||
const overviewRes = await fetch('/api/wallets/overview');
|
||||
if (overviewRes.ok) {
|
||||
const data = await overviewRes.json();
|
||||
setOverview(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save commission wallets');
|
||||
} finally {
|
||||
setWalletsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecordPayment = async () => {
|
||||
const amount = parseFloat(paymentAmount);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
@@ -761,7 +806,7 @@ export function WalletsPage() {
|
||||
<p className="text-sm text-muted-foreground">Active Wallets</p>
|
||||
<Wallet className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{overview.totalWallets}</p>
|
||||
<p className="text-2xl font-bold mt-1">{overview.activeWallets ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -1236,42 +1281,92 @@ export function WalletsPage() {
|
||||
{overview && overview.commissionWallets && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Pay to Commission Wallet</CardTitle>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg">Pay to Commission Wallet</CardTitle>
|
||||
{isSuperAdmin && !editingWallets && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={startEditWallets}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Wallets
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>Select currency and send payment to the address below</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commission-wallet-type">Currency</Label>
|
||||
<select
|
||||
id="commission-wallet-type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={selectedCommissionWallet}
|
||||
onChange={(e) => setSelectedCommissionWallet(e.target.value)}
|
||||
>
|
||||
{currencyTypes.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Address</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-muted px-3 py-2 text-xs font-mono break-all">
|
||||
{overview.commissionWallets[selectedCommissionWallet] || 'Not configured'}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => copyToClipboard(
|
||||
overview.commissionWallets[selectedCommissionWallet] || '',
|
||||
'Address'
|
||||
)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editingWallets ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{(['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const).map((t) => (
|
||||
<div key={t} className="space-y-1.5">
|
||||
<Label htmlFor={`wallet-${t}`} className="text-sm">{t}</Label>
|
||||
<Input
|
||||
id={`wallet-${t}`}
|
||||
value={walletDraft[t] || ''}
|
||||
onChange={(e) => setWalletDraft((prev) => ({ ...prev, [t]: e.target.value }))}
|
||||
className="font-mono"
|
||||
placeholder={`${t} address`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveWallets}
|
||||
disabled={walletsSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{walletsSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={cancelEditWallets}
|
||||
disabled={walletsSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commission-wallet-type">Currency</Label>
|
||||
<select
|
||||
id="commission-wallet-type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={selectedCommissionWallet}
|
||||
onChange={(e) => setSelectedCommissionWallet(e.target.value)}
|
||||
>
|
||||
{currencyTypes.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Address</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-muted px-3 py-2 text-xs font-mono break-all">
|
||||
{overview.commissionWallets[selectedCommissionWallet] || 'Not configured'}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => copyToClipboard(
|
||||
overview.commissionWallets[selectedCommissionWallet] || '',
|
||||
'Address'
|
||||
)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ADMIN_SECRET = process.env.ADMIN_SECRET || 'changeme';
|
||||
const ADMIN_SECRET = process.env.ADMIN_SECRET || '';
|
||||
const SUPER_ADMIN_SECRET = process.env.SUPER_ADMIN_SECRET || '';
|
||||
|
||||
export type AdminRole = 'admin' | 'super_admin';
|
||||
@@ -14,12 +14,12 @@ export interface AdminPayload {
|
||||
|
||||
export function createToken(secret: string): string {
|
||||
const isSuper = SUPER_ADMIN_SECRET && secret === SUPER_ADMIN_SECRET;
|
||||
const isAdmin = secret === ADMIN_SECRET;
|
||||
|
||||
const isAdmin = ADMIN_SECRET && secret === ADMIN_SECRET;
|
||||
|
||||
if (!isAdmin && !isSuper) return '';
|
||||
|
||||
// If no super secret set, all admins are super
|
||||
const role: AdminRole = (!SUPER_ADMIN_SECRET || isSuper) ? 'super_admin' : 'admin';
|
||||
// Роль: super_admin только при явном SUPER_ADMIN_SECRET и совпадении с ним
|
||||
const role: AdminRole = isSuper ? 'super_admin' : 'admin';
|
||||
|
||||
const payload: AdminPayload = {
|
||||
role,
|
||||
@@ -63,7 +63,7 @@ export function verifyToken(token: string): AdminPayload | null {
|
||||
}
|
||||
|
||||
export function verifyReAuth(token: string): boolean {
|
||||
const isSuper = SUPER_ADMIN_SECRET && token === SUPER_ADMIN_SECRET;
|
||||
const isAdmin = token === ADMIN_SECRET;
|
||||
const isSuper = !!(SUPER_ADMIN_SECRET && token === SUPER_ADMIN_SECRET);
|
||||
const isAdmin = !!(ADMIN_SECRET && token === ADMIN_SECRET);
|
||||
return isSuper || isAdmin;
|
||||
}
|
||||
|
||||
27
admin-next/src/lib/commission-wallets.ts
Normal file
27
admin-next/src/lib/commission-wallets.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const;
|
||||
|
||||
const ENV_KEYS: Record<string, string> = {
|
||||
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<Record<string, string>> {
|
||||
const keys = WALLET_TYPES.map((t) => `commission_wallet_${t}`);
|
||||
const rows = await db.siteSetting.findMany({
|
||||
where: { key: { in: keys } },
|
||||
});
|
||||
|
||||
const wallets: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
84
install.sh
84
install.sh
@@ -5,8 +5,28 @@ set -euo pipefail
|
||||
# Telegram Shop — установщик
|
||||
# Скрипт для развёртывания на x86_64 и ARM64 (Orange Pi, RPi)
|
||||
# Совместим с POSIX sh (Alpine ash)
|
||||
#
|
||||
# Режимы:
|
||||
# install.sh — интерактивный (запрашивает BOT_TOKEN/ADMIN_IDS)
|
||||
# install.sh --auto — неинтерактивный: placeholder BOT_TOKEN, ADMIN_IDS=0,
|
||||
# секреты генерируются автоматически (для CI/SSH)
|
||||
# install.sh --clean — очистка старых контейнеров/образов перед установкой
|
||||
# ============================================================
|
||||
|
||||
AUTO=0
|
||||
CLEAN=0
|
||||
for ARG in "$@"; do
|
||||
case "$ARG" in
|
||||
--auto) AUTO=1 ;;
|
||||
--clean) CLEAN=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Неинтерактивный режим также включается, если stdin не терминал
|
||||
if [ ! -t 0 ]; then
|
||||
AUTO=1
|
||||
fi
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -51,6 +71,27 @@ esac
|
||||
print_ok "Архитектура: $ARCH_NAME"
|
||||
print_info "Docker автоматически соберёт образ под текущую архитектуру"
|
||||
|
||||
# ============================================================
|
||||
# 0.5. Очистка (--clean): удалить старые контейнеры/образы/тома
|
||||
# ============================================================
|
||||
if [ "$CLEAN" -eq 1 ]; then
|
||||
print_step "Очистка старых контейнеров и образов"
|
||||
if [ -f docker-compose.yml ]; then
|
||||
docker compose down --rmi all --volumes 2>/dev/null || true
|
||||
fi
|
||||
# Удалить осиротевшие контейнеры/образы/тома проекта telegram-shop
|
||||
docker ps -aq --filter "name=telegram_shop_prod" | xargs -r docker rm -f 2>/dev/null || true
|
||||
docker ps -aq --filter "name=tg_shop_admin" | xargs -r docker rm -f 2>/dev/null || true
|
||||
docker ps -aq --filter "name=tor-proxy" | xargs -r docker rm -f 2>/dev/null || true
|
||||
docker images -q "git.softuniq.eu/telegram-market/telegram-shop*" | xargs -r docker rmi -f 2>/dev/null || true
|
||||
docker images -q "telegram-shop-*" | xargs -r docker rmi -f 2>/dev/null || true
|
||||
docker volume rm tor_proxy_data 2>/dev/null || true
|
||||
docker network rm tor_proxy_net telegram-shop_default 2>/dev/null || true
|
||||
# Удалить локальную БД и загрузки (чистая переустановка)
|
||||
rm -rf db uploads
|
||||
print_ok "Очистка завершена (tor-ssh-client не тронут)"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 1. Проверка / установка Docker
|
||||
# ============================================================
|
||||
@@ -200,10 +241,16 @@ generate_random_hex() {
|
||||
# BOT_TOKEN
|
||||
current_bot_token=$(grep -E "^BOT_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
|
||||
if [ -z "$current_bot_token" ] || [ "$current_bot_token" = "your_bot_token_here" ]; then
|
||||
print_warn "BOT_TOKEN не задан или содержит placeholder."
|
||||
read_secret "Введите BOT_TOKEN (от @BotFather): "
|
||||
update_env_var "BOT_TOKEN" "$input"
|
||||
bot_token="$input"
|
||||
if [ "$AUTO" -eq 1 ]; then
|
||||
bot_token="REPLACE_ME_LATER"
|
||||
update_env_var "BOT_TOKEN" "$bot_token"
|
||||
print_warn "BOT_TOKEN не задан — установлен placeholder (внесите реальный токен в админке)"
|
||||
else
|
||||
print_warn "BOT_TOKEN не задан или содержит placeholder."
|
||||
read_secret "Введите BOT_TOKEN (от @BotFather): "
|
||||
update_env_var "BOT_TOKEN" "$input"
|
||||
bot_token="$input"
|
||||
fi
|
||||
else
|
||||
bot_token="$current_bot_token"
|
||||
fi
|
||||
@@ -211,11 +258,17 @@ fi
|
||||
# ADMIN_IDS
|
||||
current_admin_ids=$(grep -E "^ADMIN_IDS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
|
||||
if [ -z "$current_admin_ids" ] || [ "$current_admin_ids" = "123456789,987654321" ]; then
|
||||
print_warn "ADMIN_IDS не задан или содержит placeholder."
|
||||
printf "Введите ADMIN_IDS (через запятую, например: 123456789,987654321): "
|
||||
read -r input
|
||||
update_env_var "ADMIN_IDS" "$input"
|
||||
admin_ids="$input"
|
||||
if [ "$AUTO" -eq 1 ]; then
|
||||
admin_ids="0"
|
||||
update_env_var "ADMIN_IDS" "$admin_ids"
|
||||
print_warn "ADMIN_IDS не задан — установлен 0 (настройте в админке)"
|
||||
else
|
||||
print_warn "ADMIN_IDS не задан или содержит placeholder."
|
||||
printf "Введите ADMIN_IDS (через запятую, например: 123456789,987654321): "
|
||||
read -r input
|
||||
update_env_var "ADMIN_IDS" "$input"
|
||||
admin_ids="$input"
|
||||
fi
|
||||
else
|
||||
admin_ids="$current_admin_ids"
|
||||
fi
|
||||
@@ -232,7 +285,7 @@ fi
|
||||
|
||||
# ADMIN_SECRET
|
||||
current_admin_secret=$(grep -E "^ADMIN_SECRET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
|
||||
if [ -z "$current_admin_secret" ] || [ "$current_admin_secret" = "changeme" ]; then
|
||||
if [ -z "$current_admin_secret" ] || [ "$current_admin_secret" = "changeme" ] || [ "$current_admin_secret" = "your_admin_token_here" ]; then
|
||||
admin_secret=$(generate_random_hex)
|
||||
update_env_var "ADMIN_SECRET" "$admin_secret"
|
||||
print_ok "ADMIN_SECRET сгенерирован автоматически"
|
||||
@@ -242,7 +295,7 @@ fi
|
||||
|
||||
# SUPER_ADMIN_SECRET
|
||||
current_super_admin_secret=$(grep -E "^SUPER_ADMIN_SECRET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
|
||||
if [ -z "$current_super_admin_secret" ] || [ "$current_super_admin_secret" = "changeme_super" ]; then
|
||||
if [ -z "$current_super_admin_secret" ] || [ "$current_super_admin_secret" = "changeme_super" ] || [ "$current_super_admin_secret" = "your_super_admin_token_here" ]; then
|
||||
# Генерация до тех пор, пока не получим значение, отличное от ADMIN_SECRET
|
||||
super_admin_secret=""
|
||||
while [ -z "$super_admin_secret" ] || [ "$super_admin_secret" = "$admin_secret" ]; do
|
||||
@@ -266,7 +319,7 @@ fi
|
||||
|
||||
# ADMIN_URL
|
||||
current_admin_url=$(grep -E "^ADMIN_URL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
|
||||
if [ -z "$current_admin_url" ]; then
|
||||
if [ -z "$current_admin_url" ] || [ "$current_admin_url" = "http://your-host:3000" ] || [ "$current_admin_url" = "your-host" ]; then
|
||||
# Попытка определить LAN IP
|
||||
lan_ip=$(hostname -I | awk '{print $1}')
|
||||
if [ -z "$lan_ip" ]; then
|
||||
@@ -307,6 +360,13 @@ print_info "Образы собираются в CI (Gitea Actions, buildx multi
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
print_info "Используемая версия образа: $IMAGE_TAG"
|
||||
|
||||
# Логин в приватный Gitea Container Registry (если задан токен)
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
echo "${GITEA_TOKEN}" | docker login git.softuniq.eu -u oauth2 --password-stdin >/dev/null 2>&1 \
|
||||
&& print_ok "Авторизация в Gitea Container Registry" \
|
||||
|| print_warn "Не удалось авторизоваться в registry (GITEA_TOKEN)"
|
||||
fi
|
||||
|
||||
$COMPOSE_CMD pull telegram_shop_prod tg_shop_admin
|
||||
print_ok "Образы telegram-shop:${IMAGE_TAG} и telegram-shop-admin:${IMAGE_TAG} загружены"
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "telegram-shop-bot",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"lint": "find src -name '*.js' -print0 | xargs -0 -n1 node --check"
|
||||
"lint": "find src -name '*.js' -print0 | xargs -0 -n1 node --check",
|
||||
"arch:build": "docker compose -f docker/docker-compose.architect.yml build",
|
||||
"arch:index": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer",
|
||||
"arch:index:full": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer --mode full",
|
||||
"arch:index:incremental": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer --mode incremental",
|
||||
"arch:status": "docker compose -f docker/docker-compose.architect.yml ps"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
240
src/__tests__/commissionService.test.js
Normal file
240
src/__tests__/commissionService.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,13 @@ import { dirname } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname;
|
||||
// Путь к БД: env SHOP_DB_PATH (для единой БД с админкой) → по умолчанию ../db/shop.db.
|
||||
// Рассинхрон: админка (Next.js/Prisma) писала в custom.db, бот — в shop.db → товары,
|
||||
// добавленные в админке, не были видны боту. Оба должны использовать ОДИН файл.
|
||||
const DB_PATH =
|
||||
process.env.SHOP_DB_PATH && process.env.SHOP_DB_PATH !== 'false'
|
||||
? process.env.SHOP_DB_PATH
|
||||
: new URL('../../db/shop.db', import.meta.url).pathname;
|
||||
|
||||
let betterDb;
|
||||
|
||||
|
||||
@@ -15,7 +15,53 @@ const initBot = async () => {
|
||||
}
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const instance = new TelegramBot(config.BOT_TOKEN, {polling: true});
|
||||
const instance = new TelegramBot(config.BOT_TOKEN, {polling: false});
|
||||
// ── Проверка на ДУБЛИКАТ токена ─────────────────────────────
|
||||
// Перед запуском polling делаем разовый getUpdates. Если токен уже
|
||||
// используется другой нодой/процессом — Telegram возвращает 409
|
||||
// "Conflict: terminated by other getUpdates request" (или webhook).
|
||||
// Такой бот НЕ должен стартовать: он будет конфликтовать, терять
|
||||
// обновления и «прыгать» между серверами (issue: дубликат BotFather
|
||||
// токена на нескольких нодах).
|
||||
const dupCheck = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => resolve(null), 10000);
|
||||
instance
|
||||
.getUpdates({ timeout: 0, allowed_updates: [] })
|
||||
.then((updates) => {
|
||||
clearTimeout(timeout);
|
||||
// допустимые значения — любой успешный ответ ([] или список)
|
||||
resolve({ ok: true, updates });
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
const status = error?.response?.statusCode ?? error?.response?.status;
|
||||
const description = error?.response?.body?.description ?? error?.message ?? String(error);
|
||||
const is409 = status === 409 || /conflict/i.test(description);
|
||||
resolve({ ok: false, status, description, is409 });
|
||||
});
|
||||
});
|
||||
if (!dupCheck?.ok) {
|
||||
if (dupCheck?.is409) {
|
||||
console.error(
|
||||
'\n====================================================\n' +
|
||||
'🚫 ОБНАРУЖЕН ДУБЛИКАТ ТОКЕНА ДЛЯ ЗАПУСКА\n' +
|
||||
' Telegram вернул 409 Conflict — этот BOT_TOKEN уже\n' +
|
||||
' используется другим экземпляром/нодой.\n' +
|
||||
` (${dupCheck.description})\n` +
|
||||
' Запуск бота остановлен. Создайте новый бот в @BotFather\n' +
|
||||
' или замените BOT_TOKEN в настройках сервиса.\n' +
|
||||
'====================================================\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (dupCheck?.status === 404 || (dupCheck?.description || '').includes('Not Found')) {
|
||||
// неверный токен — пробуем дальше; ниже полноценная валидация
|
||||
logger.warn('validate: getUpdates 404 (invalid token?)');
|
||||
}
|
||||
}
|
||||
|
||||
// Реальная инициализация: включаем polling и проверяем getMe
|
||||
await instance.startPolling();
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
instance.stopPolling();
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}]
|
||||
|
||||
36
src/migrations/015_shop_activation.js
Normal file
36
src/migrations/015_shop_activation.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Migration 015: shop activation flag + commission wallet addresses in site_settings
|
||||
export default async function migration015(db) {
|
||||
await db.runAsync('BEGIN TRANSACTION');
|
||||
try {
|
||||
// Индекс на key — site_settings читается ботом на каждое сообщение (shop_activated, commission_wallet_*)
|
||||
await db.runAsync(
|
||||
'CREATE INDEX IF NOT EXISTS idx_site_settings_key ON site_settings(key)'
|
||||
);
|
||||
|
||||
// Shop activation flag: default true unless SHOP_ACTIVATED='false'
|
||||
const shopActivated = process.env.SHOP_ACTIVATED === 'false' ? 'false' : 'true';
|
||||
await db.runAsync(
|
||||
"INSERT OR IGNORE INTO site_settings (key, value) VALUES ('shop_activated', ?)",
|
||||
[shopActivated]
|
||||
);
|
||||
|
||||
// Commission wallet addresses: seed from env if set, otherwise empty string
|
||||
const walletTypes = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'];
|
||||
for (const type of walletTypes) {
|
||||
const envKey = `COMMISSION_WALLET_${type}`;
|
||||
const value = process.env[envKey] || '';
|
||||
await db.runAsync(
|
||||
"INSERT OR IGNORE INTO site_settings (key, value) VALUES (?, ?)",
|
||||
[`commission_wallet_${type}`, value]
|
||||
);
|
||||
}
|
||||
|
||||
await db.runAsync('COMMIT');
|
||||
logger.info('Migration 015: shop_activation and commission_wallet_* seeded into site_settings');
|
||||
} catch (e) {
|
||||
await db.runAsync('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
106
src/services/commissionService.js
Normal file
106
src/services/commissionService.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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.
|
||||
* Приоритет: env SHOP_ACTIVATED (единый источник — панель Bbox) → БД site_settings.
|
||||
* Раньше БД была первичной, и смена «Магазин активирован» в панели (env) не
|
||||
* влияла на бота (в БД оставалось старое значение) — рассинхрон «пишет true,
|
||||
* а бот говорит not activated». Теперь env главный; БД — зеркало-фоллбэк.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function getShopActivated() {
|
||||
const now = Date.now();
|
||||
if (shopActivatedCache !== null && isCacheValid(shopActivatedCachedAt)) {
|
||||
return shopActivatedCache;
|
||||
}
|
||||
|
||||
let activated;
|
||||
// 1. env (панель Bbox / compose) — единый источник правды
|
||||
if (process.env.SHOP_ACTIVATED !== undefined) {
|
||||
activated = process.env.SHOP_ACTIVATED !== 'false';
|
||||
} else {
|
||||
// 2. БД site_settings (админка магазина может вести туда же)
|
||||
try {
|
||||
const row = await db.getAsync(
|
||||
"SELECT value FROM site_settings WHERE key = 'shop_activated'"
|
||||
);
|
||||
activated = row ? row.value !== 'false' : true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to read shop_activated from site_settings');
|
||||
activated = true;
|
||||
}
|
||||
}
|
||||
|
||||
shopActivatedCache = activated;
|
||||
shopActivatedCachedAt = now;
|
||||
return activated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all caches (useful after settings update).
|
||||
*/
|
||||
export function invalidateCache() {
|
||||
commissionCache = null;
|
||||
commissionCachedAt = 0;
|
||||
shopActivatedCache = null;
|
||||
shopActivatedCachedAt = 0;
|
||||
}
|
||||
|
||||
export default {
|
||||
getCommissionWallets,
|
||||
getShopActivated,
|
||||
invalidateCache,
|
||||
};
|
||||
Reference in New Issue
Block a user