feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled

Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 00:36:05 +07:00
co-authored by Cursor
parent 2c66d0c0ee
commit 7f91bc3624
28 changed files with 1046 additions and 437 deletions
+9
View File
@@ -79,6 +79,15 @@ vps-tracker/
Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order`
## Уведомления
- **Движок:** `apps/api/src/services/notifications/` — rules, dedup, engine, channels
- **Планировщик:** `apps/api/src/services/scheduler.ts` — sync отдельно; notify/uptime не зависят от `syncEnabled`
- **События:** `payment_expiry`, `sync_digest`, `low_balance`, `new_tariffs`, `vps_down`, `vps_up`
- **Каналы:** Telegram (`telegram.ts`) и webhook (`webhook.ts`); webhook работает без Telegram
- **Журнал:** таблица `notification_log`, API `GET /api/notifications/log`
- **Дедупликация:** `notification_state` — daily / fingerprint / state_transition
## Команды
```bash
+5 -1
View File
@@ -22,6 +22,7 @@ import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
import { dashboardRoutes } from './routes/dashboard.js'
import { auditRoutes } from './routes/audit.js'
import { notificationsRoutes } from './routes/notifications.js'
import { startScheduler } from './services/scheduler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(migrateRoutes)
await app.register(dashboardRoutes)
await app.register(auditRoutes)
await app.register(notificationsRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
@@ -88,4 +90,6 @@ async function start() {
}
}
void start()
if (!process.env.VITEST) {
void start()
}
+9
View File
@@ -0,0 +1,9 @@
import type { FastifyPluginAsync } from 'fastify'
import { notificationRepository } from '@cfdm/db/repositories/notifications'
export const notificationsRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Querystring: { limit?: string } }>('/api/notifications/log', async (req) => {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50))
return notificationRepository.listRecent(limit)
})
}
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
describe('settings telegram test', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
settingsRepository.upsert('settings-main', {
telegramBotToken: 'token',
telegramChatId: '123',
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
vi.unstubAllGlobals()
closeDb()
})
it('returns telegram API error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({ ok: false, description: 'Bad Request: chat not found' }),
),
)
const res = await app.inject({ method: 'POST', url: '/api/settings/telegram/test' })
expect(res.statusCode).toBe(200)
const body = res.json() as { ok: boolean; error?: string }
expect(body.ok).toBe(false)
expect(body.error).toContain('chat not found')
})
})
+17 -2
View File
@@ -4,6 +4,7 @@ import { settingsSchema } from '@cfdm/shared/contracts/settings'
import { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js'
import { deliverWebhook } from '../services/notifications/channels.js'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
@@ -34,12 +35,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
}
await sendTelegramMessage(
const result = await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
'✅ VPS Tracker: тестовое сообщение',
settings.telegramMessageThreadId,
)
return { ok: true }
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
})
app.post('/api/settings/webhook/test', async () => {
const settings = settingsRepository.getRow('settings-main')
if (!settings?.webhookEnabled) {
return { ok: false, error: 'Включите webhook в настройках' }
}
const result = await deliverWebhook(settings, {
event: 'test',
message: 'VPS Tracker: тестовое webhook-сообщение',
timestamp: new Date().toISOString(),
data: { source: 'settings_test' },
})
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' }
})
}
+5
View File
@@ -123,6 +123,11 @@ export function importJsonSnapshot(data: BackupPayload): void {
customFields: customFields != null ? String(customFields) : null,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
notifyVpsDownEnabled: s.notifyVpsDownEnabled ? 1 : 0,
webhookUrl: String(s.webhookUrl ?? ''),
webhookEnabled: s.webhookEnabled ? 1 : 0,
notifyIntervalMinutes: Number(s.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(s.uptimeCheckIntervalMinutes) || 5,
})
.run()
}
@@ -0,0 +1,40 @@
import { sendTelegramMessage } from '../telegram.js'
import type { WebhookPayload } from '../webhook.js'
import type { NotificationChannel, SettingsNotifyRow } from './types.js'
export async function deliverTelegram(
settings: SettingsNotifyRow,
messageHtml: string,
): Promise<{ ok: boolean; error?: string }> {
const token = settings.telegramBotToken?.trim()
const chatId = settings.telegramChatId?.trim()
if (!token || !chatId) return { ok: false, error: 'Telegram не настроен' }
return sendTelegramMessage(token, chatId, messageHtml, settings.telegramMessageThreadId)
}
export async function deliverWebhook(
settings: SettingsNotifyRow,
payload: WebhookPayload,
): Promise<{ ok: boolean; error?: string }> {
if (!settings.webhookEnabled) return { ok: false, error: 'Webhook выключен' }
const url = settings.webhookUrl?.trim()
if (!url) return { ok: false, error: 'Webhook URL не указан' }
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
export function activeChannels(settings: SettingsNotifyRow): NotificationChannel[] {
const channels: NotificationChannel[] = []
if (settings.telegramBotToken?.trim() && settings.telegramChatId?.trim()) channels.push('telegram')
if (settings.webhookEnabled && settings.webhookUrl?.trim()) channels.push('webhook')
return channels
}
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { notificationRepository } from '@cfdm/db/repositories/notifications'
import { resetTestDb } from '@cfdm/db/test-setup'
import { shouldSkipDedup, markDedupSent } from './dedup.js'
describe('notification dedup', () => {
beforeEach(() => {
resetTestDb()
})
afterEach(() => {
closeDb()
})
it('skips duplicate fingerprint mode', () => {
expect(shouldSkipDedup('sync_digest', 'fp-1', 'fingerprint')).toBe(false)
markDedupSent('sync_digest', 'fp-1', 'fingerprint')
expect(shouldSkipDedup('sync_digest', 'fp-1', 'fingerprint')).toBe(true)
expect(shouldSkipDedup('sync_digest', 'fp-2', 'fingerprint')).toBe(false)
})
it('skips state_transition when status unchanged', () => {
markDedupSent('vps_down', 'host-a', 'state_transition', 'vps_health:vps_down', 'host-a')
expect(
shouldSkipDedup('vps_down', 'host-a', 'state_transition', 'vps_health:vps_down', 'host-a'),
).toBe(true)
expect(
shouldSkipDedup('vps_down', 'host-a|host-b', 'state_transition', 'vps_health:vps_down', 'host-a|host-b'),
).toBe(false)
})
it('logs skipped entries via repository', () => {
notificationRepository.append({
event: 'test',
channel: 'webhook',
status: 'skipped',
fingerprint: 'x',
message: 'msg',
})
const rows = notificationRepository.listRecent(5)
expect(rows).toHaveLength(1)
expect(rows[0]?.status).toBe('skipped')
})
})
@@ -0,0 +1,49 @@
import { notificationRepository } from '@cfdm/db/repositories/notifications'
import type { DedupMode } from './types.js'
const DAY_MS = 24 * 60 * 60 * 1000
export function shouldSkipDedup(
event: string,
fingerprint: string,
mode: DedupMode,
stateKey?: string,
newStatus?: string,
): boolean {
const key = stateKey ?? event
const state = notificationRepository.getState(key)
const now = Date.now()
if (mode === 'state_transition') {
if (!newStatus) return false
if (state?.lastStatus === newStatus) return true
return false
}
if (mode === 'fingerprint') {
if (state?.lastFingerprint === fingerprint) return true
return false
}
// daily
if (state?.lastFingerprint === fingerprint && state.lastSentAt) {
const last = new Date(state.lastSentAt).getTime()
if (!Number.isNaN(last) && now - last < DAY_MS) return true
}
return false
}
export function markDedupSent(
event: string,
fingerprint: string,
mode: DedupMode,
stateKey?: string,
newStatus?: string,
): void {
const key = stateKey ?? event
notificationRepository.upsertState(key, {
lastFingerprint: fingerprint,
lastSentAt: new Date().toISOString(),
lastStatus: mode === 'state_transition' ? newStatus : undefined,
})
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { publishNotification } from './engine.js'
import type { NotificationPayload } from './types.js'
describe('notification engine', () => {
beforeEach(() => {
resetTestDb()
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('ok', { status: 200 })),
)
})
afterEach(() => {
vi.unstubAllGlobals()
closeDb()
})
it('sends webhook without telegram configured', async () => {
const payload: NotificationPayload = {
event: 'payment_expiry',
fingerprint: 'fp-test',
messagePlain: 'test message',
dedup: 'fingerprint',
}
const result = await publishNotification(
{
notifyPaymentExpiryEnabled: true,
webhookEnabled: true,
webhookUrl: 'https://example.com/hook',
},
payload,
)
expect(result).toBe('sent')
expect(fetch).toHaveBeenCalled()
})
it('skips when event disabled', async () => {
const payload: NotificationPayload = {
event: 'payment_expiry',
fingerprint: 'fp-test',
messagePlain: 'test',
dedup: 'fingerprint',
}
const result = await publishNotification(
{
notifyPaymentExpiryEnabled: false,
webhookEnabled: true,
webhookUrl: 'https://example.com/hook',
},
payload,
)
expect(result).toBe('skipped')
expect(fetch).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,94 @@
import { notificationRepository } from '@cfdm/db/repositories/notifications'
import { activeChannels, deliverTelegram, deliverWebhook } from './channels.js'
import { markDedupSent, shouldSkipDedup } from './dedup.js'
import { eventEnabled, type NotificationPayload, type SettingsNotifyRow } from './types.js'
export async function publishNotification(
settings: SettingsNotifyRow,
payload: NotificationPayload,
): Promise<'sent' | 'skipped' | 'failed'> {
if (!eventEnabled(settings, payload.event)) return 'skipped'
const channels = activeChannels(settings)
if (channels.length === 0) return 'skipped'
if (
shouldSkipDedup(
payload.event,
payload.fingerprint,
payload.dedup,
payload.stateKey,
payload.newStatus,
)
) {
for (const channel of channels) {
notificationRepository.append({
event: payload.event,
channel,
status: 'skipped',
fingerprint: payload.fingerprint,
message: payload.messagePlain,
payload: payload.data,
})
}
return 'skipped'
}
let anySent = false
let anyFailed = false
for (const channel of channels) {
if (channel === 'telegram') {
const result = await deliverTelegram(settings, payload.messageHtml ?? payload.messagePlain)
notificationRepository.append({
event: payload.event,
channel,
status: result.ok ? 'sent' : 'failed',
fingerprint: payload.fingerprint,
message: payload.messagePlain,
payload: { ...payload.data, error: result.error },
})
if (result.ok) anySent = true
else anyFailed = true
} else {
const result = await deliverWebhook(settings, {
event: payload.event,
message: payload.messagePlain,
data: payload.data,
timestamp: new Date().toISOString(),
})
notificationRepository.append({
event: payload.event,
channel,
status: result.ok ? 'sent' : 'failed',
fingerprint: payload.fingerprint,
message: payload.messagePlain,
payload: { ...payload.data, error: result.error },
})
if (result.ok) anySent = true
else anyFailed = true
}
}
if (anySent) {
markDedupSent(
payload.event,
payload.fingerprint,
payload.dedup,
payload.stateKey,
payload.newStatus,
)
}
if (anySent) return 'sent'
if (anyFailed) return 'failed'
return 'skipped'
}
export async function publishMany(
settings: SettingsNotifyRow,
payloads: (NotificationPayload | null)[],
): Promise<void> {
for (const payload of payloads) {
if (payload) await publishNotification(settings, payload)
}
}
@@ -0,0 +1,130 @@
import { desc } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getPaidUntilDate } from '@cfdm/shared/utils/paid-until'
import type { NotificationPayload } from './types.js'
const UPCOMING_DAYS = 7
type VpsRow = typeof schema.vps.$inferSelect
export function buildPaymentExpiryNotification(now = new Date()): NotificationPayload | null {
const db = getDb()
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const providerAccounts = db.select().from(schema.providerAccounts).all()
const payments = db.select().from(schema.payments).all()
const balanceLedger = db.select().from(schema.balanceLedger).all()
const providers = db.select().from(schema.providers).all()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps: vpsList, providerAccounts, payments, balanceLedger, now }
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(vps, ctx)
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
if (upcoming.length === 0) return null
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
const dateStr = paidUntil.toLocaleDateString('ru-RU')
return `${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const plain = `Истекает оплата (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
const html = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
const fingerprint = upcoming
.map(({ vps, paidUntil }) => `${vps.id}:${paidUntil.toISOString().slice(0, 10)}`)
.sort()
.join('|')
return {
event: 'payment_expiry',
fingerprint,
messagePlain: plain,
messageHtml: html,
data: { count: upcoming.length, vpsIds: upcoming.map((u) => u.vps.id) },
dedup: 'daily',
}
}
export function buildSyncDigestNotification(digestLines: string[]): NotificationPayload | null {
const changed = digestLines.filter((line) => !line.includes('без изменений'))
if (changed.length === 0) return null
const plain = `Синхронизация VPS:\n\n${changed.join('\n')}`
const html = `📋 <b>Синхронизация VPS</b>\n\n${changed.join('\n')}`
return {
event: 'sync_digest',
fingerprint: changed.sort().join('|'),
messagePlain: plain,
messageHtml: html,
data: { lines: changed },
dedup: 'fingerprint',
}
}
export function buildLowBalanceNotification(lines: string[]): NotificationPayload | null {
if (lines.length === 0) return null
const plain = `Низкий баланс:\n\n${lines.join('\n')}`
const html = `💰 <b>Низкий баланс</b>\n\n${lines.join('\n')}`
return {
event: 'low_balance',
fingerprint: lines.sort().join('|'),
messagePlain: plain,
messageHtml: html,
data: { lines },
dedup: 'daily',
}
}
export function buildNewTariffsNotification(
providerName: string,
tariffs: { name?: string | null; price?: string | null }[],
): NotificationPayload | null {
if (tariffs.length === 0) return null
const lines = tariffs.slice(0, 15).map((t) => `${t.name || '—'}${t.price || '—'}`)
const plain = `Новые тарифы (${providerName}):\n\n${lines.join('\n')}`
const html = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
const fingerprint = tariffs
.map((t) => `${t.name}:${t.price}`)
.sort()
.join('|')
return {
event: 'new_tariffs',
fingerprint: `${providerName}:${fingerprint}`,
messagePlain: plain,
messageHtml: html,
data: { providerName, count: tariffs.length },
dedup: 'fingerprint',
}
}
export function buildVpsHealthNotification(
event: 'vps_down' | 'vps_up',
hosts: { id: string; label: string }[],
): NotificationPayload | null {
if (hosts.length === 0) return null
const lines = hosts.map((h) => `${h.label}`)
const title = event === 'vps_down' ? 'VPS недоступны' : 'VPS восстановлены'
const icon = event === 'vps_down' ? '🔴' : '🟢'
const plain = `${title}:\n\n${lines.join('\n')}`
const html = `${icon} <b>${title}</b>\n\n${lines.join('\n')}`
const fingerprint = hosts
.map((h) => h.id)
.sort()
.join('|')
return {
event,
fingerprint,
messagePlain: plain,
messageHtml: html,
data: { hosts: hosts.map((h) => h.id) },
dedup: 'state_transition',
stateKey: `vps_health:${event}`,
newStatus: fingerprint,
}
}
@@ -0,0 +1,60 @@
export const NOTIFICATION_EVENTS = [
'payment_expiry',
'sync_digest',
'low_balance',
'new_tariffs',
'vps_down',
'vps_up',
] as const
export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number]
export type NotificationChannel = 'telegram' | 'webhook'
export type DedupMode = 'daily' | 'fingerprint' | 'state_transition'
export interface NotificationPayload {
event: NotificationEvent
fingerprint: string
messagePlain: string
messageHtml?: string
data?: Record<string, unknown>
dedup: DedupMode
stateKey?: string
newStatus?: string
}
export interface SettingsNotifyRow {
telegramBotToken?: string | null
telegramChatId?: string | null
telegramMessageThreadId?: string | null
webhookUrl?: string | null
webhookEnabled?: number | boolean | null
notifyPaymentExpiryEnabled?: number | boolean | null
notifyNewTariffsEnabled?: number | boolean | null
notifyLowBalanceEnabled?: number | boolean | null
notifySyncDigestEnabled?: number | boolean | null
notifyVpsDownEnabled?: number | boolean | null
}
export function isNotifyFlagEnabled(flag: number | boolean | null | undefined): boolean {
return flag !== 0 && flag !== false
}
export function eventEnabled(settings: SettingsNotifyRow, event: NotificationEvent): boolean {
switch (event) {
case 'payment_expiry':
return isNotifyFlagEnabled(settings.notifyPaymentExpiryEnabled)
case 'sync_digest':
return isNotifyFlagEnabled(settings.notifySyncDigestEnabled)
case 'low_balance':
return isNotifyFlagEnabled(settings.notifyLowBalanceEnabled)
case 'new_tariffs':
return isNotifyFlagEnabled(settings.notifyNewTariffsEnabled)
case 'vps_down':
case 'vps_up':
return isNotifyFlagEnabled(settings.notifyVpsDownEnabled)
default:
return false
}
}
+66 -183
View File
@@ -1,24 +1,27 @@
import { and, desc, eq, sql } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { billmanagerAccountRowForSync } from './billmanager/context.js'
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
import { sendTelegramMessage } from './telegram.js'
import { notifyWebhook } from './webhook.js'
import { runVpsUptimeChecks } from './uptime-check.js'
import { publishMany, publishNotification } from './notifications/engine.js'
import {
buildLowBalanceNotification,
buildNewTariffsNotification,
buildPaymentExpiryNotification,
buildSyncDigestNotification,
buildVpsHealthNotification,
} from './notifications/rules.js'
let syncIntervalId: ReturnType<typeof setInterval> | null = null
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const UPCOMING_DAYS = 7
const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type VpsRow = typeof schema.vps.$inferSelect
type PaymentRow = typeof schema.payments.$inferSelect
type LedgerRow = typeof schema.balanceLedger.$inferSelect
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
const db = getDb()
@@ -37,127 +40,15 @@ function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAcco
.filter((a): a is NonNullable<typeof a> => a != null)
}
function getAccountBalance(
accountId: string,
providerAccounts: AccountRow[],
balanceLedger: LedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
return Number(account.balanceApi)
export async function runNotificationTick(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
} catch (err) {
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
}
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = rows
.filter((row) => row.direction === 'credit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = rows
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits
}
function getPaidUntilDate(
vps: VpsRow,
providerAccounts: AccountRow[],
payments: PaymentRow[],
balanceLedger: LedgerRow[],
now: Date,
): Date | null {
if (vps.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
let paidUntilFromApi: Date | null = null
if (vps.paidUntil) {
const d = new Date(vps.paidUntil)
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
}
const isPaidUntilNextDay =
paidUntilFromApi &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
const dailyRate = Number(vps.dailyRate || 0)
const monthlyRate = Number(vps.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger)
const activeInAccount = getDb()
.select({ id: schema.vps.id })
.from(schema.vps)
.where(
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
)
.all().length
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
const directPayments = payments
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
const paidUntil = new Date(now)
paidUntil.setDate(paidUntil.getDate() + coveredDays)
return paidUntil
}
async function sendPaymentExpiryNotifications(): Promise<void> {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (
!settings?.notifyPaymentExpiryEnabled ||
!settings.telegramBotToken?.trim() ||
!settings.telegramChatId?.trim()
) {
return
}
const db = getDb()
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const providerAccounts = db.select().from(schema.providerAccounts).all()
const payments = db.select().from(schema.payments).all()
const balanceLedger = db.select().from(schema.balanceLedger).all()
const providers = db.select().from(schema.providers).all()
const now = new Date()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
if (upcoming.length === 0) return
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
const dateStr = paidUntil.toLocaleDateString('ru-RU')
return `${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
text,
settings.telegramMessageThreadId,
)
await notifyWebhook(settings, 'payment_expiry', text.replace(/<[^>]+>/g, ''), {
count: upcoming.length,
})
}
export async function runScheduledSync(): Promise<void> {
@@ -168,9 +59,6 @@ export async function runScheduledSync(): Promise<void> {
const accounts = getBillmanagerAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
const token = settings.telegramBotToken?.trim()
const chatId = settings.telegramChatId?.trim()
const canTg = Boolean(token && chatId)
for (const account of accounts) {
try {
@@ -185,7 +73,6 @@ export async function runScheduledSync(): Promise<void> {
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
canTg &&
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
@@ -202,20 +89,10 @@ export async function runScheduledSync(): Promise<void> {
}
}
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
const msg = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
await notifyWebhook(settings, 'sync_digest', msg.replace(/<[^>]+>/g, ''), { lines: digestLines })
}
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
const msg = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
await notifyWebhook(settings, 'low_balance', msg.replace(/<[^>]+>/g, ''), { lines: lowBalanceLines })
}
if (settings.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications()
}
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
} catch (err) {
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
}
@@ -233,21 +110,14 @@ export async function runScheduledSyncTariffs(): Promise<void> {
try {
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (
newTariffs.length > 0 &&
settings.notifyNewTariffsEnabled &&
settings.telegramBotToken?.trim() &&
settings.telegramChatId?.trim()
) {
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const lines = newTariffs.slice(0, 15).map((t) => `${t.name || '—'}${t.price || '—'}`)
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
settings.telegramMessageThreadId,
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
@@ -261,23 +131,19 @@ export async function runScheduledSyncTariffs(): Promise<void> {
export async function runScheduledUptimeChecks(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
const { checked, down } = await runVpsUptimeChecks()
if (down > 0 && settings) {
const msg = `VPS недоступны: ${down} из ${checked}`
if (
settings.notifyVpsDownEnabled &&
settings.telegramBotToken?.trim() &&
settings.telegramChatId?.trim()
) {
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
`🔴 <b>${msg}</b>`,
settings.telegramMessageThreadId,
)
}
await notifyWebhook(settings, 'vps_down', msg, { checked, down })
}
if (!settings) return
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}
@@ -288,22 +154,37 @@ export function startScheduler(): void {
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
if (notifyIntervalId) clearInterval(notifyIntervalId)
notifyIntervalId = null
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
uptimeIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
if (!settings) return
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000)
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
void runNotificationTick()
void runScheduledUptimeChecks()
console.log(
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min, uptime every 5 min`,
)
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
if (settings.syncEnabled) {
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(
() => void runScheduledSyncTariffs(),
tariffsInterval * 60 * 1000,
)
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
}
console.log(`Scheduler: ${parts.join(', ')}`)
} catch {
// ignore
}
@@ -314,6 +195,8 @@ export function stopScheduler(): void {
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
if (notifyIntervalId) clearInterval(notifyIntervalId)
notifyIntervalId = null
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
uptimeIntervalId = null
}
+23 -5
View File
@@ -2,20 +2,27 @@
* Telegram Bot API — отправка уведомлений
*/
export interface TelegramSendResult {
ok: boolean
error?: string
}
export async function sendTelegramMessage(
token: string,
chatIds: string | string[],
text: string,
messageThreadId?: string | number | null,
): Promise<void> {
if (!token?.trim() || !text?.trim()) return
): Promise<TelegramSendResult> {
if (!token?.trim() || !text?.trim()) {
return { ok: false, error: 'Пустой токен или текст' }
}
const ids = Array.isArray(chatIds)
? chatIds
: String(chatIds || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) return
if (ids.length === 0) return { ok: false, error: 'Не указан chat ID' }
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
const payload: Record<string, unknown> = {
@@ -26,6 +33,9 @@ export async function sendTelegramMessage(
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
const errors: string[] = []
let anyOk = false
for (const chatId of ids) {
try {
const res = await fetch(url, {
@@ -34,12 +44,20 @@ export async function sendTelegramMessage(
body: JSON.stringify({ ...payload, chat_id: chatId }),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!data.ok) {
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
if (data.ok) {
anyOk = true
} else {
const err = data.description || res.statusText || 'Unknown error'
errors.push(`${chatId}: ${err}`)
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
errors.push(`${chatId}: ${message}`)
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
}
}
if (anyOk) return { ok: true }
return { ok: false, error: errors.join('; ') || 'Не удалось отправить' }
}
+28 -4
View File
@@ -5,6 +5,20 @@ import { getDb, schema } from '@cfdm/db'
const CHECK_TIMEOUT_MS = 5000
export interface VpsHealthTransition {
id: string
label: string
previousStatus: string | null
currentStatus: 'up' | 'down'
}
export interface UptimeCheckResult {
checked: number
down: number
newlyDown: VpsHealthTransition[]
newlyUp: VpsHealthTransition[]
}
function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const started = Date.now()
return new Promise((resolve) => {
@@ -19,7 +33,7 @@ function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs:
})
}
export async function runVpsUptimeChecks(): Promise<{ checked: number; down: number }> {
export async function runVpsUptimeChecks(): Promise<UptimeCheckResult> {
const db = getDb()
const rows = db
.select()
@@ -29,6 +43,8 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
let checked = 0
let down = 0
const newlyDown: VpsHealthTransition[] = []
const newlyUp: VpsHealthTransition[] = []
const now = new Date().toISOString()
for (const row of rows) {
@@ -38,8 +54,16 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
const port = Number(row.sshPort) || 22
const result = await tcpCheck(host, port)
checked++
const status = result.ok ? 'up' : 'down'
if (!result.ok) down++
const status: 'up' | 'down' = result.ok ? 'up' : 'down'
if (status === 'down') down++
const previous = row.lastHealthStatus
const label = row.dns || row.ip || row.id
if (status === 'down' && previous !== 'down') {
newlyDown.push({ id: row.id, label, previousStatus: previous, currentStatus: 'down' })
} else if (status === 'up' && previous === 'down') {
newlyUp.push({ id: row.id, label, previousStatus: previous, currentStatus: 'up' })
}
db.insert(schema.vpsHealthChecks)
.values({
@@ -61,7 +85,7 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
.run()
}
return { checked, down }
return { checked, down, newlyDown, newlyUp }
}
export function listRecentHealthChecks(vpsId: string, limit = 20) {
+13 -235
View File
@@ -1,235 +1,13 @@
import { getDb } from './db.js'
import { runBillmanagerAccountSync } from './sync-account-job.js'
import { sendTelegramMessage } from './telegram.js'
import { billmanagerAccountRowForSync } from './utils/billmanager-context.js'
let syncIntervalId = null
let syncTariffsIntervalId = null
const UPCOMING_DAYS = 7
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = rows
.filter((row) => row.direction === 'credit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = rows
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits
}
function getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now) {
if (vps.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
let paidUntilFromApi = null
if (vps.paidUntil) {
const d = new Date(vps.paidUntil)
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
}
const isPaidUntilNextDay =
paidUntilFromApi &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi - today
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
const dailyRate = Number(vps.dailyRate || 0)
const monthlyRate = Number(vps.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const accountBalance = getAccountBalance(vps.providerAccountId, providerAccounts, balanceLedger)
const activeInAccount = db
.prepare('SELECT id FROM vps WHERE providerAccountId = ? AND status = ?')
.all(vps.providerAccountId, 'active').length
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
const directPayments = payments
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
const paidUntil = new Date(now)
paidUntil.setDate(paidUntil.getDate() + coveredDays)
return paidUntil
}
async function sendPaymentExpiryNotifications(db) {
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.notifyPaymentExpiryEnabled || !settings?.telegramBotToken?.trim() || !settings?.telegramChatId?.trim()) {
return
}
const vpsList = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const now = new Date()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const upcoming = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now)
if (!paidUntil || paidUntil > threshold || paidUntil < new Date(now.getFullYear(), now.getMonth(), now.getDate())) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil - b.paidUntil)
if (upcoming.length === 0) return
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
const dateStr = paidUntil.toLocaleDateString('ru-RU')
return `${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
}
export async function runScheduledSync() {
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return
const accountRows = db.prepare(`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`).all()
const providers = db.prepare('SELECT * FROM providers').all()
const providerById = new Map(providers.map((p) => [p.id, p]))
const accounts = accountRows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter(Boolean)
const digestLines = []
const lowBalanceLines = []
const token = settings?.telegramBotToken?.trim()
const chatId = settings?.telegramChatId?.trim()
const canTg = Boolean(token && chatId)
for (const account of accounts) {
try {
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
const s = result.syncSummary || {}
const parts = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
const apiBal = result.balance?.balance
const threshold = account.balance_alert_below
if (
canTg &&
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur = result.balance?.currency || account.balance_currency || account.currency || ''
lowBalanceLines.push(
`${account.name}: ${apiBal} ${cur} (порог ${threshold})`,
)
}
} catch (err) {
digestLines.push(`${account.name}: ${err.message || 'ошибка'}`)
}
}
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
const text = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId)
}
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
const text = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId)
}
if (settings?.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications(db)
}
} catch (err) {
console.warn('Scheduled sync error:', err.message)
}
}
export async function runScheduledSyncTariffs() {
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return
const accountRows = db.prepare(`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`).all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const providerById = new Map(providers.map((p) => [p.id, p]))
const accounts = accountRows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter(Boolean)
for (const account of accounts) {
try {
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
const newTariffs = result?.newTariffs || []
if (newTariffs.length > 0 && settings?.notifyNewTariffsEnabled && settings?.telegramBotToken?.trim() && settings?.telegramChatId?.trim()) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const lines = newTariffs.slice(0, 15).map((t) => `${t.name || '—'}${t.price || '—'}`)
const text = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err.message)
}
}
} catch (err) {
console.warn('Scheduled sync tariffs error:', err.message)
}
}
export function startScheduler() {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000)
syncTariffsIntervalId = setInterval(runScheduledSyncTariffs, tariffsInterval * 60 * 1000)
console.log(`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`)
} catch {
// ignore
}
}
/**
* @deprecated Используйте Fastify scheduler: apps/api/dist/services/scheduler.js (RUNTIME=fastify).
* Legacy Express entry re-exports compiled scheduler для совместимости.
*/
export {
startScheduler,
stopScheduler,
restartScheduler,
runScheduledSync,
runScheduledSyncTariffs,
runScheduledUptimeChecks,
runNotificationTick,
} from './dist/services/scheduler.js'
@@ -41,6 +41,7 @@ export const VPS_FORM_EMPTY: VpsFormValues = {
paidUntil: '',
project: '',
notes: '',
monitoringEnabled: false,
userOverrides: [] as string[],
customData: {} as Record<string, string | number | boolean>,
}
@@ -66,6 +67,7 @@ export function vpsFormFromRow(v: Vps): VpsFormValues {
paidUntil: v.paidUntil ?? '',
project: v.project ?? '',
notes: v.notes ?? '',
monitoringEnabled: Boolean((v as Vps & { monitoringEnabled?: boolean }).monitoringEnabled),
userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides),
customData: parseCustomData((v as Vps & { customData?: unknown }).customData),
}
@@ -284,6 +286,27 @@ export function VpsEditSheet({
/>
</FormField>
</div>
<Controller
control={control}
name="monitoringEnabled"
render={({ field }) => (
<FormField label="Мониторинг uptime" htmlFor="vps-monitoring">
<SelectField
triggerId="vps-monitoring"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'off') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
<p className="mt-1 text-xs text-muted-foreground">
TCP-проверка SSH-порта; уведомление при переходе в down/up
</p>
</FormField>
)}
/>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
<Input id="vps-cur" {...register('currency')} />
+9 -1
View File
@@ -109,7 +109,15 @@ export const api = {
fetchSyncStatus: () => fetchApi('/api/sync/status'),
sendTelegramTest: () =>
fetchApi('/api/settings/telegram/test', { method: 'POST' }),
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }),
sendWebhookTest: () =>
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
fetchNotificationLog: (limit = 50) =>
fetchApi<import('@/types/entities').NotificationLogRow[]>(
`/api/notifications/log?limit=${limit}`,
),
fetchProjectSuggestions: (q = '', limit = 25) => {
const params = new URLSearchParams()
+3
View File
@@ -59,6 +59,7 @@ export const vpsSchema = z.object({
paidUntil: z.string().optional().default(''),
project: z.string().optional().default(''),
notes: z.string().optional().default(''),
monitoringEnabled: z.boolean().optional().default(false),
userOverrides: z.array(z.string()).optional().default([]),
customData: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().default({}),
})
@@ -91,6 +92,8 @@ export const settingsSchema = z.object({
syncEnabled: z.boolean().optional().default(true),
syncIntervalMinutes: z.coerce.number().min(15).optional().default(60),
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
notifyIntervalMinutes: z.coerce.number().min(15).optional().default(60),
uptimeCheckIntervalMinutes: z.coerce.number().min(1).optional().default(5),
telegramChatId: z.string().optional().default(''),
telegramBotToken: z.string().optional().default(''),
telegramMessageThreadId: z.string().optional().default(''),
+105 -6
View File
@@ -4,6 +4,7 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { DownloadIcon, UploadIcon } from 'lucide-react'
import { useMemo } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
@@ -21,7 +22,7 @@ import { FormField } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
import type { Settings } from '@/types/entities'
import type { NotificationLogRow, Settings } from '@/types/entities'
export const Route = createFileRoute('/_auth/settings')({
loader: ({ context: { queryClient } }) =>
@@ -46,11 +47,13 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
notifyVpsDownEnabled: s.notifyVpsDownEnabled !== false,
notifyIntervalMinutes: s.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes: s.uptimeCheckIntervalMinutes ?? 5,
webhookUrl: s.webhookUrl ?? '',
webhookEnabled: s.webhookEnabled === true,
customFields: parseCustomFieldDefs(s.customFields),
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
}
}
@@ -103,6 +106,7 @@ function SettingsPage() {
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
void refetchLog()
toast.success('Настройки сохранены')
form.reset(form.getValues())
},
@@ -111,10 +115,38 @@ function SettingsPage() {
const telegramTestMut = useMutation({
mutationFn: () => api.sendTelegramTest(),
onSuccess: () => toast.success('Тестовое сообщение отправлено'),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка Telegram')
return
}
toast.success('Тестовое сообщение отправлено')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
const webhookTestMut = useMutation({
mutationFn: () => api.sendWebhookTest(),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка webhook')
return
}
toast.success('Тестовый webhook отправлен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
const { data: notificationLog = [], refetch: refetchLog } = useQuery({
queryKey: ['notifications', 'log'],
queryFn: () => api.fetchNotificationLog(30),
})
const notificationRows = useMemo(
() => notificationLog as NotificationLogRow[],
[notificationLog],
)
const backupActions = (
<div className="flex flex-wrap gap-2">
<Button
@@ -326,6 +358,28 @@ function SettingsPage() {
<FormField label="Интервал тарифов (мин)" htmlFor="set-tariff-int">
<Input id="set-tariff-int" type="number" min={60} {...form.register('syncTariffsIntervalMinutes')} />
</FormField>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Уведомления</CardTitle>
<CardDescription>События, интервалы и каналы доставки</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Интервал проверки оплаты (мин)" htmlFor="set-notify-int">
<Input id="set-notify-int" type="number" min={15} {...form.register('notifyIntervalMinutes')} />
</FormField>
<FormField label="Интервал uptime-проверки (мин)" htmlFor="set-uptime-int">
<Input
id="set-uptime-int"
type="number"
min={1}
{...form.register('uptimeCheckIntervalMinutes')}
/>
</FormField>
<Controller
control={form.control}
name="notifyLowBalanceEnabled"
@@ -382,10 +436,55 @@ function SettingsPage() {
<FormField label="Webhook URL" htmlFor="set-webhook-url" error={form.formState.errors.webhookUrl?.message}>
<Input id="set-webhook-url" placeholder="https://hooks.example.com/..." {...form.register('webhookUrl')} />
</FormField>
<LoadingButton
type="button"
variant="outline"
onClick={() => webhookTestMut.mutate()}
loading={webhookTestMut.isPending}
>
Тест webhook
</LoadingButton>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Журнал уведомлений</CardTitle>
<CardDescription>Последние попытки доставки (Telegram и webhook)</CardDescription>
</CardHeader>
<CardContent>
{notificationRows.length === 0 ? (
<p className="text-sm text-muted-foreground">Записей пока нет</p>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left">
<th className="px-3 py-2 font-medium">Время</th>
<th className="px-3 py-2 font-medium">Событие</th>
<th className="px-3 py-2 font-medium">Канал</th>
<th className="px-3 py-2 font-medium">Статус</th>
</tr>
</thead>
<tbody>
{notificationRows.map((row) => (
<tr key={row.id} className="border-b last:border-0">
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{new Date(row.createdAt).toLocaleString('ru-RU')}
</td>
<td className="px-3 py-2">{row.event}</td>
<td className="px-3 py-2">{row.channel}</td>
<td className="px-3 py-2">{row.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Кастомные поля VPS</CardTitle>
+18
View File
@@ -116,8 +116,15 @@ export interface Settings {
notifySyncDigestEnabled?: boolean
notifyPaymentExpiryEnabled?: boolean
notifyNewTariffsEnabled?: boolean
notifyVpsDownEnabled?: boolean
notifyIntervalMinutes?: number
uptimeCheckIntervalMinutes?: number
webhookUrl?: string
webhookEnabled?: boolean
telegramChatId?: string
telegramBotToken?: string
telegramMessageThreadId?: string
telegramBotTokenSet?: boolean
customFields?: CustomFieldDef[]
}
@@ -165,6 +172,17 @@ export interface RatesData {
date?: string
}
export interface NotificationLogRow {
id: string
event: string
channel: 'telegram' | 'webhook'
status: 'sent' | 'failed' | 'skipped'
fingerprint: string | null
message: string | null
payload: Record<string, unknown> | null
createdAt: string
}
export interface DataSnapshot {
vps: Vps[]
providers: Provider[]
@@ -0,0 +1,97 @@
import { randomUUID } from 'node:crypto'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
export type NotificationChannel = 'telegram' | 'webhook'
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
export interface NotificationLogRow {
id: string
event: string
channel: NotificationChannel
status: NotificationLogStatus
fingerprint: string | null
message: string | null
payload: Record<string, unknown> | null
createdAt: string
}
function parsePayload(raw: string | null): Record<string, unknown> | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function toLogDto(row: typeof schema.notificationLog.$inferSelect): NotificationLogRow {
return {
id: row.id,
event: row.event,
channel: row.channel as NotificationChannel,
status: row.status as NotificationLogStatus,
fingerprint: row.fingerprint,
message: row.message,
payload: parsePayload(row.payload),
createdAt: row.createdAt,
}
}
export const notificationRepository = {
listRecent(limit = 50): NotificationLogRow[] {
const rows = getDb()
.select()
.from(schema.notificationLog)
.orderBy(desc(schema.notificationLog.createdAt))
.limit(Math.min(200, Math.max(1, limit)))
.all()
return rows.map(toLogDto)
},
append(entry: {
event: string
channel: NotificationChannel
status: NotificationLogStatus
fingerprint?: string | null
message?: string | null
payload?: Record<string, unknown> | null
}): void {
getDb()
.insert(schema.notificationLog)
.values({
id: `nlog-${randomUUID()}`,
event: entry.event,
channel: entry.channel,
status: entry.status,
fingerprint: entry.fingerprint ?? null,
message: entry.message ?? null,
payload: entry.payload ? JSON.stringify(entry.payload) : null,
createdAt: new Date().toISOString(),
})
.run()
},
getState(key: string) {
return getDb().select().from(schema.notificationState).where(eq(schema.notificationState.key, key)).get()
},
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
const db = getDb()
const existing = this.getState(key)
const values = {
key,
lastFingerprint: patch.lastFingerprint ?? existing?.lastFingerprint ?? null,
lastSentAt: patch.lastSentAt ?? existing?.lastSentAt ?? null,
lastStatus: patch.lastStatus ?? existing?.lastStatus ?? null,
}
if (existing) {
db.update(schema.notificationState).set(values).where(eq(schema.notificationState.key, key)).run()
} else {
db.insert(schema.notificationState).values(values).run()
}
},
}
+14
View File
@@ -13,6 +13,8 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
notifySyncDigestEnabled: boolean
notifyVpsDownEnabled: boolean
webhookEnabled: boolean
notifyIntervalMinutes: number
uptimeCheckIntervalMinutes: number
customFields: unknown[]
}
@@ -38,6 +40,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
webhookEnabled: Boolean(row.webhookEnabled),
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
customFields: Array.isArray(customFields) ? customFields : [],
}
}
@@ -67,6 +71,8 @@ interface SettingsInput {
notifyVpsDownEnabled?: boolean
webhookUrl?: string
webhookEnabled?: boolean
notifyIntervalMinutes?: number
uptimeCheckIntervalMinutes?: number
customFields?: unknown
}
@@ -139,6 +145,14 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
webhookEnabled:
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
notifyIntervalMinutes:
r.notifyIntervalMinutes !== undefined
? Math.max(15, Number(r.notifyIntervalMinutes) || 60)
: existing?.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes:
r.uptimeCheckIntervalMinutes !== undefined
? Math.max(1, Number(r.uptimeCheckIntervalMinutes) || 5)
: existing?.uptimeCheckIntervalMinutes ?? 5,
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
}
}
+22
View File
@@ -7,6 +7,8 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN notifyVpsDownEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`,
`ALTER TABLE settings ADD COLUMN uptimeCheckIntervalMinutes INTEGER`,
]
const TABLE_MIGRATIONS: string[] = [
@@ -26,10 +28,30 @@ const TABLE_MIGRATIONS: string[] = [
diff TEXT,
createdAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS notification_log (
id TEXT PRIMARY KEY,
event TEXT NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL,
fingerprint TEXT,
message TEXT,
payload TEXT,
createdAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS notification_state (
key TEXT PRIMARY KEY,
lastFingerprint TEXT,
lastSentAt TEXT,
lastStatus TEXT
)`,
]
let migrated = false
export function resetRuntimeMigrate(): void {
migrated = false
}
export function ensureRuntimeSchema(sqlite: Database.Database): void {
if (migrated) return
for (const sql of TABLE_MIGRATIONS) {
+20
View File
@@ -126,6 +126,26 @@ export const settings = sqliteTable('settings', {
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
webhookUrl: text('webhookUrl'),
webhookEnabled: integer('webhookEnabled'),
notifyIntervalMinutes: integer('notifyIntervalMinutes'),
uptimeCheckIntervalMinutes: integer('uptimeCheckIntervalMinutes'),
})
export const notificationLog = sqliteTable('notification_log', {
id: text('id').primaryKey(),
event: text('event').notNull(),
channel: text('channel').notNull(),
status: text('status').notNull(),
fingerprint: text('fingerprint'),
message: text('message'),
payload: text('payload'),
createdAt: text('createdAt').notNull(),
})
export const notificationState = sqliteTable('notification_state', {
key: text('key').primaryKey(),
lastFingerprint: text('lastFingerprint'),
lastSentAt: text('lastSentAt'),
lastStatus: text('lastStatus'),
})
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
+44
View File
@@ -1,4 +1,5 @@
import { closeDb, getSqlite } from './index.js'
import { resetRuntimeMigrate } from './runtime-migrate.js'
const TEST_SCHEMA = `
CREATE TABLE IF NOT EXISTS providers (
@@ -85,10 +86,53 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
baseCurrency TEXT,
ratesUrl TEXT,
autoConvert INTEGER,
ratesUpdatedAt TEXT,
syncEnabled INTEGER,
syncIntervalMinutes INTEGER,
syncTariffsIntervalMinutes INTEGER,
customFields TEXT,
telegramBotToken TEXT,
telegramChatId TEXT,
notifyPaymentExpiryEnabled INTEGER,
notifyNewTariffsEnabled INTEGER,
telegramMessageThreadId TEXT,
notifyLowBalanceEnabled INTEGER,
notifySyncDigestEnabled INTEGER,
notifyVpsDownEnabled INTEGER,
webhookUrl TEXT,
webhookEnabled INTEGER,
notifyIntervalMinutes INTEGER,
uptimeCheckIntervalMinutes INTEGER
);
CREATE TABLE IF NOT EXISTS notification_log (
id TEXT PRIMARY KEY,
event TEXT NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL,
fingerprint TEXT,
message TEXT,
payload TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_state (
key TEXT PRIMARY KEY,
lastFingerprint TEXT,
lastSentAt TEXT,
lastStatus TEXT
);
`
export function resetTestDb(): void {
closeDb()
resetRuntimeMigrate()
process.env.DB_PATH = ':memory:'
const sqlite = getSqlite()
sqlite.exec(TEST_SCHEMA)
@@ -10,6 +10,8 @@ export const settingsSchema = z.object({
syncEnabled: z.boolean().optional(),
syncIntervalMinutes: z.coerce.number().optional(),
syncTariffsIntervalMinutes: z.coerce.number().optional(),
notifyIntervalMinutes: z.coerce.number().optional(),
uptimeCheckIntervalMinutes: z.coerce.number().optional(),
telegramBotToken: z.string().optional(),
telegramChatId: z.string().optional(),
telegramMessageThreadId: z.string().optional(),