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
@@ -0,0 +1,97 @@
import { randomUUID } from 'node:crypto'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
export type NotificationChannel = 'telegram' | 'webhook'
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
export interface NotificationLogRow {
id: string
event: string
channel: NotificationChannel
status: NotificationLogStatus
fingerprint: string | null
message: string | null
payload: Record<string, unknown> | null
createdAt: string
}
function parsePayload(raw: string | null): Record<string, unknown> | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function toLogDto(row: typeof schema.notificationLog.$inferSelect): NotificationLogRow {
return {
id: row.id,
event: row.event,
channel: row.channel as NotificationChannel,
status: row.status as NotificationLogStatus,
fingerprint: row.fingerprint,
message: row.message,
payload: parsePayload(row.payload),
createdAt: row.createdAt,
}
}
export const notificationRepository = {
listRecent(limit = 50): NotificationLogRow[] {
const rows = getDb()
.select()
.from(schema.notificationLog)
.orderBy(desc(schema.notificationLog.createdAt))
.limit(Math.min(200, Math.max(1, limit)))
.all()
return rows.map(toLogDto)
},
append(entry: {
event: string
channel: NotificationChannel
status: NotificationLogStatus
fingerprint?: string | null
message?: string | null
payload?: Record<string, unknown> | null
}): void {
getDb()
.insert(schema.notificationLog)
.values({
id: `nlog-${randomUUID()}`,
event: entry.event,
channel: entry.channel,
status: entry.status,
fingerprint: entry.fingerprint ?? null,
message: entry.message ?? null,
payload: entry.payload ? JSON.stringify(entry.payload) : null,
createdAt: new Date().toISOString(),
})
.run()
},
getState(key: string) {
return getDb().select().from(schema.notificationState).where(eq(schema.notificationState.key, key)).get()
},
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
const db = getDb()
const existing = this.getState(key)
const values = {
key,
lastFingerprint: patch.lastFingerprint ?? existing?.lastFingerprint ?? null,
lastSentAt: patch.lastSentAt ?? existing?.lastSentAt ?? null,
lastStatus: patch.lastStatus ?? existing?.lastStatus ?? null,
}
if (existing) {
db.update(schema.notificationState).set(values).where(eq(schema.notificationState.key, key)).run()
} else {
db.insert(schema.notificationState).values(values).run()
}
},
}
+14
View File
@@ -13,6 +13,8 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
notifySyncDigestEnabled: boolean
notifyVpsDownEnabled: boolean
webhookEnabled: boolean
notifyIntervalMinutes: number
uptimeCheckIntervalMinutes: number
customFields: unknown[]
}
@@ -38,6 +40,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
webhookEnabled: Boolean(row.webhookEnabled),
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
customFields: Array.isArray(customFields) ? customFields : [],
}
}
@@ -67,6 +71,8 @@ interface SettingsInput {
notifyVpsDownEnabled?: boolean
webhookUrl?: string
webhookEnabled?: boolean
notifyIntervalMinutes?: number
uptimeCheckIntervalMinutes?: number
customFields?: unknown
}
@@ -139,6 +145,14 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
webhookEnabled:
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
notifyIntervalMinutes:
r.notifyIntervalMinutes !== undefined
? Math.max(15, Number(r.notifyIntervalMinutes) || 60)
: existing?.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes:
r.uptimeCheckIntervalMinutes !== undefined
? Math.max(1, Number(r.uptimeCheckIntervalMinutes) || 5)
: existing?.uptimeCheckIntervalMinutes ?? 5,
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
}
}
+22
View File
@@ -7,6 +7,8 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN notifyVpsDownEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`,
`ALTER TABLE settings ADD COLUMN uptimeCheckIntervalMinutes INTEGER`,
]
const TABLE_MIGRATIONS: string[] = [
@@ -26,10 +28,30 @@ const TABLE_MIGRATIONS: string[] = [
diff TEXT,
createdAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS notification_log (
id TEXT PRIMARY KEY,
event TEXT NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL,
fingerprint TEXT,
message TEXT,
payload TEXT,
createdAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS notification_state (
key TEXT PRIMARY KEY,
lastFingerprint TEXT,
lastSentAt TEXT,
lastStatus TEXT
)`,
]
let migrated = false
export function resetRuntimeMigrate(): void {
migrated = false
}
export function ensureRuntimeSchema(sqlite: Database.Database): void {
if (migrated) return
for (const sql of TABLE_MIGRATIONS) {
+20
View File
@@ -126,6 +126,26 @@ export const settings = sqliteTable('settings', {
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
webhookUrl: text('webhookUrl'),
webhookEnabled: integer('webhookEnabled'),
notifyIntervalMinutes: integer('notifyIntervalMinutes'),
uptimeCheckIntervalMinutes: integer('uptimeCheckIntervalMinutes'),
})
export const notificationLog = sqliteTable('notification_log', {
id: text('id').primaryKey(),
event: text('event').notNull(),
channel: text('channel').notNull(),
status: text('status').notNull(),
fingerprint: text('fingerprint'),
message: text('message'),
payload: text('payload'),
createdAt: text('createdAt').notNull(),
})
export const notificationState = sqliteTable('notification_state', {
key: text('key').primaryKey(),
lastFingerprint: text('lastFingerprint'),
lastSentAt: text('lastSentAt'),
lastStatus: text('lastStatus'),
})
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
+44
View File
@@ -1,4 +1,5 @@
import { closeDb, getSqlite } from './index.js'
import { resetRuntimeMigrate } from './runtime-migrate.js'
const TEST_SCHEMA = `
CREATE TABLE IF NOT EXISTS providers (
@@ -85,10 +86,53 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
baseCurrency TEXT,
ratesUrl TEXT,
autoConvert INTEGER,
ratesUpdatedAt TEXT,
syncEnabled INTEGER,
syncIntervalMinutes INTEGER,
syncTariffsIntervalMinutes INTEGER,
customFields TEXT,
telegramBotToken TEXT,
telegramChatId TEXT,
notifyPaymentExpiryEnabled INTEGER,
notifyNewTariffsEnabled INTEGER,
telegramMessageThreadId TEXT,
notifyLowBalanceEnabled INTEGER,
notifySyncDigestEnabled INTEGER,
notifyVpsDownEnabled INTEGER,
webhookUrl TEXT,
webhookEnabled INTEGER,
notifyIntervalMinutes INTEGER,
uptimeCheckIntervalMinutes INTEGER
);
CREATE TABLE IF NOT EXISTS notification_log (
id TEXT PRIMARY KEY,
event TEXT NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL,
fingerprint TEXT,
message TEXT,
payload TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_state (
key TEXT PRIMARY KEY,
lastFingerprint TEXT,
lastSentAt TEXT,
lastStatus TEXT
);
`
export function resetTestDb(): void {
closeDb()
resetRuntimeMigrate()
process.env.DB_PATH = ':memory:'
const sqlite = getSqlite()
sqlite.exec(TEST_SCHEMA)