refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle

Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom

Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)

Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo

Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-26 13:42:05 +07:00
co-authored by Cursor
parent 8408155be6
commit 6fbd1a9113
202 changed files with 16734 additions and 12145 deletions
+348
View File
@@ -0,0 +1,348 @@
import { Router } from 'express'
import express from 'express'
import { readFileSync, existsSync } from 'node:fs'
import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js'
import { consolidateAllProviderApiSources } from '../db/migrations.js'
import { rowToVps } from './vps.js'
import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
const router = Router()
const BACKUP_VERSION = 1
function buildJsonSnapshot() {
saveDb()
const db = getDb()
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').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 settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
let serverProjects = []
try {
serverProjects = db
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
.all()
} catch {
serverProjects = []
}
let syncLog = []
try {
syncLog = db.prepare('SELECT * FROM sync_log ORDER BY startedAt DESC LIMIT 500').all()
} catch {
syncLog = []
}
return {
backupVersion: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
vps: vps.map(rowToVps),
serverProjects,
providers,
providerAccounts,
payments,
balanceLedger,
settings: settingsRows,
activeTariffs: activeTariffs.map(rowToActiveTariff),
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
syncLog,
}
}
router.get('/json', (req, res) => {
try {
const snapshot = buildJsonSnapshot()
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
res.send(JSON.stringify(snapshot, null, 2))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.get('/database', (req, res) => {
try {
saveDb()
if (!existsSync(DB_PATH)) {
return res.status(404).json({ error: 'Файл базы не найден' })
}
const buf = readFileSync(DB_PATH)
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', 'attachment; filename="vps-tracker.db"')
res.send(buf)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/json', (req, res) => {
try {
const payload = req.body
if (!payload || typeof payload !== 'object') {
return res.status(400).json({ error: 'Неверное тело запроса' })
}
importJsonSnapshot(payload)
res.json({ ok: true })
} catch (err) {
console.error('Backup JSON import error:', err)
res.status(500).json({ error: err.message || 'Импорт не удался' })
}
})
/**
* @param {object} data
*/
function importJsonSnapshot(data) {
const db = getDb()
const run = (sql, ...params) => db.prepare(sql).run(...params)
run('DELETE FROM sync_log')
run('DELETE FROM tariff_sync_options')
run('DELETE FROM active_tariffs')
run('DELETE FROM balance_ledger')
run('DELETE FROM payments')
run('DELETE FROM vps')
run('DELETE FROM provider_accounts')
run('DELETE FROM server_projects')
run('DELETE FROM providers')
run('DELETE FROM settings')
const providers = Array.isArray(data.providers) ? data.providers : []
for (const p of providers) {
run(
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.id ?? '',
p.name ?? '',
p.website ?? '',
p.contact ?? '',
p.baseCurrency ?? '',
p.usdRate ?? '',
p.eurRate ?? '',
p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
)
}
const projects = Array.isArray(data.serverProjects) ? data.serverProjects : []
for (const sp of projects) {
run(
`INSERT OR REPLACE INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, ?, ?, ?, ?)`,
sp.id ?? '',
sp.name ?? '',
sp.color ?? null,
sp.sortOrder ?? 0,
sp.notes ?? null,
sp.createdAt ?? null,
)
}
const accounts = Array.isArray(data.providerAccounts) ? data.providerAccounts : []
for (const acc of accounts) {
run(
`INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_api, balance_currency, balance_updated_at, enoughmoneyto, balance_alert_below)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
acc.id ?? '',
acc.providerId ?? '',
acc.name ?? '',
acc.panelUrl ?? '',
acc.currency ?? '',
acc.billingMode ?? '',
acc.notes ?? '',
acc.apiType ?? '',
acc.apiBaseUrl ?? '',
acc.apiCredentials ?? '',
acc.balance_api ?? null,
acc.balance_currency ?? null,
acc.balance_updated_at ?? null,
acc.enoughmoneyto ?? null,
acc.balance_alert_below != null && acc.balance_alert_below !== '' ? Number(acc.balance_alert_below) : null,
)
}
consolidateAllProviderApiSources(db)
const settingsList = Array.isArray(data.settings) ? data.settings : data.settings ? [data.settings] : []
for (const s of settingsList) {
let customFields = s.customFields
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
if (customFields === undefined) customFields = null
run(
`INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.id ?? 'settings-main',
s.baseCurrency ?? 'RUB',
s.ratesUrl ?? '',
s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
s.ratesUpdatedAt ?? '',
s.syncEnabled ? 1 : 0,
s.syncIntervalMinutes ?? 60,
s.syncTariffsIntervalMinutes ?? 1440,
s.telegramBotToken ?? '',
s.telegramChatId ?? '',
s.telegramMessageThreadId ?? '',
s.notifyPaymentExpiryEnabled ? 1 : 0,
s.notifyNewTariffsEnabled ? 1 : 0,
customFields,
s.notifyLowBalanceEnabled ? 1 : 0,
s.notifySyncDigestEnabled ? 1 : 0,
)
}
const vpsRows = Array.isArray(data.vps) ? data.vps : []
const vpsSql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const v of vpsRows) {
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '[]')
run(
vpsSql,
v.id ?? '',
v.ip ?? '',
v.ipv6 ?? '',
additionalIps,
v.dns ?? '',
v.providerId ?? '',
v.providerAccountId ?? '',
v.country ?? '',
v.city ?? '',
v.datacenter ?? '',
v.os ?? '',
v.vcpu ?? 0,
v.ramGb ?? 0,
v.diskGb ?? 0,
v.diskType ?? '',
v.virtualization ?? '',
v.bandwidthTb ?? 0,
v.sshPort ?? 22,
v.rootUser ?? '',
v.purpose ?? '',
v.environment ?? '',
v.project ?? '',
v.projectId ?? null,
v.monitoringEnabled ? 1 : 0,
v.backupEnabled ? 1 : 0,
v.status ?? 'active',
v.tariffType ?? '',
v.currency ?? '',
dailyRate,
monthlyRate,
v.createdAt ?? '',
v.paidUntil ?? '',
v.notes ?? '',
userOverrides,
)
}
const payments = Array.isArray(data.payments) ? data.payments : []
for (const pm of payments) {
run(
`INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
pm.id ?? '',
pm.type ?? '',
pm.date ?? '',
Number(pm.amount) || 0,
pm.currency ?? '',
pm.providerAccountId ?? '',
pm.vpsId ?? '',
pm.note ?? '',
)
}
const ledger = Array.isArray(data.balanceLedger) ? data.balanceLedger : []
for (const bl of ledger) {
run(
`INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
bl.id ?? '',
bl.type ?? '',
bl.date ?? '',
Number(bl.amount) || 0,
bl.currency ?? '',
bl.direction ?? '',
bl.providerAccountId ?? '',
bl.vpsId ?? '',
bl.note ?? '',
)
}
const tariffs = Array.isArray(data.activeTariffs) ? data.activeTariffs : []
for (const t of tariffs) {
run(
`INSERT OR REPLACE INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.id ?? '',
t.providerAccountId ?? '',
t.providerId ?? '',
t.externalId ?? '',
t.datacenterKey ?? '',
t.datacenterName ?? '',
t.name ?? '',
t.desc ?? '',
t.vcpu ?? 0,
t.ramGb ?? 0,
t.diskGb ?? 0,
t.diskType ?? '',
t.virtualization ?? '',
t.channel ?? '',
t.location ?? '',
t.country ?? '',
t.cpuModel ?? '',
t.orderAvailable ? 1 : 0,
t.price ?? '',
t.syncedAt ?? '',
)
}
const tso = Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []
for (const o of tso) {
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
run(
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
o.providerAccountId ?? '',
dcs,
pers,
o.syncedAt ?? '',
)
}
const logs = Array.isArray(data.syncLog) ? data.syncLog : []
for (const log of logs) {
run(
`INSERT OR REPLACE INTO sync_log (id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
log.id ?? '',
log.accountId ?? '',
log.startedAt ?? '',
log.finishedAt ?? null,
log.status ?? '',
log.vpsCount ?? null,
log.paymentsCount ?? null,
log.error ?? null,
typeof log.summary === 'string' ? log.summary : log.summary ? JSON.stringify(log.summary) : null,
)
}
saveDb()
}
router.post('/database', express.raw({ limit: '100mb', type: '*/*' }), async (req, res) => {
try {
const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || [])
if (!buf.length) {
return res.status(400).json({ error: 'Пустой файл' })
}
await reloadDatabaseFromBuffer(buf)
const { startScheduler } = await import('../sync-scheduler.js')
startScheduler()
res.json({ ok: true })
} catch (err) {
console.error('Backup DB restore error:', err)
res.status(500).json({ error: err.message || 'Восстановление не удалось' })
}
})
export default router
+82
View File
@@ -0,0 +1,82 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `ledger-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.direction ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
)
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
res.status(201).json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
db.prepare(`
UPDATE balance_ledger SET
type = ?, date = ?, amount = ?, currency = ?, direction = ?, providerAccountId = ?, vpsId = ?, note = ?
WHERE id = ?
`).run(
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.direction ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
id,
)
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM balance_ledger WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+45
View File
@@ -0,0 +1,45 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { rowToVps } from './vps.js'
import { rowToSettings } from './settings.js'
import { sanitizeAccount, rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').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 settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
let serverProjects = []
try {
serverProjects = db
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
.all()
} catch {
serverProjects = []
}
res.json({
vps: vps.map(rowToVps),
serverProjects,
providers,
providerAccounts: providerAccounts.map(sanitizeAccount),
payments,
balanceLedger,
settings: settingsRows.map(rowToSettings),
activeTariffs: activeTariffs.map(rowToActiveTariff),
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
})
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+85
View File
@@ -0,0 +1,85 @@
import { Router } from 'express'
import { getDb, saveDb } from '../db.js'
import { consolidateAllProviderApiSources } from '../db/migrations.js'
const router = Router()
router.post('/', (req, res) => {
try {
const db = getDb()
const data = req.body
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: 'Invalid payload' })
}
const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
if (Array.isArray(data.providers) && data.providers.length > 0) {
const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.providers) {
const p = typeof r === 'object' ? r : {}
db.run(
sql,
p.id ?? '',
p.name ?? '',
p.website ?? '',
p.contact ?? '',
p.baseCurrency ?? '',
p.usdRate ?? '',
p.eurRate ?? '',
p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
)
}
}
if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) {
const sql = `INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.providerAccounts) {
const acc = typeof r === 'object' ? r : {}
db.run(sql, acc.id ?? '', acc.providerId ?? '', acc.name ?? '', acc.panelUrl ?? '', acc.currency ?? '', acc.billingMode ?? '', acc.notes ?? '', acc.apiType ?? '', acc.apiBaseUrl ?? '', acc.apiCredentials ?? '')
}
}
if (Array.isArray(data.vps) && data.vps.length > 0) {
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.vps) {
const v = typeof r === 'object' ? r : {}
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '[]')
db.run(sql, v.id ?? '', v.ip ?? '', v.ipv6 ?? '', additionalIps, v.dns ?? '', v.providerId ?? '', v.providerAccountId ?? '', v.country ?? '', v.city ?? '', v.datacenter ?? '', v.os ?? '', v.vcpu ?? 0, v.ramGb ?? 0, v.diskGb ?? 0, v.diskType ?? '', v.virtualization ?? '', v.bandwidthTb ?? 0, v.sshPort ?? 22, v.rootUser ?? '', v.purpose ?? '', v.environment ?? '', v.project ?? '', v.projectId ?? null, v.monitoringEnabled ? 1 : 0, v.backupEnabled ? 1 : 0, v.status ?? 'active', v.tariffType ?? '', v.currency ?? '', dailyRate, monthlyRate, v.createdAt ?? '', v.paidUntil ?? '', v.notes ?? '', userOverrides)
}
}
if (Array.isArray(data.payments) && data.payments.length > 0) {
const sql = `INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.payments) {
const pm = typeof r === 'object' ? r : {}
db.run(sql, pm.id ?? '', pm.type ?? '', pm.date ?? '', Number(pm.amount) || 0, pm.currency ?? '', pm.providerAccountId ?? '', pm.vpsId ?? '', pm.note ?? '')
}
}
if (Array.isArray(data.balanceLedger) && data.balanceLedger.length > 0) {
const sql = `INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.balanceLedger) {
const bl = typeof r === 'object' ? r : {}
db.run(sql, bl.id ?? '', bl.type ?? '', bl.date ?? '', Number(bl.amount) || 0, bl.currency ?? '', bl.direction ?? '', bl.providerAccountId ?? '', bl.vpsId ?? '', bl.note ?? '')
}
}
if (settingsList.length > 0) {
const sql = `INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`
for (const r of settingsList) {
const s = typeof r === 'object' ? r : {}
db.run(sql, s.id ?? 'settings-main', s.baseCurrency ?? 'RUB', s.ratesUrl ?? '', s.autoConvert !== false ? 1 : 0, s.ratesUpdatedAt ?? '', s.syncEnabled ? 1 : 0, s.syncIntervalMinutes ?? 60)
}
}
consolidateAllProviderApiSources(db)
saveDb()
res.json({ ok: true })
} catch (err) {
console.error('Migrate error:', err)
res.status(500).json({ error: err.message || 'Migration failed' })
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `pay-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
)
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
res.status(201).json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
db.prepare(`
UPDATE payments SET
type = ?, date = ?, amount = ?, currency = ?, providerAccountId = ?, vpsId = ?, note = ?
WHERE id = ?
`).run(
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
id,
)
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM payments WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import {
normalizeProjectNameInput,
projectSuggestions,
resolveOrCreateProject,
} from '../projects-service.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
.all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.get('/suggest', (req, res) => {
try {
const db = getDb()
const q = req.query.q ?? ''
const limit = req.query.limit != null ? Number(req.query.limit) : 20
const rows = projectSuggestions(db, q, limit)
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/resolve-or-create', (req, res) => {
try {
const db = getDb()
const name = req.body?.name
const resolved = resolveOrCreateProject(db, name)
res.json(resolved)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const name = normalizeProjectNameInput(req.body?.name)
if (!name) {
return res.status(400).json({ error: 'name is required' })
}
const resolved = resolveOrCreateProject(db, name)
res.status(201).json(resolved)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+105
View File
@@ -0,0 +1,105 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
function sanitizeAccount(row) {
if (!row) return row
const { apiCredentials, ...rest } = row
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
}
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
res.json(rows.map(sanitizeAccount))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `account-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const alertBelow =
r.balance_alert_below != null && r.balance_alert_below !== ''
? Number(r.balance_alert_below)
: null
db.prepare(`
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below)
VALUES (?, ?, ?, ?, ?, ?, ?, '', '', ?, ?)
`).run(
id,
r.providerId ?? '',
r.name ?? '',
r.panelUrl ?? '',
r.currency ?? '',
r.billingMode ?? '',
r.notes ?? '',
r.apiCredentials ?? '',
Number.isFinite(alertBelow) ? alertBelow : null,
)
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
res.status(201).json(sanitizeAccount(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' })
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
let balanceAlertBelow = existing.balance_alert_below
if (r.balance_alert_below !== undefined) {
const v = r.balance_alert_below
balanceAlertBelow =
v === '' || v == null
? null
: Number.isFinite(Number(v))
? Number(v)
: null
}
db.prepare(`
UPDATE provider_accounts SET
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?,
apiType = '', apiBaseUrl = '', apiCredentials = ?, balance_alert_below = ?
WHERE id = ?
`).run(
r.providerId ?? existing.providerId ?? '',
r.name ?? existing.name ?? '',
r.panelUrl ?? existing.panelUrl ?? '',
r.currency ?? existing.currency ?? '',
r.billingMode ?? existing.billingMode ?? '',
r.notes ?? existing.notes ?? '',
apiCredentials,
balanceAlertBelow,
id,
)
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
res.json(sanitizeAccount(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM provider_accounts WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+89
View File
@@ -0,0 +1,89 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM providers ORDER BY name').all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.name ?? '',
r.website ?? '',
r.contact ?? '',
r.baseCurrency ?? '',
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
)
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
res.status(201).json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' })
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
const apiBaseUrl =
r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
db.prepare(`
UPDATE providers SET
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?,
apiType = ?, apiBaseUrl = ?
WHERE id = ?
`).run(
r.name ?? '',
r.website ?? '',
r.contact ?? '',
r.baseCurrency ?? '',
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
apiType,
apiBaseUrl,
id,
)
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
res.json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM providers WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express'
const router = Router()
router.get('/', async (req, res) => {
const url = req.query.url
if (!url) {
return res.status(400).json({ error: 'Missing url parameter' })
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
if (!response.ok) {
return res.status(502).json({ error: `Upstream returned ${response.status}` })
}
const data = await response.json()
res.json(data)
} catch (err) {
res.status(502).json({ error: err.message || 'Failed to fetch rates' })
}
})
export default router
+182
View File
@@ -0,0 +1,182 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { startScheduler } from '../sync-scheduler.js'
import { sendTelegramMessage } from '../telegram.js'
const router = Router()
export function rowToSettings(row) {
if (!row) return null
let customFields = []
if (row.customFields) {
try {
customFields = JSON.parse(row.customFields)
} catch {
customFields = []
}
}
const { telegramBotToken, ...rest } = row
return {
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
customFields: Array.isArray(customFields) ? customFields : [],
}
}
router.post('/telegram/test', async (req, res) => {
try {
const db = getDb()
const row = db.prepare('SELECT telegramBotToken, telegramChatId, telegramMessageThreadId FROM settings WHERE id = ?').get('settings-main')
if (!row?.telegramBotToken?.trim() || !row?.telegramChatId?.trim()) {
return res.status(400).json({ ok: false, error: 'Укажите токен бота и Chat ID в настройках' })
}
const text = '✅ <b>Тестовое уведомление</b>\n\nVPS Tracker — уведомления настроены корректно.'
await sendTelegramMessage(row.telegramBotToken, row.telegramChatId, text, row.telegramMessageThreadId || undefined)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ ok: false, error: err.message || 'Ошибка отправки' })
}
})
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM settings ORDER BY id').all()
res.json(rows.map(rowToSettings))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
function serializeCustomFields(val) {
if (val == null) return null
if (Array.isArray(val)) return JSON.stringify(val)
if (typeof val === 'string') return val || null
return null
}
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0)
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled !== undefined ? (r.notifyPaymentExpiryEnabled ? 1 : 0) : (existing?.notifyPaymentExpiryEnabled ? 1 : 0)
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled !== undefined ? (r.notifyNewTariffsEnabled ? 1 : 0) : (existing?.notifyNewTariffsEnabled ? 1 : 0)
const notifyLowBalanceEnabled = r.notifyLowBalanceEnabled !== undefined ? (r.notifyLowBalanceEnabled ? 1 : 0) : (existing?.notifyLowBalanceEnabled ? 1 : 0)
const notifySyncDigestEnabled = r.notifySyncDigestEnabled !== undefined ? (r.notifySyncDigestEnabled ? 1 : 0) : (existing?.notifySyncDigestEnabled ? 1 : 0)
const telegramBotToken = r.telegramBotToken !== undefined ? (r.telegramBotToken || '') : (existing?.telegramBotToken ?? '')
const telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
const customFields = serializeCustomFields(r.customFields ?? existing?.customFields)
if (existing) {
db.prepare(`
UPDATE settings SET
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?,
notifyLowBalanceEnabled = ?, notifySyncDigestEnabled = ?
WHERE id = ?
`).run(
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
r.ratesUrl ?? existing.ratesUrl ?? '',
r.autoConvert !== undefined ? (r.autoConvert !== false ? 1 : 0) : (existing.autoConvert ? 1 : 0),
r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '',
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
notifyLowBalanceEnabled,
notifySyncDigestEnabled,
id,
)
} else {
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
r.ratesUrl ?? '',
r.autoConvert !== false ? 1 : 0,
r.ratesUpdatedAt ?? '',
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
notifyLowBalanceEnabled,
notifySyncDigestEnabled,
)
}
startScheduler()
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
res.json(rowToSettings(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id ?? 'settings-main'
const syncEnabled = r.syncEnabled ? 1 : 0
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 1 : 0
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled ? 1 : 0
const notifyLowBalanceEnabled = r.notifyLowBalanceEnabled ? 1 : 0
const notifySyncDigestEnabled = r.notifySyncDigestEnabled ? 1 : 0
const telegramBotToken = r.telegramBotToken ?? ''
const telegramChatId = r.telegramChatId ?? ''
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
const customFields = serializeCustomFields(r.customFields)
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
r.ratesUrl ?? 'https://www.cbr-xml-daily.ru/latest.js',
r.autoConvert !== false ? 1 : 0,
r.ratesUpdatedAt ?? '',
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
notifyLowBalanceEnabled,
notifySyncDigestEnabled,
)
startScheduler()
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
res.status(201).json(rowToSettings(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+120
View File
@@ -0,0 +1,120 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
import { runBillmanagerAccountSync } from '../sync-account-job.js'
import { billmanagerAccountRowForSync } from '../utils/billmanager-context.js'
const router = Router()
router.post('/test-connection', async (req, res) => {
try {
const { apiBaseUrl, apiCredentials } = req.body || {}
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
return res.status(400).json({ ok: false, error: 'Укажите URL и учётные данные' })
}
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
res.json(result)
} catch (err) {
res.status(500).json({ ok: false, error: err.message || 'Ошибка проверки' })
}
})
router.get('/status', (req, res) => {
try {
const db = getDb()
const rows = db.prepare(`
SELECT id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary
FROM sync_log ORDER BY startedAt DESC LIMIT 50
`).all()
res.json(
rows.map((row) => {
let summaryParsed = null
if (row.summary) {
try {
summaryParsed = JSON.parse(row.summary)
} catch {
summaryParsed = null
}
}
return { ...row, summary: summaryParsed }
}),
)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.get('/:accountId/balance', async (req, res) => {
try {
const db = getDb()
const { accountId } = req.params
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
if (!row) {
return res.status(404).json({ error: 'Account not found' })
}
const provider = row.providerId
? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
: null
const syncRow = billmanagerAccountRowForSync(row, provider)
if (!syncRow) {
return res.status(400).json({
error:
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
})
}
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, syncRow.apiCredentials.trim(), {
fallbackCurrency: row.currency,
})
db.run(
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
info.balance,
info.currency || 'RUB',
new Date().toISOString(),
info.enoughmoneyto || '',
accountId,
)
res.json({ ok: true, balance: info })
} catch (err) {
console.error('Balance fetch error:', err)
res.status(500).json({ ok: false, error: err.message || 'Failed to fetch balance' })
}
})
router.post('/:accountId', async (req, res) => {
try {
const db = getDb()
const { accountId } = req.params
const { onlyTariffs = false } = req.body || {}
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
if (!row) {
return res.status(404).json({ error: 'Account not found' })
}
const provider = row.providerId
? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
: null
const syncRow = billmanagerAccountRowForSync(row, provider)
if (!syncRow) {
return res.status(400).json({
error:
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
})
}
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
const result = await runBillmanagerAccountSync(syncRow, opts)
res.json({
ok: true,
synced: {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount ?? 0,
},
})
} catch (err) {
console.error('Sync error:', err)
res.status(500).json({ ok: false, error: err.message || 'Sync failed' })
}
})
export default router
+304
View File
@@ -0,0 +1,304 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { resolveOrCreateProject } from '../projects-service.js'
const router = Router()
function projectColumnsForSave(db, projectInput) {
const resolved = resolveOrCreateProject(db, projectInput)
if (!resolved.id) {
return { project: '', projectId: '' }
}
return { project: resolved.name, projectId: resolved.id }
}
export function rowToVps(row) {
if (!row) return null
let additionalIps = []
try {
additionalIps = row.additionalIps ? JSON.parse(row.additionalIps) : []
} catch {
additionalIps = []
}
let userOverrides = []
try {
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
} catch {
userOverrides = []
}
return {
...row,
additionalIps,
userOverrides,
projectId: row.projectId ?? '',
monitoringEnabled: Boolean(row.monitoringEnabled),
backupEnabled: Boolean(row.backupEnabled),
dailyRate: row.dailyRate != null ? row.dailyRate : '',
monthlyRate: row.monthlyRate != null ? row.monthlyRate : '',
}
}
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
res.json(rows.map(rowToVps))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `vps-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
const { project, projectId } = projectColumnsForSave(db, r.project)
db.prepare(`
INSERT INTO vps (
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter,
os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser,
purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType,
currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.ip ?? '',
r.ipv6 ?? '',
additionalIps,
r.dns ?? '',
r.providerId ?? '',
r.providerAccountId ?? '',
r.country ?? '',
r.city ?? '',
r.datacenter ?? '',
r.os ?? '',
r.vcpu ?? 0,
r.ramGb ?? 0,
r.diskGb ?? 0,
r.diskType ?? '',
r.virtualization ?? '',
r.bandwidthTb ?? 0,
r.sshPort ?? 22,
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
project,
projectId || null,
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
r.tariffType ?? '',
r.currency ?? '',
dailyRate,
monthlyRate,
r.createdAt ?? new Date().toISOString().slice(0, 10),
r.paidUntil ?? '',
r.notes ?? '',
r.userOverrides ? (Array.isArray(r.userOverrides) ? JSON.stringify(r.userOverrides) : r.userOverrides) : '[]',
)
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
res.status(201).json(rowToVps(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
const USER_OVERRIDABLE_FIELDS = ['country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort', 'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled']
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' })
let userOverrides = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
const clearOverrides =
r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)
if (clearOverrides) {
userOverrides = []
}
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
let projectOut = existing.project ?? ''
let projectIdOut = existing.projectId ?? ''
if (r.project !== undefined) {
const resolved = projectColumnsForSave(db, r.project)
projectOut = resolved.project
projectIdOut = resolved.projectId
} else if (r.projectId !== undefined) {
if (!r.projectId) {
projectOut = ''
projectIdOut = ''
} else {
const prow = db.prepare('SELECT name FROM server_projects WHERE id = ?').get(r.projectId)
projectOut = prow?.name ?? ''
projectIdOut = r.projectId
}
}
if (!clearOverrides) {
for (const f of USER_OVERRIDABLE_FIELDS) {
if (f === 'project') {
const projectChanged =
String(projectOut ?? '') !== String(existing.project ?? '') ||
String(projectIdOut ?? '') !== String(existing.projectId ?? '')
if (projectChanged && !userOverrides.includes('project')) {
userOverrides.push('project')
}
continue
}
const newVal = r[f]
const oldVal = existing[f]
const changed = String(newVal ?? '') !== String(oldVal ?? '')
if (changed && !userOverrides.includes(f)) {
userOverrides.push(f)
}
}
}
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
db.prepare(`
UPDATE vps SET
ip = ?, ipv6 = ?, additionalIps = ?, dns = ?, providerId = ?, providerAccountId = ?,
country = ?, city = ?, datacenter = ?, os = ?, vcpu = ?, ramGb = ?, diskGb = ?, diskType = ?,
virtualization = ?, bandwidthTb = ?, sshPort = ?, rootUser = ?, purpose = ?, environment = ?,
project = ?, projectId = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
currency = ?, dailyRate = ?, monthlyRate = ?, createdAt = ?, paidUntil = ?, notes = ?,
userOverrides = ?
WHERE id = ?
`).run(
r.ip ?? '',
r.ipv6 ?? '',
additionalIps,
r.dns ?? '',
r.providerId ?? '',
r.providerAccountId ?? '',
r.country ?? '',
r.city ?? '',
r.datacenter ?? '',
r.os ?? '',
r.vcpu ?? 0,
r.ramGb ?? 0,
r.diskGb ?? 0,
r.diskType ?? '',
r.virtualization ?? '',
r.bandwidthTb ?? 0,
r.sshPort ?? 22,
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
projectOut,
projectIdOut || null,
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
r.tariffType ?? '',
r.currency ?? '',
dailyRate,
monthlyRate,
r.createdAt ?? '',
r.paidUntil ?? '',
r.notes ?? '',
userOverridesJson,
id,
)
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(rowToVps(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM vps WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.patch('/bulk', (req, res) => {
try {
const db = getDb()
const { ids = [], action, value } = req.body
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'ids must be a non-empty array' })
}
if (action === 'status' && value) {
const validStatus = ['active', 'paused', 'archived']
if (!validStatus.includes(value)) {
return res.status(400).json({ error: 'value must be active, paused, or archived' })
}
const stmt = db.prepare('UPDATE vps SET status = ? WHERE id = ?')
for (const id of ids) {
stmt.run(value, id)
}
return res.json({ updated: ids.length, status: value })
}
if (action === 'delete') {
const stmt = db.prepare('DELETE FROM vps WHERE id = ?')
let deleted = 0
for (const id of ids) {
const result = stmt.run(id)
if (result.changes > 0) deleted++
}
return res.json({ deleted })
}
if (action === 'project') {
const projectValue = value == null ? '' : String(value)
const { project: projName, projectId: projId } = projectColumnsForSave(db, projectValue)
const getStmt = db.prepare('SELECT * FROM vps WHERE id = ?')
const updStmt = db.prepare(
'UPDATE vps SET project = ?, projectId = ?, userOverrides = ? WHERE id = ?',
)
let updated = 0
for (const id of ids) {
const existing = getStmt.get(id)
if (!existing) continue
if (
String(existing.project ?? '') === projName &&
String(existing.projectId ?? '') === String(projId ?? '')
) {
continue
}
let userOverrides = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
if (!userOverrides.includes('project')) {
userOverrides.push('project')
}
updStmt.run(projName, projId || null, JSON.stringify([...new Set(userOverrides)]), id)
updated++
}
return res.json({ updated, project: projName, projectId: projId })
}
return res.status(400).json({ error: 'action must be status, delete, or project' })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router