feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s

Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 15:42:54 +07:00
co-authored by Cursor
parent f26b2c8777
commit e360efb885
47 changed files with 2675 additions and 237 deletions
+2
View File
@@ -5,6 +5,8 @@ AUTH_REQUIRED=false
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
# Owner of space-main after migration (portal user id)
# VPS_MAIN_SPACE_OWNER_USER_ID=
# DB_PATH=
# PORT=3001
+4
View File
@@ -27,6 +27,8 @@ import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
import { authPlugin } from './plugins/auth.js'
import { spacePlugin } from './plugins/space.js'
import { spacesRoutes } from './routes/spaces.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -46,9 +48,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(cors, { origin: true })
await app.register(sensible)
await app.register(authPlugin)
await app.register(spacePlugin)
app.get('/health', async () => ({ ok: true }))
await app.register(spacesRoutes)
await app.register(dataRoutes)
await app.register(vpsRoutes)
await app.register(providersRoutes)
+5
View File
@@ -93,6 +93,11 @@ const RULES: Rule[] = [
match: (p) => p.startsWith('/api/sync'),
permission: 'vps:sync:write',
},
{
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
match: (p) => p.startsWith('/api/spaces'),
permission: 'vps:dashboard:read',
},
{
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
match: (p) =>
+46 -15
View File
@@ -1,5 +1,6 @@
import { timingSafeEqual } from 'node:crypto'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { runWithSpace } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
function safeEqualToken(expected: string, provided: string): boolean {
@@ -20,24 +21,54 @@ export async function requireIntegrationAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const row = settingsRepository.getRow('settings-main')
if (!row?.integrationEnabled) {
return reply.code(403).send({
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
})
}
const expected = settingsRepository.getIntegrationToken()
if (!expected) {
return reply.code(503).send({
error: { code: 'INTEGRATION_NOT_CONFIGURED', message: 'Integration token не настроен' },
})
}
const provided = extractBearer(request)
if (!safeEqualToken(expected, provided)) {
const row = settingsRepository.findByIntegrationToken(provided)
if (!row) {
// Distinguish disabled vs bad token: if any space has integration enabled without match → 401
const anyEnabled = settingsRepository
.listAllSpaces()
.some((r) => r.integrationEnabled && r.integrationToken?.trim())
if (!anyEnabled) {
return reply.code(403).send({
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
})
}
if (!provided) {
return reply.code(503).send({
error: {
code: 'INTEGRATION_NOT_CONFIGURED',
message: 'Integration token не настроен',
},
})
}
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
})
}
if (!safeEqualToken(row.integrationToken!.trim(), provided)) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
})
}
request.spaceId = row.spaceId
// Enter space context for subsequent handlers in this request
// Note: integrations route registers this as onRequest — ALS via runWithSpace won't wrap handler.
// Set header for space plugin skip path — integrations are public for portal JWT.
// Store on request; integrations-cfdm should call runWithSpace when touching DB.
;(request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId =
row.spaceId
}
export function runInIntegrationSpace<T>(
request: FastifyRequest,
fn: () => T,
): T {
const spaceId =
(request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId ??
request.spaceId ??
'space-main'
return runWithSpace(spaceId, fn)
}
+162
View File
@@ -0,0 +1,162 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import fp from 'fastify-plugin'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { MAIN_SPACE_ID } from '@cfdm/db'
import {
spacesRepository,
roleAtLeast,
type SpaceRole,
} from '@cfdm/db/repositories/spaces'
import { hasPermission } from '../lib/permissions.js'
/** Request-scoped space ALS — entered in onRequest callback form so handlers inherit it. */
const spaceAls = new AsyncLocalStorage<{ spaceId: string }>()
export function getRequestSpaceId(): string {
return spaceAls.getStore()?.spaceId ?? MAIN_SPACE_ID
}
declare module 'fastify' {
interface FastifyRequest {
spaceId?: string
spaceRole?: string
}
}
function isSpacePublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path.startsWith('/api/integrations/cfdm')) return true
return false
}
function headerSpaceId(request: FastifyRequest): string | undefined {
const raw = request.headers['x-space-id']
if (typeof raw === 'string' && raw.trim()) return raw.trim()
if (Array.isArray(raw) && raw[0]?.trim()) return raw[0].trim()
return undefined
}
export async function ensureUserSpaces(request: FastifyRequest): Promise<void> {
const user = request.authUser
if (!user) return
spacesRepository.ensurePersonalSpace(user.id, user.name || user.email)
if (user.isAdmin) {
spacesRepository.claimMainOwnerIfEmpty(user.id)
}
if (hasPermission(user.permissions, 'vps:spaces:admin')) {
const mainMember = spacesRepository.getMember(MAIN_SPACE_ID, user.id)
if (!mainMember) {
spacesRepository.addMember(MAIN_SPACE_ID, user.id, 'admin')
}
}
}
/**
* Bridge request ALS into @cfdm/db space-context by syncing store.
* Handlers use getCurrentSpaceId from @cfdm/db — we enter BOTH stores.
*/
import { runWithSpace } from '@cfdm/db'
export const spacePlugin = fp(async (app) => {
app.addHook('onRequest', (request, reply, done) => {
void (async () => {
try {
if (isSpacePublicPath(request.url) || !request.url.startsWith('/api/')) {
done()
return
}
const authRequired = app.authConfig?.required
if (authRequired && request.authUser) {
await ensureUserSpaces(request)
}
let spaceId = headerSpaceId(request) ?? MAIN_SPACE_ID
if (authRequired && request.authUser) {
const user = request.authUser
const canSpacesAdmin =
Boolean(user.isAdmin) ||
hasPermission(user.permissions, 'vps:spaces:admin')
if (!spacesRepository.canAccess(spaceId, user.id, canSpacesAdmin)) {
const personal = spacesRepository.ensurePersonalSpace(
user.id,
user.name || user.email,
)
spaceId = personal.id
if (!spacesRepository.canAccess(spaceId, user.id, canSpacesAdmin)) {
reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Нет доступа к пространству',
},
})
return
}
}
const member = spacesRepository.getMember(spaceId, user.id)
request.spaceRole =
member?.role ?? (canSpacesAdmin ? 'admin' : 'viewer')
} else {
spacesRepository.getMain()
request.spaceRole = 'owner'
}
request.spaceId = spaceId
// Enter ALS for the rest of the request (callback-style keeps context)
runWithSpace(spaceId, () => {
spaceAls.run({ spaceId }, () => {
done()
})
})
} catch (err) {
done(err as Error)
}
})()
})
})
export function requireSpaceRole(
request: FastifyRequest,
reply: FastifyReply,
min: SpaceRole,
): boolean {
const user = request.authUser
const spaceId = request.spaceId ?? MAIN_SPACE_ID
if (!user) {
return true
}
const canSpacesAdmin =
Boolean(user.isAdmin) ||
hasPermission(user.permissions, 'vps:spaces:admin')
const member = spacesRepository.requireRole(
spaceId,
user.id,
min,
canSpacesAdmin,
)
if (!member) {
void reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: `Недостаточно прав в пространстве (нужно: ${min})`,
},
})
return false
}
return true
}
export function canWriteInSpace(request: FastifyRequest): boolean {
const role = request.spaceRole ?? 'viewer'
return roleAtLeast(role, 'member')
}
+14 -9
View File
@@ -2,13 +2,17 @@ import type { FastifyPluginAsync } from 'fastify'
import { cfdmSyncBindingsBodySchema } from '@cfdm/shared/contracts/integration-cfdm'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import { requireIntegrationAuth } from '../plugins/integration-auth.js'
import {
requireIntegrationAuth,
runInIntegrationSpace,
} from '../plugins/integration-auth.js'
export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/api/integrations/cfdm/ping',
{ onRequest: requireIntegrationAuth },
async () => ({ ok: true, service: 'vps-tracker' }),
async (req) =>
runInIntegrationSpace(req, () => ({ ok: true, service: 'vps-tracker' })),
)
app.post(
@@ -22,13 +26,14 @@ export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
})
}
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
settingsRepository.touchIntegrationSync()
return {
ok: true,
...result,
}
return runInIntegrationSpace(req, () => {
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
settingsRepository.touchIntegrationSync()
return {
ok: true,
...result,
}
})
},
)
}
+18 -5
View File
@@ -1,31 +1,44 @@
import type { FastifyPluginAsync } from 'fastify'
import { settingsIdForSpace, getCurrentSpaceId } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/settings'
import { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js'
import { deliverWebhook } from '../services/notifications/channels.js'
import { requireSpaceRole } from '../plugins/space.js'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
app.post('/api/settings', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'admin')) return
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'
const result = settingsRepository.upsert(id, parsed.data)
const spaceId = getCurrentSpaceId()
const id =
(req.body as { id?: string })?.id ?? settingsIdForSpace(spaceId)
const result = settingsRepository.upsertForSpace(spaceId, {
...parsed.data,
})
// Keep id stable
if (result.id !== id) {
/* upsertForSpace picks correct id */
}
restartScheduler()
return reply.code(201).send(result)
})
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'admin')) return
const parsed = settingsSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const result = settingsRepository.upsert(req.params.id, parsed.data)
const spaceId = getCurrentSpaceId()
const result = settingsRepository.upsertForSpace(spaceId, parsed.data)
restartScheduler()
return result
})
@@ -33,7 +46,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/settings/telegram/test', async (req) => {
const parsed = telegramTestBodySchema.safeParse(req.body ?? {})
const body = parsed.success ? parsed.data : {}
const settings = settingsRepository.getRow('settings-main')
const settings = settingsRepository.getBySpace(getCurrentSpaceId())
const token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || ''
const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || ''
@@ -55,7 +68,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
})
app.post('/api/settings/webhook/test', async () => {
const settings = settingsRepository.getRow('settings-main')
const settings = settingsRepository.getBySpace(getCurrentSpaceId())
if (!settings?.webhookEnabled) {
return { ok: false, error: 'Включите webhook в настройках' }
}
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { spacesRepository, vpsGrantsRepository } from '@cfdm/db/repositories/spaces'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { buildApp } from '../index.js'
describe('spaces API', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider()
seedTestProviderAccount()
spacesRepository.getMain()
})
afterEach(() => {
closeDb()
})
it('lists spaces and creates personal space', async () => {
const app = await buildApp()
const res = await app.inject({ method: 'GET', url: '/api/spaces' })
expect(res.statusCode).toBe(200)
const list = res.json() as { id: string }[]
expect(list.some((s) => s.id === MAIN_SPACE_ID)).toBe(true)
await app.close()
})
it('shares and assigns VPS between spaces', async () => {
const personal = spacesRepository.create({
id: 'space-user-u1',
name: 'User 1',
slug: 'user-u1',
kind: 'personal',
ownerUserId: 'u1',
})
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '1.2.3.4',
providerId: 'prov-1',
providerAccountId: 'acc-1',
status: 'active',
}),
)
const grant = vpsGrantsRepository.create({
vpsId: vps.id,
fromSpaceId: MAIN_SPACE_ID,
toSpaceId: personal.id,
permission: 'write',
grantedByUserId: 'admin',
})
expect(grant.permission).toBe('write')
const sharedList = runWithSpace(personal.id, () => {
const grants = vpsGrantsRepository.listToSpace(personal.id)
return vpsRepository.listByIds(grants.map((g) => g.vpsId))
})
expect(sharedList).toHaveLength(1)
expect(sharedList[0]?.id).toBe(vps.id)
vpsGrantsRepository.deleteByVps(vps.id)
const moved = vpsRepository.assignToSpace(vps.id, personal.id)
expect(moved?.spaceId).toBe(personal.id)
expect(moved?.providerAccountId).toBeFalsy()
const stillInMain = runWithSpace(MAIN_SPACE_ID, () => vpsRepository.get(vps.id))
expect(stillInMain).toBeUndefined()
const inPersonal = runWithSpace(personal.id, () => vpsRepository.get(vps.id))
expect(inPersonal?.id).toBe(vps.id)
})
it('share endpoint via inject', async () => {
const personal = spacesRepository.create({
id: 'space-user-u2',
name: 'User 2',
slug: 'user-u2',
kind: 'personal',
ownerUserId: 'u2',
})
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '10.0.0.1',
providerId: 'prov-1',
providerAccountId: 'acc-1',
status: 'active',
}),
)
// Same process DB as resetTestDb (:memory: already set)
const app = await buildApp()
const res = await app.inject({
method: 'POST',
url: `/api/spaces/${MAIN_SPACE_ID}/vps/${vps.id}/share`,
headers: { 'x-space-id': MAIN_SPACE_ID },
payload: { toSpaceId: personal.id, permission: 'read' },
})
expect(res.statusCode).toBe(201)
const body = res.json() as { toSpaceId: string; permission: string }
expect(body.toSpaceId).toBe(personal.id)
expect(body.permission).toBe('read')
await app.close()
})
})
+256
View File
@@ -0,0 +1,256 @@
import type { FastifyPluginAsync } from 'fastify'
import { MAIN_SPACE_ID } from '@cfdm/db'
import {
spacesRepository,
vpsGrantsRepository,
type SpaceRole,
type GrantPermission,
} from '@cfdm/db/repositories/spaces'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { hasPermission } from '../lib/permissions.js'
import { requireSpaceRole } from '../plugins/space.js'
const ROLES: SpaceRole[] = ['owner', 'admin', 'member', 'viewer']
function isSpacesAdmin(request: { authUser?: { isAdmin?: boolean; permissions: string[] } }) {
const u = request.authUser
if (!u) return true
return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin')
}
export const spacesRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/spaces', async (req) => {
const user = req.authUser
if (!user) {
return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' }))
}
return spacesRepository.listForUser(user.id, isSpacesAdmin(req))
})
app.post('/api/spaces', async (req, reply) => {
const user = req.authUser
if (!user) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const body = req.body as { name?: string; slug?: string }
const name = String(body.name ?? '').trim() || 'Новое пространство'
const slug =
String(body.slug ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-') || `space-${Date.now()}`
const created = spacesRepository.create({
name,
slug,
kind: 'personal',
ownerUserId: user.id,
})
return reply.code(201).send({ ...created, role: 'owner' })
})
app.get<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
const space = spacesRepository.get(req.params.id)
if (!space) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
const user = req.authUser
if (user && !spacesRepository.canAccess(space.id, user.id, isSpacesAdmin(req))) {
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
}
const member = user
? spacesRepository.getMember(space.id, user.id)
: { role: 'owner' }
return { ...space, role: member?.role ?? 'viewer' }
})
app.patch<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
// Temporarily set space for role check
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { name?: string; slug?: string }
const updated = spacesRepository.update(req.params.id, {
...(body.name !== undefined ? { name: String(body.name).trim() } : {}),
...(body.slug !== undefined
? { slug: String(body.slug).trim().toLowerCase() }
: {}),
})
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/members',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'viewer')) return
return spacesRepository.listMembers(req.params.id)
},
)
app.post<{ Params: { id: string } }>(
'/api/spaces/:id/members',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { userId?: string; role?: string }
const userId = String(body.userId ?? '').trim()
if (!userId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'userId обязателен' },
})
}
const role = (ROLES.includes(body.role as SpaceRole)
? body.role
: 'member') as SpaceRole
if (role === 'owner') {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Нельзя назначить owner через invite' },
})
}
const member = spacesRepository.addMember(req.params.id, userId, role)
return reply.code(201).send(member)
},
)
app.patch<{ Params: { id: string; userId: string } }>(
'/api/spaces/:id/members/:userId',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { role?: string }
const role = body.role as SpaceRole
if (!ROLES.includes(role) || role === 'owner') {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Некорректная роль' },
})
}
const updated = spacesRepository.updateMember(
req.params.id,
req.params.userId,
role,
)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
},
)
app.delete<{ Params: { id: string; userId: string } }>(
'/api/spaces/:id/members/:userId',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const member = spacesRepository.getMember(req.params.id, req.params.userId)
if (member?.role === 'owner') {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Нельзя удалить владельца' },
})
}
const ok = spacesRepository.removeMember(req.params.id, req.params.userId)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
},
)
/** Share VPS from current ownership space to target space */
app.post<{ Params: { id: string; vpsId: string } }>(
'/api/spaces/:id/vps/:vpsId/share',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { toSpaceId?: string; permission?: string }
const toSpaceId = String(body.toSpaceId ?? '').trim()
if (!toSpaceId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'toSpaceId обязателен' },
})
}
if (!spacesRepository.get(toSpaceId)) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'Целевое пространство не найдено' },
})
}
const vps = vpsRepository.getAnySpace(req.params.vpsId)
if (!vps || vps.spaceId !== req.params.id) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'VPS не найден в этом пространстве' },
})
}
const permission: GrantPermission =
body.permission === 'write' ? 'write' : 'read'
const grant = vpsGrantsRepository.create({
vpsId: req.params.vpsId,
fromSpaceId: req.params.id,
toSpaceId,
permission,
grantedByUserId: req.authUser?.id ?? null,
})
return reply.code(201).send(grant)
},
)
app.delete<{ Params: { id: string; grantId: string } }>(
'/api/spaces/:id/vps-grants/:grantId',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const grant = vpsGrantsRepository.get(req.params.grantId)
if (!grant || (grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
vpsGrantsRepository.delete(req.params.grantId)
return reply.code(204).send()
},
)
/** Assign (move) VPS to target space */
app.post<{ Params: { id: string; vpsId: string } }>(
'/api/spaces/:id/vps/:vpsId/assign',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { toSpaceId?: string }
const toSpaceId = String(body.toSpaceId ?? '').trim()
if (!toSpaceId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'toSpaceId обязателен' },
})
}
if (!spacesRepository.get(toSpaceId)) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'Целевое пространство не найдено' },
})
}
const vps = vpsRepository.getAnySpace(req.params.vpsId)
if (!vps || vps.spaceId !== req.params.id) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'VPS не найден в этом пространстве' },
})
}
vpsGrantsRepository.deleteByVps(req.params.vpsId)
const moved = vpsRepository.assignToSpace(req.params.vpsId, toSpaceId)
return moved
},
)
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/vps-grants',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'viewer')) return
return {
incoming: vpsGrantsRepository.listToSpace(req.params.id),
outgoing: vpsGrantsRepository.listFromSpace(req.params.id),
}
},
)
}
export { MAIN_SPACE_ID }
+66 -7
View File
@@ -1,13 +1,41 @@
import type { FastifyPluginAsync } from 'fastify'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import { vpsGrantsRepository } from '@cfdm/db/repositories/spaces'
import { getCurrentSpaceId } from '@cfdm/db'
import { vpsSchema } from '@cfdm/shared/contracts/vps'
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js'
export const vpsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/vps', async () => vpsRepository.list())
app.get('/api/vps', async () => {
const owned = vpsRepository.list()
const spaceId = getCurrentSpaceId()
const grants = vpsGrantsRepository.listToSpace(spaceId)
const ownedIds = new Set(owned.map((v) => v.id))
const shared = vpsRepository
.listByIds(grants.map((g) => g.vpsId).filter((id) => !ownedIds.has(id)))
.map((v) => {
const g = grants.find((x) => x.vpsId === v.id)
return {
...v,
access: 'shared' as const,
grantPermission: (g?.permission === 'write' ? 'write' : 'read') as
| 'read'
| 'write',
providerAccountId: '',
}
})
return [...owned, ...shared]
})
app.post('/api/vps', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const parsed = vpsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
@@ -24,18 +52,48 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
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' } })
const owned = vpsRepository.get(req.params.id)
if (owned) {
if (!requireSpaceRole(req, reply, 'member')) return
const updated = vpsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
vpsDomainsRepository.rematchAll()
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return updated
}
vpsDomainsRepository.rematchAll()
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return updated
// Shared write?
const grant = vpsGrantsRepository.getGrantInCurrentSpace(req.params.id)
if (grant?.permission === 'write') {
if (!requireSpaceRole(req, reply, 'member')) return
const updated = vpsRepository.updateAnySpace(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return { ...updated, access: 'shared', grantPermission: 'write' }
}
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
const ok = vpsRepository.delete(req.params.id)
if (!ok) {
// Shared VPS cannot be deleted from grantee space
const grant = vpsGrantsRepository.getGrantInCurrentSpace(req.params.id)
if (grant) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Общий VPS можно только отозвать у владельца, не удалить',
},
})
}
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
auditDelete('vps', req.params.id)
@@ -43,6 +101,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
})
app.patch('/api/vps/bulk', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
const body = req.body as { ids?: string[]; action?: string; value?: unknown }
const ids = Array.isArray(body.ids) ? body.ids : []
if (ids.length === 0) {
+2 -4
View File
@@ -2,10 +2,8 @@ import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm'
const SETTINGS_ID = 'settings-main'
function resolveCfdmApiBase(): string | null {
const row = settingsRepository.getRow(SETTINGS_ID)
const row = settingsRepository.getBySpace()
if (!row) return null
const explicit = row.cfdmApiUrl?.trim()
if (explicit) return explicit.replace(/\/$/, '')
@@ -19,7 +17,7 @@ export async function notifyCfdmVpsEvent(
): Promise<void> {
if (vpsIds.length === 0) return
const row = settingsRepository.getRow(SETTINGS_ID)
const row = settingsRepository.getBySpace()
if (!row?.integrationEnabled) return
const token = settingsRepository.getIntegrationToken()
@@ -22,6 +22,7 @@ import {
const account: FourvpsSyncAccount = {
id: 'acc-4vps',
spaceId: 'space-main',
providerId: 'prov-4vps',
name: '4VPS Account',
panelUrl: '',
+4 -6
View File
@@ -1,9 +1,5 @@
/**
* Generic account sync with sync_log recording
*/
import { eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getDb, schema, getCurrentSpaceId } from '@cfdm/db'
import type { ProviderAdapter, SyncResult } from './types.js'
@@ -18,12 +14,14 @@ export async function runAccountSync(
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
): Promise<RunAccountSyncResult> {
const db = getDb()
const accountRow = account as { id: string }
const accountRow = account as { id: string; spaceId?: string }
const logId = `sync-${accountRow.id}-${Date.now()}`
const spaceId = accountRow.spaceId ?? getCurrentSpaceId()
db.insert(schema.syncLog)
.values({
id: logId,
spaceId,
accountId: accountRow.id,
startedAt: new Date().toISOString(),
status: 'running',
+1
View File
@@ -33,6 +33,7 @@ import {
function makeAccount(): RuvdsSyncAccount {
return {
id: 'acc-ruvds',
spaceId: 'space-main',
providerId: 'prov-ruvds',
name: 'RuVDS',
panelUrl: '',
+184 -111
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq, sql } from 'drizzle-orm'
import { getDb, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
@@ -20,26 +20,30 @@ let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type SettingsRow = typeof schema.settings.$inferSelect
interface SyncableAccountEntry {
account: SyncReadyAccount
apiType: string
}
function getSyncableAccounts(): SyncableAccountEntry[] {
function getSyncableAccounts(spaceId: string): SyncableAccountEntry[] {
const db = getDb()
const rows = db
.all<AccountRow>(sql`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
WHERE pa.spaceId = ${spaceId}
AND lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
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 providers = db
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.all()
const providerById = new Map(providers.map((p) => [p.id, p]))
return rows
.map((a) => {
@@ -49,123 +53,184 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
.filter((e): e is SyncableAccountEntry => e != null)
}
function allSettings(): SettingsRow[] {
return settingsRepository.listAllSpaces()
}
export async function runNotificationTick(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
} catch (err) {
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
})
} catch (err) {
console.warn(
`Notification tick error [${spaceId}]:`,
err instanceof Error ? err.message : err,
)
}
}
}
export async function runScheduledSync(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
const digestLines: string[] = []
const lowBalanceLines: string[] = []
const entries = getSyncableAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
for (const { account, apiType } of entries) {
try {
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`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(
`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`,
)
for (const { account, apiType } of entries) {
try {
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`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur =
result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
} catch (err) {
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
})
} catch (err) {
console.warn(`Scheduled sync error [${spaceId}]:`, err instanceof Error ? err.message : err)
}
}
}
export async function runScheduledSyncTariffs(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
const providers = getDb()
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.all()
const entries = getSyncableAccounts()
const providers = getDb().select().from(schema.providers).all()
for (const { account, apiType } of entries) {
try {
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)
const providerName = provider?.name || account.name || '-'
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
for (const { account, apiType } of entries) {
try {
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)
const providerName = provider?.name || account.name || '-'
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
}
} catch (err) {
console.warn(
`Sync tariffs failed for account ${account.id}:`,
err instanceof Error ? err.message : err,
)
}
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
}
})
} catch (err) {
console.warn(
`Scheduled sync tariffs error [${spaceId}]:`,
err instanceof Error ? err.message : err,
)
}
} catch (err) {
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
}
}
export async function runScheduledUptimeChecks(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
}
})
} catch (err) {
console.warn(`Uptime check error [${spaceId}]:`, err instanceof Error ? err.message : err)
}
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}
}
function pickSchedulerIntervals(rows: SettingsRow[]): {
notifyInterval: number
uptimeInterval: number
syncInterval: number | null
tariffsInterval: number | null
} {
let notifyInterval = 60
let uptimeInterval = 5
let syncInterval: number | null = null
let tariffsInterval: number | null = null
for (const s of rows) {
notifyInterval = Math.min(
notifyInterval,
Math.max(15, Number(s.notifyIntervalMinutes) || 60),
)
uptimeInterval = Math.min(
uptimeInterval,
Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
)
if (s.syncEnabled) {
const si = Math.max(15, Number(s.syncIntervalMinutes) || 60)
const ti = Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440)
syncInterval = syncInterval == null ? si : Math.min(syncInterval, si)
tariffsInterval = tariffsInterval == null ? ti : Math.min(tariffsInterval, ti)
}
}
return { notifyInterval, uptimeInterval, syncInterval, tariffsInterval }
}
export function startScheduler(): void {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
@@ -177,28 +242,36 @@ export function startScheduler(): void {
uptimeIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const rows = allSettings()
if (rows.length === 0) return
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
const { notifyInterval, uptimeInterval, syncInterval, tariffsInterval } =
pickSchedulerIntervals(rows)
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
uptimeIntervalId = setInterval(
() => void runScheduledUptimeChecks(),
uptimeInterval * 60 * 1000,
)
void runNotificationTick()
void runScheduledUptimeChecks()
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
const parts = [
`notify every ${notifyInterval} min`,
`uptime every ${uptimeInterval} min`,
`spaces=${rows.length}`,
]
if (settings.syncEnabled) {
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
if (syncInterval != null) {
syncIntervalId = setInterval(() => void runScheduledSync(), syncInterval * 60 * 1000)
parts.unshift(`sync every ${syncInterval} min`)
}
if (tariffsInterval != null) {
syncTariffsIntervalId = setInterval(
() => void runScheduledSyncTariffs(),
tariffsInterval * 60 * 1000,
)
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
parts.unshift(`tariffs every ${tariffsInterval} min`)
}
console.log(`Scheduler: ${parts.join(', ')}`)
@@ -29,6 +29,7 @@ import {
function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
return {
id: `acc-${apiType}`,
spaceId: 'space-main',
providerId: `prov-${apiType}`,
name: `${apiType} Account`,
panelUrl: '',
+1
View File
@@ -22,6 +22,7 @@ import { fetchBalance, fetchInvoices, fetchTariffList, fetchVpsRecords } from '.
function makeAccount(): VeespSyncAccount {
return {
id: 'acc-veesp',
spaceId: 'space-main',
providerId: 'prov-veesp',
name: 'Veesp',
panelUrl: '',
@@ -12,6 +12,7 @@ import {
RefreshCwIcon,
FolderKanbanIcon,
HistoryIcon,
UsersIcon,
} from 'lucide-react'
import {
@@ -48,6 +49,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SpaceSwitcher } from '@/components/layout/space-switcher'
import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
@@ -101,6 +103,7 @@ const NAV_GROUPS: NavGroup[] = [
{
label: 'Система',
items: [
{ to: '/spaces', label: 'Пространство', icon: UsersIcon },
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
{ to: '/audit', label: 'Журнал изменений', icon: HistoryIcon },
{ to: '/settings', label: 'Настройки', icon: Settings },
@@ -127,6 +130,7 @@ const PARENT_ROUTE: Record<string, string> = {
'/renewals': '/dashboard',
'/sync-journal': '/settings',
'/audit': '/settings',
'/spaces': '/settings',
}
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
@@ -169,6 +173,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
<SpaceSwitcher />
</SidebarHeader>
<SidebarContent>
{navGroups.map((group) => (
@@ -0,0 +1,139 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@cfdm/ui/components/dialog'
import { Input } from '@cfdm/ui/components/input'
import { Label } from '@cfdm/ui/components/label'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@cfdm/ui/components/sidebar'
import { api } from '@/lib/api-client'
import { getStoredSpaceId, setStoredSpaceId, type SpaceDto } from '@/lib/space'
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
export function SpaceSwitcher() {
const qc = useQueryClient()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const currentId = getStoredSpaceId() ?? spaces[0]?.id
const current = spaces.find((s) => s.id === currentId) ?? spaces[0]
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
useEffect(() => {
if (!getStoredSpaceId() && spaces[0]?.id) {
setStoredSpaceId(spaces[0].id)
}
}, [spaces])
function selectSpace(space: SpaceDto) {
setStoredSpaceId(space.id)
void qc.invalidateQueries({ queryKey: snapshotKeys.all })
void qc.invalidateQueries({ queryKey: spacesKeys.all })
toast.success(`Пространство: ${space.name}`)
}
async function handleCreate() {
const n = name.trim()
if (!n) return
try {
const created = await api.createSpace({ name: n })
setStoredSpaceId(created.id)
setCreateOpen(false)
setName('')
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
toast.success('Пространство создано')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Ошибка создания')
}
}
if (spaces.length === 0) {
return (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Пространства</div>
)
}
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />}
>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{current?.name ?? 'Пространство'}</span>
<span className="truncate text-xs text-muted-foreground">
{current?.kind === 'main' ? 'Основное' : 'Личное'}
{current?.role ? ` · ${current.role}` : ''}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56 rounded-lg" align="start" sideOffset={4}>
<DropdownMenuLabel>Пространства</DropdownMenuLabel>
{spaces.map((s) => (
<DropdownMenuItem
key={s.id}
onClick={() => selectSpace(s)}
className={s.id === current?.id ? 'bg-accent' : undefined}
>
<span className="truncate">{s.name}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Создать пространство
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Новое пространство</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="space-name">Название</Label>
<Input
id="space-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Моя команда"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Отмена
</Button>
<Button onClick={() => void handleCreate()}>Создать</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import { Label } from '@cfdm/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { api } from '@/lib/api-client'
import { getStoredSpaceId } from '@/lib/space'
import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
import type { Vps } from '@/types/entities'
type Props = {
vps: Vps | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function VpsAccessSheet({ vps, open, onOpenChange }: Props) {
const qc = useQueryClient()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const fromSpaceId = getStoredSpaceId() ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
const targets = spaces.filter((s) => s.id !== fromSpaceId)
const [toSpaceId, setToSpaceId] = useState('')
const [permission, setPermission] = useState<'read' | 'write'>('read')
const shareMutation = useMutation({
mutationFn: () =>
api.shareVps(fromSpaceId, vps!.id, {
toSpaceId,
permission,
}),
onSuccess: async () => {
toast.success('Доступ выдан (share)')
onOpenChange(false)
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
},
onError: (e: Error) => toast.error(e.message),
})
const assignMutation = useMutation({
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId),
onSuccess: async () => {
toast.success('Сервер перенесён (assign)')
onOpenChange(false)
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
},
onError: (e: Error) => toast.error(e.message),
})
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Доступ к серверу</SheetTitle>
<SheetDescription>
{vps ? `${vps.ip || vps.dns || vps.id}` : ''}
{' — share оставляет запись здесь; assign переносит в другое пространство.'}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2">
<Label>Целевое пространство</Label>
<Select value={toSpaceId} onValueChange={(v) => setToSpaceId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Выберите пространство" />
</SelectTrigger>
<SelectContent>
{targets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Права (для share)</Label>
<Select
value={permission}
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">read</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
</div>
<SheetFooter className="flex-col gap-2 sm:flex-col">
<Button
disabled={!toSpaceId || !vps || shareMutation.isPending}
onClick={() => shareMutation.mutate()}
>
Share (ACL)
</Button>
<Button
variant="outline"
disabled={!toSpaceId || !vps || assignMutation.isPending}
onClick={() => {
if (
!window.confirm(
'Перенести сервер? Привязка к аккаунту провайдера будет сброшена.',
)
) {
return
}
assignMutation.mutate()
}}
>
Assign (перенос)
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+64
View File
@@ -9,6 +9,7 @@ import type {
BalanceLedgerRow,
} from '@/types/entities'
import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
import { getStoredSpaceId } from '@/lib/space'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -32,6 +33,10 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const spaceId = getStoredSpaceId()
if (spaceId && !headers.has('X-Space-Id')) {
headers.set('X-Space-Id', spaceId)
}
const res = await fetch(url, {
...options,
headers,
@@ -161,6 +166,8 @@ export const api = {
const headers = new Headers()
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const spaceId = getStoredSpaceId()
if (spaceId) headers.set('X-Space-Id', spaceId)
const res = await fetch(`${API_BASE}/api/backup/json`, { headers })
if (!res.ok) {
if (res.status === 401) {
@@ -179,6 +186,8 @@ export const api = {
const headers = new Headers()
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const spaceId = getStoredSpaceId()
if (spaceId) headers.set('X-Space-Id', spaceId)
const res = await fetch(`${API_BASE}/api/backup/database`, { headers })
if (!res.ok) {
if (res.status === 401) {
@@ -193,6 +202,61 @@ export const api = {
return res.blob()
},
fetchSpaces: () => fetchApi<import('@/lib/space').SpaceDto[]>('/api/spaces'),
createSpace: (body: { name: string; slug?: string }) =>
fetchApi<import('@/lib/space').SpaceDto>('/api/spaces', {
method: 'POST',
body: JSON.stringify(body),
}),
fetchSpaceMembers: (spaceId: string) =>
fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>(
`/api/spaces/${encodeURIComponent(spaceId)}/members`,
),
addSpaceMember: (spaceId: string, body: { userId: string; role?: string }) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateSpaceMember: (spaceId: string, userId: string, role: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify({ role }),
}),
removeSpaceMember: (spaceId: string, userId: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
}),
shareVps: (
fromSpaceId: string,
vpsId: string,
body: { toSpaceId: string; permission: 'read' | 'write' },
) =>
fetchApi(`/api/spaces/${encodeURIComponent(fromSpaceId)}/vps/${encodeURIComponent(vpsId)}/share`, {
method: 'POST',
body: JSON.stringify(body),
}),
assignVps: (fromSpaceId: string, vpsId: string, toSpaceId: string) =>
fetchApi(
`/api/spaces/${encodeURIComponent(fromSpaceId)}/vps/${encodeURIComponent(vpsId)}/assign`,
{
method: 'POST',
body: JSON.stringify({ toSpaceId }),
},
),
fetchSpaceGrants: (spaceId: string) =>
fetchApi<{
incoming: unknown[]
outgoing: unknown[]
}>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`),
importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
+5 -1
View File
@@ -199,7 +199,11 @@ export function permissionForPath(pathname: string): string | null {
return 'vps:payments:read'
}
if (pathname.startsWith('/sync-journal')) return 'vps:sync:write'
if (pathname.startsWith('/settings') || pathname.startsWith('/audit')) {
if (
pathname.startsWith('/settings') ||
pathname.startsWith('/audit') ||
pathname.startsWith('/spaces')
) {
return 'vps:settings:admin'
}
return 'vps:dashboard:read'
+35
View File
@@ -0,0 +1,35 @@
const STORAGE_KEY = 'vps_space_id'
export type SpaceDto = {
id: string
name: string
slug: string
kind: string
ownerUserId: string | null
createdAt: string
role?: string
}
export function getStoredSpaceId(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
export function setStoredSpaceId(id: string): void {
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
/* ignore */
}
}
export function clearStoredSpaceId(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}
+14 -1
View File
@@ -1,16 +1,29 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
import { getStoredSpaceId } from '../lib/space'
export const snapshotKeys = {
all: ['snapshot'] as const,
space: (spaceId: string | null) => ['snapshot', spaceId ?? 'default'] as const,
}
export const snapshotQueryOptions = () => ({
queryKey: snapshotKeys.all,
queryKey: snapshotKeys.space(getStoredSpaceId()),
queryFn: () => api.fetchData(),
staleTime: 30_000,
})
export const spacesKeys = {
all: ['spaces'] as const,
members: (spaceId: string) => ['spaces', spaceId, 'members'] as const,
}
export const spacesQueryOptions = () => ({
queryKey: spacesKeys.all,
queryFn: () => api.fetchSpaces(),
staleTime: 60_000,
})
export const ratesKeys = {
all: ['rates'] as const,
}
+21
View File
@@ -15,6 +15,7 @@ import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
import { Route as AuthSpacesRouteImport } from './routes/_auth/spaces'
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
@@ -60,6 +61,11 @@ const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
path: '/sync-journal',
getParentRoute: () => AuthRoute,
} as any)
const AuthSpacesRoute = AuthSpacesRouteImport.update({
id: '/spaces',
path: '/spaces',
getParentRoute: () => AuthRoute,
} as any)
const AuthResourcesRoute = AuthResourcesRouteImport.update({
id: '/resources',
path: '/resources',
@@ -150,6 +156,7 @@ export interface FileRoutesByFullPath {
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/spaces': typeof AuthSpacesRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
@@ -171,6 +178,7 @@ export interface FileRoutesByTo {
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/spaces': typeof AuthSpacesRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
@@ -195,6 +203,7 @@ export interface FileRoutesById {
'/_auth/renewals': typeof AuthRenewalsRoute
'/_auth/reports': typeof AuthReportsRoute
'/_auth/resources': typeof AuthResourcesRoute
'/_auth/spaces': typeof AuthSpacesRoute
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren
@@ -219,6 +228,7 @@ export interface FileRouteTypes {
| '/renewals'
| '/reports'
| '/resources'
| '/spaces'
| '/sync-journal'
| '/tariffs'
| '/vps'
@@ -240,6 +250,7 @@ export interface FileRouteTypes {
| '/renewals'
| '/reports'
| '/resources'
| '/spaces'
| '/sync-journal'
| '/tariffs'
| '/vps'
@@ -263,6 +274,7 @@ export interface FileRouteTypes {
| '/_auth/renewals'
| '/_auth/reports'
| '/_auth/resources'
| '/_auth/spaces'
| '/_auth/sync-journal'
| '/_auth/tariffs'
| '/_auth/vps'
@@ -323,6 +335,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSyncJournalRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/spaces': {
id: '/_auth/spaces'
path: '/spaces'
fullPath: '/spaces'
preLoaderRoute: typeof AuthSpacesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/resources': {
id: '/_auth/resources'
path: '/resources'
@@ -479,6 +498,7 @@ interface AuthRouteChildren {
AuthRenewalsRoute: typeof AuthRenewalsRoute
AuthReportsRoute: typeof AuthReportsRoute
AuthResourcesRoute: typeof AuthResourcesRoute
AuthSpacesRoute: typeof AuthSpacesRoute
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthVpsRoute: typeof AuthVpsRouteWithChildren
@@ -496,6 +516,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRenewalsRoute: AuthRenewalsRoute,
AuthReportsRoute: AuthReportsRoute,
AuthResourcesRoute: AuthResourcesRoute,
AuthSpacesRoute: AuthSpacesRoute,
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthVpsRoute: AuthVpsRouteWithChildren,
+166
View File
@@ -0,0 +1,166 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Label } from '@cfdm/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { api } from '@/lib/api-client'
import { getStoredSpaceId } from '@/lib/space'
import { spacesKeys, spacesQueryOptions } from '@/queries/snapshot'
export const Route = createFileRoute('/_auth/spaces')({
component: SpacesPage,
})
type MemberRow = {
spaceId: string
userId: string
role: string
createdAt: string
}
function SpacesPage() {
const qc = useQueryClient()
const spaceId = getStoredSpaceId()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const current = spaces.find((s) => s.id === spaceId) ?? spaces[0]
const currentId = current?.id ?? ''
const membersQuery = useQuery({
queryKey: spacesKeys.members(currentId),
queryFn: () => api.fetchSpaceMembers(currentId),
enabled: Boolean(currentId),
})
const [userId, setUserId] = useState('')
const [role, setRole] = useState('member')
const addMutation = useMutation({
mutationFn: () =>
api.addSpaceMember(currentId, { userId: userId.trim(), role }),
onSuccess: async () => {
setUserId('')
toast.success('Участник добавлен')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
const removeMutation = useMutation({
mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid),
onSuccess: async () => {
toast.success('Участник удалён')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
return (
<PageShell>
<PageHeader
title="Пространство"
description={
current
? `${current.name} (${current.kind === 'main' ? 'основное' : 'личное'})`
: 'Участники и доступ'
}
/>
<QueryState
data={membersQuery.data as MemberRow[] | undefined}
isLoading={membersQuery.isLoading}
isError={membersQuery.isError}
error={membersQuery.error}
onRetry={() => void membersQuery.refetch()}
empty={Boolean(membersQuery.data && membersQuery.data.length === 0)}
emptyTitle="Нет участников"
emptyDescription="Добавьте userId из auth-portal"
>
{(members) => (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-end">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="member-user-id">User ID (из auth-portal)</Label>
<Input
id="member-user-id"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="uuid пользователя"
/>
</div>
<div className="flex w-full flex-col gap-2 md:w-40">
<Label>Роль</Label>
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="member">member</SelectItem>
<SelectItem value="viewer">viewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button
disabled={!userId.trim() || addMutation.isPending}
onClick={() => addMutation.mutate()}
>
Добавить
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead>Роль</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={`${m.spaceId}-${m.userId}`}>
<TableCell className="font-mono text-xs">{m.userId}</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
{m.role !== 'owner' ? (
<Button
variant="outline"
size="sm"
onClick={() => removeMutation.mutate(m.userId)}
>
Удалить
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</QueryState>
</PageShell>
)
}
+34 -6
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo, useEffect } from 'react'
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon } from 'lucide-react'
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon, Share2Icon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
@@ -32,6 +32,7 @@ import { HealthModeBanner } from '@/components/health-mode-banner'
import { ProjectColorDot } from '@/components/project-color-dot'
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
import { VpsAccessSheet } from '@/components/vps-access-sheet'
import type { Vps } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
@@ -66,6 +67,8 @@ function VpsPage() {
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [accessVps, setAccessVps] = useState<Vps | null>(null)
const [accessOpen, setAccessOpen] = useState(false)
useEffect(() => {
if (!health) return
@@ -414,9 +417,14 @@ function VpsPage() {
header: 'Статус',
icon: CircleDotIcon,
cell: (v) => (
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
<div className="flex items-center gap-1.5">
{v.access === 'shared' ? (
<Badge variant="outline">Общий</Badge>
) : null}
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
</div>
),
},
{
@@ -490,10 +498,25 @@ function VpsPage() {
className: 'w-24 text-right',
cell: (v) => (
<RowActions
onEdit={() => openEdit(v)}
onDelete={() => deleteMutation.mutate(v.id)}
onEdit={v.access === 'shared' && v.grantPermission !== 'write' ? undefined : () => openEdit(v)}
onDelete={v.access === 'shared' ? undefined : () => deleteMutation.mutate(v.id)}
deleteTitle="Удалить VPS?"
deleteDescription={`IP ${v.ip} будет удалён безвозвратно.`}
extra={
v.access !== 'shared' ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="Доступ"
onClick={() => {
setAccessVps(v)
setAccessOpen(true)
}}
>
<Share2Icon />
</Button>
) : null
}
/>
),
},
@@ -661,6 +684,11 @@ function VpsPage() {
submitting={createMutation.isPending || updateMutation.isPending}
/>
) : null}
<VpsAccessSheet
vps={accessVps}
open={accessOpen}
onOpenChange={setAccessOpen}
/>
</PageShell>
)
}
+3
View File
@@ -83,6 +83,9 @@ export interface Vps {
paidUntil?: string
notes?: string
customData?: string | Record<string, string | number | boolean>
access?: 'owned' | 'shared'
grantPermission?: 'read' | 'write'
spaceId?: string
}
export interface Payment {
+7
View File
@@ -54,6 +54,13 @@ export function reloadDatabaseFromBuffer(buffer: Buffer): void {
}
export { schema }
export {
MAIN_SPACE_ID,
getCurrentSpaceId,
runWithSpace,
runWithSpaceAsync,
settingsIdForSpace,
} from './space-context.js'
export {
consolidateAllProviderApiSources,
consolidateProviderApiFromAccounts,
+14 -1
View File
@@ -1,12 +1,14 @@
import { randomUUID } from 'node:crypto'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export interface AuditEntryInput {
entity: string
entityId: string
action: 'create' | 'update' | 'delete'
diff?: Record<string, unknown>
actorUserId?: string | null
}
function parseDiff(row: { diff: string | null }) {
@@ -24,19 +26,23 @@ export const auditLogRepository = {
.insert(schema.auditLog)
.values({
id: `audit-${randomUUID()}`,
spaceId: getCurrentSpaceId(),
entity: input.entity,
entityId: input.entityId,
action: input.action,
diff: input.diff ? JSON.stringify(input.diff) : null,
actorUserId: input.actorUserId ?? null,
createdAt: new Date().toISOString(),
})
.run()
},
list(limit = 100) {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.auditLog)
.where(eq(schema.auditLog.spaceId, spaceId))
.orderBy(desc(schema.auditLog.createdAt))
.limit(Math.min(500, Math.max(1, limit)))
.all()
@@ -44,10 +50,17 @@ export const auditLogRepository = {
},
listForEntity(entity: string, entityId: string, limit = 50) {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.auditLog)
.where(and(eq(schema.auditLog.entity, entity), eq(schema.auditLog.entityId, entityId)))
.where(
and(
eq(schema.auditLog.spaceId, spaceId),
eq(schema.auditLog.entity, entity),
eq(schema.auditLog.entityId, entityId),
),
)
.orderBy(desc(schema.auditLog.createdAt))
.limit(limit)
.all()
+27 -7
View File
@@ -1,5 +1,6 @@
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
type Row = typeof schema.balanceLedger.$inferSelect
@@ -24,14 +25,28 @@ function normalize(input: Partial<Row>) {
export const balanceLedgerRepository = {
list(): Row[] {
return getDb().select().from(schema.balanceLedger).orderBy(desc(schema.balanceLedger.date)).all()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.balanceLedger)
.where(eq(schema.balanceLedger.spaceId, spaceId))
.orderBy(desc(schema.balanceLedger.date))
.all()
},
get(id: string): Row | undefined {
return getDb().select().from(schema.balanceLedger).where(eq(schema.balanceLedger.id, id)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.balanceLedger)
.where(and(eq(schema.balanceLedger.id, id), eq(schema.balanceLedger.spaceId, spaceId)))
.get()
},
create(input: Insert, id?: string): Row {
const finalId = id ?? input.id ?? generateId('ledger')
getDb().insert(schema.balanceLedger).values({ id: finalId, ...normalize(input) }).run()
getDb()
.insert(schema.balanceLedger)
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
update(id: string, input: Partial<Row>): Row | undefined {
@@ -39,13 +54,18 @@ export const balanceLedgerRepository = {
if (!existing) return undefined
getDb()
.update(schema.balanceLedger)
.set(normalize({ ...existing, ...input }))
.where(eq(schema.balanceLedger.id, id))
.set({ ...normalize({ ...existing, ...input }), spaceId: existing.spaceId })
.where(and(eq(schema.balanceLedger.id, id), eq(schema.balanceLedger.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.balanceLedger).where(eq(schema.balanceLedger.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.balanceLedger)
.where(and(eq(schema.balanceLedger.id, id), eq(schema.balanceLedger.spaceId, existing.spaceId)))
.run()
return r.changes > 0
},
}
+27 -3
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export type NotificationChannel = 'telegram' | 'webhook'
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
@@ -43,9 +44,11 @@ function toLogDto(row: typeof schema.notificationLog.$inferSelect): Notification
export const notificationRepository = {
listRecent(limit = 50): NotificationLogRow[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.notificationLog)
.where(eq(schema.notificationLog.spaceId, spaceId))
.orderBy(desc(schema.notificationLog.createdAt))
.limit(Math.min(200, Math.max(1, limit)))
.all()
@@ -64,6 +67,7 @@ export const notificationRepository = {
.insert(schema.notificationLog)
.values({
id: `nlog-${randomUUID()}`,
spaceId: getCurrentSpaceId(),
event: entry.event,
channel: entry.channel,
status: entry.status,
@@ -76,20 +80,40 @@ export const notificationRepository = {
},
getState(key: string) {
return getDb().select().from(schema.notificationState).where(eq(schema.notificationState.key, key)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.notificationState)
.where(
and(
eq(schema.notificationState.key, key),
eq(schema.notificationState.spaceId, spaceId),
),
)
.get()
},
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
const db = getDb()
const spaceId = getCurrentSpaceId()
const existing = this.getState(key)
const values = {
key,
spaceId,
lastFingerprint: patch.lastFingerprint ?? existing?.lastFingerprint ?? null,
lastSentAt: patch.lastSentAt ?? existing?.lastSentAt ?? null,
lastStatus: patch.lastStatus ?? existing?.lastStatus ?? null,
}
if (existing) {
db.update(schema.notificationState).set(values).where(eq(schema.notificationState.key, key)).run()
db.update(schema.notificationState)
.set(values)
.where(
and(
eq(schema.notificationState.key, key),
eq(schema.notificationState.spaceId, spaceId),
),
)
.run()
} else {
db.insert(schema.notificationState).values(values).run()
}
+27 -7
View File
@@ -1,5 +1,6 @@
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
type Row = typeof schema.payments.$inferSelect
@@ -19,14 +20,28 @@ function normalize(input: Partial<Row>) {
export const paymentsRepository = {
list(): Row[] {
return getDb().select().from(schema.payments).orderBy(desc(schema.payments.date)).all()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.payments)
.where(eq(schema.payments.spaceId, spaceId))
.orderBy(desc(schema.payments.date))
.all()
},
get(id: string): Row | undefined {
return getDb().select().from(schema.payments).where(eq(schema.payments.id, id)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.payments)
.where(and(eq(schema.payments.id, id), eq(schema.payments.spaceId, spaceId)))
.get()
},
create(input: Insert, id?: string): Row {
const finalId = id ?? input.id ?? generateId('pay')
getDb().insert(schema.payments).values({ id: finalId, ...normalize(input) }).run()
getDb()
.insert(schema.payments)
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
update(id: string, input: Partial<Row>): Row | undefined {
@@ -34,13 +49,18 @@ export const paymentsRepository = {
if (!existing) return undefined
getDb()
.update(schema.payments)
.set(normalize({ ...existing, ...input }))
.where(eq(schema.payments.id, id))
.set({ ...normalize({ ...existing, ...input }), spaceId: existing.spaceId })
.where(and(eq(schema.payments.id, id), eq(schema.payments.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.payments).where(eq(schema.payments.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.payments)
.where(and(eq(schema.payments.id, id), eq(schema.payments.spaceId, existing.spaceId)))
.run()
return r.changes > 0
},
}
+50 -7
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { eq, like, asc, sql } from 'drizzle-orm'
import { and, eq, like, asc, sql } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export function normalizeProjectNameInput(name: unknown): string {
if (name == null) return ''
@@ -12,10 +13,16 @@ export function findProjectByNameCaseInsensitive(
): (typeof schema.serverProjects.$inferSelect) | undefined {
const n = normalizeProjectNameInput(name)
if (!n) return undefined
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(sql`LOWER(${schema.serverProjects.name})`, n.toLowerCase()))
.where(
and(
eq(schema.serverProjects.spaceId, spaceId),
eq(sql`LOWER(${schema.serverProjects.name})`, n.toLowerCase()),
),
)
.get()
}
@@ -30,7 +37,15 @@ export function resolveOrCreateProject(
const now = new Date().toISOString()
getDb()
.insert(schema.serverProjects)
.values({ id, name: n, color: null, sortOrder: 0, notes: null, createdAt: now })
.values({
id,
spaceId: getCurrentSpaceId(),
name: n,
color: null,
sortOrder: 0,
notes: null,
createdAt: now,
})
.run()
return { id, name: n }
}
@@ -42,10 +57,12 @@ export function projectSuggestions(
const term = normalizeProjectNameInput(q)
const lim = Math.min(50, Math.max(1, Number(limit) || 20))
const db = getDb()
const spaceId = getCurrentSpaceId()
if (!term) {
return db
.select({ id: schema.serverProjects.id, name: schema.serverProjects.name })
.from(schema.serverProjects)
.where(eq(schema.serverProjects.spaceId, spaceId))
.orderBy(asc(schema.serverProjects.name))
.limit(lim)
.all()
@@ -55,7 +72,12 @@ export function projectSuggestions(
return db
.select({ id: schema.serverProjects.id, name: schema.serverProjects.name })
.from(schema.serverProjects)
.where(like(sql`LOWER(${schema.serverProjects.name})`, pattern))
.where(
and(
eq(schema.serverProjects.spaceId, spaceId),
like(sql`LOWER(${schema.serverProjects.name})`, pattern),
),
)
.orderBy(asc(schema.serverProjects.name))
.limit(lim)
.all()
@@ -81,17 +103,22 @@ function countVpsByProjectId(projectId: string): number {
export const projectsRepository = {
list(): (typeof schema.serverProjects.$inferSelect)[] {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.spaceId, spaceId))
.orderBy(asc(schema.serverProjects.name))
.all()
},
get(id: string): (typeof schema.serverProjects.$inferSelect) | undefined {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.id, id))
.where(
and(eq(schema.serverProjects.id, id), eq(schema.serverProjects.spaceId, spaceId)),
)
.get()
},
getDependencyCounts(id: string): { vps: number } {
@@ -104,6 +131,7 @@ export const projectsRepository = {
.insert(schema.serverProjects)
.values({
id,
spaceId: getCurrentSpaceId(),
name: input.name,
color: input.color ?? null,
sortOrder: 0,
@@ -142,7 +170,12 @@ export const projectsRepository = {
color: input.color !== undefined ? input.color : existing.color,
notes: input.notes !== undefined ? input.notes : existing.notes,
})
.where(eq(schema.serverProjects.id, id))
.where(
and(
eq(schema.serverProjects.id, id),
eq(schema.serverProjects.spaceId, existing.spaceId),
),
)
.run()
if (nextName !== existing.name) {
db.update(schema.vps)
@@ -154,7 +187,17 @@ export const projectsRepository = {
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.serverProjects).where(eq(schema.serverProjects.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.serverProjects)
.where(
and(
eq(schema.serverProjects.id, id),
eq(schema.serverProjects.spaceId, existing.spaceId),
),
)
.run()
return r.changes > 0
},
}
@@ -1,6 +1,7 @@
import { asc, count, eq } from 'drizzle-orm'
import { and, asc, count, eq } from 'drizzle-orm'
import { parseApiLogin } from '@cfdm/shared'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
type AccountRow = typeof schema.providerAccounts.$inferSelect
@@ -81,24 +82,47 @@ function countSyncLogForAccount(id: string): number {
export const providerAccountsRepository = {
list(): PublicAccountRow[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.spaceId, spaceId))
.orderBy(asc(schema.providerAccounts.name))
.all()
return rows.map((r) => sanitize(r)!) as PublicAccountRow[]
},
get(id: string): PublicAccountRow | undefined {
const spaceId = getCurrentSpaceId()
const row = getDb()
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, spaceId),
),
)
.get()
return sanitize(row)
},
getWithCredentials(id: string): AccountRow | undefined {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.providerAccounts)
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, spaceId),
),
)
.get()
},
/** Unscoped lookup for sync/scheduler (account may be in any space). */
getWithCredentialsAnySpace(id: string): AccountRow | undefined {
return getDb()
.select()
.from(schema.providerAccounts)
@@ -106,6 +130,10 @@ export const providerAccountsRepository = {
.get()
},
listAllSpaces(): AccountRow[] {
return getDb().select().from(schema.providerAccounts).all()
},
getDependencyCounts(id: string): AccountDependencyCounts {
return {
vps: countVpsForAccount(id),
@@ -120,7 +148,7 @@ export const providerAccountsRepository = {
const db = getDb()
const finalId = id ?? input.id ?? generateId('account')
db.insert(schema.providerAccounts)
.values({ id: finalId, ...normalize(input) })
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
@@ -152,16 +180,29 @@ export const providerAccountsRepository = {
apiBaseUrl: '',
apiCredentials,
balanceAlertBelow,
spaceId: existing.spaceId,
})
.where(eq(schema.providerAccounts.id, id))
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, existing.spaceId),
),
)
.run()
return this.get(id)
},
delete(id: string): boolean {
const existing = this.getWithCredentials(id)
if (!existing) return false
const res = getDb()
.delete(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, existing.spaceId),
),
)
.run()
return res.changes > 0
},
+24 -6
View File
@@ -1,5 +1,6 @@
import { asc, eq } from 'drizzle-orm'
import { and, asc, eq } from 'drizzle-orm'
import { getDb, schema, type Db } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
export type ProviderInsert = Partial<typeof schema.providers.$inferInsert> & {
@@ -22,18 +23,29 @@ function normalize(input: Partial<typeof schema.providers.$inferInsert>) {
export const providersRepository = {
list(): (typeof schema.providers.$inferSelect)[] {
return getDb().select().from(schema.providers).orderBy(asc(schema.providers.name)).all()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.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()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.providers)
.where(and(eq(schema.providers.id, id), eq(schema.providers.spaceId, spaceId)))
.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) })
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
@@ -48,16 +60,22 @@ export const providersRepository = {
const merged = {
...existing,
...normalize({ ...existing, ...input }),
spaceId: existing.spaceId,
}
db.update(schema.providers)
.set(merged)
.where(eq(schema.providers.id, id))
.where(and(eq(schema.providers.id, id), eq(schema.providers.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
delete(id: string): boolean {
const res = getDb().delete(schema.providers).where(eq(schema.providers.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const res = getDb()
.delete(schema.providers)
.where(and(eq(schema.providers.id, id), eq(schema.providers.spaceId, existing.spaceId)))
.run()
return res.changes > 0
},
}
+59 -10
View File
@@ -4,6 +4,10 @@ import {
type AppSwitcherConfig,
} from '@cfdm/shared/contracts/app-switcher'
import { getDb, schema } from '../index.js'
import {
getCurrentSpaceId,
settingsIdForSpace,
} from '../space-context.js'
type Row = typeof schema.settings.$inferSelect
@@ -150,9 +154,10 @@ interface SettingsInput {
showQuickActions?: boolean
}
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
function buildValues(id: string, spaceId: string, existing: Row | undefined, r: SettingsInput) {
return {
id,
spaceId,
baseCurrency: r.baseCurrency ?? existing?.baseCurrency ?? 'RUB',
ratesUrl: r.ratesUrl ?? existing?.ratesUrl ?? '',
autoConvert:
@@ -266,37 +271,68 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
export const settingsRepository = {
list(): SettingsDto[] {
const rows = getDb().select().from(schema.settings).orderBy(asc(schema.settings.id)).all()
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.settings)
.where(eq(schema.settings.spaceId, spaceId))
.orderBy(asc(schema.settings.id))
.all()
return rows.map((r) => toDto(r)!) as SettingsDto[]
},
listAllSpaces(): Row[] {
return getDb().select().from(schema.settings).all()
},
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()
},
getIntegrationToken(id = 'settings-main'): string {
return this.getRow(id)?.integrationToken?.trim() ?? ''
getBySpace(spaceId = getCurrentSpaceId()): Row | undefined {
return getDb()
.select()
.from(schema.settings)
.where(eq(schema.settings.spaceId, spaceId))
.get()
},
getAppSwitcher(id = 'settings-main'): AppSwitcherConfig {
const row = this.getRow(id)
getDtoBySpace(spaceId = getCurrentSpaceId()): SettingsDto | undefined {
return toDto(this.getBySpace(spaceId))
},
findByIntegrationToken(token: string): Row | undefined {
const t = token.trim()
if (!t) return undefined
const rows = getDb().select().from(schema.settings).all()
return rows.find((r) => r.integrationEnabled && r.integrationToken?.trim() === t)
},
getIntegrationToken(id?: string): string {
const row = id
? this.getRow(id)
: this.getBySpace(getCurrentSpaceId())
return row?.integrationToken?.trim() ?? ''
},
getAppSwitcher(id?: string): AppSwitcherConfig {
const row = id
? this.getRow(id)
: this.getBySpace(getCurrentSpaceId()) ?? this.getRow('settings-main')
return parseAppSwitcher(row?.appSwitcherJson)
},
touchIntegrationSync(id = 'settings-main'): void {
touchIntegrationSync(id?: string): void {
const db = getDb()
const at = new Date().toISOString()
const existing = this.getRow(id)
const existing = id ? this.getRow(id) : this.getBySpace(getCurrentSpaceId())
if (existing) {
db.update(schema.settings)
.set({ integrationLastSyncAt: at })
.where(eq(schema.settings.id, id))
.where(eq(schema.settings.id, existing.id))
.run()
}
},
upsert(id: string, input: SettingsInput): SettingsDto {
const db = getDb()
const existing = this.getRow(id)
const values = buildValues(id, existing, input)
const spaceId = existing?.spaceId ?? getCurrentSpaceId()
const values = buildValues(id, spaceId, existing, input)
if (existing) {
db.update(schema.settings).set(values).where(eq(schema.settings.id, id)).run()
} else {
@@ -304,4 +340,17 @@ export const settingsRepository = {
}
return this.get(id)!
},
upsertForSpace(spaceId: string, input: SettingsInput): SettingsDto {
const id = settingsIdForSpace(spaceId)
const existing = this.getRow(id) ?? this.getBySpace(spaceId)
const finalId = existing?.id ?? id
const db = getDb()
const values = buildValues(finalId, spaceId, existing, input)
if (existing) {
db.update(schema.settings).set(values).where(eq(schema.settings.id, existing.id)).run()
return this.get(existing.id)!
}
db.insert(schema.settings).values(values).run()
return this.get(finalId)!
},
}
+33 -3
View File
@@ -1,4 +1,4 @@
import { vpsRepository } from './vps.js'
import { vpsRepository, type VpsDto } from './vps.js'
import { providersRepository } from './providers.js'
import { providerAccountsRepository } from './provider-accounts.js'
import { paymentsRepository } from './payments.js'
@@ -8,9 +8,12 @@ import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.
import { projectsRepository } from './projects.js'
import { syncLogRepository } from './sync-log.js'
import { vpsDomainsRepository } from './vps-domains.js'
import { vpsGrantsRepository } from './spaces.js'
import { getCurrentSpaceId } from '../space-context.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
spaceId: string
vps: VpsDto[]
serverProjects: ReturnType<typeof projectsRepository.list>
providers: ReturnType<typeof providersRepository.list>
providerAccounts: ReturnType<typeof providerAccountsRepository.list>
@@ -21,11 +24,36 @@ export interface Snapshot {
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
syncLog: ReturnType<typeof syncLogRepository.listRecent>
vpsDomains: ReturnType<typeof vpsDomainsRepository.list>
vpsGrants: ReturnType<typeof vpsGrantsRepository.listToSpace>
}
function listVpsWithShared(): VpsDto[] {
const spaceId = getCurrentSpaceId()
const owned = vpsRepository.list()
const ownedIds = new Set(owned.map((v) => v.id))
const grants = vpsGrantsRepository.listToSpace(spaceId)
const sharedIds = grants.map((g) => g.vpsId).filter((id) => !ownedIds.has(id))
const sharedRows = vpsRepository.listByIds(sharedIds)
const grantByVps = new Map(grants.map((g) => [g.vpsId, g]))
const shared = sharedRows.map((v) => {
const g = grantByVps.get(v.id)
return {
...v,
access: 'shared' as const,
grantPermission: (g?.permission === 'write' ? 'write' : 'read') as 'read' | 'write',
// Hide credentials linkage for shared view
providerAccountId: '',
providerId: v.providerId ?? '',
}
})
return [...owned, ...shared]
}
export function getSnapshot(): Snapshot {
const spaceId = getCurrentSpaceId()
return {
vps: vpsRepository.list(),
spaceId,
vps: listVpsWithShared(),
serverProjects: projectsRepository.list(),
providers: providersRepository.list(),
providerAccounts: providerAccountsRepository.list(),
@@ -36,6 +64,7 @@ export function getSnapshot(): Snapshot {
tariffSyncOptions: tariffSyncOptionsRepository.list(),
syncLog: syncLogRepository.listRecent(50),
vpsDomains: vpsDomainsRepository.list(),
vpsGrants: vpsGrantsRepository.listToSpace(spaceId),
}
}
@@ -51,4 +80,5 @@ export {
projectsRepository,
syncLogRepository,
vpsDomainsRepository,
vpsGrantsRepository,
}
+366
View File
@@ -0,0 +1,366 @@
import { and, asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
import {
MAIN_SPACE_ID,
getCurrentSpaceId,
settingsIdForSpace,
} from '../space-context.js'
export type SpaceRole = 'owner' | 'admin' | 'member' | 'viewer'
export type SpaceKind = 'main' | 'personal'
export type GrantPermission = 'read' | 'write'
export type SpaceRow = typeof schema.spaces.$inferSelect
export type SpaceMemberRow = typeof schema.spaceMembers.$inferSelect
export type VpsGrantRow = typeof schema.vpsGrants.$inferSelect
const ROLE_RANK: Record<SpaceRole, number> = {
viewer: 1,
member: 2,
admin: 3,
owner: 4,
}
export function roleAtLeast(role: string, min: SpaceRole): boolean {
return (ROLE_RANK[role as SpaceRole] ?? 0) >= ROLE_RANK[min]
}
function nowIso(): string {
return new Date().toISOString()
}
export const spacesRepository = {
listAll(): SpaceRow[] {
return getDb().select().from(schema.spaces).orderBy(asc(schema.spaces.name)).all()
},
listForUser(userId: string, isAdmin = false): (SpaceRow & { role: string })[] {
if (isAdmin) {
return this.listAll().map((s) => {
const m = this.getMember(s.id, userId)
return { ...s, role: m?.role ?? (s.kind === 'main' ? 'admin' : 'viewer') }
})
}
const db = getDb()
const members = db
.select()
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, userId))
.all()
const out: (SpaceRow & { role: string })[] = []
for (const m of members) {
const space = this.get(m.spaceId)
if (space) out.push({ ...space, role: m.role })
}
return out.sort((a, b) => a.name.localeCompare(b.name))
},
get(id: string): SpaceRow | undefined {
return getDb().select().from(schema.spaces).where(eq(schema.spaces.id, id)).get()
},
getMain(): SpaceRow {
let row = this.get(MAIN_SPACE_ID)
if (!row) {
row = this.create({
id: MAIN_SPACE_ID,
name: 'Основное',
slug: 'main',
kind: 'main',
ownerUserId: process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null,
})
}
return row
},
create(input: {
id?: string
name: string
slug: string
kind?: SpaceKind
ownerUserId?: string | null
}): SpaceRow {
const db = getDb()
const id = input.id ?? generateId('space')
const createdAt = nowIso()
db.insert(schema.spaces)
.values({
id,
name: input.name,
slug: input.slug,
kind: input.kind ?? 'personal',
ownerUserId: input.ownerUserId ?? null,
createdAt,
})
.run()
if (input.ownerUserId) {
db.insert(schema.spaceMembers)
.values({
spaceId: id,
userId: input.ownerUserId,
role: 'owner',
createdAt,
})
.run()
}
// Seed settings for the space
const settingsId = settingsIdForSpace(id)
const existingSettings = db
.select()
.from(schema.settings)
.where(eq(schema.settings.id, settingsId))
.get()
if (!existingSettings) {
db.insert(schema.settings)
.values({
id: settingsId,
spaceId: id,
baseCurrency: 'RUB',
syncEnabled: 0,
autoConvert: 0,
})
.run()
}
return this.get(id)!
},
update(
id: string,
input: Partial<{ name: string; slug: string; ownerUserId: string | null }>,
): SpaceRow | undefined {
const existing = this.get(id)
if (!existing) return undefined
getDb()
.update(schema.spaces)
.set({
name: input.name ?? existing.name,
slug: input.slug ?? existing.slug,
ownerUserId:
input.ownerUserId !== undefined ? input.ownerUserId : existing.ownerUserId,
})
.where(eq(schema.spaces.id, id))
.run()
return this.get(id)
},
ensurePersonalSpace(userId: string, name?: string): SpaceRow {
const id = `space-user-${userId}`
const existing = this.get(id)
if (existing) {
const member = this.getMember(id, userId)
if (!member) {
this.addMember(id, userId, 'owner')
}
return existing
}
return this.create({
id,
name: name?.trim() || 'Моё пространство',
slug: `user-${userId}`,
kind: 'personal',
ownerUserId: userId,
})
},
claimMainOwnerIfEmpty(userId: string): void {
const main = this.getMain()
if (!main.ownerUserId) {
this.update(MAIN_SPACE_ID, { ownerUserId: userId })
}
const member = this.getMember(MAIN_SPACE_ID, userId)
if (!member) {
this.addMember(MAIN_SPACE_ID, userId, 'owner')
} else if (!roleAtLeast(member.role, 'admin')) {
this.updateMember(MAIN_SPACE_ID, userId, 'owner')
}
},
getMember(spaceId: string, userId: string): SpaceMemberRow | undefined {
return getDb()
.select()
.from(schema.spaceMembers)
.where(
and(
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
),
)
.get()
},
listMembers(spaceId: string): SpaceMemberRow[] {
return getDb()
.select()
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, spaceId))
.all()
},
addMember(spaceId: string, userId: string, role: SpaceRole = 'member'): SpaceMemberRow {
const existing = this.getMember(spaceId, userId)
if (existing) {
return this.updateMember(spaceId, userId, role) ?? existing
}
getDb()
.insert(schema.spaceMembers)
.values({
spaceId,
userId,
role,
createdAt: nowIso(),
})
.run()
return this.getMember(spaceId, userId)!
},
updateMember(
spaceId: string,
userId: string,
role: SpaceRole,
): SpaceMemberRow | undefined {
const existing = this.getMember(spaceId, userId)
if (!existing) return undefined
getDb()
.update(schema.spaceMembers)
.set({ role })
.where(
and(
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
),
)
.run()
return this.getMember(spaceId, userId)
},
removeMember(spaceId: string, userId: string): boolean {
const r = getDb()
.delete(schema.spaceMembers)
.where(
and(
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
),
)
.run()
return r.changes > 0
},
canAccess(spaceId: string, userId: string, isAdmin = false): boolean {
if (isAdmin) return true
return Boolean(this.getMember(spaceId, userId))
},
requireRole(
spaceId: string,
userId: string,
min: SpaceRole,
isAdmin = false,
): SpaceMemberRow | null {
if (isAdmin) {
return (
this.getMember(spaceId, userId) ?? {
spaceId,
userId,
role: 'owner',
createdAt: nowIso(),
}
)
}
const m = this.getMember(spaceId, userId)
if (!m || !roleAtLeast(m.role, min)) return null
return m
},
}
export const vpsGrantsRepository = {
listToSpace(toSpaceId: string): VpsGrantRow[] {
return getDb()
.select()
.from(schema.vpsGrants)
.where(eq(schema.vpsGrants.toSpaceId, toSpaceId))
.all()
},
listFromSpace(fromSpaceId: string): VpsGrantRow[] {
return getDb()
.select()
.from(schema.vpsGrants)
.where(eq(schema.vpsGrants.fromSpaceId, fromSpaceId))
.all()
},
get(id: string): VpsGrantRow | undefined {
return getDb().select().from(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).get()
},
getForVpsToSpace(vpsId: string, toSpaceId: string): VpsGrantRow | undefined {
return getDb()
.select()
.from(schema.vpsGrants)
.where(
and(
eq(schema.vpsGrants.vpsId, vpsId),
eq(schema.vpsGrants.toSpaceId, toSpaceId),
),
)
.get()
},
create(input: {
vpsId: string
fromSpaceId: string
toSpaceId: string
permission: GrantPermission
grantedByUserId?: string | null
}): VpsGrantRow {
const existing = this.getForVpsToSpace(input.vpsId, input.toSpaceId)
if (existing) {
getDb()
.update(schema.vpsGrants)
.set({
permission: input.permission,
grantedByUserId: input.grantedByUserId ?? existing.grantedByUserId,
})
.where(eq(schema.vpsGrants.id, existing.id))
.run()
return this.get(existing.id)!
}
const id = generateId('grant')
getDb()
.insert(schema.vpsGrants)
.values({
id,
vpsId: input.vpsId,
fromSpaceId: input.fromSpaceId,
toSpaceId: input.toSpaceId,
permission: input.permission,
grantedByUserId: input.grantedByUserId ?? null,
createdAt: nowIso(),
})
.run()
return this.get(id)!
},
delete(id: string): boolean {
const r = getDb().delete(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).run()
return r.changes > 0
},
deleteByVps(vpsId: string): number {
const r = getDb()
.delete(schema.vpsGrants)
.where(eq(schema.vpsGrants.vpsId, vpsId))
.run()
return r.changes
},
/** Effective grant for current space context on a VPS owned elsewhere. */
getGrantInCurrentSpace(vpsId: string): VpsGrantRow | undefined {
return this.getForVpsToSpace(vpsId, getCurrentSpaceId())
},
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { desc } from 'drizzle-orm'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export interface SyncLogDto {
id: string
@@ -38,9 +39,11 @@ function toDto(row: typeof schema.syncLog.$inferSelect): SyncLogDto {
export const syncLogRepository = {
listRecent(limit = 50): SyncLogDto[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.syncLog)
.where(eq(schema.syncLog.spaceId, spaceId))
.orderBy(desc(schema.syncLog.startedAt))
.limit(limit)
.all()
+16 -5
View File
@@ -1,5 +1,6 @@
import { asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
type Row = typeof schema.activeTariffs.$inferSelect
@@ -94,9 +95,11 @@ function toDto(row: Row | undefined): ActiveTariffDto | undefined {
export const activeTariffsRepository = {
list(): ActiveTariffDto[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.spaceId, spaceId))
.orderBy(asc(schema.activeTariffs.name))
.all()
return rows.map((r) => toDto(r)!) as ActiveTariffDto[]
@@ -111,16 +114,18 @@ export const activeTariffsRepository = {
},
upsertMany(rows: (typeof schema.activeTariffs.$inferInsert)[]): void {
const db = getDb()
const spaceId = getCurrentSpaceId()
for (const r of rows) {
const withSpace = { ...r, spaceId: r.spaceId ?? spaceId }
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()
db.update(schema.activeTariffs).set(withSpace).where(eq(schema.activeTariffs.id, r.id)).run()
} else {
db.insert(schema.activeTariffs).values(r).run()
db.insert(schema.activeTariffs).values(withSpace).run()
}
}
},
@@ -160,7 +165,12 @@ export function toTariffSyncOptionsDto(
export const tariffSyncOptionsRepository = {
list(): TariffSyncOptionsDto[] {
const rows = getDb().select().from(schema.tariffSyncOptions).all()
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.tariffSyncOptions)
.where(eq(schema.tariffSyncOptions.spaceId, spaceId))
.all()
return rows.map((r) => toTariffSyncOptionsDto(r)!) as TariffSyncOptionsDto[]
},
byAccount(accountId: string): TariffSyncOptionsDto | undefined {
@@ -174,6 +184,7 @@ export const tariffSyncOptionsRepository = {
},
upsert(input: typeof schema.tariffSyncOptions.$inferInsert): void {
const db = getDb()
const withSpace = { ...input, spaceId: input.spaceId ?? getCurrentSpaceId() }
const existing = db
.select({ providerAccountId: schema.tariffSyncOptions.providerAccountId })
.from(schema.tariffSyncOptions)
@@ -181,11 +192,11 @@ export const tariffSyncOptionsRepository = {
.get()
if (existing) {
db.update(schema.tariffSyncOptions)
.set(input)
.set(withSpace)
.where(eq(schema.tariffSyncOptions.providerAccountId, input.providerAccountId))
.run()
} else {
db.insert(schema.tariffSyncOptions).values(input).run()
db.insert(schema.tariffSyncOptions).values(withSpace).run()
}
},
}
+24 -4
View File
@@ -1,6 +1,8 @@
import { asc, eq, isNull } from 'drizzle-orm'
import { and, asc, eq, isNull } from 'drizzle-orm'
// and used in markOrphaned / listUnmatched
import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
import { vpsRepository } from './vps.js'
@@ -45,9 +47,11 @@ function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' {
export const vpsDomainsRepository = {
list(): VpsDomainDto[] {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.spaceId, spaceId))
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
@@ -78,8 +82,13 @@ export const vpsDomainsRepository = {
rematchAll(): { updated: number } {
const db = getDb()
const spaceId = getCurrentSpaceId()
const allVps = vpsRepository.list()
const rows = db.select().from(schema.vpsDomains).all()
const rows = db
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.spaceId, spaceId))
.all()
let updated = 0
const vpsIds = new Set(allVps.map((v) => v.id))
@@ -123,6 +132,7 @@ export const vpsDomainsRepository = {
upserted: number
} {
const db = getDb()
const spaceId = getCurrentSpaceId()
const allVps = vpsRepository.list()
const now = new Date().toISOString()
let matched = 0
@@ -143,6 +153,7 @@ export const vpsDomainsRepository = {
const existing = this.getByCfdmBindingId(item.bindingId)
const values = {
spaceId,
vpsId,
fqdn: item.fqdn,
zoneName: item.zoneName,
@@ -170,10 +181,16 @@ export const vpsDomainsRepository = {
markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number {
const db = getDb()
const spaceId = getCurrentSpaceId()
const rows = db
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmServiceId, serviceId))
.where(
and(
eq(schema.vpsDomains.cfdmServiceId, serviceId),
eq(schema.vpsDomains.spaceId, spaceId),
),
)
.all()
let removed = 0
for (const row of rows) {
@@ -186,10 +203,13 @@ export const vpsDomainsRepository = {
},
listUnmatched(): VpsDomainDto[] {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.vpsDomains)
.where(isNull(schema.vpsDomains.vpsId))
.where(
and(eq(schema.vpsDomains.spaceId, spaceId), isNull(schema.vpsDomains.vpsId)),
)
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
+145 -9
View File
@@ -1,5 +1,6 @@
import { desc, eq, inArray } from 'drizzle-orm'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
import { resolveOrCreateProject, getProjectNameById } from './projects.js'
@@ -13,6 +14,8 @@ export type VpsDto = Omit<VpsRow, 'additionalIps' | 'userOverrides' | 'projectId
backupEnabled: boolean
dailyRate: number | ''
monthlyRate: number | ''
access?: 'owned' | 'shared'
grantPermission?: 'read' | 'write'
}
const USER_OVERRIDABLE_FIELDS = [
@@ -133,13 +136,37 @@ function serializeCustomData(v: unknown): string | null {
return JSON.stringify(v)
}
function spaceFilter() {
return eq(schema.vps.spaceId, getCurrentSpaceId())
}
export const vpsRepository = {
list(): VpsDto[] {
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const rows = getDb()
.select()
.from(schema.vps)
.where(spaceFilter())
.orderBy(desc(schema.vps.createdAt))
.all()
return rows.map((r) => ({ ...toDto(r)!, access: 'owned' as const }))
},
listByIds(ids: string[]): VpsDto[] {
if (ids.length === 0) return []
const rows = getDb().select().from(schema.vps).where(inArray(schema.vps.id, ids)).all()
return rows.map((r) => toDto(r)!) as VpsDto[]
},
get(id: string): VpsDto | undefined {
const row = getDb()
.select()
.from(schema.vps)
.where(and(eq(schema.vps.id, id), spaceFilter()))
.get()
return row ? { ...toDto(row)!, access: 'owned' } : undefined
},
getAnySpace(id: string): VpsDto | undefined {
const row = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
return toDto(row)
},
@@ -152,6 +179,7 @@ export const vpsRepository = {
db.insert(schema.vps)
.values({
id: finalId,
spaceId: getCurrentSpaceId(),
ip: input.ip ?? '',
ipv6: input.ipv6 ?? '',
additionalIps,
@@ -195,7 +223,11 @@ export const vpsRepository = {
update(id: string, input: VpsInput): VpsDto | undefined {
const db = getDb()
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
const existing = getDb()
.select()
.from(schema.vps)
.where(and(eq(schema.vps.id, id), spaceFilter()))
.get()
if (!existing) return undefined
let userOverrides: string[] = []
@@ -284,36 +316,140 @@ export const vpsRepository = {
paidUntil: input.paidUntil ?? '',
notes: input.notes ?? '',
userOverrides: userOverridesJson,
spaceId: existing.spaceId,
...(input.customData !== undefined
? { customData: serializeCustomData(input.customData) }
: {}),
})
.where(eq(schema.vps.id, id))
.where(and(eq(schema.vps.id, id), eq(schema.vps.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
/** Update VPS by id regardless of current space (for shared write grants). */
updateAnySpace(id: string, input: VpsInput): VpsDto | undefined {
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
if (!existing) return undefined
// Temporarily treat as owned update in its home space via raw set of fields
const additionalIps = Array.isArray(input.additionalIps)
? JSON.stringify(input.additionalIps)
: existing.additionalIps ?? '[]'
let projectOut = existing.project ?? ''
let projectIdOut = existing.projectId ?? ''
if (input.project !== undefined) {
const r = projectColumnsForSave(input.project)
projectOut = r.project
projectIdOut = r.projectId
}
getDb()
.update(schema.vps)
.set({
ip: input.ip ?? existing.ip ?? '',
ipv6: input.ipv6 ?? existing.ipv6 ?? '',
additionalIps,
dns: input.dns ?? existing.dns ?? '',
country: input.country ?? existing.country ?? '',
city: input.city ?? existing.city ?? '',
datacenter: input.datacenter ?? existing.datacenter ?? '',
os: input.os ?? existing.os ?? '',
vcpu: input.vcpu ?? existing.vcpu ?? 0,
ramGb: input.ramGb ?? existing.ramGb ?? 0,
diskGb: input.diskGb ?? existing.diskGb ?? 0,
diskType: input.diskType ?? existing.diskType ?? '',
virtualization: input.virtualization ?? existing.virtualization ?? '',
bandwidthTb: input.bandwidthTb ?? existing.bandwidthTb ?? 0,
sshPort: input.sshPort ?? existing.sshPort ?? 22,
rootUser: input.rootUser ?? existing.rootUser ?? '',
purpose: input.purpose ?? existing.purpose ?? '',
environment: input.environment ?? existing.environment ?? '',
project: projectOut,
projectId: projectIdOut || null,
monitoringEnabled:
input.monitoringEnabled !== undefined
? boolToInt(input.monitoringEnabled)
: existing.monitoringEnabled,
backupEnabled:
input.backupEnabled !== undefined
? boolToInt(input.backupEnabled)
: existing.backupEnabled,
status: input.status ?? existing.status ?? 'active',
tariffType: input.tariffType ?? existing.tariffType ?? '',
currency: input.currency ?? existing.currency ?? '',
dailyRate:
input.dailyRate !== undefined ? numOrNull(input.dailyRate) : existing.dailyRate,
monthlyRate:
input.monthlyRate !== undefined ? numOrNull(input.monthlyRate) : existing.monthlyRate,
paidUntil: input.paidUntil ?? existing.paidUntil ?? '',
notes: input.notes ?? existing.notes ?? '',
// Do not change providerAccountId / providerId / spaceId on shared edit
})
.where(eq(schema.vps.id, id))
.run()
return this.getAnySpace(id)
},
assignToSpace(id: string, toSpaceId: string): VpsDto | undefined {
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
if (!existing) return undefined
getDb()
.update(schema.vps)
.set({
spaceId: toSpaceId,
providerId: null,
providerAccountId: null,
projectId: null,
project: '',
})
.where(eq(schema.vps.id, id))
.run()
return this.getAnySpace(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.vps).where(eq(schema.vps.id, id)).run()
const existing = getDb()
.select()
.from(schema.vps)
.where(and(eq(schema.vps.id, id), spaceFilter()))
.get()
if (!existing) return false
const r = getDb()
.delete(schema.vps)
.where(and(eq(schema.vps.id, id), eq(schema.vps.spaceId, existing.spaceId)))
.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
const spaceId = getCurrentSpaceId()
const owned = getDb()
.select({ id: schema.vps.id })
.from(schema.vps)
.where(and(inArray(schema.vps.id, ids), eq(schema.vps.spaceId, spaceId)))
.all()
.map((r) => r.id)
if (owned.length === 0) return 0
getDb().update(schema.vps).set({ status }).where(inArray(schema.vps.id, owned)).run()
return owned.length
},
bulkDelete(ids: string[]): number {
const r = getDb().delete(schema.vps).where(inArray(schema.vps.id, ids)).run()
const spaceId = getCurrentSpaceId()
const r = getDb()
.delete(schema.vps)
.where(and(inArray(schema.vps.id, ids), eq(schema.vps.spaceId, spaceId)))
.run()
return r.changes
},
bulkProject(ids: string[], project: string | null): { updated: number; project: string; projectId: string } {
const { project: projName, projectId: projId } = projectColumnsForSave(project ?? '')
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.vps)
.where(inArray(schema.vps.id, ids))
.where(and(inArray(schema.vps.id, ids), eq(schema.vps.spaceId, spaceId)))
.all()
let updated = 0
for (const row of rows) {
+97
View File
@@ -1,5 +1,7 @@
import type Database from 'better-sqlite3'
const MAIN_SPACE_ID = 'space-main'
const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE vps ADD COLUMN customData TEXT`,
`ALTER TABLE vps ADD COLUMN last_health_status TEXT`,
@@ -16,9 +18,40 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
`ALTER TABLE providers ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN spaceId TEXT`,
`ALTER TABLE server_projects ADD COLUMN spaceId TEXT`,
`ALTER TABLE vps ADD COLUMN spaceId TEXT`,
`ALTER TABLE payments ADD COLUMN spaceId TEXT`,
`ALTER TABLE balance_ledger ADD COLUMN spaceId TEXT`,
`ALTER TABLE settings ADD COLUMN spaceId TEXT`,
`ALTER TABLE vps_domains ADD COLUMN spaceId TEXT`,
`ALTER TABLE notification_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE notification_state ADD COLUMN spaceId TEXT`,
`ALTER TABLE vps_health_checks ADD COLUMN spaceId TEXT`,
`ALTER TABLE audit_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE audit_log ADD COLUMN actorUserId TEXT`,
`ALTER TABLE sync_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`,
`ALTER TABLE tariff_sync_options ADD COLUMN spaceId TEXT`,
]
const TABLE_MIGRATIONS: string[] = [
`CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'personal',
ownerUserId TEXT,
createdAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS space_members (
spaceId TEXT NOT NULL REFERENCES spaces(id),
userId TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
createdAt TEXT NOT NULL,
UNIQUE(spaceId, userId)
)`,
`CREATE TABLE IF NOT EXISTS vps_health_checks (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL REFERENCES vps(id),
@@ -74,8 +107,70 @@ const TABLE_MIGRATIONS: string[] = [
targetIps TEXT,
syncedAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS vps_grants (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL REFERENCES vps(id),
fromSpaceId TEXT NOT NULL REFERENCES spaces(id),
toSpaceId TEXT NOT NULL REFERENCES spaces(id),
permission TEXT NOT NULL DEFAULT 'read',
grantedByUserId TEXT,
createdAt TEXT NOT NULL,
UNIQUE(vpsId, toSpaceId)
)`,
]
const SPACE_BACKFILL_TABLES = [
'providers',
'provider_accounts',
'server_projects',
'vps',
'payments',
'balance_ledger',
'settings',
'vps_domains',
'notification_log',
'notification_state',
'vps_health_checks',
'audit_log',
'sync_log',
'active_tariffs',
'tariff_sync_options',
] as const
function ensureMainSpace(sqlite: Database.Database): void {
const now = new Date().toISOString()
const owner = process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null
sqlite
.prepare(
`INSERT OR IGNORE INTO spaces (id, name, slug, kind, ownerUserId, createdAt)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(MAIN_SPACE_ID, 'Основное', 'main', 'main', owner, now)
if (owner) {
sqlite
.prepare(
`INSERT OR IGNORE INTO space_members (spaceId, userId, role, createdAt)
VALUES (?, ?, 'owner', ?)`,
)
.run(MAIN_SPACE_ID, owner, now)
}
}
function backfillSpaceIds(sqlite: Database.Database): void {
for (const table of SPACE_BACKFILL_TABLES) {
try {
sqlite
.prepare(
`UPDATE ${table} SET spaceId = ? WHERE spaceId IS NULL OR spaceId = ''`,
)
.run(MAIN_SPACE_ID)
} catch {
/* table may not exist yet */
}
}
}
let migrated = false
export function resetRuntimeMigrate(): void {
@@ -94,5 +189,7 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
/* column exists */
}
}
ensureMainSpace(sqlite)
backfillSpaceIds(sqlite)
migrated = true
}
+108 -1
View File
@@ -1,8 +1,58 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const spaces = sqliteTable('spaces', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
kind: text('kind').notNull().default('personal'),
ownerUserId: text('ownerUserId'),
createdAt: text('createdAt').notNull(),
})
export const spaceMembers = sqliteTable(
'space_members',
{
spaceId: text('spaceId')
.notNull()
.references(() => spaces.id),
userId: text('userId').notNull(),
role: text('role').notNull().default('member'),
createdAt: text('createdAt').notNull(),
},
(t) => ({
pk: uniqueIndex('space_members_pk').on(t.spaceId, t.userId),
}),
)
export const vpsGrants = sqliteTable(
'vps_grants',
{
id: text('id').primaryKey(),
vpsId: text('vpsId')
.notNull()
.references(() => vps.id),
fromSpaceId: text('fromSpaceId')
.notNull()
.references(() => spaces.id),
toSpaceId: text('toSpaceId')
.notNull()
.references(() => spaces.id),
permission: text('permission').notNull().default('read'),
grantedByUserId: text('grantedByUserId'),
createdAt: text('createdAt').notNull(),
},
(t) => ({
uniq: uniqueIndex('vps_grants_vps_to').on(t.vpsId, t.toSpaceId),
}),
)
export const providers = sqliteTable('providers', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
name: text('name').notNull(),
website: text('website'),
contact: text('contact'),
@@ -16,6 +66,10 @@ export const providers = sqliteTable('providers', {
export const providerAccounts = sqliteTable('provider_accounts', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
providerId: text('providerId')
.notNull()
.references(() => providers.id),
@@ -36,6 +90,10 @@ export const providerAccounts = sqliteTable('provider_accounts', {
export const serverProjects = sqliteTable('server_projects', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
name: text('name').notNull(),
color: text('color'),
sortOrder: integer('sortOrder').default(0),
@@ -45,6 +103,10 @@ export const serverProjects = sqliteTable('server_projects', {
export const vps = sqliteTable('vps', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
ip: text('ip'),
ipv6: text('ipv6'),
additionalIps: text('additionalIps'),
@@ -85,6 +147,10 @@ export const vps = sqliteTable('vps', {
export const payments = sqliteTable('payments', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
type: text('type').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
@@ -96,6 +162,10 @@ export const payments = sqliteTable('payments', {
export const balanceLedger = sqliteTable('balance_ledger', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
type: text('type').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
@@ -108,6 +178,10 @@ export const balanceLedger = sqliteTable('balance_ledger', {
export const settings = sqliteTable('settings', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
baseCurrency: text('baseCurrency'),
ratesUrl: text('ratesUrl'),
autoConvert: integer('autoConvert'),
@@ -138,6 +212,10 @@ export const settings = sqliteTable('settings', {
export const vpsDomains = sqliteTable('vps_domains', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
fqdn: text('fqdn').notNull(),
zoneName: text('zoneName').notNull(),
@@ -154,6 +232,10 @@ export const vpsDomains = sqliteTable('vps_domains', {
export const notificationLog = sqliteTable('notification_log', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
event: text('event').notNull(),
channel: text('channel').notNull(),
status: text('status').notNull(),
@@ -165,6 +247,10 @@ export const notificationLog = sqliteTable('notification_log', {
export const notificationState = sqliteTable('notification_state', {
key: text('key').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
lastFingerprint: text('lastFingerprint'),
lastSentAt: text('lastSentAt'),
lastStatus: text('lastStatus'),
@@ -172,6 +258,10 @@ export const notificationState = sqliteTable('notification_state', {
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
vpsId: text('vpsId')
.notNull()
.references(() => vps.id),
@@ -183,15 +273,24 @@ export const vpsHealthChecks = sqliteTable('vps_health_checks', {
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
entity: text('entity').notNull(),
entityId: text('entityId').notNull(),
action: text('action').notNull(),
diff: text('diff'),
actorUserId: text('actorUserId'),
createdAt: text('createdAt').notNull(),
})
export const syncLog = sqliteTable('sync_log', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
accountId: text('accountId')
.notNull()
.references(() => providerAccounts.id),
@@ -206,6 +305,10 @@ export const syncLog = sqliteTable('sync_log', {
export const activeTariffs = sqliteTable('active_tariffs', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
providerAccountId: text('providerAccountId')
.notNull()
.references(() => providerAccounts.id),
@@ -235,6 +338,10 @@ export const tariffSyncOptions = sqliteTable('tariff_sync_options', {
providerAccountId: text('providerAccountId')
.primaryKey()
.references(() => providerAccounts.id),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
datacenters: text('datacenters'),
periods: text('periods'),
syncedAt: text('syncedAt'),
+24
View File
@@ -0,0 +1,24 @@
import { AsyncLocalStorage } from 'node:async_hooks'
export const MAIN_SPACE_ID = 'space-main'
const storage = new AsyncLocalStorage<{ spaceId: string }>()
export function runWithSpace<T>(spaceId: string, fn: () => T): T {
return storage.run({ spaceId }, fn)
}
export async function runWithSpaceAsync<T>(
spaceId: string,
fn: () => Promise<T>,
): Promise<T> {
return storage.run({ spaceId }, fn)
}
export function getCurrentSpaceId(): string {
return storage.getStore()?.spaceId ?? MAIN_SPACE_ID
}
export function settingsIdForSpace(spaceId: string): string {
return spaceId === MAIN_SPACE_ID ? 'settings-main' : `settings-${spaceId}`
}
+82 -3
View File
@@ -2,8 +2,26 @@ import { closeDb, getSqlite } from './index.js'
import { resetRuntimeMigrate } from './runtime-migrate.js'
const TEST_SCHEMA = `
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'personal',
ownerUserId TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS space_members (
spaceId TEXT NOT NULL,
userId TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
createdAt TEXT NOT NULL,
UNIQUE(spaceId, userId)
);
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
name TEXT NOT NULL,
website TEXT,
contact TEXT,
@@ -17,6 +35,7 @@ CREATE TABLE IF NOT EXISTS providers (
CREATE TABLE IF NOT EXISTS provider_accounts (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
providerId TEXT NOT NULL,
name TEXT NOT NULL,
panelUrl TEXT,
@@ -36,6 +55,7 @@ CREATE TABLE IF NOT EXISTS provider_accounts (
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
ip TEXT,
ipv6 TEXT,
additionalIps TEXT,
@@ -76,8 +96,20 @@ CREATE TABLE IF NOT EXISTS vps (
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS vps_grants (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL,
fromSpaceId TEXT NOT NULL,
toSpaceId TEXT NOT NULL,
permission TEXT NOT NULL DEFAULT 'read',
grantedByUserId TEXT,
createdAt TEXT NOT NULL,
UNIQUE(vpsId, toSpaceId)
);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
@@ -90,6 +122,7 @@ CREATE TABLE IF NOT EXISTS payments (
CREATE TABLE IF NOT EXISTS balance_ledger (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
@@ -103,6 +136,7 @@ CREATE TABLE IF NOT EXISTS balance_ledger (
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
accountId TEXT NOT NULL,
startedAt TEXT NOT NULL,
finishedAt TEXT,
@@ -116,6 +150,7 @@ CREATE TABLE IF NOT EXISTS sync_log (
CREATE TABLE IF NOT EXISTS active_tariffs (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
providerAccountId TEXT NOT NULL,
providerId TEXT NOT NULL,
externalId TEXT NOT NULL,
@@ -139,8 +174,17 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS tariff_sync_options (
providerAccountId TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
datacenters TEXT,
periods TEXT,
syncedAt TEXT
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
baseCurrency TEXT,
ratesUrl TEXT,
autoConvert INTEGER,
@@ -165,11 +209,13 @@ CREATE TABLE IF NOT EXISTS settings (
integrationToken TEXT,
integrationEnabled INTEGER,
integrationLastSyncAt TEXT,
cfdmApiUrl TEXT
cfdmApiUrl TEXT,
showQuickActions INTEGER
);
CREATE TABLE IF NOT EXISTS vps_domains (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
vpsId TEXT,
fqdn TEXT NOT NULL,
zoneName TEXT NOT NULL,
@@ -187,6 +233,7 @@ CREATE TABLE IF NOT EXISTS vps_domains (
CREATE TABLE IF NOT EXISTS notification_log (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
event TEXT NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL,
@@ -198,6 +245,7 @@ CREATE TABLE IF NOT EXISTS notification_log (
CREATE TABLE IF NOT EXISTS notification_state (
key TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
lastFingerprint TEXT,
lastSentAt TEXT,
lastStatus TEXT
@@ -205,12 +253,34 @@ CREATE TABLE IF NOT EXISTS notification_state (
CREATE TABLE IF NOT EXISTS server_projects (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
name TEXT NOT NULL,
color TEXT,
sortOrder INTEGER DEFAULT 0,
notes TEXT,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
entity TEXT NOT NULL,
entityId TEXT NOT NULL,
action TEXT NOT NULL,
diff TEXT,
actorUserId TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vps_health_checks (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
vpsId TEXT NOT NULL,
checkedAt TEXT NOT NULL,
status TEXT NOT NULL,
latencyMs INTEGER,
error TEXT
);
`
export function resetTestDb(): void {
@@ -219,13 +289,20 @@ export function resetTestDb(): void {
process.env.DB_PATH = ':memory:'
const sqlite = getSqlite()
sqlite.exec(TEST_SCHEMA)
const now = new Date().toISOString()
sqlite
.prepare(
`INSERT OR IGNORE INTO spaces (id, name, slug, kind, ownerUserId, createdAt)
VALUES ('space-main', 'Основное', 'main', 'main', NULL, ?)`,
)
.run(now)
}
export function seedTestProvider(id = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES (?, 'Test Host', 'billmanager', 'https://bm.test')`,
`INSERT INTO providers (id, spaceId, name, apiType, apiBaseUrl) VALUES (?, 'space-main', 'Test Host', 'billmanager', 'https://bm.test')`,
)
.run(id)
}
@@ -233,6 +310,8 @@ export function seedTestProvider(id = 'prov-1'): void {
export function seedTestProviderAccount(id = 'acc-1', providerId = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(`INSERT INTO provider_accounts (id, providerId, name) VALUES (?, ?, 'Test Account')`)
.prepare(
`INSERT INTO provider_accounts (id, spaceId, providerId, name) VALUES (?, 'space-main', ?, 'Test Account')`,
)
.run(id, providerId)
}