Files
vps-tracker/apps/api/src/services/veesp/sync.ts
T
DenozordecandClaude Opus 5.5 01a771407d fix(sync): сохранять данные синка в текущий space и не склеивать VPS
Вставки VPS, платежей, тарифов и uptime-проверок не задавали spaceId и
уходили в space-main. Поиск существующего VPS матчил bm-1 с bm-12 и
сервера с пустым IP между собой; теперь сначала по id, затем по точному
токену в заметке, затем по непустому IP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:22:38 +07:00

343 lines
10 KiB
TypeScript

/**
* Sync Veesp client area data into vps-tracker DB
*/
import { eq } from 'drizzle-orm'
import { getCurrentSpaceId, getDb, schema } from '@cfdm/db'
import { findSyncedVps } from '../providers/find-synced-vps.js'
import type { VeespSyncAccount } from './context.js'
import { veespCredentialsString } from './context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
import { mapInvoiceToPayment, mapVpsRecordToVps } from './mappers.js'
import {
fetchBalance,
fetchInvoices,
fetchTariffList,
fetchVpsRecords,
type VeespBalanceResult,
} from './operations.js'
export interface SyncFromVeespOptions {
skipTariffs?: boolean
skipVpsPayments?: boolean
}
export interface SyncSummary {
added: { id: string; label: string }[]
updated: { id: string; label: string; fields: string[] }[]
paymentsAdded: number
tariffsOnly?: boolean
}
export interface SyncFromVeespResult {
vpsCount: number
paymentsCount: number
tariffsCount: number
newTariffs: { name: string; price: string; providerId: string }[]
balance: VeespBalanceResult | null
syncSummary: SyncSummary
}
const SYNC_UPDATE_FIELDS = [
'country',
'city',
'datacenter',
'os',
'notes',
'status',
'tariffType',
'currency',
'dailyRate',
'monthlyRate',
'paidUntil',
] as const
const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] as const
function normVal(v: unknown): string {
if (v == null || v === '') return ''
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
return String(v)
}
export async function syncFromVeesp(
account: VeespSyncAccount,
opts: SyncFromVeespOptions = {},
): Promise<SyncFromVeespResult> {
const { skipTariffs = false, skipVpsPayments = false } = opts
const { apiBaseUrl, providerId, id: accountId } = account
const credentials = veespCredentialsString(account)
if (!apiBaseUrl?.trim() || !account.apiLogin?.trim() || !account.apiPassword) {
throw new Error('API URL and credentials are required')
}
const db = getDb()
const fetchVpsData = !skipVpsPayments
const fetchTariffs = !skipTariffs
const [records, balanceInfo, tariffItems, invoices] = await Promise.all([
fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [],
fetchVpsData
? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null)
: null,
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => []) : [],
fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [],
])
const fallbackCurrency = syncFallbackCurrency(account, {
balanceCurrency: balanceInfo?.currency,
})
let vpsCount = 0
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
if (fetchVpsData) {
for (const record of records) {
const vps = mapVpsRecordToVps(record, providerId, accountId, fallbackCurrency)
const id = `vps-veesp-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate
const monthlyRate = vps.monthlyRate
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = findSyncedVps(accountId, id, [`veesp-${vps.externalId}`, `veesp-${record.serviceId}`], vps.ip)
if (existing) {
let userOverrides: string[] = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
const merged = {
ip: vps.ip,
ipv6: vps.ipv6,
additionalIps,
dns: vps.dns,
country: vps.country,
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
dailyRate,
monthlyRate,
paidUntil,
notes,
}
for (const f of SYNC_UPDATE_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
for (const f of SYNC_SPEC_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
const compareFields = [
'ip',
'ipv6',
'dns',
...SYNC_SPEC_FIELDS,
...SYNC_UPDATE_FIELDS,
] as const
const changedFields = compareFields.filter(
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
)
if (changedFields.length > 0) {
const label = merged.dns || merged.ip || existing.id
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
}
db.update(schema.vps)
.set({
ip: merged.ip,
ipv6: merged.ipv6,
additionalIps: merged.additionalIps,
dns: merged.dns,
country: merged.country,
city: merged.city,
datacenter: merged.datacenter,
os: merged.os,
vcpu: merged.vcpu,
ramGb: merged.ramGb,
diskGb: merged.diskGb,
status: merged.status,
tariffType: merged.tariffType,
currency: merged.currency,
dailyRate: merged.dailyRate,
monthlyRate: merged.monthlyRate,
paidUntil: merged.paidUntil,
notes: merged.notes,
})
.where(eq(schema.vps.id, existing.id))
.run()
} else {
const label = vps.dns || vps.ip || id
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
spaceId: getCurrentSpaceId(),
id,
ip: vps.ip,
ipv6: vps.ipv6,
additionalIps,
dns: vps.dns,
providerId: vps.providerId,
providerAccountId: vps.providerAccountId,
country: vps.country,
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
diskType: vps.diskType,
virtualization: vps.virtualization,
bandwidthTb: vps.bandwidthTb,
sshPort: vps.sshPort,
rootUser: vps.rootUser,
purpose: vps.purpose,
environment: vps.environment,
project: vps.project,
projectId: null,
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
backupEnabled: vps.backupEnabled ? 1 : 0,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
dailyRate,
monthlyRate,
createdAt: vps.createdAt || new Date().toISOString().slice(0, 10),
paidUntil,
notes,
userOverrides: '[]',
})
.run()
}
vpsCount++
}
}
let paymentsCount = 0
if (fetchVpsData) {
const existingPaymentRows = db
.select({ note: schema.payments.note })
.from(schema.payments)
.where(eq(schema.payments.providerAccountId, accountId))
.all()
const existingPayments = new Set(
existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)),
)
for (const item of invoices) {
const payment = mapInvoiceToPayment(item, accountId, fallbackCurrency)
if (!payment) continue
const note = payment.note
if (existingPayments.has(note)) continue
const payId = `pay-veesp-${accountId}-${payment.externalId}`
db.insert(schema.payments)
.values({
spaceId: getCurrentSpaceId(),
id: payId,
type: payment.type,
date: payment.date,
amount: payment.amount,
currency: payment.currency,
providerAccountId: payment.providerAccountId,
vpsId: payment.vpsId,
note,
})
.run()
existingPayments.add(note)
paymentsCount++
syncSummary.paymentsAdded += 1
}
}
if (fetchVpsData && balanceInfo) {
db.update(schema.providerAccounts)
.set({
balanceApi: balanceInfo.balance,
balanceCurrency: balanceInfo.currency || fallbackCurrency,
balanceUpdatedAt: new Date().toISOString(),
enoughmoneyto: balanceInfo.enoughmoneyto || '',
})
.where(eq(schema.providerAccounts.id, accountId))
.run()
}
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-veesp-${accountId}-${t.externalId}-${dcKey}`
: `tariff-veesp-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
spaceId: getCurrentSpaceId(),
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
}
if (!fetchVpsData) {
syncSummary.tariffsOnly = true
}
return {
vpsCount,
paymentsCount,
tariffsCount,
newTariffs,
balance: balanceInfo,
syncSummary,
}
}