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

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

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

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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-26 13:42:05 +07:00
co-authored by Cursor
parent 8408155be6
commit 6fbd1a9113
202 changed files with 16734 additions and 12145 deletions
+38
View File
@@ -0,0 +1,38 @@
import Database from 'better-sqlite3'
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import * as schema from './schema/index.js'
export type Db = BetterSQLite3Database<typeof schema>
let _db: Db | null = null
let _sqlite: Database.Database | null = null
const DEFAULT_DB_PATH = resolve(process.cwd(), 'data', 'vps-tracker.db')
export function getDbPath(): string {
return process.env.DB_PATH ?? DEFAULT_DB_PATH
}
export function getDb(): Db {
if (_db) return _db
const dbPath = getDbPath()
const dir = dirname(dbPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
_sqlite = new Database(dbPath)
_sqlite.pragma('journal_mode = WAL')
_sqlite.pragma('foreign_keys = ON')
_db = drizzle(_sqlite, { schema })
return _db
}
export function closeDb(): void {
if (_sqlite) {
_sqlite.close()
_sqlite = null
_db = null
}
}
export { schema }
@@ -0,0 +1,51 @@
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
type Row = typeof schema.balanceLedger.$inferSelect
type Insert = Partial<typeof schema.balanceLedger.$inferInsert> & {
type: string
date: string
amount: number
}
function normalize(input: Partial<Row>) {
return {
type: input.type ?? '',
date: input.date ?? '',
amount: Number(input.amount) || 0,
currency: input.currency ?? '',
direction: input.direction ?? '',
providerAccountId: input.providerAccountId ?? '',
vpsId: input.vpsId ?? '',
note: input.note ?? '',
}
}
export const balanceLedgerRepository = {
list(): Row[] {
return getDb().select().from(schema.balanceLedger).orderBy(desc(schema.balanceLedger.date)).all()
},
get(id: string): Row | undefined {
return getDb().select().from(schema.balanceLedger).where(eq(schema.balanceLedger.id, id)).get()
},
create(input: Insert, id?: string): Row {
const finalId = id ?? input.id ?? generateId('ledger')
getDb().insert(schema.balanceLedger).values({ id: finalId, ...normalize(input) }).run()
return this.get(finalId)!
},
update(id: string, input: Partial<Row>): Row | undefined {
const existing = this.get(id)
if (!existing) return undefined
getDb()
.update(schema.balanceLedger)
.set(normalize({ ...existing, ...input }))
.where(eq(schema.balanceLedger.id, id))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.balanceLedger).where(eq(schema.balanceLedger.id, id)).run()
return r.changes > 0
},
}
+46
View File
@@ -0,0 +1,46 @@
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
type Row = typeof schema.payments.$inferSelect
type Insert = Partial<typeof schema.payments.$inferInsert> & { type: string; date: string; amount: number }
function normalize(input: Partial<Row>) {
return {
type: input.type ?? '',
date: input.date ?? '',
amount: Number(input.amount) || 0,
currency: input.currency ?? '',
providerAccountId: input.providerAccountId ?? '',
vpsId: input.vpsId ?? '',
note: input.note ?? '',
}
}
export const paymentsRepository = {
list(): Row[] {
return getDb().select().from(schema.payments).orderBy(desc(schema.payments.date)).all()
},
get(id: string): Row | undefined {
return getDb().select().from(schema.payments).where(eq(schema.payments.id, id)).get()
},
create(input: Insert, id?: string): Row {
const finalId = id ?? input.id ?? generateId('pay')
getDb().insert(schema.payments).values({ id: finalId, ...normalize(input) }).run()
return this.get(finalId)!
},
update(id: string, input: Partial<Row>): Row | undefined {
const existing = this.get(id)
if (!existing) return undefined
getDb()
.update(schema.payments)
.set(normalize({ ...existing, ...input }))
.where(eq(schema.payments.id, id))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.payments).where(eq(schema.payments.id, id)).run()
return r.changes > 0
},
}
+122
View File
@@ -0,0 +1,122 @@
import { randomUUID } from 'node:crypto'
import { eq, like, asc, sql } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
export function normalizeProjectNameInput(name: unknown): string {
if (name == null) return ''
return String(name).trim()
}
export function findProjectByNameCaseInsensitive(
name: string,
): (typeof schema.serverProjects.$inferSelect) | undefined {
const n = normalizeProjectNameInput(name)
if (!n) return undefined
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(sql`LOWER(${schema.serverProjects.name})`, n.toLowerCase()))
.get()
}
export function resolveOrCreateProject(
name: unknown,
): { id: string | null; name: string } {
const n = normalizeProjectNameInput(name)
if (!n) return { id: null, name: '' }
const existing = findProjectByNameCaseInsensitive(n)
if (existing) return { id: existing.id, name: existing.name }
const id = `proj-${randomUUID()}`
const now = new Date().toISOString()
getDb()
.insert(schema.serverProjects)
.values({ id, name: n, color: null, sortOrder: 0, notes: null, createdAt: now })
.run()
return { id, name: n }
}
export function projectSuggestions(
q: string,
limit = 20,
): { id: string; name: string }[] {
const term = normalizeProjectNameInput(q)
const lim = Math.min(50, Math.max(1, Number(limit) || 20))
const db = getDb()
if (!term) {
return db
.select({ id: schema.serverProjects.id, name: schema.serverProjects.name })
.from(schema.serverProjects)
.orderBy(asc(schema.serverProjects.name))
.limit(lim)
.all()
}
const esc = term.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
const pattern = `%${esc.toLowerCase()}%`
return db
.select({ id: schema.serverProjects.id, name: schema.serverProjects.name })
.from(schema.serverProjects)
.where(like(sql`LOWER(${schema.serverProjects.name})`, pattern))
.orderBy(asc(schema.serverProjects.name))
.limit(lim)
.all()
}
export function getProjectNameById(id: string): string {
const row = getDb()
.select({ name: schema.serverProjects.name })
.from(schema.serverProjects)
.where(eq(schema.serverProjects.id, id))
.get()
return row?.name ?? ''
}
export const projectsRepository = {
list(): (typeof schema.serverProjects.$inferSelect)[] {
return getDb()
.select()
.from(schema.serverProjects)
.orderBy(asc(schema.serverProjects.name))
.all()
},
create(input: { name: string; color?: string | null; notes?: string | null }) {
const id = `proj-${randomUUID()}`
const now = new Date().toISOString()
getDb()
.insert(schema.serverProjects)
.values({
id,
name: input.name,
color: input.color ?? null,
sortOrder: 0,
notes: input.notes ?? null,
createdAt: now,
})
.run()
return this.list().find((p) => p.id === id)!
},
update(
id: string,
input: Partial<{ name: string; color: string | null; notes: string | null }>,
) {
const existing = getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.id, id))
.get()
if (!existing) return undefined
getDb()
.update(schema.serverProjects)
.set({
name: input.name ?? existing.name,
color: input.color ?? existing.color,
notes: input.notes ?? existing.notes,
})
.where(eq(schema.serverProjects.id, id))
.run()
return getDb().select().from(schema.serverProjects).where(eq(schema.serverProjects.id, id)).get()
},
delete(id: string): boolean {
const r = getDb().delete(schema.serverProjects).where(eq(schema.serverProjects.id, id)).run()
return r.changes > 0
},
}
@@ -0,0 +1,114 @@
import { asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type AccountInsert = Partial<typeof schema.providerAccounts.$inferInsert> & {
providerId: string
name: string
}
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
apiCredentialsSet: boolean
}
function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined {
if (!row) return undefined
const { apiCredentials, ...rest } = row
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
}
function normalize(input: Partial<AccountRow>) {
const rawAlert = input.balanceAlertBelow
const alertBelow = rawAlert != null && !Number.isNaN(Number(rawAlert)) ? Number(rawAlert) : null
return {
providerId: input.providerId ?? '',
name: input.name ?? '',
panelUrl: input.panelUrl ?? '',
currency: input.currency ?? '',
billingMode: input.billingMode ?? '',
notes: input.notes ?? '',
apiType: '',
apiBaseUrl: '',
apiCredentials: input.apiCredentials ?? '',
balanceAlertBelow: Number.isFinite(alertBelow) ? alertBelow : null,
}
}
export const providerAccountsRepository = {
list(): PublicAccountRow[] {
const rows = getDb()
.select()
.from(schema.providerAccounts)
.orderBy(asc(schema.providerAccounts.name))
.all()
return rows.map((r) => sanitize(r)!) as PublicAccountRow[]
},
get(id: string): PublicAccountRow | undefined {
const row = getDb()
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.get()
return sanitize(row)
},
getWithCredentials(id: string): AccountRow | undefined {
return getDb()
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.get()
},
create(input: AccountInsert, id?: string): PublicAccountRow {
const db = getDb()
const finalId = id ?? input.id ?? generateId('account')
db.insert(schema.providerAccounts)
.values({ id: finalId, ...normalize(input) })
.run()
return this.get(finalId)!
},
update(id: string, input: Partial<AccountRow>): PublicAccountRow | undefined {
const db = getDb()
const existing = this.getWithCredentials(id)
if (!existing) return undefined
const apiCredentials =
input.apiCredentials !== undefined
? String(input.apiCredentials || '')
: (existing.apiCredentials || '')
let balanceAlertBelow = existing.balanceAlertBelow
if (input.balanceAlertBelow !== undefined) {
const v = input.balanceAlertBelow
balanceAlertBelow = v == null ? null : Number.isFinite(Number(v)) ? Number(v) : null
}
db.update(schema.providerAccounts)
.set({
providerId: input.providerId ?? existing.providerId,
name: input.name ?? existing.name,
panelUrl: input.panelUrl ?? existing.panelUrl,
currency: input.currency ?? existing.currency,
billingMode: input.billingMode ?? existing.billingMode,
notes: input.notes ?? existing.notes,
apiType: '',
apiBaseUrl: '',
apiCredentials,
balanceAlertBelow,
})
.where(eq(schema.providerAccounts.id, id))
.run()
return this.get(id)
},
delete(id: string): boolean {
const res = getDb()
.delete(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.run()
return res.changes > 0
},
}
+63
View File
@@ -0,0 +1,63 @@
import { asc, eq } from 'drizzle-orm'
import { getDb, schema, type Db } from '../index.js'
import { generateId } from './utils.js'
export type ProviderInsert = Partial<typeof schema.providers.$inferInsert> & {
name: string
}
function normalize(input: Partial<typeof schema.providers.$inferInsert>) {
return {
name: input.name ?? '',
website: input.website ?? '',
contact: input.contact ?? '',
baseCurrency: input.baseCurrency ?? '',
usdRate: input.usdRate ?? '',
eurRate: input.eurRate ?? '',
notes: input.notes ?? '',
apiType: input.apiType ?? '',
apiBaseUrl: input.apiBaseUrl ?? '',
}
}
export const providersRepository = {
list(): (typeof schema.providers.$inferSelect)[] {
return getDb().select().from(schema.providers).orderBy(asc(schema.providers.name)).all()
},
get(id: string): (typeof schema.providers.$inferSelect) | undefined {
return getDb().select().from(schema.providers).where(eq(schema.providers.id, id)).get()
},
create(input: ProviderInsert, id?: string): (typeof schema.providers.$inferSelect) {
const db: Db = getDb()
const finalId = id ?? input.id ?? generateId('provider')
db.insert(schema.providers)
.values({ id: finalId, ...normalize(input) })
.run()
return this.get(finalId)!
},
update(
id: string,
input: Partial<typeof schema.providers.$inferInsert>,
): (typeof schema.providers.$inferSelect) | undefined {
const db = getDb()
const existing = this.get(id)
if (!existing) return undefined
const merged = {
...existing,
...normalize({ ...existing, ...input }),
}
db.update(schema.providers)
.set(merged)
.where(eq(schema.providers.id, id))
.run()
return this.get(id)
},
delete(id: string): boolean {
const res = getDb().delete(schema.providers).where(eq(schema.providers.id, id)).run()
return res.changes > 0
},
}
+150
View File
@@ -0,0 +1,150 @@
import { asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
type Row = typeof schema.settings.$inferSelect
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'customFields'> & {
telegramBotTokenSet: boolean
autoConvert: boolean
syncEnabled: boolean
notifyPaymentExpiryEnabled: boolean
notifyNewTariffsEnabled: boolean
notifyLowBalanceEnabled: boolean
notifySyncDigestEnabled: boolean
customFields: unknown[]
}
function toDto(row: Row | undefined): SettingsDto | undefined {
if (!row) return undefined
let customFields: unknown[] = []
if (row.customFields) {
try {
customFields = JSON.parse(row.customFields)
} catch {
customFields = []
}
}
const { telegramBotToken, ...rest } = row
return {
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
customFields: Array.isArray(customFields) ? customFields : [],
}
}
function serializeCustomFields(val: unknown): string | null {
if (val == null) return null
if (Array.isArray(val)) return JSON.stringify(val)
if (typeof val === 'string') return val || null
return null
}
interface SettingsInput {
baseCurrency?: string
ratesUrl?: string
autoConvert?: boolean
ratesUpdatedAt?: string
syncEnabled?: boolean
syncIntervalMinutes?: number
syncTariffsIntervalMinutes?: number
telegramBotToken?: string
telegramChatId?: string
telegramMessageThreadId?: string
notifyPaymentExpiryEnabled?: boolean
notifyNewTariffsEnabled?: boolean
notifyLowBalanceEnabled?: boolean
notifySyncDigestEnabled?: boolean
customFields?: unknown
}
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
return {
id,
baseCurrency: r.baseCurrency ?? existing?.baseCurrency ?? 'RUB',
ratesUrl: r.ratesUrl ?? existing?.ratesUrl ?? '',
autoConvert:
r.autoConvert !== undefined ? (r.autoConvert ? 1 : 0) : existing?.autoConvert ? 1 : 0,
ratesUpdatedAt: r.ratesUpdatedAt ?? existing?.ratesUpdatedAt ?? '',
syncEnabled:
r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : existing?.syncEnabled ? 1 : 0,
syncIntervalMinutes:
r.syncIntervalMinutes !== undefined
? Math.max(15, Number(r.syncIntervalMinutes) || 60)
: existing?.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes:
r.syncTariffsIntervalMinutes !== undefined
? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
: existing?.syncTariffsIntervalMinutes ?? 1440,
telegramBotToken:
r.telegramBotToken !== undefined ? r.telegramBotToken || '' : existing?.telegramBotToken ?? '',
telegramChatId:
r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '',
telegramMessageThreadId:
r.telegramMessageThreadId !== undefined
? r.telegramMessageThreadId || ''
: existing?.telegramMessageThreadId ?? '',
notifyPaymentExpiryEnabled:
r.notifyPaymentExpiryEnabled !== undefined
? r.notifyPaymentExpiryEnabled
? 1
: 0
: existing?.notifyPaymentExpiryEnabled
? 1
: 0,
notifyNewTariffsEnabled:
r.notifyNewTariffsEnabled !== undefined
? r.notifyNewTariffsEnabled
? 1
: 0
: existing?.notifyNewTariffsEnabled
? 1
: 0,
notifyLowBalanceEnabled:
r.notifyLowBalanceEnabled !== undefined
? r.notifyLowBalanceEnabled
? 1
: 0
: existing?.notifyLowBalanceEnabled
? 1
: 0,
notifySyncDigestEnabled:
r.notifySyncDigestEnabled !== undefined
? r.notifySyncDigestEnabled
? 1
: 0
: existing?.notifySyncDigestEnabled
? 1
: 0,
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
}
}
export const settingsRepository = {
list(): SettingsDto[] {
const rows = getDb().select().from(schema.settings).orderBy(asc(schema.settings.id)).all()
return rows.map((r) => toDto(r)!) as SettingsDto[]
},
get(id: string): SettingsDto | undefined {
return toDto(getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get())
},
getRow(id: string): Row | undefined {
return getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get()
},
upsert(id: string, input: SettingsInput): SettingsDto {
const db = getDb()
const existing = this.getRow(id)
const values = buildValues(id, existing, input)
if (existing) {
db.update(schema.settings).set(values).where(eq(schema.settings.id, id)).run()
} else {
db.insert(schema.settings).values(values).run()
}
return this.get(id)!
},
}
+46
View File
@@ -0,0 +1,46 @@
import { vpsRepository } from './vps.js'
import { providersRepository } from './providers.js'
import { providerAccountsRepository } from './provider-accounts.js'
import { paymentsRepository } from './payments.js'
import { balanceLedgerRepository } from './balance-ledger.js'
import { settingsRepository } from './settings.js'
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
import { projectsRepository } from './projects.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
serverProjects: ReturnType<typeof projectsRepository.list>
providers: ReturnType<typeof providersRepository.list>
providerAccounts: ReturnType<typeof providerAccountsRepository.list>
payments: ReturnType<typeof paymentsRepository.list>
balanceLedger: ReturnType<typeof balanceLedgerRepository.list>
settings: ReturnType<typeof settingsRepository.list>
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
}
export function getSnapshot(): Snapshot {
return {
vps: vpsRepository.list(),
serverProjects: projectsRepository.list(),
providers: providersRepository.list(),
providerAccounts: providerAccountsRepository.list(),
payments: paymentsRepository.list(),
balanceLedger: balanceLedgerRepository.list(),
settings: settingsRepository.list(),
activeTariffs: activeTariffsRepository.list(),
tariffSyncOptions: tariffSyncOptionsRepository.list(),
}
}
export {
vpsRepository,
providersRepository,
providerAccountsRepository,
paymentsRepository,
balanceLedgerRepository,
settingsRepository,
activeTariffsRepository,
tariffSyncOptionsRepository,
projectsRepository,
}
+116
View File
@@ -0,0 +1,116 @@
import { asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
type Row = typeof schema.activeTariffs.$inferSelect
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb'> & {
orderAvailable: boolean
ramGb: number
}
function toDto(row: Row | undefined): ActiveTariffDto | undefined {
if (!row) return undefined
return {
...row,
orderAvailable: Boolean(row.orderAvailable),
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
}
}
export const activeTariffsRepository = {
list(): ActiveTariffDto[] {
const rows = getDb()
.select()
.from(schema.activeTariffs)
.orderBy(asc(schema.activeTariffs.name))
.all()
return rows.map((r) => toDto(r)!) as ActiveTariffDto[]
},
byAccount(accountId: string): ActiveTariffDto[] {
const rows = getDb()
.select()
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
return rows.map((r) => toDto(r)!) as ActiveTariffDto[]
},
upsertMany(rows: (typeof schema.activeTariffs.$inferInsert)[]): void {
const db = getDb()
for (const r of rows) {
const existing = db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.id, r.id))
.get()
if (existing) {
db.update(schema.activeTariffs).set(r).where(eq(schema.activeTariffs.id, r.id)).run()
} else {
db.insert(schema.activeTariffs).values(r).run()
}
}
},
}
export type TariffSyncOptionsRow = typeof schema.tariffSyncOptions.$inferSelect
export interface TariffSyncOptionsDto {
providerAccountId: string
datacenters: unknown[]
periods: unknown[]
syncedAt: string
}
export function toTariffSyncOptionsDto(
row: TariffSyncOptionsRow | undefined,
): TariffSyncOptionsDto | undefined {
if (!row) return undefined
let datacenters: unknown[] = []
let periods: unknown[] = []
try {
datacenters = row.datacenters ? JSON.parse(row.datacenters) : []
} catch {
datacenters = []
}
try {
periods = row.periods ? JSON.parse(row.periods) : []
} catch {
periods = []
}
return {
providerAccountId: row.providerAccountId,
datacenters: Array.isArray(datacenters) ? datacenters : [],
periods: Array.isArray(periods) ? periods : [],
syncedAt: row.syncedAt ?? '',
}
}
export const tariffSyncOptionsRepository = {
list(): TariffSyncOptionsDto[] {
const rows = getDb().select().from(schema.tariffSyncOptions).all()
return rows.map((r) => toTariffSyncOptionsDto(r)!) as TariffSyncOptionsDto[]
},
byAccount(accountId: string): TariffSyncOptionsDto | undefined {
return toTariffSyncOptionsDto(
getDb()
.select()
.from(schema.tariffSyncOptions)
.where(eq(schema.tariffSyncOptions.providerAccountId, accountId))
.get(),
)
},
upsert(input: typeof schema.tariffSyncOptions.$inferInsert): void {
const db = getDb()
const existing = db
.select({ providerAccountId: schema.tariffSyncOptions.providerAccountId })
.from(schema.tariffSyncOptions)
.where(eq(schema.tariffSyncOptions.providerAccountId, input.providerAccountId))
.get()
if (existing) {
db.update(schema.tariffSyncOptions)
.set(input)
.where(eq(schema.tariffSyncOptions.providerAccountId, input.providerAccountId))
.run()
} else {
db.insert(schema.tariffSyncOptions).values(input).run()
}
},
}
+5
View File
@@ -0,0 +1,5 @@
export function generateId(prefix: string): string {
const ts = Date.now()
const rand = Math.random().toString(36).slice(2, 9)
return `${prefix}-${ts}-${rand}`
}
+304
View File
@@ -0,0 +1,304 @@
import { desc, eq, inArray } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
import { resolveOrCreateProject, getProjectNameById } from './projects.js'
type VpsRow = typeof schema.vps.$inferSelect
export type VpsDto = Omit<VpsRow, 'additionalIps' | 'userOverrides' | 'projectId' | 'monitoringEnabled' | 'backupEnabled' | 'dailyRate' | 'monthlyRate'> & {
additionalIps: string[]
userOverrides: string[]
projectId: string
monitoringEnabled: boolean
backupEnabled: boolean
dailyRate: number | ''
monthlyRate: number | ''
}
const USER_OVERRIDABLE_FIELDS = [
'country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType',
'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort',
'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled',
] as const
function toDto(row: VpsRow | undefined): VpsDto | undefined {
if (!row) return undefined
let additionalIps: string[] = []
try {
additionalIps = row.additionalIps ? JSON.parse(row.additionalIps) : []
} catch {
additionalIps = []
}
let userOverrides: string[] = []
try {
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
} catch {
userOverrides = []
}
return {
...row,
additionalIps,
userOverrides,
projectId: row.projectId ?? '',
monitoringEnabled: Boolean(row.monitoringEnabled),
backupEnabled: Boolean(row.backupEnabled),
dailyRate: row.dailyRate != null ? row.dailyRate : '',
monthlyRate: row.monthlyRate != null ? row.monthlyRate : '',
}
}
interface VpsInput {
ip?: string
ipv6?: string
additionalIps?: string[]
dns?: 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
projectId?: string
monitoringEnabled?: boolean
backupEnabled?: boolean
status?: string
tariffType?: string
currency?: string
dailyRate?: number | ''
monthlyRate?: number | ''
createdAt?: string
paidUntil?: string
notes?: string
userOverrides?: string[] | 'clear'
}
function projectColumnsForSave(projectInput: unknown): { project: string; projectId: string } {
const resolved = resolveOrCreateProject(projectInput)
if (!resolved.id) return { project: '', projectId: '' }
return { project: resolved.name, projectId: resolved.id }
}
function numOrNull(v: unknown): number | null {
if (v === '' || v == null) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function boolToInt(v: unknown): number {
return v ? 1 : 0
}
export const vpsRepository = {
list(): VpsDto[] {
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
return rows.map((r) => toDto(r)!) as VpsDto[]
},
get(id: string): VpsDto | undefined {
const row = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
return toDto(row)
},
create(input: VpsInput, id?: string): VpsDto {
const db = getDb()
const finalId = id ?? generateId('vps')
const additionalIps = Array.isArray(input.additionalIps) ? JSON.stringify(input.additionalIps) : '[]'
const { project, projectId } = projectColumnsForSave(input.project)
db.insert(schema.vps)
.values({
id: finalId,
ip: input.ip ?? '',
ipv6: input.ipv6 ?? '',
additionalIps,
dns: input.dns ?? '',
providerId: input.providerId ?? '',
providerAccountId: input.providerAccountId ?? '',
country: input.country ?? '',
city: input.city ?? '',
datacenter: input.datacenter ?? '',
os: input.os ?? '',
vcpu: input.vcpu ?? 0,
ramGb: input.ramGb ?? 0,
diskGb: input.diskGb ?? 0,
diskType: input.diskType ?? '',
virtualization: input.virtualization ?? '',
bandwidthTb: input.bandwidthTb ?? 0,
sshPort: input.sshPort ?? 22,
rootUser: input.rootUser ?? '',
purpose: input.purpose ?? '',
environment: input.environment ?? '',
project,
projectId: projectId || null,
monitoringEnabled: boolToInt(input.monitoringEnabled),
backupEnabled: boolToInt(input.backupEnabled),
status: input.status ?? 'active',
tariffType: input.tariffType ?? '',
currency: input.currency ?? '',
dailyRate: numOrNull(input.dailyRate),
monthlyRate: numOrNull(input.monthlyRate),
createdAt: input.createdAt ?? new Date().toISOString().slice(0, 10),
paidUntil: input.paidUntil ?? '',
notes: input.notes ?? '',
userOverrides: input.userOverrides && Array.isArray(input.userOverrides)
? JSON.stringify(input.userOverrides)
: '[]',
})
.run()
return this.get(finalId)!
},
update(id: string, input: VpsInput): VpsDto | undefined {
const db = getDb()
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
if (!existing) return undefined
let userOverrides: string[] = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
const clearOverrides =
input.userOverrides === 'clear' ||
(Array.isArray(input.userOverrides) && input.userOverrides.length === 0)
if (clearOverrides) userOverrides = []
const additionalIps = Array.isArray(input.additionalIps) ? JSON.stringify(input.additionalIps) : '[]'
let projectOut = existing.project ?? ''
let projectIdOut = existing.projectId ?? ''
if (input.project !== undefined) {
const r = projectColumnsForSave(input.project)
projectOut = r.project
projectIdOut = r.projectId
} else if (input.projectId !== undefined) {
if (!input.projectId) {
projectOut = ''
projectIdOut = ''
} else {
projectOut = getProjectNameById(input.projectId)
projectIdOut = input.projectId
}
}
if (!clearOverrides) {
for (const f of USER_OVERRIDABLE_FIELDS) {
if (f === 'project') {
const projectChanged =
String(projectOut ?? '') !== String(existing.project ?? '') ||
String(projectIdOut ?? '') !== String(existing.projectId ?? '')
if (projectChanged && !userOverrides.includes('project')) {
userOverrides.push('project')
}
continue
}
const newVal = (input as Record<string, unknown>)[f]
const oldVal = (existing as Record<string, unknown>)[f]
const changed = String(newVal ?? '') !== String(oldVal ?? '')
if (changed && !userOverrides.includes(f)) {
userOverrides.push(f)
}
}
}
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
db.update(schema.vps)
.set({
ip: input.ip ?? '',
ipv6: input.ipv6 ?? '',
additionalIps,
dns: input.dns ?? '',
providerId: input.providerId ?? '',
providerAccountId: input.providerAccountId ?? '',
country: input.country ?? '',
city: input.city ?? '',
datacenter: input.datacenter ?? '',
os: input.os ?? '',
vcpu: input.vcpu ?? 0,
ramGb: input.ramGb ?? 0,
diskGb: input.diskGb ?? 0,
diskType: input.diskType ?? '',
virtualization: input.virtualization ?? '',
bandwidthTb: input.bandwidthTb ?? 0,
sshPort: input.sshPort ?? 22,
rootUser: input.rootUser ?? '',
purpose: input.purpose ?? '',
environment: input.environment ?? '',
project: projectOut,
projectId: projectIdOut || null,
monitoringEnabled: boolToInt(input.monitoringEnabled),
backupEnabled: boolToInt(input.backupEnabled),
status: input.status ?? 'active',
tariffType: input.tariffType ?? '',
currency: input.currency ?? '',
dailyRate: numOrNull(input.dailyRate),
monthlyRate: numOrNull(input.monthlyRate),
createdAt: input.createdAt ?? '',
paidUntil: input.paidUntil ?? '',
notes: input.notes ?? '',
userOverrides: userOverridesJson,
})
.where(eq(schema.vps.id, id))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.vps).where(eq(schema.vps.id, id)).run()
return r.changes > 0
},
bulkStatus(ids: string[], status: string): number {
getDb().update(schema.vps).set({ status }).where(inArray(schema.vps.id, ids)).run()
return ids.length
},
bulkDelete(ids: string[]): number {
const r = getDb().delete(schema.vps).where(inArray(schema.vps.id, ids)).run()
return r.changes
},
bulkProject(ids: string[], project: string | null): { updated: number; project: string; projectId: string } {
const { project: projName, projectId: projId } = projectColumnsForSave(project ?? '')
const rows = getDb()
.select()
.from(schema.vps)
.where(inArray(schema.vps.id, ids))
.all()
let updated = 0
for (const row of rows) {
if (String(row.project ?? '') === projName && String(row.projectId ?? '') === String(projId ?? '')) {
continue
}
let userOverrides: string[] = []
try {
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
} catch {
userOverrides = []
}
if (!userOverrides.includes('project')) userOverrides.push('project')
getDb()
.update(schema.vps)
.set({
project: projName,
projectId: projId || null,
userOverrides: JSON.stringify([...new Set(userOverrides)]),
})
.where(eq(schema.vps.id, row.id))
.run()
updated++
}
return { updated, project: projName, projectId: projId }
},
}
+175
View File
@@ -0,0 +1,175 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const providers = sqliteTable('providers', {
id: text('id').primaryKey(),
name: text('name').notNull(),
website: text('website'),
contact: text('contact'),
baseCurrency: text('baseCurrency'),
usdRate: text('usdRate'),
eurRate: text('eurRate'),
notes: text('notes'),
apiType: text('apiType'),
apiBaseUrl: text('apiBaseUrl'),
})
export const providerAccounts = sqliteTable('provider_accounts', {
id: text('id').primaryKey(),
providerId: text('providerId')
.notNull()
.references(() => providers.id),
name: text('name').notNull(),
panelUrl: text('panelUrl'),
currency: text('currency'),
billingMode: text('billingMode'),
notes: text('notes'),
apiType: text('apiType'),
apiBaseUrl: text('apiBaseUrl'),
apiCredentials: text('apiCredentials'),
balanceApi: real('balance_api'),
balanceCurrency: text('balance_currency'),
balanceUpdatedAt: text('balance_updated_at'),
enoughmoneyto: text('enoughmoneyto'),
balanceAlertBelow: real('balance_alert_below'),
})
export const serverProjects = sqliteTable('server_projects', {
id: text('id').primaryKey(),
name: text('name').notNull(),
color: text('color'),
sortOrder: integer('sortOrder').default(0),
notes: text('notes'),
createdAt: text('createdAt'),
})
export const vps = sqliteTable('vps', {
id: text('id').primaryKey(),
ip: text('ip'),
ipv6: text('ipv6'),
additionalIps: text('additionalIps'),
dns: text('dns'),
providerId: text('providerId').references(() => providers.id),
providerAccountId: text('providerAccountId').references(() => providerAccounts.id),
country: text('country'),
city: text('city'),
datacenter: text('datacenter'),
os: text('os'),
vcpu: integer('vcpu'),
ramGb: integer('ramGb'),
diskGb: integer('diskGb'),
diskType: text('diskType'),
virtualization: text('virtualization'),
bandwidthTb: integer('bandwidthTb'),
sshPort: integer('sshPort'),
rootUser: text('rootUser'),
purpose: text('purpose'),
environment: text('environment'),
project: text('project'),
projectId: text('projectId').references(() => serverProjects.id),
monitoringEnabled: integer('monitoringEnabled'),
backupEnabled: integer('backupEnabled'),
status: text('status'),
tariffType: text('tariffType'),
currency: text('currency'),
dailyRate: real('dailyRate'),
monthlyRate: real('monthlyRate'),
createdAt: text('createdAt'),
paidUntil: text('paidUntil'),
notes: text('notes'),
userOverrides: text('userOverrides'),
})
export const payments = sqliteTable('payments', {
id: text('id').primaryKey(),
type: text('type').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
currency: text('currency'),
providerAccountId: text('providerAccountId').references(() => providerAccounts.id),
vpsId: text('vpsId').references(() => vps.id),
note: text('note'),
})
export const balanceLedger = sqliteTable('balance_ledger', {
id: text('id').primaryKey(),
type: text('type').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
currency: text('currency'),
direction: text('direction'),
providerAccountId: text('providerAccountId').references(() => providerAccounts.id),
vpsId: text('vpsId').references(() => vps.id),
note: text('note'),
})
export const settings = sqliteTable('settings', {
id: text('id').primaryKey(),
baseCurrency: text('baseCurrency'),
ratesUrl: text('ratesUrl'),
autoConvert: integer('autoConvert'),
ratesUpdatedAt: text('ratesUpdatedAt'),
syncEnabled: integer('syncEnabled'),
syncIntervalMinutes: integer('syncIntervalMinutes'),
syncTariffsIntervalMinutes: integer('syncTariffsIntervalMinutes'),
customFields: text('customFields'),
telegramBotToken: text('telegramBotToken'),
telegramChatId: text('telegramChatId'),
notifyPaymentExpiryEnabled: integer('notifyPaymentExpiryEnabled'),
notifyNewTariffsEnabled: integer('notifyNewTariffsEnabled'),
telegramMessageThreadId: text('telegramMessageThreadId'),
notifyLowBalanceEnabled: integer('notifyLowBalanceEnabled'),
notifySyncDigestEnabled: integer('notifySyncDigestEnabled'),
})
export const syncLog = sqliteTable('sync_log', {
id: text('id').primaryKey(),
accountId: text('accountId')
.notNull()
.references(() => providerAccounts.id),
startedAt: text('startedAt').notNull(),
finishedAt: text('finishedAt'),
status: text('status'),
vpsCount: integer('vpsCount'),
paymentsCount: integer('paymentsCount'),
error: text('error'),
summary: text('summary'),
})
export const activeTariffs = sqliteTable('active_tariffs', {
id: text('id').primaryKey(),
providerAccountId: text('providerAccountId')
.notNull()
.references(() => providerAccounts.id),
providerId: text('providerId')
.notNull()
.references(() => providers.id),
externalId: text('externalId').notNull(),
datacenterKey: text('datacenterKey'),
datacenterName: text('datacenterName'),
name: text('name'),
desc: text('desc'),
vcpu: integer('vcpu'),
ramGb: real('ramGb'),
diskGb: integer('diskGb'),
diskType: text('diskType'),
virtualization: text('virtualization'),
channel: text('channel'),
location: text('location'),
country: text('country'),
cpuModel: text('cpuModel'),
orderAvailable: integer('orderAvailable'),
price: text('price'),
syncedAt: text('syncedAt'),
})
export const tariffSyncOptions = sqliteTable('tariff_sync_options', {
providerAccountId: text('providerAccountId')
.primaryKey()
.references(() => providerAccounts.id),
datacenters: text('datacenters'),
periods: text('periods'),
syncedAt: text('syncedAt'),
})
export const now = sql`(datetime('now'))`