diff --git a/AGENTS.md b/AGENTS.md index 1c75b41..348c7d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,15 @@ vps-tracker/ Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order` +## Уведомления + +- **Движок:** `apps/api/src/services/notifications/` — rules, dedup, engine, channels +- **Планировщик:** `apps/api/src/services/scheduler.ts` — sync отдельно; notify/uptime не зависят от `syncEnabled` +- **События:** `payment_expiry`, `sync_digest`, `low_balance`, `new_tariffs`, `vps_down`, `vps_up` +- **Каналы:** Telegram (`telegram.ts`) и webhook (`webhook.ts`); webhook работает без Telegram +- **Журнал:** таблица `notification_log`, API `GET /api/notifications/log` +- **Дедупликация:** `notification_state` — daily / fingerprint / state_transition + ## Команды ```bash diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8a79fa5..f3dcb0e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,6 +22,7 @@ import { ratesProxyRoutes } from './routes/rates-proxy.js' import { migrateRoutes } from './routes/migrate.js' import { dashboardRoutes } from './routes/dashboard.js' import { auditRoutes } from './routes/audit.js' +import { notificationsRoutes } from './routes/notifications.js' import { startScheduler } from './services/scheduler.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { await app.register(migrateRoutes) await app.register(dashboardRoutes) await app.register(auditRoutes) + await app.register(notificationsRoutes) const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist') if (existsSync(staticDir)) { @@ -88,4 +90,6 @@ async function start() { } } -void start() +if (!process.env.VITEST) { + void start() +} diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts new file mode 100644 index 0000000..17ade3e --- /dev/null +++ b/apps/api/src/routes/notifications.ts @@ -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) + }) +} diff --git a/apps/api/src/routes/settings.test.ts b/apps/api/src/routes/settings.test.ts new file mode 100644 index 0000000..0492adf --- /dev/null +++ b/apps/api/src/routes/settings.test.ts @@ -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> + + 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') + }) +}) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 8055563..3f4c210 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -4,6 +4,7 @@ import { settingsSchema } from '@cfdm/shared/contracts/settings' import { restartScheduler } from '../services/scheduler.js' import { sendTelegramMessage } from '../services/telegram.js' +import { deliverWebhook } from '../services/notifications/channels.js' export const settingsRoutes: FastifyPluginAsync = async (app) => { app.get('/api/settings', async () => settingsRepository.list()) @@ -34,12 +35,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => { if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) { return { ok: false, error: 'Укажите токен бота и chat ID в настройках' } } - await sendTelegramMessage( + const result = await sendTelegramMessage( settings.telegramBotToken, settings.telegramChatId, '✅ VPS Tracker: тестовое сообщение', settings.telegramMessageThreadId, ) - return { ok: true } + return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' } + }) + + app.post('/api/settings/webhook/test', async () => { + const settings = settingsRepository.getRow('settings-main') + if (!settings?.webhookEnabled) { + return { ok: false, error: 'Включите webhook в настройках' } + } + const result = await deliverWebhook(settings, { + event: 'test', + message: 'VPS Tracker: тестовое webhook-сообщение', + timestamp: new Date().toISOString(), + data: { source: 'settings_test' }, + }) + return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' } }) } diff --git a/apps/api/src/services/backup-import.ts b/apps/api/src/services/backup-import.ts index cdb84e5..542b58e 100644 --- a/apps/api/src/services/backup-import.ts +++ b/apps/api/src/services/backup-import.ts @@ -123,6 +123,11 @@ export function importJsonSnapshot(data: BackupPayload): void { customFields: customFields != null ? String(customFields) : null, notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0, notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0, + notifyVpsDownEnabled: s.notifyVpsDownEnabled ? 1 : 0, + webhookUrl: String(s.webhookUrl ?? ''), + webhookEnabled: s.webhookEnabled ? 1 : 0, + notifyIntervalMinutes: Number(s.notifyIntervalMinutes) || 60, + uptimeCheckIntervalMinutes: Number(s.uptimeCheckIntervalMinutes) || 5, }) .run() } diff --git a/apps/api/src/services/notifications/channels.ts b/apps/api/src/services/notifications/channels.ts new file mode 100644 index 0000000..59651dc --- /dev/null +++ b/apps/api/src/services/notifications/channels.ts @@ -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 +} diff --git a/apps/api/src/services/notifications/dedup.test.ts b/apps/api/src/services/notifications/dedup.test.ts new file mode 100644 index 0000000..c083775 --- /dev/null +++ b/apps/api/src/services/notifications/dedup.test.ts @@ -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') + }) +}) diff --git a/apps/api/src/services/notifications/dedup.ts b/apps/api/src/services/notifications/dedup.ts new file mode 100644 index 0000000..9252420 --- /dev/null +++ b/apps/api/src/services/notifications/dedup.ts @@ -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, + }) +} diff --git a/apps/api/src/services/notifications/engine.test.ts b/apps/api/src/services/notifications/engine.test.ts new file mode 100644 index 0000000..8fd2b93 --- /dev/null +++ b/apps/api/src/services/notifications/engine.test.ts @@ -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() + }) +}) diff --git a/apps/api/src/services/notifications/engine.ts b/apps/api/src/services/notifications/engine.ts new file mode 100644 index 0000000..5c089d6 --- /dev/null +++ b/apps/api/src/services/notifications/engine.ts @@ -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 { + for (const payload of payloads) { + if (payload) await publishNotification(settings, payload) + } +} diff --git a/apps/api/src/services/notifications/rules.ts b/apps/api/src/services/notifications/rules.ts new file mode 100644 index 0000000..e7e0e35 --- /dev/null +++ b/apps/api/src/services/notifications/rules.ts @@ -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 = `⚠️ Истекает оплата (ближайшие ${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 = `📋 Синхронизация VPS\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 = `💰 Низкий баланс\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 = `🆕 Новые тарифы (${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} ${title}\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, + } +} diff --git a/apps/api/src/services/notifications/types.ts b/apps/api/src/services/notifications/types.ts new file mode 100644 index 0000000..e3b2756 --- /dev/null +++ b/apps/api/src/services/notifications/types.ts @@ -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 + 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 + } +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index 9517fb4..048bf3a 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -1,24 +1,27 @@ -import { and, desc, eq, sql } from 'drizzle-orm' +import { sql } from 'drizzle-orm' import { getDb, schema } from '@cfdm/db' import { settingsRepository } from '@cfdm/db/repositories/settings' import { billmanagerAccountRowForSync } from './billmanager/context.js' import { runBillmanagerAccountSync } from './billmanager/sync-job.js' -import { sendTelegramMessage } from './telegram.js' -import { notifyWebhook } from './webhook.js' import { runVpsUptimeChecks } from './uptime-check.js' +import { publishMany, publishNotification } from './notifications/engine.js' +import { + buildLowBalanceNotification, + buildNewTariffsNotification, + buildPaymentExpiryNotification, + buildSyncDigestNotification, + buildVpsHealthNotification, +} from './notifications/rules.js' let syncIntervalId: ReturnType | null = null let syncTariffsIntervalId: ReturnType | null = null +let notifyIntervalId: ReturnType | null = null let uptimeIntervalId: ReturnType | null = null -const UPCOMING_DAYS = 7 const SETTINGS_ID = 'settings-main' type AccountRow = typeof schema.providerAccounts.$inferSelect -type VpsRow = typeof schema.vps.$inferSelect -type PaymentRow = typeof schema.payments.$inferSelect -type LedgerRow = typeof schema.balanceLedger.$inferSelect function getBillmanagerAccounts(): NonNullable>[] { const db = getDb() @@ -37,127 +40,15 @@ function getBillmanagerAccounts(): NonNullable => a != null) } -function getAccountBalance( - accountId: string, - providerAccounts: AccountRow[], - balanceLedger: LedgerRow[], -): number { - const account = providerAccounts.find((a) => a.id === accountId) - if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) { - return Number(account.balanceApi) +export async function runNotificationTick(): Promise { + try { + const settings = settingsRepository.getRow(SETTINGS_ID) + if (!settings) return + const payload = buildPaymentExpiryNotification() + if (payload) await publishNotification(settings, payload) + } catch (err) { + console.warn('Notification tick error:', err instanceof Error ? err.message : err) } - const rows = balanceLedger.filter((row) => row.providerAccountId === accountId) - const credits = rows - .filter((row) => row.direction === 'credit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) - const debits = rows - .filter((row) => row.direction === 'debit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) - return credits - debits -} - -function getPaidUntilDate( - vps: VpsRow, - providerAccounts: AccountRow[], - payments: PaymentRow[], - balanceLedger: LedgerRow[], - now: Date, -): Date | null { - if (vps.status !== 'active') return null - const account = providerAccounts.find((a) => a.id === vps.providerAccountId) - const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly') - const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily' - - let paidUntilFromApi: Date | null = null - if (vps.paidUntil) { - const d = new Date(vps.paidUntil) - paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d - } - - const isPaidUntilNextDay = - paidUntilFromApi && - (() => { - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)) - return diffDays >= 0 && diffDays <= 2 - })() - - const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay - if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi - - const dailyRate = Number(vps.dailyRate || 0) - const monthlyRate = Number(vps.monthlyRate || 0) - const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30 - if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi - - const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger) - const activeInAccount = getDb() - .select({ id: schema.vps.id }) - .from(schema.vps) - .where( - and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')), - ) - .all().length - const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0 - const directPayments = payments - .filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment') - .reduce((acc, p) => acc + Number(p.amount || 0), 0) - const funds = directPayments + allocatedBalance - const coveredDays = Math.floor(funds / burnRate) - if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi - - const paidUntil = new Date(now) - paidUntil.setDate(paidUntil.getDate() + coveredDays) - return paidUntil -} - -async function sendPaymentExpiryNotifications(): Promise { - 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 = `⚠️ Истекает оплата (ближайшие ${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 { @@ -168,9 +59,6 @@ export async function runScheduledSync(): Promise { const accounts = getBillmanagerAccounts() const digestLines: string[] = [] const lowBalanceLines: string[] = [] - const token = settings.telegramBotToken?.trim() - const chatId = settings.telegramChatId?.trim() - const canTg = Boolean(token && chatId) for (const account of accounts) { try { @@ -185,7 +73,6 @@ export async function runScheduledSync(): Promise { const apiBal = result.balance?.balance const threshold = account.balanceAlertBelow if ( - canTg && settings.notifyLowBalanceEnabled && threshold != null && Number.isFinite(Number(threshold)) && @@ -202,20 +89,10 @@ export async function runScheduledSync(): Promise { } } - if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) { - const msg = `📋 Синхронизация VPS\n\n${digestLines.join('\n')}` - await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId) - await notifyWebhook(settings, 'sync_digest', msg.replace(/<[^>]+>/g, ''), { lines: digestLines }) - } - if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) { - const msg = `💰 Низкий баланс\n\n${lowBalanceLines.join('\n')}` - await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId) - await notifyWebhook(settings, 'low_balance', msg.replace(/<[^>]+>/g, ''), { lines: lowBalanceLines }) - } - - if (settings.notifyPaymentExpiryEnabled) { - await sendPaymentExpiryNotifications() - } + await publishMany(settings, [ + buildSyncDigestNotification(digestLines), + buildLowBalanceNotification(lowBalanceLines), + ]) } catch (err) { console.warn('Scheduled sync error:', err instanceof Error ? err.message : err) } @@ -233,21 +110,14 @@ export async function runScheduledSyncTariffs(): Promise { try { const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true }) const newTariffs = result.newTariffs || [] - if ( - newTariffs.length > 0 && - settings.notifyNewTariffsEnabled && - settings.telegramBotToken?.trim() && - settings.telegramChatId?.trim() - ) { + if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) { const provider = providers.find((p) => p.id === account.providerId) const providerName = provider?.name || account.name || '-' - const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`) - await sendTelegramMessage( - settings.telegramBotToken, - settings.telegramChatId, - `🆕 Новые тарифы (${providerName}):\n\n${lines.join('\n')}`, - settings.telegramMessageThreadId, + const payload = buildNewTariffsNotification( + providerName, + newTariffs.map((t) => ({ name: t.name, price: t.price })), ) + if (payload) await publishNotification(settings, payload) } } catch (err) { console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err) @@ -261,23 +131,19 @@ export async function runScheduledSyncTariffs(): Promise { export async function runScheduledUptimeChecks(): Promise { try { const settings = settingsRepository.getRow(SETTINGS_ID) - const { checked, down } = await runVpsUptimeChecks() - if (down > 0 && settings) { - const msg = `VPS недоступны: ${down} из ${checked}` - if ( - settings.notifyVpsDownEnabled && - settings.telegramBotToken?.trim() && - settings.telegramChatId?.trim() - ) { - await sendTelegramMessage( - settings.telegramBotToken, - settings.telegramChatId, - `🔴 ${msg}`, - settings.telegramMessageThreadId, - ) - } - await notifyWebhook(settings, 'vps_down', msg, { checked, down }) - } + if (!settings) return + + const { newlyDown, newlyUp } = await runVpsUptimeChecks() + await publishMany(settings, [ + buildVpsHealthNotification( + 'vps_down', + newlyDown.map((h) => ({ id: h.id, label: h.label })), + ), + buildVpsHealthNotification( + 'vps_up', + newlyUp.map((h) => ({ id: h.id, label: h.label })), + ), + ]) } catch (err) { console.warn('Uptime check error:', err instanceof Error ? err.message : err) } @@ -288,22 +154,37 @@ export function startScheduler(): void { syncIntervalId = null if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId) syncTariffsIntervalId = null + if (notifyIntervalId) clearInterval(notifyIntervalId) + notifyIntervalId = null if (uptimeIntervalId) clearInterval(uptimeIntervalId) uptimeIntervalId = null try { const settings = settingsRepository.getRow(SETTINGS_ID) - if (!settings?.syncEnabled) return + if (!settings) return - const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60) - const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440) - syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000) - syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000) - uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000) + const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60) + const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5) + + notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000) + uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000) + void runNotificationTick() void runScheduledUptimeChecks() - console.log( - `Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min, uptime every 5 min`, - ) + + const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`] + + if (settings.syncEnabled) { + const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60) + const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440) + syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000) + syncTariffsIntervalId = setInterval( + () => void runScheduledSyncTariffs(), + tariffsInterval * 60 * 1000, + ) + parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`) + } + + console.log(`Scheduler: ${parts.join(', ')}`) } catch { // ignore } @@ -314,6 +195,8 @@ export function stopScheduler(): void { syncIntervalId = null if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId) syncTariffsIntervalId = null + if (notifyIntervalId) clearInterval(notifyIntervalId) + notifyIntervalId = null if (uptimeIntervalId) clearInterval(uptimeIntervalId) uptimeIntervalId = null } diff --git a/apps/api/src/services/telegram.ts b/apps/api/src/services/telegram.ts index 79db8a1..da04edb 100644 --- a/apps/api/src/services/telegram.ts +++ b/apps/api/src/services/telegram.ts @@ -2,20 +2,27 @@ * Telegram Bot API — отправка уведомлений */ +export interface TelegramSendResult { + ok: boolean + error?: string +} + export async function sendTelegramMessage( token: string, chatIds: string | string[], text: string, messageThreadId?: string | number | null, -): Promise { - if (!token?.trim() || !text?.trim()) return +): Promise { + if (!token?.trim() || !text?.trim()) { + return { ok: false, error: 'Пустой токен или текст' } + } const ids = Array.isArray(chatIds) ? chatIds : String(chatIds || '') .split(',') .map((id) => id.trim()) .filter(Boolean) - if (ids.length === 0) return + if (ids.length === 0) return { ok: false, error: 'Не указан chat ID' } const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null const payload: Record = { @@ -26,6 +33,9 @@ export async function sendTelegramMessage( if (Number.isFinite(threadId)) payload.message_thread_id = threadId const url = `https://api.telegram.org/bot${token.trim()}/sendMessage` + const errors: string[] = [] + let anyOk = false + for (const chatId of ids) { try { const res = await fetch(url, { @@ -34,12 +44,20 @@ export async function sendTelegramMessage( body: JSON.stringify({ ...payload, chat_id: chatId }), }) const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string } - if (!data.ok) { - console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText) + if (data.ok) { + anyOk = true + } else { + const err = data.description || res.statusText || 'Unknown error' + errors.push(`${chatId}: ${err}`) + console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err) } } catch (err) { const message = err instanceof Error ? err.message : String(err) + errors.push(`${chatId}: ${message}`) console.warn(`Telegram sendMessage error for chat ${chatId}:`, message) } } + + if (anyOk) return { ok: true } + return { ok: false, error: errors.join('; ') || 'Не удалось отправить' } } diff --git a/apps/api/src/services/uptime-check.ts b/apps/api/src/services/uptime-check.ts index 3154922..120d150 100644 --- a/apps/api/src/services/uptime-check.ts +++ b/apps/api/src/services/uptime-check.ts @@ -5,6 +5,20 @@ import { getDb, schema } from '@cfdm/db' const CHECK_TIMEOUT_MS = 5000 +export interface VpsHealthTransition { + id: string + label: string + previousStatus: string | null + currentStatus: 'up' | 'down' +} + +export interface UptimeCheckResult { + checked: number + down: number + newlyDown: VpsHealthTransition[] + newlyUp: VpsHealthTransition[] +} + function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: number; error?: string }> { const started = Date.now() return new Promise((resolve) => { @@ -19,7 +33,7 @@ function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: }) } -export async function runVpsUptimeChecks(): Promise<{ checked: number; down: number }> { +export async function runVpsUptimeChecks(): Promise { const db = getDb() const rows = db .select() @@ -29,6 +43,8 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num let checked = 0 let down = 0 + const newlyDown: VpsHealthTransition[] = [] + const newlyUp: VpsHealthTransition[] = [] const now = new Date().toISOString() for (const row of rows) { @@ -38,8 +54,16 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num const port = Number(row.sshPort) || 22 const result = await tcpCheck(host, port) checked++ - const status = result.ok ? 'up' : 'down' - if (!result.ok) down++ + const status: 'up' | 'down' = result.ok ? 'up' : 'down' + if (status === 'down') down++ + + const previous = row.lastHealthStatus + const label = row.dns || row.ip || row.id + if (status === 'down' && previous !== 'down') { + newlyDown.push({ id: row.id, label, previousStatus: previous, currentStatus: 'down' }) + } else if (status === 'up' && previous === 'down') { + newlyUp.push({ id: row.id, label, previousStatus: previous, currentStatus: 'up' }) + } db.insert(schema.vpsHealthChecks) .values({ @@ -61,7 +85,7 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num .run() } - return { checked, down } + return { checked, down, newlyDown, newlyUp } } export function listRecentHealthChecks(vpsId: string, limit = 20) { diff --git a/apps/api/sync-scheduler.js b/apps/api/sync-scheduler.js index bd949d6..1e5f043 100644 --- a/apps/api/sync-scheduler.js +++ b/apps/api/sync-scheduler.js @@ -1,235 +1,13 @@ -import { getDb } from './db.js' -import { runBillmanagerAccountSync } from './sync-account-job.js' -import { sendTelegramMessage } from './telegram.js' -import { billmanagerAccountRowForSync } from './utils/billmanager-context.js' - -let syncIntervalId = null -let syncTariffsIntervalId = null - -const UPCOMING_DAYS = 7 - -function getAccountBalance(accountId, providerAccounts, balanceLedger) { - const account = providerAccounts.find((a) => a.id === accountId) - if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) { - return Number(account.balance_api) - } - const rows = balanceLedger.filter((row) => row.providerAccountId === accountId) - const credits = rows - .filter((row) => row.direction === 'credit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) - const debits = rows - .filter((row) => row.direction === 'debit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) - return credits - debits -} - -function getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now) { - if (vps.status !== 'active') return null - const account = providerAccounts.find((a) => a.id === vps.providerAccountId) - const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly') - const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily' - - let paidUntilFromApi = null - if (vps.paidUntil) { - const d = new Date(vps.paidUntil) - paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d - } - - const isPaidUntilNextDay = - paidUntilFromApi && - (() => { - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const diffMs = paidUntilFromApi - today - const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000)) - return diffDays >= 0 && diffDays <= 2 - })() - - const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay - - if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi - - const dailyRate = Number(vps.dailyRate || 0) - const monthlyRate = Number(vps.monthlyRate || 0) - const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30 - if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi - - const accountBalance = getAccountBalance(vps.providerAccountId, providerAccounts, balanceLedger) - const activeInAccount = db - .prepare('SELECT id FROM vps WHERE providerAccountId = ? AND status = ?') - .all(vps.providerAccountId, 'active').length - const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0 - const directPayments = payments - .filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment') - .reduce((acc, p) => acc + Number(p.amount || 0), 0) - const funds = directPayments + allocatedBalance - const coveredDays = Math.floor(funds / burnRate) - if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi - - const paidUntil = new Date(now) - paidUntil.setDate(paidUntil.getDate() + coveredDays) - return paidUntil -} - -async function sendPaymentExpiryNotifications(db) { - const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main') - if (!settings?.notifyPaymentExpiryEnabled || !settings?.telegramBotToken?.trim() || !settings?.telegramChatId?.trim()) { - return - } - const vpsList = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all() - const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all() - const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all() - const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all() - const providers = db.prepare('SELECT * FROM providers ORDER BY name').all() - - const now = new Date() - const threshold = new Date(now) - threshold.setDate(threshold.getDate() + UPCOMING_DAYS) - - const upcoming = [] - for (const vps of vpsList) { - if (vps.status !== 'active') continue - const paidUntil = getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now) - if (!paidUntil || paidUntil > threshold || paidUntil < new Date(now.getFullYear(), now.getMonth(), now.getDate())) continue - const provider = providers.find((p) => p.id === vps.providerId) - upcoming.push({ vps, paidUntil, provider: provider?.name || '-' }) - } - upcoming.sort((a, b) => a.paidUntil - b.paidUntil) - - if (upcoming.length === 0) return - - const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => { - const dateStr = paidUntil.toLocaleDateString('ru-RU') - return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}` - }) - const text = `⚠️ Истекает оплата (ближайшие ${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 = `📋 Синхронизация VPS\n\n${digestLines.join('\n')}` - await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId) - } - if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) { - const text = `💰 Низкий баланс\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 = `🆕 Новые тарифы (${providerName}):\n\n${lines.join('\n')}` - await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId) - } - } catch (err) { - console.warn(`Sync tariffs failed for account ${account.id}:`, err.message) - } - } - } catch (err) { - console.warn('Scheduled sync tariffs error:', err.message) - } -} - -export function startScheduler() { - if (syncIntervalId) clearInterval(syncIntervalId) - syncIntervalId = null - if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId) - syncTariffsIntervalId = null - try { - const db = getDb() - const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main') - if (!settings?.syncEnabled) return - const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60) - const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440) - syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000) - syncTariffsIntervalId = setInterval(runScheduledSyncTariffs, tariffsInterval * 60 * 1000) - console.log(`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`) - } catch { - // ignore - } -} +/** + * @deprecated Используйте Fastify scheduler: apps/api/dist/services/scheduler.js (RUNTIME=fastify). + * Legacy Express entry re-exports compiled scheduler для совместимости. + */ +export { + startScheduler, + stopScheduler, + restartScheduler, + runScheduledSync, + runScheduledSyncTariffs, + runScheduledUptimeChecks, + runNotificationTick, +} from './dist/services/scheduler.js' diff --git a/apps/web/src/components/domain/vps-edit-sheet.tsx b/apps/web/src/components/domain/vps-edit-sheet.tsx index a390642..78311e8 100644 --- a/apps/web/src/components/domain/vps-edit-sheet.tsx +++ b/apps/web/src/components/domain/vps-edit-sheet.tsx @@ -41,6 +41,7 @@ export const VPS_FORM_EMPTY: VpsFormValues = { paidUntil: '', project: '', notes: '', + monitoringEnabled: false, userOverrides: [] as string[], customData: {} as Record, } @@ -66,6 +67,7 @@ export function vpsFormFromRow(v: Vps): VpsFormValues { paidUntil: v.paidUntil ?? '', project: v.project ?? '', notes: v.notes ?? '', + monitoringEnabled: Boolean((v as Vps & { monitoringEnabled?: boolean }).monitoringEnabled), userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides), customData: parseCustomData((v as Vps & { customData?: unknown }).customData), } @@ -284,6 +286,27 @@ export function VpsEditSheet({ /> + ( + + field.onChange((v ?? 'off') === 'on')} + options={[ + { value: 'on', label: 'Вкл' }, + { value: 'off', label: 'Выкл' }, + ]} + /> +

+ TCP-проверка SSH-порта; уведомление при переходе в down/up +

+
+ )} + />
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 158c7e2..48d421e 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -109,7 +109,15 @@ export const api = { fetchSyncStatus: () => fetchApi('/api/sync/status'), sendTelegramTest: () => - fetchApi('/api/settings/telegram/test', { method: 'POST' }), + fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }), + + sendWebhookTest: () => + fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }), + + fetchNotificationLog: (limit = 50) => + fetchApi( + `/api/notifications/log?limit=${limit}`, + ), fetchProjectSuggestions: (q = '', limit = 25) => { const params = new URLSearchParams() diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 0b4d470..cbb7d58 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -59,6 +59,7 @@ export const vpsSchema = z.object({ paidUntil: z.string().optional().default(''), project: z.string().optional().default(''), notes: z.string().optional().default(''), + monitoringEnabled: z.boolean().optional().default(false), userOverrides: z.array(z.string()).optional().default([]), customData: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().default({}), }) @@ -91,6 +92,8 @@ export const settingsSchema = z.object({ syncEnabled: z.boolean().optional().default(true), syncIntervalMinutes: z.coerce.number().min(15).optional().default(60), syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440), + notifyIntervalMinutes: z.coerce.number().min(15).optional().default(60), + uptimeCheckIntervalMinutes: z.coerce.number().min(1).optional().default(5), telegramChatId: z.string().optional().default(''), telegramBotToken: z.string().optional().default(''), telegramMessageThreadId: z.string().optional().default(''), diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index 6e0a345..5c32556 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -4,6 +4,7 @@ import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' import { DownloadIcon, UploadIcon } from 'lucide-react' +import { useMemo } from 'react' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' @@ -21,7 +22,7 @@ import { FormField } from '@/components/form-field' import { Button } from '@cfdm/ui/components/button' import { settingsSchema, type SettingsFormValues } from '@/lib/schemas' import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor' -import type { Settings } from '@/types/entities' +import type { NotificationLogRow, Settings } from '@/types/entities' export const Route = createFileRoute('/_auth/settings')({ loader: ({ context: { queryClient } }) => @@ -46,11 +47,13 @@ function settingsToFormValues(s: Settings): SettingsFormValues { notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false, notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false, notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false, - notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false, - webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '', - webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true, + notifyVpsDownEnabled: s.notifyVpsDownEnabled !== false, + notifyIntervalMinutes: s.notifyIntervalMinutes ?? 60, + uptimeCheckIntervalMinutes: s.uptimeCheckIntervalMinutes ?? 5, + webhookUrl: s.webhookUrl ?? '', + webhookEnabled: s.webhookEnabled === true, customFields: parseCustomFieldDefs(s.customFields), - telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '', + telegramMessageThreadId: s.telegramMessageThreadId ?? '', } } @@ -103,6 +106,7 @@ function SettingsPage() { }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['snapshot'] }) + void refetchLog() toast.success('Настройки сохранены') form.reset(form.getValues()) }, @@ -111,10 +115,38 @@ function SettingsPage() { const telegramTestMut = useMutation({ mutationFn: () => api.sendTelegramTest(), - onSuccess: () => toast.success('Тестовое сообщение отправлено'), + onSuccess: (data) => { + if (!data.ok) { + toast.error(data.error ?? 'Ошибка Telegram') + return + } + toast.success('Тестовое сообщение отправлено') + }, onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'), }) + const webhookTestMut = useMutation({ + mutationFn: () => api.sendWebhookTest(), + onSuccess: (data) => { + if (!data.ok) { + toast.error(data.error ?? 'Ошибка webhook') + return + } + toast.success('Тестовый webhook отправлен') + }, + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'), + }) + + const { data: notificationLog = [], refetch: refetchLog } = useQuery({ + queryKey: ['notifications', 'log'], + queryFn: () => api.fetchNotificationLog(30), + }) + + const notificationRows = useMemo( + () => notificationLog as NotificationLogRow[], + [notificationLog], + ) + const backupActions = (