feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '../index.js'
|
||||
|
||||
export type NotificationChannel = 'telegram' | 'webhook'
|
||||
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
|
||||
|
||||
export interface NotificationLogRow {
|
||||
id: string
|
||||
event: string
|
||||
channel: NotificationChannel
|
||||
status: NotificationLogStatus
|
||||
fingerprint: string | null
|
||||
message: string | null
|
||||
payload: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function parsePayload(raw: string | null): Record<string, unknown> | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toLogDto(row: typeof schema.notificationLog.$inferSelect): NotificationLogRow {
|
||||
return {
|
||||
id: row.id,
|
||||
event: row.event,
|
||||
channel: row.channel as NotificationChannel,
|
||||
status: row.status as NotificationLogStatus,
|
||||
fingerprint: row.fingerprint,
|
||||
message: row.message,
|
||||
payload: parsePayload(row.payload),
|
||||
createdAt: row.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationRepository = {
|
||||
listRecent(limit = 50): NotificationLogRow[] {
|
||||
const rows = getDb()
|
||||
.select()
|
||||
.from(schema.notificationLog)
|
||||
.orderBy(desc(schema.notificationLog.createdAt))
|
||||
.limit(Math.min(200, Math.max(1, limit)))
|
||||
.all()
|
||||
return rows.map(toLogDto)
|
||||
},
|
||||
|
||||
append(entry: {
|
||||
event: string
|
||||
channel: NotificationChannel
|
||||
status: NotificationLogStatus
|
||||
fingerprint?: string | null
|
||||
message?: string | null
|
||||
payload?: Record<string, unknown> | null
|
||||
}): void {
|
||||
getDb()
|
||||
.insert(schema.notificationLog)
|
||||
.values({
|
||||
id: `nlog-${randomUUID()}`,
|
||||
event: entry.event,
|
||||
channel: entry.channel,
|
||||
status: entry.status,
|
||||
fingerprint: entry.fingerprint ?? null,
|
||||
message: entry.message ?? null,
|
||||
payload: entry.payload ? JSON.stringify(entry.payload) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
},
|
||||
|
||||
getState(key: string) {
|
||||
return getDb().select().from(schema.notificationState).where(eq(schema.notificationState.key, key)).get()
|
||||
},
|
||||
|
||||
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
|
||||
const db = getDb()
|
||||
const existing = this.getState(key)
|
||||
const values = {
|
||||
key,
|
||||
lastFingerprint: patch.lastFingerprint ?? existing?.lastFingerprint ?? null,
|
||||
lastSentAt: patch.lastSentAt ?? existing?.lastSentAt ?? null,
|
||||
lastStatus: patch.lastStatus ?? existing?.lastStatus ?? null,
|
||||
}
|
||||
if (existing) {
|
||||
db.update(schema.notificationState).set(values).where(eq(schema.notificationState.key, key)).run()
|
||||
} else {
|
||||
db.insert(schema.notificationState).values(values).run()
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
|
||||
notifySyncDigestEnabled: boolean
|
||||
notifyVpsDownEnabled: boolean
|
||||
webhookEnabled: boolean
|
||||
notifyIntervalMinutes: number
|
||||
uptimeCheckIntervalMinutes: number
|
||||
customFields: unknown[]
|
||||
}
|
||||
|
||||
@@ -38,6 +40,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||
webhookEnabled: Boolean(row.webhookEnabled),
|
||||
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
||||
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
||||
customFields: Array.isArray(customFields) ? customFields : [],
|
||||
}
|
||||
}
|
||||
@@ -67,6 +71,8 @@ interface SettingsInput {
|
||||
notifyVpsDownEnabled?: boolean
|
||||
webhookUrl?: string
|
||||
webhookEnabled?: boolean
|
||||
notifyIntervalMinutes?: number
|
||||
uptimeCheckIntervalMinutes?: number
|
||||
customFields?: unknown
|
||||
}
|
||||
|
||||
@@ -139,6 +145,14 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
||||
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
|
||||
webhookEnabled:
|
||||
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
|
||||
notifyIntervalMinutes:
|
||||
r.notifyIntervalMinutes !== undefined
|
||||
? Math.max(15, Number(r.notifyIntervalMinutes) || 60)
|
||||
: existing?.notifyIntervalMinutes ?? 60,
|
||||
uptimeCheckIntervalMinutes:
|
||||
r.uptimeCheckIntervalMinutes !== undefined
|
||||
? Math.max(1, Number(r.uptimeCheckIntervalMinutes) || 5)
|
||||
: existing?.uptimeCheckIntervalMinutes ?? 5,
|
||||
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const COLUMN_MIGRATIONS: string[] = [
|
||||
`ALTER TABLE settings ADD COLUMN notifyVpsDownEnabled INTEGER`,
|
||||
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
|
||||
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
|
||||
`ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`,
|
||||
`ALTER TABLE settings ADD COLUMN uptimeCheckIntervalMinutes INTEGER`,
|
||||
]
|
||||
|
||||
const TABLE_MIGRATIONS: string[] = [
|
||||
@@ -26,10 +28,30 @@ const TABLE_MIGRATIONS: string[] = [
|
||||
diff TEXT,
|
||||
createdAt TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
message TEXT,
|
||||
payload TEXT,
|
||||
createdAt TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
lastFingerprint TEXT,
|
||||
lastSentAt TEXT,
|
||||
lastStatus TEXT
|
||||
)`,
|
||||
]
|
||||
|
||||
let migrated = false
|
||||
|
||||
export function resetRuntimeMigrate(): void {
|
||||
migrated = false
|
||||
}
|
||||
|
||||
export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||
if (migrated) return
|
||||
for (const sql of TABLE_MIGRATIONS) {
|
||||
|
||||
@@ -126,6 +126,26 @@ export const settings = sqliteTable('settings', {
|
||||
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
|
||||
webhookUrl: text('webhookUrl'),
|
||||
webhookEnabled: integer('webhookEnabled'),
|
||||
notifyIntervalMinutes: integer('notifyIntervalMinutes'),
|
||||
uptimeCheckIntervalMinutes: integer('uptimeCheckIntervalMinutes'),
|
||||
})
|
||||
|
||||
export const notificationLog = sqliteTable('notification_log', {
|
||||
id: text('id').primaryKey(),
|
||||
event: text('event').notNull(),
|
||||
channel: text('channel').notNull(),
|
||||
status: text('status').notNull(),
|
||||
fingerprint: text('fingerprint'),
|
||||
message: text('message'),
|
||||
payload: text('payload'),
|
||||
createdAt: text('createdAt').notNull(),
|
||||
})
|
||||
|
||||
export const notificationState = sqliteTable('notification_state', {
|
||||
key: text('key').primaryKey(),
|
||||
lastFingerprint: text('lastFingerprint'),
|
||||
lastSentAt: text('lastSentAt'),
|
||||
lastStatus: text('lastStatus'),
|
||||
})
|
||||
|
||||
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { closeDb, getSqlite } from './index.js'
|
||||
import { resetRuntimeMigrate } from './runtime-migrate.js'
|
||||
|
||||
const TEST_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
@@ -85,10 +86,53 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
|
||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||
FOREIGN KEY (providerId) REFERENCES providers(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
baseCurrency TEXT,
|
||||
ratesUrl TEXT,
|
||||
autoConvert INTEGER,
|
||||
ratesUpdatedAt TEXT,
|
||||
syncEnabled INTEGER,
|
||||
syncIntervalMinutes INTEGER,
|
||||
syncTariffsIntervalMinutes INTEGER,
|
||||
customFields TEXT,
|
||||
telegramBotToken TEXT,
|
||||
telegramChatId TEXT,
|
||||
notifyPaymentExpiryEnabled INTEGER,
|
||||
notifyNewTariffsEnabled INTEGER,
|
||||
telegramMessageThreadId TEXT,
|
||||
notifyLowBalanceEnabled INTEGER,
|
||||
notifySyncDigestEnabled INTEGER,
|
||||
notifyVpsDownEnabled INTEGER,
|
||||
webhookUrl TEXT,
|
||||
webhookEnabled INTEGER,
|
||||
notifyIntervalMinutes INTEGER,
|
||||
uptimeCheckIntervalMinutes INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
message TEXT,
|
||||
payload TEXT,
|
||||
createdAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
lastFingerprint TEXT,
|
||||
lastSentAt TEXT,
|
||||
lastStatus TEXT
|
||||
);
|
||||
`
|
||||
|
||||
export function resetTestDb(): void {
|
||||
closeDb()
|
||||
resetRuntimeMigrate()
|
||||
process.env.DB_PATH = ':memory:'
|
||||
const sqlite = getSqlite()
|
||||
sqlite.exec(TEST_SCHEMA)
|
||||
|
||||
Reference in New Issue
Block a user