From e37f69ebf0987953cf38f57dc0729739f5645e32 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 6 Nov 2026 22:22:37 +0700 Subject: [PATCH] =?UTF-8?q?fix(db):=20=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20null=20=D0=B2=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=B5=20FK=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B5=20=D0=91?= =?UTF-8?q?=D0=94=20=D0=B1=D0=B5=D0=B7=20apiType?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пустая строка в vpsId/providerAccountId/providerId давала 500 SQLITE_CONSTRAINT_FOREIGNKEY при создании платежа, записи баланса и VPS без провайдера. Старые БД падали с «no such column apiType»: добавлены колонки providers.apiType/apiBaseUrl и перенос URL BILLmanager с аккаунтов. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/migrate.ts | 14 +++--- apps/api/src/routes/payments.test.ts | 47 +++++++++++++++++++ .../db/src/repositories/balance-ledger.ts | 4 +- packages/db/src/repositories/payments.ts | 4 +- packages/db/src/repositories/vps.ts | 8 ++-- packages/db/src/runtime-migrate.test.ts | 25 +++++++++- packages/db/src/runtime-migrate.ts | 17 +++++++ 7 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/routes/payments.test.ts diff --git a/apps/api/src/routes/migrate.ts b/apps/api/src/routes/migrate.ts index 24b67e6..78ef827 100644 --- a/apps/api/src/routes/migrate.ts +++ b/apps/api/src/routes/migrate.ts @@ -71,8 +71,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => { v.ipv6 ?? '', additionalIps, v.dns ?? '', - v.providerId ?? '', - v.providerAccountId ?? '', + v.providerId || null, + v.providerAccountId || null, v.country ?? '', v.city ?? '', v.datacenter ?? '', @@ -88,7 +88,7 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => { v.purpose ?? '', v.environment ?? '', v.project ?? '', - v.projectId ?? null, + v.projectId || null, v.monitoringEnabled ? 1 : 0, v.backupEnabled ? 1 : 0, v.status ?? 'active', @@ -116,8 +116,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => { pm.date ?? '', Number(pm.amount) || 0, pm.currency ?? '', - pm.providerAccountId ?? '', - pm.vpsId ?? '', + pm.providerAccountId || null, + pm.vpsId || null, pm.note ?? '', ) } @@ -136,8 +136,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => { Number(bl.amount) || 0, bl.currency ?? '', bl.direction ?? '', - bl.providerAccountId ?? '', - bl.vpsId ?? '', + bl.providerAccountId || null, + bl.vpsId || null, bl.note ?? '', ) } diff --git a/apps/api/src/routes/payments.test.ts b/apps/api/src/routes/payments.test.ts new file mode 100644 index 0000000..0e0cb27 --- /dev/null +++ b/apps/api/src/routes/payments.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb } from '@cfdm/db' +import { resetTestDb } from '@cfdm/db/test-setup' +import { buildApp } from '../index.js' + +describe('payments / balance-ledger without VPS', () => { + let app: Awaited> + + beforeEach(async () => { + resetTestDb() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('POST /api/payments stores empty vpsId/providerAccountId as null', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/payments', + payload: { type: 'topup', date: '2026-01-01', amount: 10, vpsId: '', providerAccountId: '' }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() as { id: string; vpsId: string | null; providerAccountId: string | null } + expect(body.vpsId).toBeNull() + expect(body.providerAccountId).toBeNull() + + const put = await app.inject({ method: 'PUT', url: `/api/payments/${body.id}`, payload: { note: 'x' } }) + expect(put.statusCode).toBe(200) + }) + + it('POST /api/balance-ledger stores empty refs as null', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/balance-ledger', + payload: { type: 'topup', date: '2026-01-01', amount: 10, vpsId: '', providerAccountId: '' }, + }) + expect(res.statusCode).toBe(201) + }) + + it('POST /api/vps without provider does not hit FK', async () => { + const res = await app.inject({ method: 'POST', url: '/api/vps', payload: { ip: '10.0.0.1' } }) + expect(res.statusCode).toBeLessThan(300) + }) +}) diff --git a/packages/db/src/repositories/balance-ledger.ts b/packages/db/src/repositories/balance-ledger.ts index f28f417..c6ce433 100644 --- a/packages/db/src/repositories/balance-ledger.ts +++ b/packages/db/src/repositories/balance-ledger.ts @@ -17,8 +17,8 @@ function normalize(input: Partial) { amount: Number(input.amount) || 0, currency: input.currency ?? '', direction: input.direction ?? '', - providerAccountId: input.providerAccountId ?? '', - vpsId: input.vpsId ?? '', + providerAccountId: input.providerAccountId || null, + vpsId: input.vpsId || null, note: input.note ?? '', } } diff --git a/packages/db/src/repositories/payments.ts b/packages/db/src/repositories/payments.ts index 10c8d7b..d1d227e 100644 --- a/packages/db/src/repositories/payments.ts +++ b/packages/db/src/repositories/payments.ts @@ -12,8 +12,8 @@ function normalize(input: Partial) { date: input.date ?? '', amount: Number(input.amount) || 0, currency: input.currency ?? '', - providerAccountId: input.providerAccountId ?? '', - vpsId: input.vpsId ?? '', + providerAccountId: input.providerAccountId || null, + vpsId: input.vpsId || null, note: input.note ?? '', } } diff --git a/packages/db/src/repositories/vps.ts b/packages/db/src/repositories/vps.ts index 49577d7..72046ff 100644 --- a/packages/db/src/repositories/vps.ts +++ b/packages/db/src/repositories/vps.ts @@ -189,8 +189,8 @@ export const vpsRepository = { ipv6: input.ipv6 ?? '', additionalIps, dns: input.dns ?? '', - providerId: input.providerId ?? '', - providerAccountId: input.providerAccountId ?? '', + providerId: input.providerId || null, + providerAccountId: input.providerAccountId || null, country: input.country ?? '', city: input.city ?? '', datacenter: input.datacenter ?? '', @@ -292,8 +292,8 @@ export const vpsRepository = { ipv6: input.ipv6 ?? '', additionalIps, dns: input.dns ?? '', - providerId: input.providerId ?? '', - providerAccountId: input.providerAccountId ?? '', + providerId: input.providerId || null, + providerAccountId: input.providerAccountId || null, country: input.country ?? '', city: input.city ?? '', datacenter: input.datacenter ?? '', diff --git a/packages/db/src/runtime-migrate.test.ts b/packages/db/src/runtime-migrate.test.ts index 4caf4ad..4db0b62 100644 --- a/packages/db/src/runtime-migrate.test.ts +++ b/packages/db/src/runtime-migrate.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { closeDb, getDb, getSqlite } from './index.js' -import { resetRuntimeMigrate } from './runtime-migrate.js' +import { ensureRuntimeSchema, resetRuntimeMigrate } from './runtime-migrate.js' +import Database from 'better-sqlite3' import { getSnapshot } from './repositories/snapshot.js' import { runWithSpace, MAIN_SPACE_ID } from './space-context.js' @@ -42,3 +43,25 @@ describe('ensureRuntimeSchema (empty DB)', () => { expect(snap.settings.length).toBeGreaterThanOrEqual(1) }) }) + +describe('ensureRuntimeSchema (legacy DB without providers.apiType)', () => { + afterEach(() => resetRuntimeMigrate()) + + it('adds provider API columns and moves BILLmanager URL from accounts', () => { + const sqlite = new Database(':memory:') + sqlite.exec(`CREATE TABLE providers (id TEXT PRIMARY KEY, name TEXT NOT NULL, website TEXT, contact TEXT, baseCurrency TEXT, usdRate TEXT, eurRate TEXT, notes TEXT)`) + sqlite.exec(`CREATE TABLE provider_accounts (id TEXT PRIMARY KEY, providerId TEXT NOT NULL, name TEXT NOT NULL, panelUrl TEXT, currency TEXT, billingMode TEXT, notes TEXT, apiType TEXT, apiBaseUrl TEXT, apiCredentials TEXT)`) + sqlite.exec(`INSERT INTO providers (id, name) VALUES ('p1', 'Host')`) + sqlite.exec(`INSERT INTO provider_accounts (id, providerId, name, apiType, apiBaseUrl, apiCredentials) VALUES ('a1', 'p1', 'Acc', 'billmanager', 'https://bm.test/billmgr', 'u:p')`) + + resetRuntimeMigrate() + ensureRuntimeSchema(sqlite) + + expect(sqlite.prepare(`SELECT apiType, apiBaseUrl, spaceId FROM providers`).get()).toEqual({ + apiType: 'billmanager', + apiBaseUrl: 'https://bm.test/billmgr', + spaceId: 'space-main', + }) + expect(sqlite.prepare(`SELECT apiCredentials FROM provider_accounts`).get()).toEqual({ apiCredentials: 'u:p' }) + }) +}) diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 8ac2977..95fd705 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -1,4 +1,5 @@ import type Database from 'better-sqlite3' +import { consolidateAllProviderApiSources } from './maintenance/consolidate-api.js' const MAIN_SPACE_ID = 'space-main' @@ -405,6 +406,11 @@ const COLUMN_MIGRATIONS: string[] = [ `ALTER TABLE censorcheck_runs ADD COLUMN detectedHoster TEXT`, ] +const PROVIDER_API_MIGRATIONS: string[] = [ + `ALTER TABLE providers ADD COLUMN apiType TEXT`, + `ALTER TABLE providers ADD COLUMN apiBaseUrl TEXT`, +] + const SPACE_BACKFILL_TABLES = [ 'providers', 'provider_accounts', @@ -536,6 +542,17 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void { /* column exists */ } } + // Pre-provider-API DBs: columns appear empty, URLs still sit on accounts → move them once. + let providerApiAdded = false + for (const sql of PROVIDER_API_MIGRATIONS) { + try { + sqlite.exec(sql) + providerApiAdded = true + } catch { + /* column exists */ + } + } + if (providerApiAdded) consolidateAllProviderApiSources(sqlite) rebuildVpsDomainsBindingUnique(sqlite) ensureMainSpace(sqlite) backfillSpaceIds(sqlite)