diff --git a/apps/api/src/routes/sync.test.ts b/apps/api/src/routes/sync.test.ts new file mode 100644 index 0000000..7b569e5 --- /dev/null +++ b/apps/api/src/routes/sync.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { closeDb } from '@cfdm/db' +import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup' +import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts' +import { providersRepository } from '@cfdm/db/repositories/providers' + +import { buildApp } from '../index.js' + +vi.mock('../services/fourvps/sync.js', () => ({ + syncFromFourvps: vi.fn().mockResolvedValue({ + vpsCount: 2, + paymentsCount: 0, + tariffsCount: 3, + newTariffs: [], + balance: { balance: 1000, currency: 'RUB' }, + syncSummary: { added: [], updated: [], paymentsAdded: 0 }, + }), +})) + +describe('sync routes — 4vps', () => { + let app: Awaited> + + beforeEach(async () => { + resetTestDb() + seedTestProvider('prov-4vps') + providersRepository.update('prov-4vps', { + apiType: '4vps', + apiBaseUrl: 'https://4vps.su/api', + }) + providerAccountsRepository.create({ + id: 'acc-4vps', + providerId: 'prov-4vps', + name: '4VPS', + apiCredentials: '1:secret-key', + }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('POST /api/sync/:accountId syncs 4vps account', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/sync/acc-4vps', + 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(2) + }) + + it('POST /api/sync/test-connection uses apiType 4vps', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ error: false, data: { userBalance: 500, serverlist: [] } }), + }), + ) + + const res = await app.inject({ + method: 'POST', + url: '/api/sync/test-connection', + payload: { + apiBaseUrl: 'https://4vps.su/api', + apiCredentials: '1:key', + apiType: '4vps', + }, + }) + expect(res.statusCode).toBe(200) + const body = res.json() as { ok?: boolean } + expect(body.ok).toBe(true) + vi.unstubAllGlobals() + }) +}) diff --git a/apps/api/src/routes/sync.ts b/apps/api/src/routes/sync.ts index 05e8e71..89b0e3c 100644 --- a/apps/api/src/routes/sync.ts +++ b/apps/api/src/routes/sync.ts @@ -5,11 +5,11 @@ import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accou import { providersRepository } from '@cfdm/db/repositories/providers' import { - billmanagerAccountRowForSync, - fetchDashboardInfo, - runBillmanagerAccountSync, - testConnection, -} from '../services/billmanager/index.js' + getProviderAdapter, + resolveSyncAccount, + SYNC_SETUP_ERRORS, +} from '../services/providers/index.js' +import { runAccountSync } from '../services/providers/sync-job.js' interface SyncLogRow { id: string @@ -45,8 +45,9 @@ function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow { } } -const BILLMANAGER_SETUP_ERROR = - 'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API' +function setupErrorMessage(apiType: string): string { + return SYNC_SETUP_ERRORS[apiType] ?? 'Настройте API хостера и учётные данные аккаунта' +} export const syncRoutes: FastifyPluginAsync = async (app) => { app.get('/api/sync/status', async () => { @@ -67,16 +68,18 @@ export const syncRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } }) } const provider = account.providerId ? providersRepository.get(account.providerId) : undefined - const syncRow = billmanagerAccountRowForSync(account, provider) - if (!syncRow) { - return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } }) + const resolved = resolveSyncAccount(account, provider) + if (!resolved) { + const apiType = String(provider?.apiType || 'billmanager').toLowerCase() + return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } }) } const onlyTariffs = Boolean(req.body?.onlyTariffs) const opts = onlyTariffs ? { skipVpsPayments: true } : {} + const adapter = getProviderAdapter(resolved.apiType) try { - const result = await runBillmanagerAccountSync(syncRow, opts) + const result = await runAccountSync(adapter, resolved.account, opts) return { ok: true, synced: { @@ -99,15 +102,19 @@ export const syncRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } }) } const provider = account.providerId ? providersRepository.get(account.providerId) : undefined - const syncRow = billmanagerAccountRowForSync(account, provider) - if (!syncRow) { - return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } }) + const resolved = resolveSyncAccount(account, provider) + if (!resolved) { + const apiType = String(provider?.apiType || 'billmanager').toLowerCase() + return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } }) + } + + const adapter = getProviderAdapter(resolved.apiType) + if (!adapter.fetchBalance) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Баланс через API недоступен для этого типа хостера' } }) } try { - const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), { - fallbackCurrency: account.currency, - }) + const info = await adapter.fetchBalance(resolved.account) getDb() .update(schema.providerAccounts) .set({ @@ -127,15 +134,17 @@ export const syncRoutes: FastifyPluginAsync = async (app) => { }) app.post('/api/sync/test-connection', async (req, reply) => { - const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as { + const { apiBaseUrl, apiCredentials, apiType } = (req.body ?? {}) as { apiBaseUrl?: string apiCredentials?: string + apiType?: string } if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) { return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' }) } try { - const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim()) + const adapter = getProviderAdapter(apiType || 'billmanager') + const result = await adapter.testConnection(apiBaseUrl.trim(), apiCredentials.trim()) return result } catch (err) { req.log.error(err) diff --git a/apps/api/src/services/billmanager/sync-job.ts b/apps/api/src/services/billmanager/sync-job.ts index 1b08b50..6d14670 100644 --- a/apps/api/src/services/billmanager/sync-job.ts +++ b/apps/api/src/services/billmanager/sync-job.ts @@ -2,11 +2,10 @@ * Запуск синхронизации BILLmanager с записью в sync_log */ -import { eq } from 'drizzle-orm' -import { getDb, schema } from '@cfdm/db' - import type { BillmanagerSyncAccount } from './context.js' -import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js' +import type { SyncFromBillmanagerOptions, SyncFromBillmanagerResult } from './sync.js' +import { billmanagerAdapter } from '../providers/billmanager-adapter.js' +import { runAccountSync } from '../providers/sync-job.js' export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult { ok: true @@ -17,48 +16,5 @@ export async function runBillmanagerAccountSync( account: BillmanagerSyncAccount, opts: SyncFromBillmanagerOptions = {}, ): Promise { - const db = getDb() - const logId = `sync-${account.id}-${Date.now()}` - - db.insert(schema.syncLog) - .values({ - id: logId, - accountId: account.id, - startedAt: new Date().toISOString(), - status: 'running', - }) - .run() - - try { - const result = await syncFromBillmanager(account, opts) - const summaryPayload = { - ...(result.syncSummary || {}), - vpsCount: result.vpsCount, - paymentsCount: result.paymentsCount, - tariffsCount: result.tariffsCount ?? 0, - } - db.update(schema.syncLog) - .set({ - finishedAt: new Date().toISOString(), - status: 'ok', - vpsCount: result.vpsCount, - paymentsCount: result.paymentsCount, - summary: JSON.stringify(summaryPayload), - }) - .where(eq(schema.syncLog.id, logId)) - .run() - return { ok: true, logId, ...result } - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error' - db.update(schema.syncLog) - .set({ - finishedAt: new Date().toISOString(), - status: 'error', - error: message, - summary: JSON.stringify({ error: message }), - }) - .where(eq(schema.syncLog.id, logId)) - .run() - throw err - } + return runAccountSync(billmanagerAdapter, account, opts) } diff --git a/apps/api/src/services/dashboard-stats.ts b/apps/api/src/services/dashboard-stats.ts index af8860f..ec5de1e 100644 --- a/apps/api/src/services/dashboard-stats.ts +++ b/apps/api/src/services/dashboard-stats.ts @@ -1,6 +1,7 @@ import { getSnapshot } from '@cfdm/db/repositories/snapshot' import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health' import { accountBalanceApi } from '@cfdm/shared/utils/account-balance' +import { isSyncApiType } from '@cfdm/shared/contracts/provider' const STALE_SYNC_HOURS = 48 @@ -84,11 +85,11 @@ export function computeDashboardStats(): DashboardStats { const providerById = new Map(snap.providers.map((p) => [p.id, p])) const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 - const bmAccounts = snap.providerAccounts.filter((a) => { + const syncAccounts = snap.providerAccounts.filter((a) => { const p = providerById.get(a.providerId) - return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet + return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet }) - const staleSyncAccountCount = bmAccounts.filter((a) => { + const staleSyncAccountCount = syncAccounts.filter((a) => { const t = lastOkSyncAt(a.id, snap.syncLog) if (t == null) return true return now.getTime() - t > staleMs diff --git a/apps/api/src/services/fourvps/client.test.ts b/apps/api/src/services/fourvps/client.test.ts new file mode 100644 index 0000000..efb6fd0 --- /dev/null +++ b/apps/api/src/services/fourvps/client.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { fourvpsRequest, FourVpsApiError } from './client.js' + +describe('fourvpsRequest', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sends Bearer auth and panel_id query param', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ error: false, data: { userBalance: 100 } }), + }) + vi.stubGlobal('fetch', fetchMock) + + const data = await fourvpsRequest('https://4vps.su/api', 'test-key', '/userBalance', { + panelId: 2, + }) + + expect(data).toEqual({ userBalance: 100 }) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('panel_id=2') + expect((init.headers as Record).Authorization).toBe('Bearer test-key') + }) + + it('throws FourVpsApiError on API error flag', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + error: true, + errorMessage: 'Authentication error', + data: false, + }), + }), + ) + + await expect(fourvpsRequest('https://4vps.su/api', 'bad', '/userBalance')).rejects.toThrow( + FourVpsApiError, + ) + }) +}) diff --git a/apps/api/src/services/fourvps/client.ts b/apps/api/src/services/fourvps/client.ts new file mode 100644 index 0000000..250def4 --- /dev/null +++ b/apps/api/src/services/fourvps/client.ts @@ -0,0 +1,78 @@ +/** + * 4VPS.SU REST API HTTP client + * @see https://4vps.su/page/api + */ + +export interface FourVpsResponse { + error: boolean + errorMessage?: string | Record + data: T +} + +export class FourVpsApiError extends Error { + constructor(message: string) { + super(message) + this.name = 'FourVpsApiError' + } +} + +function formatErrorMessage(errorMessage: FourVpsResponse['errorMessage']): string { + if (!errorMessage) return '4VPS API error' + if (typeof errorMessage === 'string') return errorMessage + const msg = errorMessage.message + if (typeof msg === 'string') return msg + return JSON.stringify(errorMessage) +} + +function joinUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') + const p = path.startsWith('/') ? path : `/${path}` + return `${base}${p}` +} + +export interface FourVpsRequestOptions { + method?: 'GET' | 'POST' + panelId?: number | null + body?: Record +} + +export async function fourvpsRequest( + baseUrl: string, + apiKey: string, + path: string, + opts: FourVpsRequestOptions = {}, +): Promise { + const { method = 'GET', panelId, body } = opts + const url = new URL(joinUrl(baseUrl, path)) + if (panelId != null) { + url.searchParams.set('panel_id', String(panelId)) + } + + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + } + + if (method === 'POST') { + const payload: Record = { ...(body ?? {}) } + if (panelId != null && payload.panel_id == null) { + payload.panel_id = panelId + } + init.headers = { ...init.headers, 'Content-Type': 'application/json' } + init.body = JSON.stringify(payload) + } + + const res = await fetch(url.toString(), init) + if (!res.ok) { + throw new FourVpsApiError(`4VPS API HTTP ${res.status}: ${res.statusText}`) + } + + const json = (await res.json()) as FourVpsResponse + if (json.error) { + throw new FourVpsApiError(formatErrorMessage(json.errorMessage)) + } + return json.data +} diff --git a/apps/api/src/services/fourvps/context.ts b/apps/api/src/services/fourvps/context.ts new file mode 100644 index 0000000..f41842a --- /dev/null +++ b/apps/api/src/services/fourvps/context.ts @@ -0,0 +1,33 @@ +import type { schema } from '@cfdm/db' +import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials' + +type AccountRow = typeof schema.providerAccounts.$inferSelect +type ProviderRow = typeof schema.providers.$inferSelect + +export interface FourvpsSyncAccount extends AccountRow { + apiType: '4vps' + apiBaseUrl: string + panelId: number | null + apiKey: string +} + +export function resolveFourvpsApi( + 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 fourvpsAccountRowForSync( + accountRow: AccountRow | null | undefined, + providerRow: ProviderRow | null | undefined, +): FourvpsSyncAccount | null { + if (!accountRow) return null + const { apiType, apiBaseUrl } = resolveFourvpsApi(accountRow, providerRow) + const cred = String(accountRow.apiCredentials || '').trim() + const { panelId, apiKey } = parseFourVpsCredentials(cred) + if (apiType !== '4vps' || !apiBaseUrl || !apiKey) return null + return { ...accountRow, apiType: '4vps', apiBaseUrl, panelId, apiKey } +} diff --git a/apps/api/src/services/fourvps/index.ts b/apps/api/src/services/fourvps/index.ts new file mode 100644 index 0000000..9b2e243 --- /dev/null +++ b/apps/api/src/services/fourvps/index.ts @@ -0,0 +1,13 @@ +export { fourvpsRequest, FourVpsApiError } from './client.js' +export type { FourvpsSyncAccount } from './context.js' +export { fourvpsAccountRowForSync, resolveFourvpsApi } from './context.js' +export { mapServerToVps } from './mappers.js' +export { + fetchDcList, + fetchMyServers, + fetchTarifList, + fetchUserBalance, + testConnection, +} from './operations.js' +export { syncFromFourvps } from './sync.js' +export type { SyncFromFourvpsOptions, SyncFromFourvpsResult } from './sync.js' diff --git a/apps/api/src/services/fourvps/mappers.test.ts b/apps/api/src/services/fourvps/mappers.test.ts new file mode 100644 index 0000000..af077b6 --- /dev/null +++ b/apps/api/src/services/fourvps/mappers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { mapServerToVps } from './mappers.js' +import type { FourVpsServer } from './operations.js' + +const sampleServer: FourVpsServer = { + id: 4140, + name: 'MyFirstServer', + price: 420, + dc: 7, + image: 'Alma Linux 8', + mem: 1, + cpu: 1, + disk: 10, + ipv4: '185.143.223.29', + status: 'active', + tname: 'USA-cx01', + time: 1664275501, + expired: 1666781101, +} + +describe('mapServerToVps', () => { + it('maps myservers fields to VPS model', () => { + const dcMap = new Map([ + [7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }], + ]) + const vps = mapServerToVps(sampleServer, 'prov-1', 'acc-1', dcMap) + + expect(vps.externalId).toBe('4140') + expect(vps.ip).toBe('185.143.223.29') + expect(vps.vcpu).toBe(1) + expect(vps.ramGb).toBe(1) + expect(vps.diskGb).toBe(10) + expect(vps.os).toBe('Alma Linux 8') + expect(vps.status).toBe('active') + expect(vps.monthlyRate).toBe(420) + expect(vps.datacenter).toBe('USA DC1') + expect(vps.country).toBe('US') + expect(vps.paidUntil).toBe('2022-10-26') + expect(vps.notes).toContain('4vps-4140') + }) +}) diff --git a/apps/api/src/services/fourvps/mappers.ts b/apps/api/src/services/fourvps/mappers.ts new file mode 100644 index 0000000..f1b5438 --- /dev/null +++ b/apps/api/src/services/fourvps/mappers.ts @@ -0,0 +1,101 @@ +/** + * 4VPS API response → vps-tracker model mappers + */ + +import type { FourVpsDatacenter, FourVpsServer } from './operations.js' + +const STATUS_MAP: Record = { + active: 'active', + paused: 'paused', + suspended: 'paused', + deleted: 'archived', +} + +function unixToIso(ts: number | null | undefined): string { + if (ts == null || !Number.isFinite(ts) || ts <= 0) return '' + return new Date(ts * 1000).toISOString().slice(0, 10) +} + +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: null + monthlyRate: number | null + createdAt: string + paidUntil: string + notes: string +} + +export function mapServerToVps( + server: FourVpsServer, + providerId: string, + providerAccountId: string, + dcMap: Map, +): MappedVps { + const dc = dcMap.get(server.dc) + const datacenter = dc?.dc_name ?? String(server.dc) + const country = (dc?.flag ?? '').toUpperCase() + const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active' + const monthlyRate = Number.isFinite(server.price) ? server.price : null + const name = String(server.name || '').trim() + + return { + externalId: String(server.id), + ip: String(server.ipv4 || '').trim(), + dns: name, + ipv6: '', + additionalIps: [], + providerId, + providerAccountId, + country, + city: '', + datacenter, + os: String(server.image || '').trim(), + vcpu: server.cpu ?? 0, + ramGb: server.mem ?? 0, + diskGb: server.disk ?? 0, + diskType: 'NVMe', + virtualization: 'KVM', + bandwidthTb: 0, + sshPort: 22, + rootUser: 'root', + purpose: '', + environment: '', + project: '', + monitoringEnabled: false, + backupEnabled: false, + status, + tariffType: 'monthly', + currency: 'RUB', + dailyRate: null, + monthlyRate, + createdAt: unixToIso(server.time), + paidUntil: unixToIso(server.expired), + notes: name ? `${name} [4vps-${server.id}]` : `4vps-${server.id}`, + } +} diff --git a/apps/api/src/services/fourvps/operations.ts b/apps/api/src/services/fourvps/operations.ts new file mode 100644 index 0000000..5b308d9 --- /dev/null +++ b/apps/api/src/services/fourvps/operations.ts @@ -0,0 +1,220 @@ +/** + * 4VPS.SU API operations + */ + +import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials' + +import { fourvpsRequest } from './client.js' + +export interface FourVpsServer { + id: number + name: string + price: number + dc: number + image: string + mem: number + cpu: number + disk: number + ipv4: string + status: string + tname: string + time: number + expired: number + autoprolong?: number +} + +export interface FourVpsDatacenter { + id: number + dc_name: string + flag: string + cpu_name?: string + t_name?: string +} + +export interface FourVpsTariffPreset { + id: number + name: string + nameFull?: string + cpu_number: number + ram_mib: number + rom?: number + commentParsed?: { price?: string | number; eth?: string } + disks?: { size_mib?: number; tags?: { name?: string }[] }[] +} + +export interface FourVpsTariffItem { + 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 interface FourVpsBalanceInfo { + balance: number + currency: string +} + +function parseCredentials(baseUrl: string, credentials: string) { + const url = baseUrl.trim() + const { panelId, apiKey } = parseFourVpsCredentials(credentials) + if (!url || !apiKey) { + throw new Error('API URL and credentials are required') + } + return { baseUrl: url, panelId, apiKey } +} + +export async function fetchUserBalance( + baseUrl: string, + credentials: string, + fallbackCurrency = 'RUB', +): Promise { + const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials) + const data = await fourvpsRequest<{ userBalance?: number }>(url, apiKey, '/userBalance') + const raw = data?.userBalance + const balance = typeof raw === 'number' ? raw : Number.parseFloat(String(raw ?? '')) + return { + balance: Number.isFinite(balance) ? balance : 0, + currency: fallbackCurrency || 'RUB', + } +} + +export async function fetchMyServers(baseUrl: string, credentials: string): Promise { + const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials) + const data = await fourvpsRequest<{ serverlist?: FourVpsServer[] }>(url, apiKey, '/myservers') + return Array.isArray(data?.serverlist) ? data.serverlist : [] +} + +export async function fetchDcList( + baseUrl: string, + credentials: string, +): Promise> { + const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials) + if (panelId == null) return new Map() + + const data = await fourvpsRequest<{ dcList?: Record }>( + url, + apiKey, + '/getDcList', + { panelId }, + ) + const map = new Map() + const dcList = data?.dcList ?? {} + for (const [key, dc] of Object.entries(dcList)) { + const id = dc?.id ?? Number.parseInt(key, 10) + if (Number.isFinite(id)) { + map.set(id, { ...dc, id }) + } + } + return map +} + +function mapPresetToTariffItem( + preset: FourVpsTariffPreset, + dcId: string, + dcName: string, + country: string, + cpuModel: string, +): FourVpsTariffItem { + const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0) + const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0 + const diskTag = preset.disks?.[0]?.tags?.find((t) => t.name)?.name ?? 'nvme' + const priceRaw = preset.commentParsed?.price + const price = priceRaw != null ? String(priceRaw) : '' + + return { + externalId: String(preset.id), + datacenterKey: dcId, + datacenterName: dcName, + name: preset.nameFull || preset.name || '', + desc: preset.commentParsed?.eth ? `Канал: ${preset.commentParsed.eth}` : '', + vcpu: preset.cpu_number ?? 0, + ramGb: preset.ram_mib ? Math.round((preset.ram_mib / 1024) * 10) / 10 : 0, + diskGb: typeof diskGb === 'number' ? diskGb : 0, + diskType: diskTag.toUpperCase(), + virtualization: 'KVM', + channel: preset.commentParsed?.eth ?? '', + location: dcName, + country, + cpuModel, + orderAvailable: true, + price, + } +} + +export async function fetchTarifList( + baseUrl: string, + credentials: string, +): Promise { + const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials) + if (panelId == null) return [] + + const [tarifData, dcMap] = await Promise.all([ + fourvpsRequest<{ + tarifList?: Record< + string, + { + clusterInfo?: { + id?: number + dc_name?: string + flag?: string + cpu_name?: string + presets?: number[] + } + presets?: Record + } + > + }>(url, apiKey, '/getTarifList', { panelId }), + fetchDcList(baseUrl, credentials), + ]) + + const items: FourVpsTariffItem[] = [] + const tarifList = tarifData?.tarifList ?? {} + + for (const [dcKey, cluster] of Object.entries(tarifList)) { + const clusterInfo = cluster.clusterInfo ?? {} + const dcId = String(clusterInfo.id ?? dcKey) + const dcFromList = dcMap.get(Number(clusterInfo.id ?? dcKey)) + const dcName = clusterInfo.dc_name ?? dcFromList?.dc_name ?? '' + const country = (clusterInfo.flag ?? dcFromList?.flag ?? '').toUpperCase() + const cpuModel = clusterInfo.cpu_name ?? dcFromList?.cpu_name ?? '' + const presets = cluster.presets ?? {} + + for (const preset of Object.values(presets)) { + if (!preset?.id) continue + items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel)) + } + } + + return items +} + +export async function testConnection( + baseUrl: string, + credentials: string, +): Promise<{ ok: boolean; error?: string; vdsCount?: number; balance?: number }> { + if (!baseUrl?.trim() || !credentials?.trim()) { + return { ok: false, error: 'Укажите URL и учётные данные' } + } + try { + const [balanceInfo, servers] = await Promise.all([ + fetchUserBalance(baseUrl, credentials), + fetchMyServers(baseUrl, credentials), + ]) + return { ok: true, vdsCount: servers.length, balance: balanceInfo.balance } + } catch (err) { + const message = err instanceof Error ? err.message : 'Ошибка подключения' + return { ok: false, error: message } + } +} diff --git a/apps/api/src/services/fourvps/sync.test.ts b/apps/api/src/services/fourvps/sync.test.ts new file mode 100644 index 0000000..cb99e14 --- /dev/null +++ b/apps/api/src/services/fourvps/sync.test.ts @@ -0,0 +1,130 @@ +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 { syncFromFourvps } from './sync.js' +import type { FourvpsSyncAccount } from './context.js' + +vi.mock('./operations.js', () => ({ + fetchMyServers: vi.fn(), + fetchUserBalance: vi.fn(), + fetchTarifList: vi.fn(), + fetchDcList: vi.fn(), +})) + +import { + fetchDcList, + fetchMyServers, + fetchTarifList, + fetchUserBalance, +} from './operations.js' + +const account: FourvpsSyncAccount = { + id: 'acc-4vps', + providerId: 'prov-4vps', + name: '4VPS Account', + panelUrl: '', + currency: 'RUB', + billingMode: 'monthly', + notes: '', + apiType: '4vps', + apiBaseUrl: 'https://4vps.su/api', + apiCredentials: '1:secret', + panelId: 1, + apiKey: 'secret', + balanceApi: null, + balanceCurrency: null, + balanceUpdatedAt: null, + enoughmoneyto: '', + balanceAlertBelow: null, +} + +describe('syncFromFourvps', () => { + beforeEach(() => { + resetTestDb() + getSqlite() + .prepare( + `INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-4vps', '4VPS', '4vps', 'https://4vps.su/api')`, + ) + .run() + providerAccountsRepository.create({ + id: 'acc-4vps', + providerId: 'prov-4vps', + name: '4VPS Account', + apiCredentials: '1:secret', + }) + + vi.mocked(fetchMyServers).mockResolvedValue([ + { + id: 100, + name: 'srv1', + price: 420, + dc: 1, + image: 'Debian 11', + mem: 2, + cpu: 2, + disk: 20, + ipv4: '1.2.3.4', + status: 'active', + tname: 'cx01', + time: 1664275501, + expired: 1666781101, + }, + ]) + vi.mocked(fetchUserBalance).mockResolvedValue({ balance: 77825, currency: 'RUB' }) + vi.mocked(fetchDcList).mockResolvedValue( + new Map([[1, { id: 1, dc_name: 'AE DC1', flag: 'ae' }]]), + ) + vi.mocked(fetchTarifList).mockResolvedValue([ + { + externalId: '13', + datacenterKey: '1', + datacenterName: 'AE DC1', + name: 'AE-cx01', + desc: '', + vcpu: 1, + ramGb: 1, + diskGb: 10, + diskType: 'NVME', + virtualization: 'KVM', + channel: '1Gbit/s', + location: 'AE DC1', + country: 'AE', + cpuModel: 'E5', + orderAvailable: true, + price: '420', + }, + ]) + }) + + afterEach(() => { + closeDb() + vi.clearAllMocks() + }) + + it('upserts VPS, balance and tariffs', async () => { + const result = await syncFromFourvps(account) + + expect(result.vpsCount).toBe(1) + expect(result.paymentsCount).toBe(0) + expect(result.tariffsCount).toBe(1) + expect(result.balance?.balance).toBe(77825) + + const vps = getSqlite() + .prepare(`SELECT id, ip FROM vps WHERE providerAccountId = ?`) + .get('acc-4vps') as { id: string; ip: string } + expect(vps.id).toBe('vps-4vps-acc-4vps-100') + expect(vps.ip).toBe('1.2.3.4') + + const tariffs = getSqlite() + .prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`) + .get('acc-4vps') as { c: number } + expect(tariffs.c).toBe(1) + + const bal = getSqlite() + .prepare(`SELECT balance_api FROM provider_accounts WHERE id = ?`) + .get('acc-4vps') as { balance_api: number } + expect(bal.balance_api).toBe(77825) + }) +}) diff --git a/apps/api/src/services/fourvps/sync.ts b/apps/api/src/services/fourvps/sync.ts new file mode 100644 index 0000000..ae70a08 --- /dev/null +++ b/apps/api/src/services/fourvps/sync.ts @@ -0,0 +1,293 @@ +/** + * Sync 4VPS data into vps-tracker DB (Drizzle / @cfdm/db) + */ + +import { and, eq, like, or } from 'drizzle-orm' +import { getDb, schema } from '@cfdm/db' + +import type { FourvpsSyncAccount } from './context.js' +import { mapServerToVps } from './mappers.js' +import { + fetchDcList, + fetchMyServers, + fetchTarifList, + fetchUserBalance, + type FourVpsBalanceInfo, +} from './operations.js' + +export interface SyncFromFourvpsOptions { + 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 SyncFromFourvpsResult { + vpsCount: number + paymentsCount: number + tariffsCount: number + newTariffs: { name: string; price: string; providerId: string }[] + balance: FourVpsBalanceInfo | null + syncSummary: SyncSummary +} + +const SYNC_UPDATE_FIELDS = [ + 'country', + 'city', + 'datacenter', + 'os', + 'notes', + 'status', + 'tariffType', + 'currency', + 'dailyRate', + 'monthlyRate', + 'paidUntil', +] 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) +} + +function buildCredentials(account: FourvpsSyncAccount): string { + const { panelId, apiKey } = account + if (panelId != null) return `${panelId}:${apiKey}` + return apiKey +} + +export async function syncFromFourvps( + account: FourvpsSyncAccount, + opts: SyncFromFourvpsOptions = {}, +): Promise { + const { skipTariffs = false, skipVpsPayments = false } = opts + const { apiBaseUrl, providerId, id: accountId } = account + const credentials = buildCredentials(account) + if (!apiBaseUrl?.trim() || !account.apiKey?.trim()) { + throw new Error('API URL and credentials are required') + } + const db = getDb() + + const fetchVpsData = !skipVpsPayments + const fetchTariffs = !skipTariffs + + const [servers, balanceInfo, tariffItems, dcMap] = await Promise.all([ + fetchVpsData ? fetchMyServers(apiBaseUrl, credentials) : [], + fetchVpsData + ? fetchUserBalance(apiBaseUrl, credentials, account.currency || 'RUB').catch(() => null) + : null, + fetchTariffs ? fetchTarifList(apiBaseUrl, credentials).catch(() => []) : [], + fetchVpsData || fetchTariffs ? fetchDcList(apiBaseUrl, credentials).catch(() => new Map()) : new Map(), + ]) + + let vpsCount = 0 + const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 } + + if (fetchVpsData) { + for (const server of servers) { + const vps = mapServerToVps(server, providerId, accountId, dcMap) + const id = `vps-4vps-${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(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%4vps-${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, + 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 + } + } + const compareFields = ['ip', 'ipv6', 'dns', ...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, + 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++ + } + } + + if (fetchVpsData && balanceInfo) { + db.update(schema.providerAccounts) + .set({ + balanceApi: balanceInfo.balance, + balanceCurrency: balanceInfo.currency || 'RUB', + balanceUpdatedAt: new Date().toISOString(), + 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-4vps-${accountId}-${t.externalId}-${dcKey}` + : `tariff-4vps-${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: 0, + tariffsCount, + newTariffs, + balance: balanceInfo, + syncSummary, + } +} diff --git a/apps/api/src/services/providers/billmanager-adapter.ts b/apps/api/src/services/providers/billmanager-adapter.ts new file mode 100644 index 0000000..850c763 --- /dev/null +++ b/apps/api/src/services/providers/billmanager-adapter.ts @@ -0,0 +1,40 @@ +import { syncFromBillmanager } from '../billmanager/sync.js' +import { testConnection as bmTestConnection, fetchDashboardInfo } from '../billmanager/operations.js' +import type { BillmanagerSyncAccount } from '../billmanager/context.js' + +import type { ProviderAdapter, SyncResult } from './types.js' + +export const billmanagerAdapter: ProviderAdapter = { + type: 'billmanager', + + async testConnection(apiBaseUrl: string, apiCredentials: string) { + const result = await bmTestConnection(apiBaseUrl, apiCredentials) + return { ok: result.ok, message: result.error } + }, + + async syncAccount( + account: BillmanagerSyncAccount, + options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }, + ): Promise { + const result = await syncFromBillmanager(account, options) + return { + vpsCount: result.vpsCount, + paymentsCount: result.paymentsCount, + tariffsCount: result.tariffsCount, + balance: result.balance, + syncSummary: result.syncSummary, + newTariffs: result.newTariffs, + } + }, + + async fetchBalance(account: BillmanagerSyncAccount) { + const info = await fetchDashboardInfo(account.apiBaseUrl, String(account.apiCredentials).trim(), { + fallbackCurrency: account.currency, + }) + return { + balance: info.balance, + currency: info.currency || 'RUB', + enoughmoneyto: info.enoughmoneyto || '', + } + }, +} diff --git a/apps/api/src/services/providers/fourvps-adapter.ts b/apps/api/src/services/providers/fourvps-adapter.ts new file mode 100644 index 0000000..c69e828 --- /dev/null +++ b/apps/api/src/services/providers/fourvps-adapter.ts @@ -0,0 +1,43 @@ +import { syncFromFourvps } from '../fourvps/sync.js' +import { + fetchUserBalance as fetchFourvpsBalance, + testConnection as fourvpsTestConnection, +} from '../fourvps/operations.js' +import type { FourvpsSyncAccount } from '../fourvps/context.js' + +import type { ProviderAdapter, SyncResult } from './types.js' + +export const fourvpsAdapter: ProviderAdapter = { + type: '4vps', + + async testConnection(apiBaseUrl: string, apiCredentials: string) { + const result = await fourvpsTestConnection(apiBaseUrl, apiCredentials) + return { ok: result.ok, message: result.error } + }, + + async syncAccount( + account: FourvpsSyncAccount, + options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }, + ): Promise { + const result = await syncFromFourvps(account, options) + return { + vpsCount: result.vpsCount, + paymentsCount: result.paymentsCount, + tariffsCount: result.tariffsCount, + balance: result.balance, + syncSummary: result.syncSummary, + newTariffs: result.newTariffs, + } + }, + + async fetchBalance(account: FourvpsSyncAccount) { + const cred = + account.panelId != null ? `${account.panelId}:${account.apiKey}` : account.apiKey + const info = await fetchFourvpsBalance(account.apiBaseUrl, cred, account.currency || 'RUB') + return { + balance: info.balance, + currency: info.currency || 'RUB', + enoughmoneyto: '', + } + }, +} diff --git a/apps/api/src/services/providers/index.ts b/apps/api/src/services/providers/index.ts index e4e2083..07dc70b 100644 --- a/apps/api/src/services/providers/index.ts +++ b/apps/api/src/services/providers/index.ts @@ -1,28 +1,16 @@ -import type { ProviderAdapter, SyncResult } from './types.js' -import { syncFromBillmanager } from '../billmanager/sync.js' +import type { schema } from '@cfdm/db' + +import { billmanagerAccountRowForSync } from '../billmanager/context.js' +import { fourvpsAccountRowForSync } from '../fourvps/context.js' import type { BillmanagerSyncAccount } from '../billmanager/context.js' -import { testConnection as bmTestConnection } from '../billmanager/operations.js' +import type { FourvpsSyncAccount } from '../fourvps/context.js' -export const billmanagerAdapter: ProviderAdapter = { - type: 'billmanager', +import { billmanagerAdapter } from './billmanager-adapter.js' +import { fourvpsAdapter } from './fourvps-adapter.js' +import type { ProviderAdapter } from './types.js' - async testConnection(apiBaseUrl: string, apiCredentials: string) { - const result = await bmTestConnection(apiBaseUrl, apiCredentials) - return { ok: result.ok, message: result.error } - }, - - async syncAccount(account: BillmanagerSyncAccount, options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }): Promise { - const result = await syncFromBillmanager(account, options) - return { - vpsCount: result.vpsCount, - paymentsCount: result.paymentsCount, - tariffsCount: result.tariffsCount, - balance: result.balance, - syncSummary: result.syncSummary, - newTariffs: result.newTariffs, - } - }, -} +export { billmanagerAdapter } from './billmanager-adapter.js' +export { fourvpsAdapter } from './fourvps-adapter.js' export const manualAdapter: ProviderAdapter = { type: 'manual', @@ -43,6 +31,7 @@ export const manualAdapter: ProviderAdapter = { const adapters: Record = { billmanager: billmanagerAdapter, + '4vps': fourvpsAdapter, manual: manualAdapter, none: manualAdapter, } @@ -51,3 +40,33 @@ export function getProviderAdapter(apiType: string | null | undefined): Provider const key = (apiType || 'none').toLowerCase().trim() return adapters[key] ?? manualAdapter } + +type AccountRow = typeof schema.providerAccounts.$inferSelect +type ProviderRow = typeof schema.providers.$inferSelect + +export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount + +export function resolveSyncAccount( + accountRow: AccountRow | null | undefined, + providerRow: ProviderRow | null | undefined, +): { apiType: string; account: SyncReadyAccount } | null { + if (!accountRow) return null + const apiType = String(providerRow?.apiType || accountRow.apiType || '') + .trim() + .toLowerCase() + + if (apiType === 'billmanager') { + const account = billmanagerAccountRowForSync(accountRow, providerRow) + return account ? { apiType, account } : null + } + if (apiType === '4vps') { + const account = fourvpsAccountRowForSync(accountRow, providerRow) + return account ? { apiType, account } : null + } + return null +} + +export const SYNC_SETUP_ERRORS: Record = { + billmanager: 'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API', + '4vps': 'Укажите в настройках хостера тип API 4VPS и URL; в аккаунте — Panel ID и API Key', +} diff --git a/apps/api/src/services/providers/sync-job.ts b/apps/api/src/services/providers/sync-job.ts new file mode 100644 index 0000000..36a7947 --- /dev/null +++ b/apps/api/src/services/providers/sync-job.ts @@ -0,0 +1,65 @@ +/** + * Generic account sync with sync_log recording + */ + +import { eq } from 'drizzle-orm' +import { getDb, schema } from '@cfdm/db' + +import type { ProviderAdapter, SyncResult } from './types.js' + +export interface RunAccountSyncResult extends SyncResult { + ok: true + logId: string +} + +export async function runAccountSync( + adapter: ProviderAdapter, + account: unknown, + opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {}, +): Promise { + const db = getDb() + const accountRow = account as { id: string } + const logId = `sync-${accountRow.id}-${Date.now()}` + + db.insert(schema.syncLog) + .values({ + id: logId, + accountId: accountRow.id, + startedAt: new Date().toISOString(), + status: 'running', + }) + .run() + + try { + const result = await adapter.syncAccount(account, opts) + const summaryPayload = { + ...(result.syncSummary || {}), + vpsCount: result.vpsCount, + paymentsCount: result.paymentsCount, + tariffsCount: result.tariffsCount ?? 0, + } + db.update(schema.syncLog) + .set({ + finishedAt: new Date().toISOString(), + status: 'ok', + vpsCount: result.vpsCount, + paymentsCount: result.paymentsCount, + summary: JSON.stringify(summaryPayload), + }) + .where(eq(schema.syncLog.id, logId)) + .run() + return { ok: true, logId, ...result } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + db.update(schema.syncLog) + .set({ + finishedAt: new Date().toISOString(), + status: 'error', + error: message, + summary: JSON.stringify({ error: message }), + }) + .where(eq(schema.syncLog.id, logId)) + .run() + throw err + } +} diff --git a/apps/api/src/services/providers/types.ts b/apps/api/src/services/providers/types.ts index cc74fa6..babb851 100644 --- a/apps/api/src/services/providers/types.ts +++ b/apps/api/src/services/providers/types.ts @@ -21,4 +21,9 @@ export interface ProviderAdapter { account: unknown, options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }, ): Promise + fetchBalance?(account: unknown): Promise<{ + balance?: number + currency?: string + enoughmoneyto?: string + }> } diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index 048bf3a..9efb045 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -2,8 +2,8 @@ import { sql } from 'drizzle-orm' import { getDb, schema } from '@cfdm/db' import { settingsRepository } from '@cfdm/db/repositories/settings' -import { billmanagerAccountRowForSync } from './billmanager/context.js' -import { runBillmanagerAccountSync } from './billmanager/sync-job.js' +import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js' +import { runAccountSync } from './providers/sync-job.js' import { runVpsUptimeChecks } from './uptime-check.js' import { publishMany, publishNotification } from './notifications/engine.js' import { @@ -23,21 +23,29 @@ const SETTINGS_ID = 'settings-main' type AccountRow = typeof schema.providerAccounts.$inferSelect -function getBillmanagerAccounts(): NonNullable>[] { +interface SyncableAccountEntry { + account: SyncReadyAccount + apiType: string +} + +function getSyncableAccounts(): SyncableAccountEntry[] { const db = getDb() const rows = db .all(sql` SELECT pa.* FROM provider_accounts pa INNER JOIN providers p ON p.id = pa.providerId - WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager' + WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps') AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0 AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0 `) const providers = db.select().from(schema.providers).all() const providerById = new Map(providers.map((p) => [p.id, p])) return rows - .map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId))) - .filter((a): a is NonNullable => a != null) + .map((a) => { + const resolved = resolveSyncAccount(a, providerById.get(a.providerId)) + return resolved ? { account: resolved.account, apiType: resolved.apiType } : null + }) + .filter((e): e is SyncableAccountEntry => e != null) } export async function runNotificationTick(): Promise { @@ -56,13 +64,14 @@ export async function runScheduledSync(): Promise { const settings = settingsRepository.getRow(SETTINGS_ID) if (!settings?.syncEnabled) return - const accounts = getBillmanagerAccounts() + const entries = getSyncableAccounts() const digestLines: string[] = [] const lowBalanceLines: string[] = [] - for (const account of accounts) { + for (const { account, apiType } of entries) { try { - const result = await runBillmanagerAccountSync(account, { skipTariffs: true }) + const adapter = getProviderAdapter(apiType) + const result = await runAccountSync(adapter, account, { skipTariffs: true }) const s = result.syncSummary const parts: string[] = [] if (s.added?.length) parts.push(`+${s.added.length} VPS`) @@ -103,12 +112,13 @@ export async function runScheduledSyncTariffs(): Promise { const settings = settingsRepository.getRow(SETTINGS_ID) if (!settings?.syncEnabled) return - const accounts = getBillmanagerAccounts() + const entries = getSyncableAccounts() const providers = getDb().select().from(schema.providers).all() - for (const account of accounts) { + for (const { account, apiType } of entries) { try { - const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true }) + const adapter = getProviderAdapter(apiType) + const result = await runAccountSync(adapter, account, { skipVpsPayments: true }) const newTariffs = result.newTariffs || [] if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) { const provider = providers.find((p) => p.id === account.providerId) diff --git a/apps/api/src/utils/api-credentials.test.ts b/apps/api/src/utils/api-credentials.test.ts new file mode 100644 index 0000000..e49bb02 --- /dev/null +++ b/apps/api/src/utils/api-credentials.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + buildFourVpsCredentials, + parseFourVpsCredentials, +} from '@cfdm/shared/utils/api-credentials' + +describe('parseFourVpsCredentials', () => { + it('parses panelId:apiKey', () => { + expect(parseFourVpsCredentials('1:secret-key')).toEqual({ + panelId: 1, + apiKey: 'secret-key', + }) + }) + + it('returns apiKey only when no colon', () => { + expect(parseFourVpsCredentials('secret-only')).toEqual({ + panelId: null, + apiKey: 'secret-only', + }) + }) + + it('handles empty', () => { + expect(parseFourVpsCredentials('')).toEqual({ panelId: null, apiKey: '' }) + }) +}) + +describe('buildFourVpsCredentials', () => { + it('builds panelId:apiKey', () => { + expect(buildFourVpsCredentials('1', 'key')).toBe('1:key') + }) + + it('returns key only without panel', () => { + expect(buildFourVpsCredentials('', 'key')).toBe('key') + }) +}) diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index d7f21d5..43eef2d 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -14,6 +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 } from '@/lib/provider-sync' const EMPTY: ProviderAccountFormValues = { providerId: '', @@ -59,10 +60,11 @@ export function ProviderAccountEditSheet({ onBalanceRefreshed, }: ProviderAccountEditSheetProps) { const isEdit = Boolean(defaultValues.id) + const initialProvider = providers.find((p) => p.id === defaultValues.providerId) const testMut = useMutation({ - mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) => - api.testConnection(values.apiBaseUrl, values.apiCredentials), + mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string; apiType: string }) => + api.testConnection(values.apiBaseUrl, values.apiCredentials, values.apiType), onSuccess: () => toast.success('Подключение успешно'), onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'), }) @@ -81,7 +83,11 @@ export function ProviderAccountEditSheet({ open={open} onOpenChange={onOpenChange} title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'} - description="API-креды хранятся на сервере и используются для синка с BILLmanager" + description={ + initialProvider?.apiType === '4vps' + ? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS' + : 'API-креды хранятся на сервере и используются для синка с BILLmanager' + } schema={providerAccountSchema as unknown as ZodType} defaultValues={defaultValues} onSubmit={onSubmit} @@ -96,6 +102,7 @@ export function ProviderAccountEditSheet({ const apiPassword = watch('apiPassword')?.trim() ?? '' const apiCredentials = buildApiCredentials(apiLogin, apiPassword) const canTest = Boolean(apiBaseUrl && apiCredentials) + const credLabels = accountCredentialLabels(provider?.apiType) return ( <> @@ -111,13 +118,18 @@ export function ProviderAccountEditSheet({ - - + + @@ -128,7 +140,13 @@ export function ProviderAccountEditSheet({ size="sm" disabled={!canTest} loading={testMut.isPending} - onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })} + onClick={() => + testMut.mutate({ + apiBaseUrl, + apiCredentials, + apiType: provider?.apiType ?? 'billmanager', + }) + } > Проверить подключение diff --git a/apps/web/src/components/domain/provider-edit-sheet.tsx b/apps/web/src/components/domain/provider-edit-sheet.tsx index 0d2bd4c..58be12a 100644 --- a/apps/web/src/components/domain/provider-edit-sheet.tsx +++ b/apps/web/src/components/domain/provider-edit-sheet.tsx @@ -68,12 +68,25 @@ export function ProviderEditSheet({ onValueChange={(v) => setValue('apiType', (v ?? 'none') as ApiType, { shouldValidate: true })} options={[ { value: 'billmanager', label: 'BILLmanager' }, + { value: '4vps', label: '4VPS.SU' }, { value: 'none', label: 'Нет' }, ]} /> - - + +
diff --git a/apps/web/src/lib/account-health.ts b/apps/web/src/lib/account-health.ts index 542a15f..e5520e4 100644 --- a/apps/web/src/lib/account-health.ts +++ b/apps/web/src/lib/account-health.ts @@ -5,7 +5,8 @@ import type { BalanceLedgerRow, } from '@/types/entities' import { accountBalanceApi } from '@/lib/account' -import { accountBillmanagerUiReady } from '@/lib/billmanager' +import { isSyncApiType } from '@cfdm/shared/contracts/provider' +import { accountSyncUiReady } from '@/lib/provider-sync' import { accountHasApiLedgerMismatch, getStaleSyncAccountIds, @@ -40,7 +41,7 @@ export function getAccountHealthFlags( const provider = ctx.providers.find((p) => p.id === account.providerId) const flags: AccountHealthFlag[] = [] - if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) { + if (isSyncApiType(provider?.apiType) && !account.apiCredentialsSet) { flags.push('no-creds') } @@ -99,5 +100,5 @@ export function countLowBalanceAccounts( export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean { const provider = providers.find((p) => p.id === account.providerId) - return accountBillmanagerUiReady(account, provider) + return accountSyncUiReady(account, provider) } diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index b1cc500..8c4d3f0 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -101,10 +101,10 @@ export const api = { `/api/sync/${encodeURIComponent(accountId)}/balance`, ), - testConnection: (apiBaseUrl: string, apiCredentials: string) => + testConnection: (apiBaseUrl: string, apiCredentials: string, apiType?: string) => fetchApi('/api/sync/test-connection', { method: 'POST', - body: JSON.stringify({ apiBaseUrl, apiCredentials }), + body: JSON.stringify({ apiBaseUrl, apiCredentials, apiType }), }), fetchSyncStatus: () => fetchApi('/api/sync/status'), diff --git a/apps/web/src/lib/billmanager.ts b/apps/web/src/lib/billmanager.ts index 50211c8..20a0680 100644 --- a/apps/web/src/lib/billmanager.ts +++ b/apps/web/src/lib/billmanager.ts @@ -1,45 +1,12 @@ -import type { ProviderAccount, Provider } from '@/types/entities' - -export function providerByIdMap(providers: Provider[]): Map { - return new Map(providers.map((p) => [p.id, p])) -} - -export function billmanagerSyncableAccounts( - providerAccounts: ProviderAccount[], - providers: Provider[], -): ProviderAccount[] { - const pmap = providerByIdMap(providers) - return providerAccounts.filter((a) => { - const p = pmap.get(a.providerId) - return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet - }) -} - -export function accountBillmanagerUiReady( - account: ProviderAccount, - provider?: Provider | null, -): boolean { - return ( - provider?.apiType === 'billmanager' && - Boolean((provider.apiBaseUrl || '').trim()) && - Boolean(account.apiCredentialsSet) - ) -} - -export function accountUsesBillmanagerBalanceApi( - account: ProviderAccount, - provider?: Provider | null, -): boolean { - return provider?.apiType === 'billmanager' && account.balance_api != null -} - -export function accountSelectLabel( - account: ProviderAccount, - providerById: Map, - scopedProviderId?: string, -): string { - const name = account.name?.trim() || '—' - if (scopedProviderId) return name - const providerName = providerById.get(account.providerId)?.name ?? '—' - return `${providerName} / ${name}` -} +export { + providerByIdMap, + syncableAccounts, + billmanagerSyncableAccounts, + accountSyncUiReady, + accountBillmanagerUiReady, + accountUsesApiBalance, + accountUsesBillmanagerBalanceApi, + accountSelectLabel, + isSyncApiType, + accountCredentialLabels, +} from './provider-sync' diff --git a/apps/web/src/lib/provider-sync.ts b/apps/web/src/lib/provider-sync.ts new file mode 100644 index 0000000..24c4341 --- /dev/null +++ b/apps/web/src/lib/provider-sync.ts @@ -0,0 +1,77 @@ +import { isSyncApiType } from '@cfdm/shared/contracts/provider' + +import type { ProviderAccount, Provider } from '@/types/entities' + +export { isSyncApiType } + +export function providerByIdMap(providers: Provider[]): Map { + return new Map(providers.map((p) => [p.id, p])) +} + +export function syncableAccounts( + providerAccounts: ProviderAccount[], + providers: Provider[], +): ProviderAccount[] { + const pmap = providerByIdMap(providers) + return providerAccounts.filter((a) => { + const p = pmap.get(a.providerId) + return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet + }) +} + +/** @deprecated Используйте syncableAccounts */ +export const billmanagerSyncableAccounts = syncableAccounts + +export function accountSyncUiReady( + account: ProviderAccount, + provider?: Provider | null, +): boolean { + return ( + isSyncApiType(provider?.apiType) && + Boolean((provider?.apiBaseUrl || '').trim()) && + Boolean(account.apiCredentialsSet) + ) +} + +/** @deprecated Используйте accountSyncUiReady */ +export const accountBillmanagerUiReady = accountSyncUiReady + +export function accountUsesApiBalance( + account: ProviderAccount, + provider?: Provider | null, +): boolean { + return isSyncApiType(provider?.apiType) && account.balance_api != null +} + +/** @deprecated Используйте accountUsesApiBalance */ +export const accountUsesBillmanagerBalanceApi = accountUsesApiBalance + +export function accountSelectLabel( + account: ProviderAccount, + providerById: Map, + scopedProviderId?: string, +): string { + const name = account.name?.trim() || '—' + if (scopedProviderId) return name + const providerName = providerById.get(account.providerId)?.name ?? '—' + return `${providerName} / ${name}` +} + +export function accountCredentialLabels(apiType?: string | null): { + loginLabel: string + passwordLabel: string + loginPlaceholder: string +} { + if (String(apiType).toLowerCase() === '4vps') { + return { + loginLabel: 'Panel ID', + passwordLabel: 'API Key', + loginPlaceholder: '1', + } + } + return { + loginLabel: 'Логин API', + passwordLabel: 'Пароль API', + loginPlaceholder: '', + } +} diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index cbb7d58..5876bbe 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider' import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account' import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields' @@ -12,7 +13,7 @@ export const paymentTypeSchema = z.enum([ 'monthly_debit', ]) export const ledgerDirectionSchema = z.enum(['credit', 'debit']) -export const apiTypeSchema = z.enum(['billmanager', 'none']) +export const apiTypeSchema = sharedApiTypeSchema export const providerSchema = z.object({ id: z.string().min(1).optional(), diff --git a/apps/web/src/types/entities.ts b/apps/web/src/types/entities.ts index 1827490..8eabf71 100644 --- a/apps/web/src/types/entities.ts +++ b/apps/web/src/types/entities.ts @@ -1,5 +1,7 @@ import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields' +import type { ApiType as SharedApiType } from '@cfdm/shared/contracts/provider' + export type VpsStatus = 'active' | 'paused' | 'archived' export type TariffType = 'daily' | 'monthly' export type BillingMode = 'daily' | 'monthly' @@ -9,7 +11,7 @@ export type PaymentType = | 'daily_debit' | 'monthly_debit' export type LedgerDirection = 'credit' | 'debit' -export type ApiType = 'billmanager' | 'none' +export type ApiType = SharedApiType export interface Provider { id: string diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index 566bff0..8327e3c 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -37,9 +37,41 @@ CREATE TABLE IF NOT EXISTS provider_accounts ( CREATE TABLE IF NOT EXISTS vps ( id TEXT PRIMARY KEY, ip TEXT, + ipv6 TEXT, + additionalIps TEXT, + dns TEXT, providerId TEXT, providerAccountId TEXT, + country TEXT, + city TEXT, + datacenter TEXT, + os TEXT, + vcpu INTEGER, + ramGb REAL, + diskGb INTEGER, + diskType TEXT, + virtualization TEXT, + bandwidthTb INTEGER, + sshPort INTEGER, + rootUser TEXT, + purpose TEXT, + environment TEXT, + project TEXT, + projectId TEXT, + monitoringEnabled INTEGER, + backupEnabled INTEGER, status TEXT, + tariffType TEXT, + currency TEXT, + dailyRate REAL, + monthlyRate REAL, + createdAt TEXT, + paidUntil TEXT, + notes TEXT, + userOverrides TEXT, + customData TEXT, + last_health_status TEXT, + last_health_checked_at TEXT, FOREIGN KEY (providerId) REFERENCES providers(id), FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id) ); @@ -75,6 +107,10 @@ CREATE TABLE IF NOT EXISTS sync_log ( startedAt TEXT NOT NULL, finishedAt TEXT, status TEXT, + vpsCount INTEGER, + paymentsCount INTEGER, + error TEXT, + summary TEXT, FOREIGN KEY (accountId) REFERENCES provider_accounts(id) ); @@ -83,6 +119,22 @@ CREATE TABLE IF NOT EXISTS active_tariffs ( providerAccountId TEXT NOT NULL, providerId TEXT NOT NULL, externalId TEXT NOT NULL, + datacenterKey TEXT, + datacenterName TEXT, + name TEXT, + desc TEXT, + vcpu INTEGER, + ramGb REAL, + diskGb INTEGER, + diskType TEXT, + virtualization TEXT, + channel TEXT, + location TEXT, + country TEXT, + cpuModel TEXT, + orderAvailable INTEGER, + price TEXT, + syncedAt TEXT, FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id), FOREIGN KEY (providerId) REFERENCES providers(id) ); diff --git a/packages/shared/src/contracts/provider.ts b/packages/shared/src/contracts/provider.ts index c438f80..7327cf8 100644 --- a/packages/shared/src/contracts/provider.ts +++ b/packages/shared/src/contracts/provider.ts @@ -1,5 +1,17 @@ import { z } from 'zod' +export const API_TYPES = ['billmanager', '4vps', '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'] as const +export type SyncApiType = (typeof SYNC_API_TYPES)[number] + +export function isSyncApiType(apiType?: string | null): apiType is SyncApiType { + const key = String(apiType || '').toLowerCase() + return (SYNC_API_TYPES as readonly string[]).includes(key) +} + export const providerSchema = z.object({ id: z.string().optional(), name: z.string().min(1, 'Name is required'), @@ -9,7 +21,7 @@ export const providerSchema = z.object({ usdRate: z.string().optional().default(''), eurRate: z.string().optional().default(''), notes: z.string().optional().default(''), - apiType: z.string().optional().default(''), + apiType: apiTypeSchema, apiBaseUrl: z.string().optional().default(''), }) diff --git a/packages/shared/src/utils/api-credentials.ts b/packages/shared/src/utils/api-credentials.ts index 782bccb..7fd8d69 100644 --- a/packages/shared/src/utils/api-credentials.ts +++ b/packages/shared/src/utils/api-credentials.ts @@ -13,3 +13,32 @@ export function buildApiCredentials(login: string, password: string): string { if (!l) return p return p ? `${l}:${p}` : '' } + +export interface FourVpsCredentials { + panelId: number | null + apiKey: string +} + +/** Разбор 4VPS-кредов формата `panelId:apiKey` (panelId опционален). */ +export function parseFourVpsCredentials(credentials: string | null | undefined): FourVpsCredentials { + const cred = String(credentials ?? '').trim() + if (!cred) return { panelId: null, apiKey: '' } + const idx = cred.indexOf(':') + if (idx <= 0) return { panelId: null, apiKey: cred } + const panelPart = cred.slice(0, idx).trim() + const apiKey = cred.slice(idx + 1) + const panelId = panelPart ? Number.parseInt(panelPart, 10) : null + return { + panelId: panelId != null && Number.isFinite(panelId) ? panelId : null, + apiKey, + } +} + +/** Собрать 4VPS-креды: panelId + API key. */ +export function buildFourVpsCredentials(panelId: string, apiKey: string): string { + const pid = panelId.trim() + const key = apiKey + if (!pid && !key) return '' + if (!pid) return key + return key ? `${pid}:${key}` : pid +} diff --git a/packages/shared/src/utils/inventory-health.ts b/packages/shared/src/utils/inventory-health.ts index b92fcbb..266b794 100644 --- a/packages/shared/src/utils/inventory-health.ts +++ b/packages/shared/src/utils/inventory-health.ts @@ -1,4 +1,5 @@ import { accountBalanceApi } from './account-balance.js' +import { isSyncApiType } from '../contracts/provider.js' import { getPaidUntilDate, type PaidUntilContext } from './paid-until.js' export const STALE_SYNC_HOURS = 48 @@ -166,12 +167,12 @@ export function computeInventoryHealth(input: InventoryHealthInput): InventoryIs }) } - const bmAccounts = providerAccounts.filter((a) => { + const syncAccounts = providerAccounts.filter((a) => { const p = providerById.get(a.providerId) - return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet + return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet }) const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 - const staleAccounts = bmAccounts.filter((a) => { + const staleAccounts = syncAccounts.filter((a) => { const t = lastOkSyncFinishedAt(a.id, syncLog) if (t == null) return true return now.getTime() - t > staleMs @@ -226,12 +227,12 @@ export function getStaleSyncAccountIds( now = new Date(), ): string[] { const providerById = new Map(providers.map((p) => [p.id, p])) - const bmAccounts = providerAccounts.filter((a) => { + const syncAccounts = providerAccounts.filter((a) => { const p = providerById.get(a.providerId) - return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet + return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet }) const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 - return bmAccounts + return syncAccounts .filter((a) => { const t = lastOkSyncFinishedAt(a.id, syncLog) if (t == null) return true diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 65b1f81..3ee52b7 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -11,5 +11,6 @@ "declarationMap": true, "verbatimModuleSyntax": false }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] }