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:
@@ -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()
|
||||
@@ -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' } })
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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' } })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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' } })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user