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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 00:36:05 +07:00
co-authored by Cursor
parent 2c66d0c0ee
commit 7f91bc3624
28 changed files with 1046 additions and 437 deletions
+9
View File
@@ -79,6 +79,15 @@ vps-tracker/
Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order` Формат запроса: `?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
+5 -1
View File
@@ -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()
}
+9
View File
@@ -0,0 +1,9 @@
import type { FastifyPluginAsync } from 'fastify'
import { notificationRepository } from '@cfdm/db/repositories/notifications'
export const notificationsRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Querystring: { limit?: string } }>('/api/notifications/log', async (req) => {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50))
return notificationRepository.listRecent(limit)
})
}
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
describe('settings telegram test', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
settingsRepository.upsert('settings-main', {
telegramBotToken: 'token',
telegramChatId: '123',
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
vi.unstubAllGlobals()
closeDb()
})
it('returns telegram API error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({ ok: false, description: 'Bad Request: chat not found' }),
),
)
const res = await app.inject({ method: 'POST', url: '/api/settings/telegram/test' })
expect(res.statusCode).toBe(200)
const body = res.json() as { ok: boolean; error?: string }
expect(body.ok).toBe(false)
expect(body.error).toContain('chat not found')
})
})
+17 -2
View File
@@ -4,6 +4,7 @@ import { settingsSchema } from '@cfdm/shared/contracts/settings'
import { restartScheduler } from '../services/scheduler.js' import { 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' }
}) })
} }
+5
View File
@@ -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
}
}
+62 -179
View File
@@ -1,24 +1,27 @@
import { and, desc, eq, sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db' import { 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[],
balanceLedger: LedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
return Number(account.balanceApi)
}
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) const settings = settingsRepository.getRow(SETTINGS_ID)
if ( if (!settings) return
!settings?.notifyPaymentExpiryEnabled || const payload = buildPaymentExpiryNotification()
!settings.telegramBotToken?.trim() || if (payload) await publishNotification(settings, payload)
!settings.telegramChatId?.trim() } catch (err) {
) { console.warn('Notification tick error:', err instanceof Error ? err.message : err)
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 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()
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
if (settings.syncEnabled) {
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60) const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440) const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000) syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000) syncTariffsIntervalId = setInterval(
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000) () => void runScheduledSyncTariffs(),
void runScheduledUptimeChecks() tariffsInterval * 60 * 1000,
console.log(
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min, uptime every 5 min`,
) )
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
} }
+23 -5
View File
@@ -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('; ') || 'Не удалось отправить' }
} }
+28 -4
View File
@@ -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
View File
@@ -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')} />
+9 -1
View File
@@ -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()
+3
View File
@@ -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(''),
+105 -6
View File
@@ -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>
+18
View File
@@ -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()
}
},
}
+14
View File
@@ -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),
} }
} }
+22
View File
@@ -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) {
+20
View File
@@ -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', {
+44
View File
@@ -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(),