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
+83
View File
@@ -0,0 +1,83 @@
import Fastify from 'fastify'
import cors from '@fastify/cors'
import sensible from '@fastify/sensible'
import staticPlugin from '@fastify/static'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getDb } from '@cfdm/db'
import { dataRoutes } from './routes/data.js'
import { vpsRoutes } from './routes/vps.js'
import { providersRoutes } from './routes/providers.js'
import { providerAccountsRoutes } from './routes/provider-accounts.js'
import { paymentsRoutes } from './routes/payments.js'
import { balanceLedgerRoutes } from './routes/balance-ledger.js'
import { settingsRoutes } from './routes/settings.js'
import { syncRoutes } from './routes/sync.js'
import { projectsRoutes } from './routes/projects.js'
import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
export interface BuildAppOptions {
dbPath?: string
staticDir?: string
}
export async function buildApp(opts: BuildAppOptions = {}) {
if (opts.dbPath) process.env.DB_PATH = opts.dbPath
getDb()
const app = Fastify({
logger: process.env.NODE_ENV !== 'production',
})
await app.register(cors, { origin: true })
await app.register(sensible)
await app.register(dataRoutes)
await app.register(vpsRoutes)
await app.register(providersRoutes)
await app.register(providerAccountsRoutes)
await app.register(paymentsRoutes)
await app.register(balanceLedgerRoutes)
await app.register(settingsRoutes)
await app.register(syncRoutes)
await app.register(projectsRoutes)
await app.register(backupRoutes)
await app.register(ratesProxyRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
await app.register(staticPlugin, {
root: staticDir,
prefix: '/',
wildcard: false,
})
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api')) {
reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
return
}
reply.sendFile('index.html')
})
}
return app
}
async function start() {
const port = Number(process.env.PORT ?? 3001)
const app = await buildApp()
try {
await app.listen({ port, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}
}
void start()
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyPluginAsync } from 'fastify'
import { existsSync, readFileSync } from 'node:fs'
import { getDbPath } from '@cfdm/db'
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
const BACKUP_VERSION = 1
export const backupRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/backup/json', async (_req, reply) => {
const snapshot = { backupVersion: BACKUP_VERSION, exportedAt: new Date().toISOString(), ...getSnapshot() }
reply.header('Content-Type', 'application/json; charset=utf-8')
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
return reply.send(JSON.stringify(snapshot, null, 2))
})
app.get('/api/backup/database', async (_req, reply) => {
const dbPath = getDbPath()
if (!existsSync(dbPath)) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Файл базы не найден' } })
}
const buf = readFileSync(dbPath)
reply.header('Content-Type', 'application/octet-stream')
reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"')
return reply.send(buf)
})
app.post('/api/backup/json', async (req, reply) => {
const payload = req.body
if (!payload || typeof payload !== 'object') {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
}
// TODO: implement JSON snapshot import via repositories
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'JSON import pending migration' } })
})
app.post('/api/backup/database', async (req, reply) => {
const buf = req.body as Buffer
if (!buf || !buf.length) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
}
// TODO: implement DB restore via better-sqlite3 backup API
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'DB restore pending migration' } })
})
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyPluginAsync } from 'fastify'
import { balanceLedgerRepository } from '@cfdm/db/repositories/balance-ledger'
import { balanceLedgerSchema } from '@cfdm/shared/contracts/balance-ledger'
export const balanceLedgerRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/balance-ledger', async () => balanceLedgerRepository.list())
app.post('/api/balance-ledger', async (req, reply) => {
const parsed = balanceLedgerSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(balanceLedgerRepository.create(parsed.data))
})
app.put<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => {
const parsed = balanceLedgerSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = balanceLedgerRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => {
const ok = balanceLedgerRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+6
View File
@@ -0,0 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
export const dataRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/data', async () => getSnapshot())
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyPluginAsync } from 'fastify'
import { paymentsRepository } from '@cfdm/db/repositories/payments'
import { paymentSchema } from '@cfdm/shared/contracts/payment'
export const paymentsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/payments', async () => paymentsRepository.list())
app.post('/api/payments', async (req, reply) => {
const parsed = paymentSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(paymentsRepository.create(parsed.data))
})
app.put<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => {
const parsed = paymentSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = paymentsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => {
const ok = paymentsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+30
View File
@@ -0,0 +1,30 @@
import type { FastifyPluginAsync } from 'fastify'
import {
projectsRepository,
projectSuggestions,
resolveOrCreateProject,
normalizeProjectNameInput,
} from '@cfdm/db/repositories/projects'
export const projectsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/projects', async () => projectsRepository.list())
app.get('/api/projects/suggest', async (req) => {
const q = (req.query as { q?: string })?.q ?? ''
const limit = (req.query as { limit?: string })?.limit
return projectSuggestions(q, limit ? Number(limit) : 20)
})
app.post('/api/projects/resolve-or-create', async (req) => {
const name = (req.body as { name?: unknown })?.name
return resolveOrCreateProject(name)
})
app.post('/api/projects', async (req, reply) => {
const name = normalizeProjectNameInput((req.body as { name?: unknown })?.name)
if (!name) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } })
}
return reply.code(201).send(resolveOrCreateProject(name))
})
}
+36
View File
@@ -0,0 +1,36 @@
import type { FastifyPluginAsync } from 'fastify'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account'
export const providerAccountsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/provider-accounts', async () => providerAccountsRepository.list())
app.post('/api/provider-accounts', async (req, reply) => {
const parsed = providerAccountSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const created = providerAccountsRepository.create(parsed.data)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
const parsed = providerAccountSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = providerAccountsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
const ok = providerAccountsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+36
View File
@@ -0,0 +1,36 @@
import type { FastifyPluginAsync } from 'fastify'
import { providersRepository } from '@cfdm/db/repositories/providers'
import { providerSchema } from '@cfdm/shared/contracts/provider'
export const providersRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/providers', async () => providersRepository.list())
app.post('/api/providers', async (req, reply) => {
const parsed = providerSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const created = providersRepository.create(parsed.data)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => {
const parsed = providerSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = providersRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => {
const ok = providersRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+19
View File
@@ -0,0 +1,19 @@
import type { FastifyPluginAsync } from 'fastify'
export const ratesProxyRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/rates-proxy', async (req, reply) => {
const url = (req.query as { url?: string })?.url
if (!url) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Missing url parameter' } })
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
if (!response.ok) {
return reply.code(502).send({ error: { code: 'UPSTREAM', message: `Upstream returned ${response.status}` } })
}
return await response.json()
} catch (err) {
return reply.code(502).send({ error: { code: 'UPSTREAM', message: (err as Error).message || 'Failed to fetch rates' } })
}
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { FastifyPluginAsync } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { settingsSchema } from '@cfdm/shared/contracts/settings'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
app.post('/api/settings', async (req, reply) => {
const parsed = settingsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const id = (req.body as { id?: string })?.id ?? 'settings-main'
return reply.code(201).send(settingsRepository.upsert(id, parsed.data))
})
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
const parsed = settingsSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return settingsRepository.upsert(req.params.id, parsed.data)
})
app.post('/api/settings/telegram/test', async () => ({ ok: true }))
}
+95
View File
@@ -0,0 +1,95 @@
import type { FastifyPluginAsync } from 'fastify'
import { desc } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providersRepository } from '@cfdm/db/repositories/providers'
interface SyncLogRow {
id: string
accountId: string
startedAt: string
finishedAt: string | null
status: string | null
vpsCount: number | null
paymentsCount: number | null
error: string | null
summary: unknown
}
function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
let summaryParsed: unknown = null
if (row.summary) {
try {
summaryParsed = JSON.parse(row.summary)
} catch {
summaryParsed = null
}
}
return {
id: row.id,
accountId: row.accountId,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
status: row.status,
vpsCount: row.vpsCount,
paymentsCount: row.paymentsCount,
error: row.error,
summary: summaryParsed,
}
}
export const syncRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/sync/status', async () => {
const rows = getDb()
.select()
.from(schema.syncLog)
.orderBy(desc(schema.syncLog.startedAt))
.limit(50)
.all()
return rows.map(mapSyncLog)
})
app.post<{ Params: { accountId: string } }>('/api/sync/:accountId', async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
// TODO: port billmanager sync job
return reply.code(501).send({
accountId: req.params.accountId,
provider: provider?.name ?? null,
status: 'pending-migration',
note: 'Sync job port pending migration from Express adapters',
})
})
app.get<{ Params: { accountId: string } }>('/api/sync/:accountId/balance', async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
// TODO: port fetchDashboardInfo
return reply.code(501).send({
accountId: req.params.accountId,
status: 'pending-migration',
note: 'Balance fetch pending migration from Express adapters',
})
})
app.post('/api/sync/test-connection', async (req, reply) => {
const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as {
apiBaseUrl?: string
apiCredentials?: string
}
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Укажите URL и учётные данные' } })
}
// TODO: port testConnection
return reply.code(501).send({
ok: false,
status: 'pending-migration',
note: 'Connection test pending migration from Express adapters',
})
})
}
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyPluginAsync } from 'fastify'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { vpsSchema } from '@cfdm/shared/contracts/vps'
export const vpsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/vps', async () => vpsRepository.list())
app.post('/api/vps', async (req, reply) => {
const parsed = vpsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(vpsRepository.create(parsed.data))
})
app.put<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
const parsed = vpsSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = vpsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
const ok = vpsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
app.patch('/api/vps/bulk', async (req, reply) => {
const body = req.body as { ids?: string[]; action?: string; value?: unknown }
const ids = Array.isArray(body.ids) ? body.ids : []
if (ids.length === 0) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'ids must be a non-empty array' } })
}
if (body.action === 'status') {
const validStatus = ['active', 'paused', 'archived']
const value = String(body.value ?? '')
if (!validStatus.includes(value)) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'value must be active, paused, or archived' } })
}
return { updated: vpsRepository.bulkStatus(ids, value), status: value }
}
if (body.action === 'delete') {
return { deleted: vpsRepository.bulkDelete(ids) }
}
if (body.action === 'project') {
const value = body.value == null ? '' : String(body.value)
return vpsRepository.bulkProject(ids, value)
}
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'action must be status, delete, or project' } })
})
}