feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('; ') || 'Не удалось отправить' }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')} />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user