feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 в настройках' }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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,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: '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
function makeAccount(): RuvdsSyncAccount {
|
||||
return {
|
||||
id: 'acc-ruvds',
|
||||
spaceId: 'space-main',
|
||||
providerId: 'prov-ruvds',
|
||||
name: 'RuVDS',
|
||||
panelUrl: '',
|
||||
|
||||
+184
-111
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) }),
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user