Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -3,6 +3,7 @@ import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import * as schema from './schema/index.js'
|
||||
import { ensureRuntimeSchema } from './runtime-migrate.js'
|
||||
|
||||
export type Db = BetterSQLite3Database<typeof schema>
|
||||
|
||||
@@ -29,6 +30,7 @@ function openDatabase(): void {
|
||||
_sqlite.pragma('journal_mode = WAL')
|
||||
_sqlite.pragma('foreign_keys = ON')
|
||||
_db = drizzle(_sqlite, { schema })
|
||||
ensureRuntimeSchema(_sqlite)
|
||||
}
|
||||
|
||||
export function getSqlite(): Database.Database {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '../index.js'
|
||||
|
||||
export interface AuditEntryInput {
|
||||
entity: string
|
||||
entityId: string
|
||||
action: 'create' | 'update' | 'delete'
|
||||
diff?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseDiff(row: { diff: string | null }) {
|
||||
if (!row.diff) return null
|
||||
try {
|
||||
return JSON.parse(row.diff) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const auditLogRepository = {
|
||||
append(input: AuditEntryInput): void {
|
||||
getDb()
|
||||
.insert(schema.auditLog)
|
||||
.values({
|
||||
id: `audit-${randomUUID()}`,
|
||||
entity: input.entity,
|
||||
entityId: input.entityId,
|
||||
action: input.action,
|
||||
diff: input.diff ? JSON.stringify(input.diff) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
},
|
||||
|
||||
list(limit = 100) {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.orderBy(desc(schema.auditLog.createdAt))
|
||||
.limit(Math.min(500, Math.max(1, limit)))
|
||||
.all()
|
||||
.map((row) => ({ ...row, diff: parseDiff(row) }))
|
||||
},
|
||||
|
||||
listForEntity(entity: string, entityId: string, limit = 50) {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.where(and(eq(schema.auditLog.entity, entity), eq(schema.auditLog.entityId, entityId)))
|
||||
.orderBy(desc(schema.auditLog.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
.map((row) => ({ ...row, diff: parseDiff(row) }))
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getDb, schema } from '../index.js'
|
||||
|
||||
type Row = typeof schema.settings.$inferSelect
|
||||
|
||||
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'customFields'> & {
|
||||
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'notifyVpsDownEnabled' | 'webhookEnabled' | 'customFields'> & {
|
||||
telegramBotTokenSet: boolean
|
||||
autoConvert: boolean
|
||||
syncEnabled: boolean
|
||||
@@ -11,6 +11,8 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
|
||||
notifyNewTariffsEnabled: boolean
|
||||
notifyLowBalanceEnabled: boolean
|
||||
notifySyncDigestEnabled: boolean
|
||||
notifyVpsDownEnabled: boolean
|
||||
webhookEnabled: boolean
|
||||
customFields: unknown[]
|
||||
}
|
||||
|
||||
@@ -34,6 +36,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
||||
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
||||
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
|
||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||
webhookEnabled: Boolean(row.webhookEnabled),
|
||||
customFields: Array.isArray(customFields) ? customFields : [],
|
||||
}
|
||||
}
|
||||
@@ -60,6 +64,9 @@ interface SettingsInput {
|
||||
notifyNewTariffsEnabled?: boolean
|
||||
notifyLowBalanceEnabled?: boolean
|
||||
notifySyncDigestEnabled?: boolean
|
||||
notifyVpsDownEnabled?: boolean
|
||||
webhookUrl?: string
|
||||
webhookEnabled?: boolean
|
||||
customFields?: unknown
|
||||
}
|
||||
|
||||
@@ -121,6 +128,17 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
||||
: existing?.notifySyncDigestEnabled
|
||||
? 1
|
||||
: 0,
|
||||
notifyVpsDownEnabled:
|
||||
r.notifyVpsDownEnabled !== undefined
|
||||
? r.notifyVpsDownEnabled
|
||||
? 1
|
||||
: 0
|
||||
: existing?.notifyVpsDownEnabled
|
||||
? 1
|
||||
: 0,
|
||||
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
|
||||
webhookEnabled:
|
||||
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
|
||||
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ interface VpsInput {
|
||||
paidUntil?: string
|
||||
notes?: string
|
||||
userOverrides?: string[] | 'clear'
|
||||
customData?: string | Record<string, unknown>
|
||||
}
|
||||
|
||||
function projectColumnsForSave(projectInput: unknown): { project: string; projectId: string } {
|
||||
@@ -99,6 +100,12 @@ function boolToInt(v: unknown): number {
|
||||
return v ? 1 : 0
|
||||
}
|
||||
|
||||
function serializeCustomData(v: unknown): string | null {
|
||||
if (v == null) return null
|
||||
if (typeof v === 'string') return v || null
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
|
||||
export const vpsRepository = {
|
||||
list(): VpsDto[] {
|
||||
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
@@ -153,6 +160,7 @@ export const vpsRepository = {
|
||||
userOverrides: input.userOverrides && Array.isArray(input.userOverrides)
|
||||
? JSON.stringify(input.userOverrides)
|
||||
: '[]',
|
||||
customData: serializeCustomData(input.customData),
|
||||
})
|
||||
.run()
|
||||
return this.get(finalId)!
|
||||
@@ -248,6 +256,9 @@ export const vpsRepository = {
|
||||
paidUntil: input.paidUntil ?? '',
|
||||
notes: input.notes ?? '',
|
||||
userOverrides: userOverridesJson,
|
||||
...(input.customData !== undefined
|
||||
? { customData: serializeCustomData(input.customData) }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(schema.vps.id, id))
|
||||
.run()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
|
||||
const COLUMN_MIGRATIONS: string[] = [
|
||||
`ALTER TABLE vps ADD COLUMN customData TEXT`,
|
||||
`ALTER TABLE vps ADD COLUMN last_health_status TEXT`,
|
||||
`ALTER TABLE vps ADD COLUMN last_health_checked_at TEXT`,
|
||||
`ALTER TABLE settings ADD COLUMN notifyVpsDownEnabled INTEGER`,
|
||||
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
|
||||
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
|
||||
]
|
||||
|
||||
const TABLE_MIGRATIONS: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS vps_health_checks (
|
||||
id TEXT PRIMARY KEY,
|
||||
vpsId TEXT NOT NULL REFERENCES vps(id),
|
||||
checkedAt TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latencyMs INTEGER,
|
||||
error TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity TEXT NOT NULL,
|
||||
entityId TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
diff TEXT,
|
||||
createdAt TEXT NOT NULL
|
||||
)`,
|
||||
]
|
||||
|
||||
let migrated = false
|
||||
|
||||
export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||
if (migrated) return
|
||||
for (const sql of TABLE_MIGRATIONS) {
|
||||
sqlite.exec(sql)
|
||||
}
|
||||
for (const sql of COLUMN_MIGRATIONS) {
|
||||
try {
|
||||
sqlite.exec(sql)
|
||||
} catch {
|
||||
/* column exists */
|
||||
}
|
||||
}
|
||||
migrated = true
|
||||
}
|
||||
@@ -78,6 +78,9 @@ export const vps = sqliteTable('vps', {
|
||||
paidUntil: text('paidUntil'),
|
||||
notes: text('notes'),
|
||||
userOverrides: text('userOverrides'),
|
||||
customData: text('customData'),
|
||||
lastHealthStatus: text('last_health_status'),
|
||||
lastHealthCheckedAt: text('last_health_checked_at'),
|
||||
})
|
||||
|
||||
export const payments = sqliteTable('payments', {
|
||||
@@ -120,6 +123,29 @@ export const settings = sqliteTable('settings', {
|
||||
telegramMessageThreadId: text('telegramMessageThreadId'),
|
||||
notifyLowBalanceEnabled: integer('notifyLowBalanceEnabled'),
|
||||
notifySyncDigestEnabled: integer('notifySyncDigestEnabled'),
|
||||
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
|
||||
webhookUrl: text('webhookUrl'),
|
||||
webhookEnabled: integer('webhookEnabled'),
|
||||
})
|
||||
|
||||
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
|
||||
id: text('id').primaryKey(),
|
||||
vpsId: text('vpsId')
|
||||
.notNull()
|
||||
.references(() => vps.id),
|
||||
checkedAt: text('checkedAt').notNull(),
|
||||
status: text('status').notNull(),
|
||||
latencyMs: integer('latencyMs'),
|
||||
error: text('error'),
|
||||
})
|
||||
|
||||
export const auditLog = sqliteTable('audit_log', {
|
||||
id: text('id').primaryKey(),
|
||||
entity: text('entity').notNull(),
|
||||
entityId: text('entityId').notNull(),
|
||||
action: text('action').notNull(),
|
||||
diff: text('diff'),
|
||||
createdAt: text('createdAt').notNull(),
|
||||
})
|
||||
|
||||
export const syncLog = sqliteTable('sync_log', {
|
||||
|
||||
@@ -16,6 +16,9 @@ export const settingsSchema = z.object({
|
||||
notifyNewTariffsEnabled: z.boolean().optional(),
|
||||
notifyLowBalanceEnabled: z.boolean().optional(),
|
||||
notifySyncDigestEnabled: z.boolean().optional(),
|
||||
notifyVpsDownEnabled: z.boolean().optional(),
|
||||
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||
webhookEnabled: z.boolean().optional(),
|
||||
customFields: z.any().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export const vpsSchema = z.object({
|
||||
paidUntil: z.string().optional().default(''),
|
||||
notes: z.string().optional().default(''),
|
||||
userOverrides: z.union([z.array(z.string()), z.literal('clear')]).optional(),
|
||||
customData: z.union([z.string(), z.record(z.unknown())]).optional(),
|
||||
})
|
||||
|
||||
export type VpsFormValues = z.infer<typeof vpsSchema>
|
||||
|
||||
Reference in New Issue
Block a user