feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -79,6 +79,15 @@ vps-tracker/
|
|||||||
|
|
||||||
Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order`
|
Формат запроса: `?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
|
```bash
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
|||||||
import { migrateRoutes } from './routes/migrate.js'
|
import { migrateRoutes } from './routes/migrate.js'
|
||||||
import { dashboardRoutes } from './routes/dashboard.js'
|
import { dashboardRoutes } from './routes/dashboard.js'
|
||||||
import { auditRoutes } from './routes/audit.js'
|
import { auditRoutes } from './routes/audit.js'
|
||||||
|
import { notificationsRoutes } from './routes/notifications.js'
|
||||||
import { startScheduler } from './services/scheduler.js'
|
import { startScheduler } from './services/scheduler.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await app.register(migrateRoutes)
|
await app.register(migrateRoutes)
|
||||||
await app.register(dashboardRoutes)
|
await app.register(dashboardRoutes)
|
||||||
await app.register(auditRoutes)
|
await app.register(auditRoutes)
|
||||||
|
await app.register(notificationsRoutes)
|
||||||
|
|
||||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||||
if (existsSync(staticDir)) {
|
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 { restartScheduler } from '../services/scheduler.js'
|
||||||
import { sendTelegramMessage } from '../services/telegram.js'
|
import { sendTelegramMessage } from '../services/telegram.js'
|
||||||
|
import { deliverWebhook } from '../services/notifications/channels.js'
|
||||||
|
|
||||||
export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.get('/api/settings', async () => settingsRepository.list())
|
app.get('/api/settings', async () => settingsRepository.list())
|
||||||
@@ -34,12 +35,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
|
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
|
||||||
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
||||||
}
|
}
|
||||||
await sendTelegramMessage(
|
const result = await sendTelegramMessage(
|
||||||
settings.telegramBotToken,
|
settings.telegramBotToken,
|
||||||
settings.telegramChatId,
|
settings.telegramChatId,
|
||||||
'✅ VPS Tracker: тестовое сообщение',
|
'✅ VPS Tracker: тестовое сообщение',
|
||||||
settings.telegramMessageThreadId,
|
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,
|
customFields: customFields != null ? String(customFields) : null,
|
||||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
|
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
|
||||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 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()
|
.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 { getDb, schema } from '@cfdm/db'
|
||||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||||
|
|
||||||
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
||||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.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 { 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 syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let syncTariffsIntervalId: 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
|
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const UPCOMING_DAYS = 7
|
|
||||||
const SETTINGS_ID = 'settings-main'
|
const SETTINGS_ID = 'settings-main'
|
||||||
|
|
||||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
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>>[] {
|
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
@@ -37,127 +40,15 @@ function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAcco
|
|||||||
.filter((a): a is NonNullable<typeof a> => a != null)
|
.filter((a): a is NonNullable<typeof a> => a != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccountBalance(
|
export async function runNotificationTick(): Promise<void> {
|
||||||
accountId: string,
|
try {
|
||||||
providerAccounts: AccountRow[],
|
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||||
balanceLedger: LedgerRow[],
|
if (!settings) return
|
||||||
): number {
|
const payload = buildPaymentExpiryNotification()
|
||||||
const account = providerAccounts.find((a) => a.id === accountId)
|
if (payload) await publishNotification(settings, payload)
|
||||||
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
|
} catch (err) {
|
||||||
return Number(account.balanceApi)
|
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> {
|
export async function runScheduledSync(): Promise<void> {
|
||||||
@@ -168,9 +59,6 @@ export async function runScheduledSync(): Promise<void> {
|
|||||||
const accounts = getBillmanagerAccounts()
|
const accounts = getBillmanagerAccounts()
|
||||||
const digestLines: string[] = []
|
const digestLines: string[] = []
|
||||||
const lowBalanceLines: string[] = []
|
const lowBalanceLines: string[] = []
|
||||||
const token = settings.telegramBotToken?.trim()
|
|
||||||
const chatId = settings.telegramChatId?.trim()
|
|
||||||
const canTg = Boolean(token && chatId)
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
try {
|
try {
|
||||||
@@ -185,7 +73,6 @@ export async function runScheduledSync(): Promise<void> {
|
|||||||
const apiBal = result.balance?.balance
|
const apiBal = result.balance?.balance
|
||||||
const threshold = account.balanceAlertBelow
|
const threshold = account.balanceAlertBelow
|
||||||
if (
|
if (
|
||||||
canTg &&
|
|
||||||
settings.notifyLowBalanceEnabled &&
|
settings.notifyLowBalanceEnabled &&
|
||||||
threshold != null &&
|
threshold != null &&
|
||||||
Number.isFinite(Number(threshold)) &&
|
Number.isFinite(Number(threshold)) &&
|
||||||
@@ -202,20 +89,10 @@ export async function runScheduledSync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
await publishMany(settings, [
|
||||||
const msg = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
|
buildSyncDigestNotification(digestLines),
|
||||||
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
|
buildLowBalanceNotification(lowBalanceLines),
|
||||||
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()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
||||||
}
|
}
|
||||||
@@ -233,21 +110,14 @@ export async function runScheduledSyncTariffs(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||||
const newTariffs = result.newTariffs || []
|
const newTariffs = result.newTariffs || []
|
||||||
if (
|
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
||||||
newTariffs.length > 0 &&
|
|
||||||
settings.notifyNewTariffsEnabled &&
|
|
||||||
settings.telegramBotToken?.trim() &&
|
|
||||||
settings.telegramChatId?.trim()
|
|
||||||
) {
|
|
||||||
const provider = providers.find((p) => p.id === account.providerId)
|
const provider = providers.find((p) => p.id === account.providerId)
|
||||||
const providerName = provider?.name || account.name || '-'
|
const providerName = provider?.name || account.name || '-'
|
||||||
const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
const payload = buildNewTariffsNotification(
|
||||||
await sendTelegramMessage(
|
providerName,
|
||||||
settings.telegramBotToken,
|
newTariffs.map((t) => ({ name: t.name, price: t.price })),
|
||||||
settings.telegramChatId,
|
|
||||||
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
|
|
||||||
settings.telegramMessageThreadId,
|
|
||||||
)
|
)
|
||||||
|
if (payload) await publishNotification(settings, payload)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : 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> {
|
export async function runScheduledUptimeChecks(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||||
const { checked, down } = await runVpsUptimeChecks()
|
if (!settings) return
|
||||||
if (down > 0 && settings) {
|
|
||||||
const msg = `VPS недоступны: ${down} из ${checked}`
|
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
|
||||||
if (
|
await publishMany(settings, [
|
||||||
settings.notifyVpsDownEnabled &&
|
buildVpsHealthNotification(
|
||||||
settings.telegramBotToken?.trim() &&
|
'vps_down',
|
||||||
settings.telegramChatId?.trim()
|
newlyDown.map((h) => ({ id: h.id, label: h.label })),
|
||||||
) {
|
),
|
||||||
await sendTelegramMessage(
|
buildVpsHealthNotification(
|
||||||
settings.telegramBotToken,
|
'vps_up',
|
||||||
settings.telegramChatId,
|
newlyUp.map((h) => ({ id: h.id, label: h.label })),
|
||||||
`🔴 <b>${msg}</b>`,
|
),
|
||||||
settings.telegramMessageThreadId,
|
])
|
||||||
)
|
|
||||||
}
|
|
||||||
await notifyWebhook(settings, 'vps_down', msg, { checked, down })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
|
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
|
||||||
}
|
}
|
||||||
@@ -288,22 +154,37 @@ export function startScheduler(): void {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||||
syncTariffsIntervalId = null
|
syncTariffsIntervalId = null
|
||||||
|
if (notifyIntervalId) clearInterval(notifyIntervalId)
|
||||||
|
notifyIntervalId = null
|
||||||
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
||||||
uptimeIntervalId = null
|
uptimeIntervalId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||||
if (!settings?.syncEnabled) return
|
if (!settings) return
|
||||||
|
|
||||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
|
||||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
|
||||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
|
||||||
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
|
||||||
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000)
|
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
|
||||||
|
void runNotificationTick()
|
||||||
void runScheduledUptimeChecks()
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -314,6 +195,8 @@ export function stopScheduler(): void {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||||
syncTariffsIntervalId = null
|
syncTariffsIntervalId = null
|
||||||
|
if (notifyIntervalId) clearInterval(notifyIntervalId)
|
||||||
|
notifyIntervalId = null
|
||||||
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
||||||
uptimeIntervalId = null
|
uptimeIntervalId = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,27 @@
|
|||||||
* Telegram Bot API — отправка уведомлений
|
* Telegram Bot API — отправка уведомлений
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface TelegramSendResult {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendTelegramMessage(
|
export async function sendTelegramMessage(
|
||||||
token: string,
|
token: string,
|
||||||
chatIds: string | string[],
|
chatIds: string | string[],
|
||||||
text: string,
|
text: string,
|
||||||
messageThreadId?: string | number | null,
|
messageThreadId?: string | number | null,
|
||||||
): Promise<void> {
|
): Promise<TelegramSendResult> {
|
||||||
if (!token?.trim() || !text?.trim()) return
|
if (!token?.trim() || !text?.trim()) {
|
||||||
|
return { ok: false, error: 'Пустой токен или текст' }
|
||||||
|
}
|
||||||
const ids = Array.isArray(chatIds)
|
const ids = Array.isArray(chatIds)
|
||||||
? chatIds
|
? chatIds
|
||||||
: String(chatIds || '')
|
: String(chatIds || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((id) => id.trim())
|
.map((id) => id.trim())
|
||||||
.filter(Boolean)
|
.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 threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
@@ -26,6 +33,9 @@ export async function sendTelegramMessage(
|
|||||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
||||||
|
|
||||||
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
||||||
|
const errors: string[] = []
|
||||||
|
let anyOk = false
|
||||||
|
|
||||||
for (const chatId of ids) {
|
for (const chatId of ids) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -34,12 +44,20 @@ export async function sendTelegramMessage(
|
|||||||
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
||||||
})
|
})
|
||||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
||||||
if (!data.ok) {
|
if (data.ok) {
|
||||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
errors.push(`${chatId}: ${message}`)
|
||||||
console.warn(`Telegram sendMessage error for chat ${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
|
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 }> {
|
function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
return new Promise((resolve) => {
|
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 db = getDb()
|
||||||
const rows = db
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
@@ -29,6 +43,8 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
|
|||||||
|
|
||||||
let checked = 0
|
let checked = 0
|
||||||
let down = 0
|
let down = 0
|
||||||
|
const newlyDown: VpsHealthTransition[] = []
|
||||||
|
const newlyUp: VpsHealthTransition[] = []
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
for (const row of rows) {
|
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 port = Number(row.sshPort) || 22
|
||||||
const result = await tcpCheck(host, port)
|
const result = await tcpCheck(host, port)
|
||||||
checked++
|
checked++
|
||||||
const status = result.ok ? 'up' : 'down'
|
const status: 'up' | 'down' = result.ok ? 'up' : 'down'
|
||||||
if (!result.ok) 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)
|
db.insert(schema.vpsHealthChecks)
|
||||||
.values({
|
.values({
|
||||||
@@ -61,7 +85,7 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { checked, down }
|
return { checked, down, newlyDown, newlyUp }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecentHealthChecks(vpsId: string, limit = 20) {
|
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'
|
* @deprecated Используйте Fastify scheduler: apps/api/dist/services/scheduler.js (RUNTIME=fastify).
|
||||||
import { sendTelegramMessage } from './telegram.js'
|
* Legacy Express entry re-exports compiled scheduler для совместимости.
|
||||||
import { billmanagerAccountRowForSync } from './utils/billmanager-context.js'
|
*/
|
||||||
|
export {
|
||||||
let syncIntervalId = null
|
startScheduler,
|
||||||
let syncTariffsIntervalId = null
|
stopScheduler,
|
||||||
|
restartScheduler,
|
||||||
const UPCOMING_DAYS = 7
|
runScheduledSync,
|
||||||
|
runScheduledSyncTariffs,
|
||||||
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
|
runScheduledUptimeChecks,
|
||||||
const account = providerAccounts.find((a) => a.id === accountId)
|
runNotificationTick,
|
||||||
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
} from './dist/services/scheduler.js'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const VPS_FORM_EMPTY: VpsFormValues = {
|
|||||||
paidUntil: '',
|
paidUntil: '',
|
||||||
project: '',
|
project: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
monitoringEnabled: false,
|
||||||
userOverrides: [] as string[],
|
userOverrides: [] as string[],
|
||||||
customData: {} as Record<string, string | number | boolean>,
|
customData: {} as Record<string, string | number | boolean>,
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ export function vpsFormFromRow(v: Vps): VpsFormValues {
|
|||||||
paidUntil: v.paidUntil ?? '',
|
paidUntil: v.paidUntil ?? '',
|
||||||
project: v.project ?? '',
|
project: v.project ?? '',
|
||||||
notes: v.notes ?? '',
|
notes: v.notes ?? '',
|
||||||
|
monitoringEnabled: Boolean((v as Vps & { monitoringEnabled?: boolean }).monitoringEnabled),
|
||||||
userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides),
|
userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides),
|
||||||
customData: parseCustomData((v as Vps & { customData?: unknown }).customData),
|
customData: parseCustomData((v as Vps & { customData?: unknown }).customData),
|
||||||
}
|
}
|
||||||
@@ -284,6 +286,27 @@ export function VpsEditSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
|
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
|
||||||
<Input id="vps-cur" {...register('currency')} />
|
<Input id="vps-cur" {...register('currency')} />
|
||||||
|
|||||||
@@ -109,7 +109,15 @@ export const api = {
|
|||||||
|
|
||||||
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
||||||
sendTelegramTest: () =>
|
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) => {
|
fetchProjectSuggestions: (q = '', limit = 25) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const vpsSchema = z.object({
|
|||||||
paidUntil: z.string().optional().default(''),
|
paidUntil: z.string().optional().default(''),
|
||||||
project: z.string().optional().default(''),
|
project: z.string().optional().default(''),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
|
monitoringEnabled: z.boolean().optional().default(false),
|
||||||
userOverrides: z.array(z.string()).optional().default([]),
|
userOverrides: z.array(z.string()).optional().default([]),
|
||||||
customData: z.record(z.union([z.string(), z.number(), z.boolean()])).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),
|
syncEnabled: z.boolean().optional().default(true),
|
||||||
syncIntervalMinutes: z.coerce.number().min(15).optional().default(60),
|
syncIntervalMinutes: z.coerce.number().min(15).optional().default(60),
|
||||||
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
|
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(''),
|
telegramChatId: z.string().optional().default(''),
|
||||||
telegramBotToken: z.string().optional().default(''),
|
telegramBotToken: z.string().optional().default(''),
|
||||||
telegramMessageThreadId: 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 { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
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 { Button } from '@cfdm/ui/components/button'
|
||||||
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
||||||
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
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')({
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -46,11 +47,13 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
|
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
|
||||||
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
|
notifyVpsDownEnabled: s.notifyVpsDownEnabled !== false,
|
||||||
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
|
notifyIntervalMinutes: s.notifyIntervalMinutes ?? 60,
|
||||||
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
|
uptimeCheckIntervalMinutes: s.uptimeCheckIntervalMinutes ?? 5,
|
||||||
|
webhookUrl: s.webhookUrl ?? '',
|
||||||
|
webhookEnabled: s.webhookEnabled === true,
|
||||||
customFields: parseCustomFieldDefs(s.customFields),
|
customFields: parseCustomFieldDefs(s.customFields),
|
||||||
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
|
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +106,7 @@ function SettingsPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
void refetchLog()
|
||||||
toast.success('Настройки сохранены')
|
toast.success('Настройки сохранены')
|
||||||
form.reset(form.getValues())
|
form.reset(form.getValues())
|
||||||
},
|
},
|
||||||
@@ -111,10 +115,38 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const telegramTestMut = useMutation({
|
const telegramTestMut = useMutation({
|
||||||
mutationFn: () => api.sendTelegramTest(),
|
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 : 'Ошибка отправки'),
|
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 = (
|
const backupActions = (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -326,6 +358,28 @@ function SettingsPage() {
|
|||||||
<FormField label="Интервал тарифов (мин)" htmlFor="set-tariff-int">
|
<FormField label="Интервал тарифов (мин)" htmlFor="set-tariff-int">
|
||||||
<Input id="set-tariff-int" type="number" min={60} {...form.register('syncTariffsIntervalMinutes')} />
|
<Input id="set-tariff-int" type="number" min={60} {...form.register('syncTariffsIntervalMinutes')} />
|
||||||
</FormField>
|
</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
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="notifyLowBalanceEnabled"
|
name="notifyLowBalanceEnabled"
|
||||||
@@ -382,10 +436,55 @@ function SettingsPage() {
|
|||||||
<FormField label="Webhook URL" htmlFor="set-webhook-url" error={form.formState.errors.webhookUrl?.message}>
|
<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')} />
|
<Input id="set-webhook-url" placeholder="https://hooks.example.com/..." {...form.register('webhookUrl')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => webhookTestMut.mutate()}
|
||||||
|
loading={webhookTestMut.isPending}
|
||||||
|
>
|
||||||
|
Тест webhook
|
||||||
|
</LoadingButton>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Кастомные поля VPS</CardTitle>
|
<CardTitle>Кастомные поля VPS</CardTitle>
|
||||||
|
|||||||
@@ -116,8 +116,15 @@ export interface Settings {
|
|||||||
notifySyncDigestEnabled?: boolean
|
notifySyncDigestEnabled?: boolean
|
||||||
notifyPaymentExpiryEnabled?: boolean
|
notifyPaymentExpiryEnabled?: boolean
|
||||||
notifyNewTariffsEnabled?: boolean
|
notifyNewTariffsEnabled?: boolean
|
||||||
|
notifyVpsDownEnabled?: boolean
|
||||||
|
notifyIntervalMinutes?: number
|
||||||
|
uptimeCheckIntervalMinutes?: number
|
||||||
|
webhookUrl?: string
|
||||||
|
webhookEnabled?: boolean
|
||||||
telegramChatId?: string
|
telegramChatId?: string
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
|
telegramMessageThreadId?: string
|
||||||
|
telegramBotTokenSet?: boolean
|
||||||
customFields?: CustomFieldDef[]
|
customFields?: CustomFieldDef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +172,17 @@ export interface RatesData {
|
|||||||
date?: string
|
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 {
|
export interface DataSnapshot {
|
||||||
vps: Vps[]
|
vps: Vps[]
|
||||||
providers: Provider[]
|
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
|
notifySyncDigestEnabled: boolean
|
||||||
notifyVpsDownEnabled: boolean
|
notifyVpsDownEnabled: boolean
|
||||||
webhookEnabled: boolean
|
webhookEnabled: boolean
|
||||||
|
notifyIntervalMinutes: number
|
||||||
|
uptimeCheckIntervalMinutes: number
|
||||||
customFields: unknown[]
|
customFields: unknown[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||||
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||||
webhookEnabled: Boolean(row.webhookEnabled),
|
webhookEnabled: Boolean(row.webhookEnabled),
|
||||||
|
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
||||||
|
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
||||||
customFields: Array.isArray(customFields) ? customFields : [],
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,8 @@ interface SettingsInput {
|
|||||||
notifyVpsDownEnabled?: boolean
|
notifyVpsDownEnabled?: boolean
|
||||||
webhookUrl?: string
|
webhookUrl?: string
|
||||||
webhookEnabled?: boolean
|
webhookEnabled?: boolean
|
||||||
|
notifyIntervalMinutes?: number
|
||||||
|
uptimeCheckIntervalMinutes?: number
|
||||||
customFields?: unknown
|
customFields?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +145,14 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
|||||||
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
|
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
|
||||||
webhookEnabled:
|
webhookEnabled:
|
||||||
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
|
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),
|
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 notifyVpsDownEnabled INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
|
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
|
||||||
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
|
`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[] = [
|
const TABLE_MIGRATIONS: string[] = [
|
||||||
@@ -26,10 +28,30 @@ const TABLE_MIGRATIONS: string[] = [
|
|||||||
diff TEXT,
|
diff TEXT,
|
||||||
createdAt TEXT NOT NULL
|
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
|
let migrated = false
|
||||||
|
|
||||||
|
export function resetRuntimeMigrate(): void {
|
||||||
|
migrated = false
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||||
if (migrated) return
|
if (migrated) return
|
||||||
for (const sql of TABLE_MIGRATIONS) {
|
for (const sql of TABLE_MIGRATIONS) {
|
||||||
|
|||||||
@@ -126,6 +126,26 @@ export const settings = sqliteTable('settings', {
|
|||||||
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
|
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
|
||||||
webhookUrl: text('webhookUrl'),
|
webhookUrl: text('webhookUrl'),
|
||||||
webhookEnabled: integer('webhookEnabled'),
|
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', {
|
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { closeDb, getSqlite } from './index.js'
|
import { closeDb, getSqlite } from './index.js'
|
||||||
|
import { resetRuntimeMigrate } from './runtime-migrate.js'
|
||||||
|
|
||||||
const TEST_SCHEMA = `
|
const TEST_SCHEMA = `
|
||||||
CREATE TABLE IF NOT EXISTS providers (
|
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 (providerAccountId) REFERENCES provider_accounts(id),
|
||||||
FOREIGN KEY (providerId) REFERENCES providers(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 {
|
export function resetTestDb(): void {
|
||||||
closeDb()
|
closeDb()
|
||||||
|
resetRuntimeMigrate()
|
||||||
process.env.DB_PATH = ':memory:'
|
process.env.DB_PATH = ':memory:'
|
||||||
const sqlite = getSqlite()
|
const sqlite = getSqlite()
|
||||||
sqlite.exec(TEST_SCHEMA)
|
sqlite.exec(TEST_SCHEMA)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const settingsSchema = z.object({
|
|||||||
syncEnabled: z.boolean().optional(),
|
syncEnabled: z.boolean().optional(),
|
||||||
syncIntervalMinutes: z.coerce.number().optional(),
|
syncIntervalMinutes: z.coerce.number().optional(),
|
||||||
syncTariffsIntervalMinutes: z.coerce.number().optional(),
|
syncTariffsIntervalMinutes: z.coerce.number().optional(),
|
||||||
|
notifyIntervalMinutes: z.coerce.number().optional(),
|
||||||
|
uptimeCheckIntervalMinutes: z.coerce.number().optional(),
|
||||||
telegramBotToken: z.string().optional(),
|
telegramBotToken: z.string().optional(),
|
||||||
telegramChatId: z.string().optional(),
|
telegramChatId: z.string().optional(),
|
||||||
telegramMessageThreadId: z.string().optional(),
|
telegramMessageThreadId: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user