Синк VPS, баланса, платежей и тарифов через Bearer-токен из личного кабинета. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -39,6 +39,17 @@ vi.mock('../services/veesp/sync.js', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../services/ruvds/sync.js', () => ({
|
||||
syncFromRuvds: vi.fn().mockResolvedValue({
|
||||
vpsCount: 3,
|
||||
paymentsCount: 2,
|
||||
tariffsCount: 2,
|
||||
newTariffs: [],
|
||||
balance: { balance: 2000, currency: 'RUB', enoughmoneyto: '' },
|
||||
syncSummary: { added: [], updated: [], paymentsAdded: 2 },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('sync routes — 4vps', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
@@ -277,3 +288,63 @@ describe('sync routes — veesp', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync routes — ruvds', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
seedTestProvider('prov-ruvds')
|
||||
providersRepository.update('prov-ruvds', {
|
||||
apiType: 'ruvds',
|
||||
apiBaseUrl: 'https://api.ruvds.com',
|
||||
})
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-ruvds',
|
||||
providerId: 'prov-ruvds',
|
||||
name: 'RuVDS',
|
||||
apiCredentials: 'apiv2.test-token',
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('POST /api/sync/:accountId syncs ruvds account', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/acc-ruvds',
|
||||
payload: {},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { ok?: boolean; synced?: { vpsCount?: number } }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.synced?.vpsCount).toBe(3)
|
||||
})
|
||||
|
||||
it('POST /api/sync/test-connection uses apiType ruvds', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ amount: 100, currency: 1, type: 'default' }),
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/test-connection',
|
||||
payload: {
|
||||
apiBaseUrl: 'https://api.ruvds.com',
|
||||
apiCredentials: 'apiv2.test-token',
|
||||
apiType: 'ruvds',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect((res.json() as { ok?: boolean }).ok).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,21 +4,25 @@ import { billmanagerAccountRowForSync } from '../billmanager/context.js'
|
||||
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
|
||||
import { userApiAccountRowForSync } from '../userapi/context.js'
|
||||
import { veespAccountRowForSync } from '../veesp/context.js'
|
||||
import { ruvdsAccountRowForSync } from '../ruvds/context.js'
|
||||
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||
import type { UserApiSyncAccount } from '../userapi/context.js'
|
||||
import type { VeespSyncAccount } from '../veesp/context.js'
|
||||
import type { RuvdsSyncAccount } from '../ruvds/context.js'
|
||||
|
||||
import { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
import { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
import { userapiAdapter } from './userapi-adapter.js'
|
||||
import { veespAdapter } from './veesp-adapter.js'
|
||||
import { ruvdsAdapter } from './ruvds-adapter.js'
|
||||
import type { ProviderAdapter } from './types.js'
|
||||
|
||||
export { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
export { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
export { userapiAdapter } from './userapi-adapter.js'
|
||||
export { veespAdapter } from './veesp-adapter.js'
|
||||
export { ruvdsAdapter } from './ruvds-adapter.js'
|
||||
|
||||
export const manualAdapter: ProviderAdapter = {
|
||||
type: 'manual',
|
||||
@@ -43,6 +47,7 @@ const adapters: Record<string, ProviderAdapter> = {
|
||||
macloud: userapiAdapter,
|
||||
vdsina: userapiAdapter,
|
||||
veesp: veespAdapter,
|
||||
ruvds: ruvdsAdapter,
|
||||
manual: manualAdapter,
|
||||
none: manualAdapter,
|
||||
}
|
||||
@@ -60,6 +65,7 @@ export type SyncReadyAccount =
|
||||
| FourvpsSyncAccount
|
||||
| UserApiSyncAccount
|
||||
| VeespSyncAccount
|
||||
| RuvdsSyncAccount
|
||||
|
||||
export function resolveSyncAccount(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
@@ -86,6 +92,10 @@ export function resolveSyncAccount(
|
||||
const account = veespAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
if (apiType === 'ruvds') {
|
||||
const account = ruvdsAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -95,4 +105,5 @@ export const SYNC_SETUP_ERRORS: Record<string, string> = {
|
||||
macloud: 'Укажите тип API Маклауд и URL; в аккаунте — API Token',
|
||||
vdsina: 'Укажите тип API VDSina и URL; в аккаунте — API Token',
|
||||
veesp: 'Укажите тип API Veesp и URL; в аккаунте — email и пароль client area',
|
||||
ruvds: 'Укажите тип API RuVDS и URL; в аккаунте — API-токен из настроек ЛК',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { syncFromRuvds } from '../ruvds/sync.js'
|
||||
import { fetchBalance, testConnection } from '../ruvds/operations.js'
|
||||
import type { RuvdsSyncAccount } from '../ruvds/context.js'
|
||||
import { ruvdsCredentialsString } from '../ruvds/context.js'
|
||||
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
export const ruvdsAdapter: ProviderAdapter = {
|
||||
type: 'ruvds',
|
||||
|
||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||
const result = await testConnection(apiBaseUrl, apiCredentials)
|
||||
return { ok: result.ok, message: result.error }
|
||||
},
|
||||
|
||||
async syncAccount(
|
||||
account: RuvdsSyncAccount,
|
||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<SyncResult> {
|
||||
const result = await syncFromRuvds(account, options)
|
||||
return {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount,
|
||||
balance: result.balance,
|
||||
syncSummary: result.syncSummary,
|
||||
newTariffs: result.newTariffs,
|
||||
}
|
||||
},
|
||||
|
||||
async fetchBalance(account: RuvdsSyncAccount) {
|
||||
const fallbackCurrency = syncFallbackCurrency(account)
|
||||
const info = await fetchBalance(
|
||||
account.apiBaseUrl,
|
||||
ruvdsCredentialsString(account),
|
||||
fallbackCurrency,
|
||||
)
|
||||
return {
|
||||
balance: info.balance,
|
||||
currency: info.currency || fallbackCurrency,
|
||||
enoughmoneyto: info.enoughmoneyto || '',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RuvdsClient } from './client.js'
|
||||
|
||||
describe('RuvdsClient', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('sends Bearer authorization header', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ amount: 100, currency: 1 }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const client = new RuvdsClient('https://api.ruvds.com', 'test-token')
|
||||
await client.request('/v2/balance')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: 'Bearer test-token',
|
||||
Accept: 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on 401 with message from API', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ id: 'unauthorized', message: 'Invalid token' }),
|
||||
}),
|
||||
)
|
||||
|
||||
const client = new RuvdsClient('https://api.ruvds.com', 'bad-token')
|
||||
await expect(client.request('/v2/balance')).rejects.toThrow('Invalid token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* RuVDS API v2 HTTP client
|
||||
* @see https://ruvds.com/api-docs
|
||||
*/
|
||||
|
||||
import type { RuvdsApiErrorBody } from './types.js'
|
||||
|
||||
export class RuvdsApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RuvdsApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${p}`
|
||||
}
|
||||
|
||||
export interface RuvdsRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
body?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function extractErrorMessage(json: unknown, status: number): string {
|
||||
if (json && typeof json === 'object') {
|
||||
const obj = json as RuvdsApiErrorBody
|
||||
if (typeof obj.message === 'string' && obj.message.trim()) return obj.message
|
||||
if (typeof obj.id === 'string' && obj.id.trim()) return obj.id
|
||||
}
|
||||
return `RuVDS API HTTP ${status}`
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export class RuvdsClient {
|
||||
constructor(
|
||||
readonly baseUrl: string,
|
||||
readonly token: string,
|
||||
) {}
|
||||
|
||||
async request<T = unknown>(path: string, opts: RuvdsRequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', query, body } = opts
|
||||
const url = new URL(joinUrl(this.baseUrl, path))
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value != null && value !== '') {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${this.token.trim()}`,
|
||||
}
|
||||
if (body && method !== 'GET') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const init: RequestInit = { method, headers }
|
||||
if (body && method !== 'GET') {
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt > 0) await sleep(500 * attempt)
|
||||
|
||||
const res = await fetch(url.toString(), init)
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch {
|
||||
if (!res.ok) throw new RuvdsApiError(`RuVDS API HTTP ${res.status}`)
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
if (res.status === 429 && attempt < 2) {
|
||||
lastError = new RuvdsApiError('Превышен лимит запросов RuVDS API')
|
||||
continue
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new RuvdsApiError(extractErrorMessage(json, res.status))
|
||||
}
|
||||
|
||||
return json as T
|
||||
}
|
||||
|
||||
throw lastError ?? new RuvdsApiError('RuVDS API request failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuvdsClient(baseUrl: string, token: string): RuvdsClient {
|
||||
const trimmed = token.trim()
|
||||
if (!trimmed) throw new RuvdsApiError('Укажите API-токен RuVDS')
|
||||
return new RuvdsClient(baseUrl.trim(), trimmed)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
import { parseRuvdsToken } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface RuvdsSyncAccount extends AccountRow {
|
||||
apiType: 'ruvds'
|
||||
apiBaseUrl: string
|
||||
apiToken: string
|
||||
providerBaseCurrency?: string | null
|
||||
}
|
||||
|
||||
export function resolveRuvdsApi(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): { apiType: string; apiBaseUrl: string } {
|
||||
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
|
||||
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||
return { apiType, apiBaseUrl }
|
||||
}
|
||||
|
||||
export function ruvdsAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): RuvdsSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveRuvdsApi(accountRow, providerRow)
|
||||
const apiToken = parseRuvdsToken(accountRow.apiCredentials)
|
||||
if (apiType !== 'ruvds' || !apiBaseUrl || !apiToken) return null
|
||||
return {
|
||||
...accountRow,
|
||||
apiType: 'ruvds',
|
||||
apiBaseUrl,
|
||||
apiToken,
|
||||
providerBaseCurrency: providerRow?.baseCurrency ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function ruvdsCredentialsString(account: RuvdsSyncAccount): string {
|
||||
return account.apiToken
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { createRuvdsClient, RuvdsApiError } from './client.js'
|
||||
export type { RuvdsSyncAccount } from './context.js'
|
||||
export { ruvdsAccountRowForSync, ruvdsCredentialsString } from './context.js'
|
||||
export {
|
||||
fetchAllPayments,
|
||||
fetchAllServers,
|
||||
fetchBalance,
|
||||
fetchTariffList,
|
||||
testConnection,
|
||||
} from './operations.js'
|
||||
export { syncFromRuvds } from './sync.js'
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildLookupMaps, mapPaymentToPayment, mapServerToVps } from './mappers.js'
|
||||
import type { RuvdsPayment, RuvdsServer } from './types.js'
|
||||
|
||||
const server: RuvdsServer = {
|
||||
virtual_server_id: 38420,
|
||||
status: 'active',
|
||||
datacenter: 1,
|
||||
cpu: 2,
|
||||
ram: 4,
|
||||
drive: 40,
|
||||
payment_period: 3,
|
||||
os_id: 18,
|
||||
paid_till: '2027-06-15T12:00:00Z',
|
||||
user_comment: 'Prod VPS',
|
||||
network_v4: [{ ip_address: '198.51.100.10' }, { ip_address: '198.51.100.11' }],
|
||||
}
|
||||
|
||||
const lookups = buildLookupMaps(
|
||||
[{ id: 1, name: 'Rucloud: Россия, Москва', country: 'RU' }],
|
||||
[{ id: 18, name: 'Ubuntu 22.04' }],
|
||||
new Map([[38420, 900]]),
|
||||
)
|
||||
|
||||
describe('mapServerToVps', () => {
|
||||
it('maps server fields and notes marker', () => {
|
||||
const vps = mapServerToVps(server, 'prov-ruvds', 'acc-ruvds', 'RUB', lookups)
|
||||
expect(vps.externalId).toBe('38420')
|
||||
expect(vps.ip).toBe('198.51.100.10')
|
||||
expect(vps.additionalIps).toEqual(['198.51.100.11'])
|
||||
expect(vps.vcpu).toBe(2)
|
||||
expect(vps.ramGb).toBe(4)
|
||||
expect(vps.diskGb).toBe(40)
|
||||
expect(vps.paidUntil).toBe('2027-06-15')
|
||||
expect(vps.os).toBe('Ubuntu 22.04')
|
||||
expect(vps.monthlyRate).toBe(300)
|
||||
expect(vps.notes).toContain('ruvds-38420')
|
||||
expect(vps.datacenter).toContain('Rucloud')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapPaymentToPayment', () => {
|
||||
it('maps income payments only', () => {
|
||||
const payment: RuvdsPayment = {
|
||||
dt: '2026-01-10T10:00:00Z',
|
||||
direction: 1,
|
||||
amount: 500,
|
||||
currency: 1,
|
||||
pay_source: 'card',
|
||||
}
|
||||
const mapped = mapPaymentToPayment(payment, 'acc-ruvds', 'RUB')
|
||||
expect(mapped).not.toBeNull()
|
||||
expect(mapped?.type).toBe('topup')
|
||||
expect(mapped?.amount).toBe(500)
|
||||
expect(mapped?.currency).toBe('RUB')
|
||||
expect(mapped?.note).toContain('ruvds-')
|
||||
})
|
||||
|
||||
it('skips debit payments', () => {
|
||||
const payment: RuvdsPayment = {
|
||||
dt: '2026-01-10T10:00:00Z',
|
||||
direction: 2,
|
||||
amount: 100,
|
||||
currency: 1,
|
||||
}
|
||||
expect(mapPaymentToPayment(payment, 'acc-ruvds', 'RUB')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* RuVDS API v2 → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import type { RuvdsDatacenter, RuvdsOsItem, RuvdsPayment, RuvdsServer } from './types.js'
|
||||
import { ruvdsCurrencyCode } from './operations.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
new: 'active',
|
||||
notpaid: 'paused',
|
||||
blocked: 'paused',
|
||||
deleted: 'archived',
|
||||
removed: 'archived',
|
||||
}
|
||||
|
||||
function dateToIso(value: string | undefined | null): string {
|
||||
if (!value) return ''
|
||||
return String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
function roundRate(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function paymentPeriodMonths(period: number | undefined): number {
|
||||
switch (period) {
|
||||
case 2:
|
||||
return 1
|
||||
case 3:
|
||||
return 3
|
||||
case 4:
|
||||
return 6
|
||||
case 5:
|
||||
return 12
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
function ratesFromCost(
|
||||
costRub: number | null | undefined,
|
||||
paymentPeriod: number | undefined,
|
||||
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
|
||||
if (costRub == null || !Number.isFinite(costRub) || costRub <= 0) {
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: null }
|
||||
}
|
||||
const months = paymentPeriodMonths(paymentPeriod)
|
||||
const monthlyRate = roundRate(costRub / months)
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate }
|
||||
}
|
||||
|
||||
function parseDatacenterName(name: string): { city: string; country: string; datacenter: string } {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return { city: '', country: 'RU', datacenter: '' }
|
||||
const parts = trimmed.split(':').map((p) => p.trim())
|
||||
if (parts.length >= 2) {
|
||||
const location = parts[1]
|
||||
const comma = location.split(',').map((p) => p.trim())
|
||||
const country = comma.length >= 2 ? comma[0] : 'RU'
|
||||
const city = comma.length >= 2 ? comma[1] : location
|
||||
return { city, country: country.length <= 3 ? country.toUpperCase() : 'RU', datacenter: trimmed }
|
||||
}
|
||||
return { city: '', country: 'RU', datacenter: trimmed }
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
externalId: string
|
||||
ip: string
|
||||
dns: string
|
||||
ipv6: string
|
||||
additionalIps: string[]
|
||||
providerId: string
|
||||
providerAccountId: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
os: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
bandwidthTb: number
|
||||
sshPort: number
|
||||
rootUser: string
|
||||
purpose: string
|
||||
environment: string
|
||||
project: string
|
||||
monitoringEnabled: boolean
|
||||
backupEnabled: boolean
|
||||
status: string
|
||||
tariffType: string
|
||||
currency: string
|
||||
dailyRate: number | null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface MappedPayment {
|
||||
externalId: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId: null
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface RuvdsLookupMaps {
|
||||
datacenters: Map<number, RuvdsDatacenter>
|
||||
os: Map<number, RuvdsOsItem>
|
||||
costs: Map<number, number>
|
||||
}
|
||||
|
||||
export function buildLookupMaps(
|
||||
datacenters: RuvdsDatacenter[],
|
||||
osList: RuvdsOsItem[],
|
||||
costs: Map<number, number>,
|
||||
): RuvdsLookupMaps {
|
||||
return {
|
||||
datacenters: new Map(datacenters.map((d) => [d.id, d])),
|
||||
os: new Map(osList.map((o) => [o.id, o])),
|
||||
costs,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapServerToVps(
|
||||
server: RuvdsServer,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
fallbackCurrency: string,
|
||||
lookups: RuvdsLookupMaps,
|
||||
): MappedVps {
|
||||
const externalId = String(server.virtual_server_id)
|
||||
const networks = server.network_v4 ?? []
|
||||
const ipv4List = networks.map((n) => String(n.ip_address || '').trim()).filter(Boolean)
|
||||
const ip = ipv4List[0] ?? ''
|
||||
const additionalIps = ipv4List.slice(1)
|
||||
|
||||
const dc = server.datacenter != null ? lookups.datacenters.get(server.datacenter) : undefined
|
||||
const dcParsed = parseDatacenterName(dc?.name ?? '')
|
||||
const osItem = server.os_id != null ? lookups.os.get(server.os_id) : undefined
|
||||
const status = STATUS_MAP[String(server.status ?? '').toLowerCase()] ?? 'active'
|
||||
|
||||
const diskGb =
|
||||
(Number(server.drive) || 0) + (Number(server.additional_drive) || 0) || Number(server.drive) || 0
|
||||
const costRub = lookups.costs.get(server.virtual_server_id)
|
||||
const { tariffType, dailyRate, monthlyRate } = ratesFromCost(costRub, server.payment_period)
|
||||
|
||||
const comment = String(server.user_comment ?? '').trim()
|
||||
const marker = `ruvds-${externalId}`
|
||||
const notes = comment ? `${comment} [${marker}]` : `[${marker}]`
|
||||
const dns = comment || `RU${externalId}`
|
||||
|
||||
return {
|
||||
externalId,
|
||||
ip,
|
||||
dns,
|
||||
ipv6: '',
|
||||
additionalIps,
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: (dc?.country ?? dcParsed.country).toUpperCase() || 'RU',
|
||||
city: dcParsed.city,
|
||||
datacenter: dcParsed.datacenter,
|
||||
os: osItem?.name ?? '',
|
||||
vcpu: Number(server.cpu) || 0,
|
||||
ramGb: Number(server.ram) || 0,
|
||||
diskGb,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: '',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType,
|
||||
currency: fallbackCurrency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: '',
|
||||
paidUntil: dateToIso(server.paid_till),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
function paymentExternalId(payment: RuvdsPayment): string {
|
||||
const raw = `${payment.dt}|${payment.direction}|${payment.amount}|${payment.pay_source ?? ''}`
|
||||
return createHash('sha256').update(raw).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
export function mapPaymentToPayment(
|
||||
payment: RuvdsPayment,
|
||||
providerAccountId: string,
|
||||
fallbackCurrency: string,
|
||||
): MappedPayment | null {
|
||||
if (payment.direction !== 1) return null
|
||||
const amount = Number(payment.amount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
const externalId = paymentExternalId(payment)
|
||||
const currency = ruvdsCurrencyCode(payment.currency, fallbackCurrency)
|
||||
const date = dateToIso(payment.dt) || new Date().toISOString().slice(0, 10)
|
||||
const source = String(payment.pay_source ?? '').trim()
|
||||
return {
|
||||
externalId,
|
||||
type: 'topup',
|
||||
date,
|
||||
amount,
|
||||
currency,
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: source ? `ruvds-${externalId} ${source}` : `ruvds-${externalId}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* RuVDS API v2 operations
|
||||
*/
|
||||
|
||||
import { parseRuvdsToken } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { createRuvdsClient, RuvdsApiError, type RuvdsClient } from './client.js'
|
||||
import type {
|
||||
RuvdsBalanceResponse,
|
||||
RuvdsDatacenter,
|
||||
RuvdsOsItem,
|
||||
RuvdsPagination,
|
||||
RuvdsPayment,
|
||||
RuvdsServer,
|
||||
RuvdsTariffsResponse,
|
||||
} from './types.js'
|
||||
|
||||
const CURRENCY_MAP: Record<number, string> = {
|
||||
1: 'RUB',
|
||||
3: 'USD',
|
||||
4: 'EUR',
|
||||
}
|
||||
|
||||
export function ruvdsCurrencyCode(currencyId: number | undefined | null, fallback = 'RUB'): string {
|
||||
if (currencyId == null) return fallback
|
||||
return CURRENCY_MAP[currencyId] ?? fallback
|
||||
}
|
||||
|
||||
function clientFor(baseUrl: string, credentials: string): RuvdsClient {
|
||||
return createRuvdsClient(baseUrl, parseRuvdsToken(credentials))
|
||||
}
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
client: RuvdsClient,
|
||||
path: string,
|
||||
extract: (json: Record<string, unknown>) => T[],
|
||||
query: Record<string, string | number | boolean | undefined> = {},
|
||||
): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let page = 1
|
||||
let lastPage = 1
|
||||
|
||||
while (page <= lastPage) {
|
||||
const json = (await client.request<Record<string, unknown>>(path, {
|
||||
query: { ...query, page, per_page: 100 },
|
||||
})) as Record<string, unknown>
|
||||
items.push(...extract(json))
|
||||
const pagination = json.pagination as RuvdsPagination | undefined
|
||||
lastPage = pagination?.last_page ?? page
|
||||
if (!pagination?.next_page) break
|
||||
page = pagination.next_page
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export interface RuvdsBalanceResult {
|
||||
balance: number
|
||||
currency: string
|
||||
enoughmoneyto: string
|
||||
}
|
||||
|
||||
export interface RuvdsTariffItem {
|
||||
externalId: string
|
||||
datacenterKey: string
|
||||
datacenterName: string
|
||||
name: string
|
||||
desc: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
country: string
|
||||
cpuModel: string
|
||||
orderAvailable: boolean
|
||||
price: string
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await fetchBalance(baseUrl, credentials)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof RuvdsApiError ? err.message : String(err)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBalance(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
fallbackCurrency = 'RUB',
|
||||
): Promise<RuvdsBalanceResult> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<RuvdsBalanceResponse>('/v2/balance')
|
||||
return {
|
||||
balance: Number(json.amount) || 0,
|
||||
currency: ruvdsCurrencyCode(json.currency, fallbackCurrency),
|
||||
enoughmoneyto: '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllServers(baseUrl: string, credentials: string): Promise<RuvdsServer[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
return fetchAllPages<RuvdsServer>(
|
||||
client,
|
||||
'/v2/servers',
|
||||
(json) => (Array.isArray(json.servers) ? (json.servers as RuvdsServer[]) : []),
|
||||
{ get_paid_till: true, get_network: true },
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchServerCost(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serverId: number,
|
||||
): Promise<number | null> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<{ cost_rub?: number }>(`/v2/servers/${serverId}/cost`)
|
||||
const cost = json.cost_rub
|
||||
return cost != null && Number.isFinite(cost) ? cost : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllPayments(baseUrl: string, credentials: string): Promise<RuvdsPayment[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
return fetchAllPages<RuvdsPayment>(
|
||||
client,
|
||||
'/v2/payments',
|
||||
(json) => (Array.isArray(json.payments) ? (json.payments as RuvdsPayment[]) : []),
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchTariffs(baseUrl: string, credentials: string): Promise<RuvdsTariffsResponse> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
return client.request<RuvdsTariffsResponse>('/v2/tariffs')
|
||||
}
|
||||
|
||||
export async function fetchDatacenters(baseUrl: string, credentials: string): Promise<RuvdsDatacenter[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<{ datacenters?: RuvdsDatacenter[] }>('/v2/datacenters')
|
||||
return Array.isArray(json.datacenters) ? json.datacenters : []
|
||||
}
|
||||
|
||||
export async function fetchOsList(baseUrl: string, credentials: string): Promise<RuvdsOsItem[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<{ os?: RuvdsOsItem[] }>('/v2/os')
|
||||
return Array.isArray(json.os) ? json.os : []
|
||||
}
|
||||
|
||||
export async function fetchTariffList(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<RuvdsTariffItem[]> {
|
||||
const [tariffs, datacenters] = await Promise.all([
|
||||
fetchTariffs(baseUrl, credentials),
|
||||
fetchDatacenters(baseUrl, credentials).catch(() => [] as RuvdsDatacenter[]),
|
||||
])
|
||||
|
||||
const dc = datacenters[0]
|
||||
const dcKey = dc ? String(dc.id) : ''
|
||||
const dcName = dc?.name ?? 'RuVDS'
|
||||
|
||||
const items: RuvdsTariffItem[] = []
|
||||
for (const t of tariffs.vps ?? []) {
|
||||
if (t.is_active === false) continue
|
||||
const cpuPrice = t.cpu ?? 0
|
||||
const ramPrice = t.ram ?? 0
|
||||
const sampleCpu = 2
|
||||
const sampleRam = 2
|
||||
const sampleDisk = 40
|
||||
const drivePrice = tariffs.drive?.find((d) => d.is_active !== false)?.price ?? 5.5
|
||||
const monthly = Math.round((cpuPrice * sampleCpu + ramPrice * sampleRam + drivePrice * sampleDisk) * 100) / 100
|
||||
items.push({
|
||||
externalId: String(t.id),
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
name: t.name || `Tariff ${t.id}`,
|
||||
desc: `CPU ${cpuPrice} RUB/core, RAM ${ramPrice} RUB/GB`,
|
||||
vcpu: sampleCpu,
|
||||
ramGb: sampleRam,
|
||||
diskGb: sampleDisk,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: dcName,
|
||||
country: (dc?.country ?? 'RU').toUpperCase(),
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: monthly > 0 ? `${monthly} RUB/mo` : '',
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const COST_CONCURRENCY = 4
|
||||
|
||||
export async function enrichServersWithCost(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
servers: RuvdsServer[],
|
||||
): Promise<Map<number, number>> {
|
||||
const costById = new Map<number, number>()
|
||||
const ids = servers.map((s) => s.virtual_server_id).filter((id) => id > 0)
|
||||
if (ids.length === 0) return costById
|
||||
|
||||
for (let i = 0; i < ids.length; i += COST_CONCURRENCY) {
|
||||
const batch = ids.slice(i, i + COST_CONCURRENCY)
|
||||
const results = await Promise.all(
|
||||
batch.map(async (id) => {
|
||||
const cost = await fetchServerCost(baseUrl, credentials, id)
|
||||
return { id, cost }
|
||||
}),
|
||||
)
|
||||
for (const { id, cost } of results) {
|
||||
if (cost != null) costById.set(id, cost)
|
||||
}
|
||||
}
|
||||
return costById
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeDb, getSqlite } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
|
||||
import { syncFromRuvds } from './sync.js'
|
||||
import type { RuvdsSyncAccount } from './context.js'
|
||||
|
||||
vi.mock('./operations.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./operations.js')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchAllServers: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchTariffList: vi.fn(),
|
||||
fetchAllPayments: vi.fn(),
|
||||
fetchDatacenters: vi.fn(),
|
||||
fetchOsList: vi.fn(),
|
||||
enrichServersWithCost: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
enrichServersWithCost,
|
||||
fetchAllPayments,
|
||||
fetchAllServers,
|
||||
fetchBalance,
|
||||
fetchDatacenters,
|
||||
fetchOsList,
|
||||
fetchTariffList,
|
||||
} from './operations.js'
|
||||
|
||||
function makeAccount(): RuvdsSyncAccount {
|
||||
return {
|
||||
id: 'acc-ruvds',
|
||||
providerId: 'prov-ruvds',
|
||||
name: 'RuVDS',
|
||||
panelUrl: '',
|
||||
currency: '',
|
||||
billingMode: 'monthly',
|
||||
notes: '',
|
||||
apiType: 'ruvds',
|
||||
apiBaseUrl: 'https://api.ruvds.com',
|
||||
apiCredentials: 'apiv2.test-token',
|
||||
apiToken: 'apiv2.test-token',
|
||||
balanceApi: null,
|
||||
balanceCurrency: null,
|
||||
balanceUpdatedAt: null,
|
||||
enoughmoneyto: '',
|
||||
balanceAlertBelow: null,
|
||||
providerBaseCurrency: 'RUB',
|
||||
}
|
||||
}
|
||||
|
||||
describe('syncFromRuvds', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
getSqlite().exec(
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl, baseCurrency) VALUES ('prov-ruvds', 'RuVDS', 'ruvds', 'https://api.ruvds.com', 'RUB')`,
|
||||
)
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-ruvds',
|
||||
providerId: 'prov-ruvds',
|
||||
name: 'RuVDS',
|
||||
apiCredentials: 'apiv2.test-token',
|
||||
})
|
||||
|
||||
vi.mocked(fetchAllServers).mockResolvedValue([
|
||||
{
|
||||
virtual_server_id: 1001,
|
||||
status: 'active',
|
||||
datacenter: 1,
|
||||
cpu: 2,
|
||||
ram: 2,
|
||||
drive: 40,
|
||||
payment_period: 2,
|
||||
os_id: 18,
|
||||
paid_till: '2027-03-01T00:00:00Z',
|
||||
user_comment: 'web',
|
||||
network_v4: [{ ip_address: '203.0.113.5' }],
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchBalance).mockResolvedValue({
|
||||
balance: 1500,
|
||||
currency: 'RUB',
|
||||
enoughmoneyto: '',
|
||||
})
|
||||
vi.mocked(fetchTariffList).mockResolvedValue([
|
||||
{
|
||||
externalId: '14',
|
||||
datacenterKey: '1',
|
||||
datacenterName: 'Moscow',
|
||||
name: 'Regular',
|
||||
desc: 'CPU 79 RUB/core',
|
||||
vcpu: 2,
|
||||
ramGb: 2,
|
||||
diskGb: 40,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: 'Moscow',
|
||||
country: 'RU',
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: '500 RUB/mo',
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchAllPayments).mockResolvedValue([
|
||||
{
|
||||
dt: '2026-01-01T00:00:00Z',
|
||||
direction: 1,
|
||||
amount: 1000,
|
||||
currency: 1,
|
||||
pay_source: 'card',
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchDatacenters).mockResolvedValue([
|
||||
{ id: 1, name: 'Rucloud: Россия, Москва', country: 'RU' },
|
||||
])
|
||||
vi.mocked(fetchOsList).mockResolvedValue([{ id: 18, name: 'Ubuntu 22.04' }])
|
||||
vi.mocked(enrichServersWithCost).mockResolvedValue(new Map([[1001, 500]]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('syncs vps, balance, payments and tariffs', async () => {
|
||||
const result = await syncFromRuvds(makeAccount())
|
||||
expect(result.vpsCount).toBe(1)
|
||||
expect(result.paymentsCount).toBe(1)
|
||||
expect(result.tariffsCount).toBe(1)
|
||||
expect(result.balance?.balance).toBe(1500)
|
||||
expect(result.syncSummary.added).toHaveLength(1)
|
||||
expect(result.syncSummary.added[0].id).toBe('vps-ruvds-acc-ruvds-1001')
|
||||
})
|
||||
|
||||
it('updates existing vps by notes marker', async () => {
|
||||
await syncFromRuvds(makeAccount())
|
||||
vi.mocked(fetchAllServers).mockResolvedValue([
|
||||
{
|
||||
virtual_server_id: 1001,
|
||||
status: 'active',
|
||||
datacenter: 1,
|
||||
cpu: 4,
|
||||
ram: 4,
|
||||
drive: 80,
|
||||
payment_period: 2,
|
||||
os_id: 18,
|
||||
paid_till: '2028-01-01T00:00:00Z',
|
||||
network_v4: [{ ip_address: '203.0.113.5' }],
|
||||
},
|
||||
])
|
||||
vi.mocked(enrichServersWithCost).mockResolvedValue(new Map([[1001, 800]]))
|
||||
|
||||
const result = await syncFromRuvds(makeAccount())
|
||||
expect(result.vpsCount).toBe(1)
|
||||
expect(result.syncSummary.updated.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Sync RuVDS API v2 data into vps-tracker DB
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { RuvdsSyncAccount } from './context.js'
|
||||
import { ruvdsCredentialsString } from './context.js'
|
||||
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
|
||||
import { buildLookupMaps, mapPaymentToPayment, mapServerToVps } from './mappers.js'
|
||||
import {
|
||||
enrichServersWithCost,
|
||||
fetchAllPayments,
|
||||
fetchAllServers,
|
||||
fetchDatacenters,
|
||||
fetchOsList,
|
||||
fetchTariffList,
|
||||
fetchBalance,
|
||||
type RuvdsBalanceResult,
|
||||
} from './operations.js'
|
||||
|
||||
export interface SyncFromRuvdsOptions {
|
||||
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 SyncFromRuvdsResult {
|
||||
vpsCount: number
|
||||
paymentsCount: number
|
||||
tariffsCount: number
|
||||
newTariffs: { name: string; price: string; providerId: string }[]
|
||||
balance: RuvdsBalanceResult | 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 syncFromRuvds(
|
||||
account: RuvdsSyncAccount,
|
||||
opts: SyncFromRuvdsOptions = {},
|
||||
): Promise<SyncFromRuvdsResult> {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, providerId, id: accountId } = account
|
||||
const credentials = ruvdsCredentialsString(account)
|
||||
if (!apiBaseUrl?.trim() || !credentials) {
|
||||
throw new Error('API URL and token are required')
|
||||
}
|
||||
const db = getDb()
|
||||
|
||||
const fetchVpsData = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [servers, balanceInfo, tariffItems, payments, datacenters, osList] = await Promise.all([
|
||||
fetchVpsData ? fetchAllServers(apiBaseUrl, credentials) : [],
|
||||
fetchVpsData
|
||||
? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchVpsData ? fetchAllPayments(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchVpsData ? fetchDatacenters(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchVpsData ? fetchOsList(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
])
|
||||
|
||||
const costs = fetchVpsData
|
||||
? await enrichServersWithCost(apiBaseUrl, credentials, servers)
|
||||
: new Map<number, number>()
|
||||
|
||||
const lookups = buildLookupMaps(datacenters, osList, costs)
|
||||
|
||||
const fallbackCurrency = syncFallbackCurrency(account, {
|
||||
balanceCurrency: balanceInfo?.currency,
|
||||
})
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsData) {
|
||||
for (const server of servers) {
|
||||
const vps = mapServerToVps(server, providerId, accountId, fallbackCurrency, lookups)
|
||||
const id = `vps-ruvds-${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 = db
|
||||
.select()
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.vps.providerAccountId, accountId),
|
||||
or(
|
||||
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
|
||||
like(schema.vps.notes, `%ruvds-${vps.externalId}%`),
|
||||
),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
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({
|
||||
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 payments) {
|
||||
const payment = mapPaymentToPayment(item, accountId, fallbackCurrency)
|
||||
if (!payment) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const payId = `pay-ruvds-${accountId}-${payment.externalId}`
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
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-ruvds-${accountId}-${t.externalId}-${dcKey}`
|
||||
: `tariff-ruvds-${accountId}-${t.externalId}`
|
||||
if (!existingTariffIds.has(tariffId)) {
|
||||
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
||||
}
|
||||
db.insert(schema.activeTariffs)
|
||||
.values({
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export interface RuvdsPagination {
|
||||
page: number
|
||||
per_page: number
|
||||
previous_page: number | null
|
||||
next_page: number | null
|
||||
last_page: number
|
||||
total_entries: number
|
||||
}
|
||||
|
||||
export interface RuvdsNetworkV4 {
|
||||
ip_address: string
|
||||
netmask?: string
|
||||
gateway?: string
|
||||
}
|
||||
|
||||
export interface RuvdsServer {
|
||||
virtual_server_id: number
|
||||
status?: string
|
||||
create_progress?: number
|
||||
datacenter?: number
|
||||
tariff_id?: number
|
||||
payment_period?: number
|
||||
os_id?: number
|
||||
template_id?: string | null
|
||||
cpu?: number
|
||||
ram?: number
|
||||
vram?: number
|
||||
drive?: number
|
||||
drive_tariff_id?: number
|
||||
additional_drive?: number | null
|
||||
additional_drive_tariff_id?: number | null
|
||||
ip?: number
|
||||
ddos_protection?: number
|
||||
user_comment?: string
|
||||
paid_till?: string
|
||||
network_v4?: RuvdsNetworkV4[]
|
||||
}
|
||||
|
||||
export interface RuvdsServersResponse {
|
||||
servers: RuvdsServer[]
|
||||
pagination?: RuvdsPagination
|
||||
}
|
||||
|
||||
export interface RuvdsBalanceResponse {
|
||||
amount: number
|
||||
currency: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface RuvdsPayment {
|
||||
dt: string
|
||||
direction: number
|
||||
pay_source?: string
|
||||
amount: number
|
||||
currency: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface RuvdsPaymentsResponse {
|
||||
payments: RuvdsPayment[]
|
||||
pagination?: RuvdsPagination
|
||||
}
|
||||
|
||||
export interface RuvdsVpsTariff {
|
||||
id: number
|
||||
name: string
|
||||
cpu?: number
|
||||
ram?: number
|
||||
vram?: number
|
||||
ip?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface RuvdsDriveTariff {
|
||||
id: number
|
||||
name: string
|
||||
price?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface RuvdsTariffsResponse {
|
||||
vps?: RuvdsVpsTariff[]
|
||||
drive?: RuvdsDriveTariff[]
|
||||
additional_drive?: RuvdsDriveTariff[]
|
||||
additional_service?: { id: number; name: string; price?: number; is_active?: boolean }[]
|
||||
payment_period_discount?: { payment_period: number; discount: number }[]
|
||||
}
|
||||
|
||||
export interface RuvdsDatacenter {
|
||||
id: number
|
||||
name: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface RuvdsDatacentersResponse {
|
||||
datacenters?: RuvdsDatacenter[]
|
||||
}
|
||||
|
||||
export interface RuvdsOsItem {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface RuvdsOsResponse {
|
||||
os?: RuvdsOsItem[]
|
||||
}
|
||||
|
||||
export interface RuvdsServerCostResponse {
|
||||
cost_rub?: number
|
||||
}
|
||||
|
||||
export interface RuvdsApiErrorBody {
|
||||
id?: string
|
||||
message?: string
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||
.all<AccountRow>(sql`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp')
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/sch
|
||||
import type { BillingMode, Provider } from '@/types/entities'
|
||||
import { billingModeLabel } from '@/lib/format'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { accountCredentialLabels, isUserApiType } from '@/lib/provider-sync'
|
||||
import { accountCredentialLabels, isTokenApiType } from '@/lib/provider-sync'
|
||||
|
||||
const EMPTY: ProviderAccountFormValues = {
|
||||
providerId: '',
|
||||
@@ -85,8 +85,10 @@ export function ProviderAccountEditSheet({
|
||||
onOpenChange={onOpenChange}
|
||||
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
|
||||
description={
|
||||
isUserApiType(initialProvider?.apiType)
|
||||
? 'API Token хранится на сервере и используется для синка с UserAPI'
|
||||
isTokenApiType(initialProvider?.apiType)
|
||||
? initialProvider?.apiType === 'ruvds'
|
||||
? 'API-токен RuVDS хранится на сервере (создаётся в настройках ЛК RuVDS)'
|
||||
: 'API Token хранится на сервере и используется для синка с UserAPI'
|
||||
: initialProvider?.apiType === '4vps'
|
||||
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
|
||||
: initialProvider?.apiType === 'veesp'
|
||||
@@ -105,10 +107,12 @@ export function ProviderAccountEditSheet({
|
||||
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
|
||||
const apiLogin = watch('apiLogin')?.trim() ?? ''
|
||||
const apiPassword = watch('apiPassword')?.trim() ?? ''
|
||||
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
||||
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
||||
const credLabels = accountCredentialLabels(provider?.apiType)
|
||||
const tokenOnly = isUserApiType(provider?.apiType)
|
||||
const tokenOnly = isTokenApiType(provider?.apiType)
|
||||
const apiCredentials = tokenOnly
|
||||
? apiPassword
|
||||
: buildApiCredentials(apiLogin, apiPassword)
|
||||
const canTest = Boolean(apiBaseUrl && (tokenOnly ? apiPassword : apiCredentials))
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelectField } from '@/components/select-field'
|
||||
import type { ZodType } from 'zod'
|
||||
import { providerSchema, type ProviderFormValues } from '@/lib/schemas'
|
||||
import type { ApiType } from '@/types/entities'
|
||||
import { isUserApiType, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider'
|
||||
import { isUserApiType, RUVDS_DEFAULT_BASE_URL, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
const EMPTY: ProviderFormValues = {
|
||||
name: '',
|
||||
@@ -60,6 +60,8 @@ export function ProviderEditSheet({
|
||||
? 'Базовый URL API, например https://4vps.su/api'
|
||||
: apiType === 'veesp'
|
||||
? 'Базовый URL Veesp API, например https://secure.veesp.com/api'
|
||||
: apiType === 'ruvds'
|
||||
? 'Базовый URL RuVDS API v2, например https://api.ruvds.com'
|
||||
: isUserApiType(apiType)
|
||||
? `Базовый URL UserAPI, например ${USER_API_DEFAULT_BASE_URL[apiType]}`
|
||||
: 'Один URL на хостера для BILLmanager'
|
||||
@@ -68,6 +70,8 @@ export function ProviderEditSheet({
|
||||
? 'https://4vps.su/api'
|
||||
: apiType === 'veesp'
|
||||
? VEESP_DEFAULT_BASE_URL
|
||||
: apiType === 'ruvds'
|
||||
? RUVDS_DEFAULT_BASE_URL
|
||||
: isUserApiType(apiType)
|
||||
? USER_API_DEFAULT_BASE_URL[apiType]
|
||||
: undefined
|
||||
@@ -91,6 +95,7 @@ export function ProviderEditSheet({
|
||||
{ value: 'macloud', label: 'Маклауд' },
|
||||
{ value: 'vdsina', label: 'VDSina' },
|
||||
{ value: 'veesp', label: 'Veesp' },
|
||||
{ value: 'ruvds', label: 'RuVDS' },
|
||||
{ value: 'none', label: 'Нет' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isSyncApiType, isUserApiType } from '@cfdm/shared/contracts/provider'
|
||||
import { isSyncApiType, isTokenApiType, isUserApiType } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||
|
||||
export { isSyncApiType, isUserApiType }
|
||||
export { isSyncApiType, isTokenApiType, isUserApiType }
|
||||
|
||||
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
||||
return new Map(providers.map((p) => [p.id, p]))
|
||||
@@ -76,6 +76,13 @@ export function accountCredentialLabels(apiType?: string | null): {
|
||||
loginPlaceholder: '[email protected]',
|
||||
}
|
||||
}
|
||||
if (String(apiType).toLowerCase() === 'ruvds') {
|
||||
return {
|
||||
loginLabel: '',
|
||||
passwordLabel: 'API Token',
|
||||
loginPlaceholder: '',
|
||||
}
|
||||
}
|
||||
if (isUserApiType(apiType)) {
|
||||
return {
|
||||
loginLabel: '',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'none'] as const
|
||||
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds', 'none'] as const
|
||||
export type ApiType = (typeof API_TYPES)[number]
|
||||
export const apiTypeSchema = z.enum(API_TYPES).optional().default('none')
|
||||
|
||||
export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp'] as const
|
||||
export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds'] as const
|
||||
export type SyncApiType = (typeof SYNC_API_TYPES)[number]
|
||||
|
||||
export const USER_API_TYPES = ['macloud', 'vdsina'] as const
|
||||
@@ -17,6 +17,16 @@ export const USER_API_DEFAULT_BASE_URL: Record<UserApiType, string> = {
|
||||
|
||||
export const VEESP_DEFAULT_BASE_URL = 'https://secure.veesp.com/api'
|
||||
|
||||
export const RUVDS_DEFAULT_BASE_URL = 'https://api.ruvds.com'
|
||||
|
||||
export const TOKEN_API_TYPES = ['macloud', 'vdsina', 'ruvds'] as const
|
||||
export type TokenApiType = (typeof TOKEN_API_TYPES)[number]
|
||||
|
||||
export function isTokenApiType(apiType?: string | null): apiType is TokenApiType {
|
||||
const key = String(apiType || '').toLowerCase()
|
||||
return (TOKEN_API_TYPES as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
export function isUserApiType(apiType?: string | null): apiType is UserApiType {
|
||||
const key = String(apiType || '').toLowerCase()
|
||||
return (USER_API_TYPES as readonly string[]).includes(key)
|
||||
|
||||
@@ -39,6 +39,11 @@ export function parseUserApiToken(credentials: string | null | undefined): strin
|
||||
return String(credentials ?? '').trim()
|
||||
}
|
||||
|
||||
/** Bearer token для RuVDS API v2. */
|
||||
export function parseRuvdsToken(credentials: string | null | undefined): string {
|
||||
return String(credentials ?? '').trim()
|
||||
}
|
||||
|
||||
/** Собрать 4VPS-креды: panelId + API key. */
|
||||
export function buildFourVpsCredentials(panelId: string, apiKey: string): string {
|
||||
const pid = panelId.trim()
|
||||
|
||||
Reference in New Issue
Block a user