diff --git a/.env.example b/.env.example index 84a7374..7b39f4e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 878e68d..bf6db1d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index e8c67f6..e67e07a 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -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) => diff --git a/apps/api/src/plugins/integration-auth.ts b/apps/api/src/plugins/integration-auth.ts index 7de4b56..0fba142 100644 --- a/apps/api/src/plugins/integration-auth.ts +++ b/apps/api/src/plugins/integration-auth.ts @@ -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 { - 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( + request: FastifyRequest, + fn: () => T, +): T { + const spaceId = + (request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId ?? + request.spaceId ?? + 'space-main' + return runWithSpace(spaceId, fn) } diff --git a/apps/api/src/plugins/space.ts b/apps/api/src/plugins/space.ts new file mode 100644 index 0000000..4f5f09c --- /dev/null +++ b/apps/api/src/plugins/space.ts @@ -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 { + 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') +} diff --git a/apps/api/src/routes/integrations-cfdm.ts b/apps/api/src/routes/integrations-cfdm.ts index 8c7e51c..f563bbf 100644 --- a/apps/api/src/routes/integrations-cfdm.ts +++ b/apps/api/src/routes/integrations-cfdm.ts @@ -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, + } + }) }, ) } diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index e0f6359..9470049 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -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 в настройках' } } diff --git a/apps/api/src/routes/spaces.test.ts b/apps/api/src/routes/spaces.test.ts new file mode 100644 index 0000000..09ac373 --- /dev/null +++ b/apps/api/src/routes/spaces.test.ts @@ -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() + }) +}) diff --git a/apps/api/src/routes/spaces.ts b/apps/api/src/routes/spaces.ts new file mode 100644 index 0000000..fbd016e --- /dev/null +++ b/apps/api/src/routes/spaces.ts @@ -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 } diff --git a/apps/api/src/routes/vps.ts b/apps/api/src/routes/vps.ts index 470e4b1..f5ef072 100644 --- a/apps/api/src/routes/vps.ts +++ b/apps/api/src/routes/vps.ts @@ -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) + return updated } - vpsDomainsRepository.rematchAll() - auditUpdate('vps', req.params.id, parsed.data as Record) - 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) + 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) { diff --git a/apps/api/src/services/cfdm-notify.ts b/apps/api/src/services/cfdm-notify.ts index 740b52a..d10eada 100644 --- a/apps/api/src/services/cfdm-notify.ts +++ b/apps/api/src/services/cfdm-notify.ts @@ -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 { if (vpsIds.length === 0) return - const row = settingsRepository.getRow(SETTINGS_ID) + const row = settingsRepository.getBySpace() if (!row?.integrationEnabled) return const token = settingsRepository.getIntegrationToken() diff --git a/apps/api/src/services/fourvps/sync.test.ts b/apps/api/src/services/fourvps/sync.test.ts index cb99e14..4295734 100644 --- a/apps/api/src/services/fourvps/sync.test.ts +++ b/apps/api/src/services/fourvps/sync.test.ts @@ -22,6 +22,7 @@ import { const account: FourvpsSyncAccount = { id: 'acc-4vps', + spaceId: 'space-main', providerId: 'prov-4vps', name: '4VPS Account', panelUrl: '', diff --git a/apps/api/src/services/providers/sync-job.ts b/apps/api/src/services/providers/sync-job.ts index 36a7947..47681fe 100644 --- a/apps/api/src/services/providers/sync-job.ts +++ b/apps/api/src/services/providers/sync-job.ts @@ -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 { 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', diff --git a/apps/api/src/services/ruvds/sync.test.ts b/apps/api/src/services/ruvds/sync.test.ts index be8ff3c..e85b792 100644 --- a/apps/api/src/services/ruvds/sync.test.ts +++ b/apps/api/src/services/ruvds/sync.test.ts @@ -33,6 +33,7 @@ import { function makeAccount(): RuvdsSyncAccount { return { id: 'acc-ruvds', + spaceId: 'space-main', providerId: 'prov-ruvds', name: 'RuVDS', panelUrl: '', diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index ad93d95..d0c6c97 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -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 | null = null let notifyIntervalId: ReturnType | null = null let uptimeIntervalId: ReturnType | 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(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 { - 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 { - 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 { - 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 { - 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(', ')}`) diff --git a/apps/api/src/services/userapi/sync.test.ts b/apps/api/src/services/userapi/sync.test.ts index f5a9162..e604493 100644 --- a/apps/api/src/services/userapi/sync.test.ts +++ b/apps/api/src/services/userapi/sync.test.ts @@ -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: '', diff --git a/apps/api/src/services/veesp/sync.test.ts b/apps/api/src/services/veesp/sync.test.ts index 6befb8f..af6077e 100644 --- a/apps/api/src/services/veesp/sync.test.ts +++ b/apps/api/src/services/veesp/sync.test.ts @@ -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: '', diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 3abbd8c..a873912 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 = { '/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 }) { + {navGroups.map((group) => ( diff --git a/apps/web/src/components/layout/space-switcher.tsx b/apps/web/src/components/layout/space-switcher.tsx new file mode 100644 index 0000000..de139dd --- /dev/null +++ b/apps/web/src/components/layout/space-switcher.tsx @@ -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 ( +
Пространства…
+ ) + } + + return ( + <> + + + + } + > +
+ {current?.name ?? 'Пространство'} + + {current?.kind === 'main' ? 'Основное' : 'Личное'} + {current?.role ? ` · ${current.role}` : ''} + +
+ +
+ + Пространства + {spaces.map((s) => ( + selectSpace(s)} + className={s.id === current?.id ? 'bg-accent' : undefined} + > + {s.name} + + ))} + + setCreateOpen(true)}> + + Создать пространство + + +
+
+
+ + + + + Новое пространство + +
+ + setName(e.target.value)} + placeholder="Моя команда" + /> +
+ + + + +
+
+ + ) +} diff --git a/apps/web/src/components/vps-access-sheet.tsx b/apps/web/src/components/vps-access-sheet.tsx new file mode 100644 index 0000000..b1e6d01 --- /dev/null +++ b/apps/web/src/components/vps-access-sheet.tsx @@ -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 ( + + + + Доступ к серверу + + {vps ? `${vps.ip || vps.dns || vps.id}` : ''} + {' — share оставляет запись здесь; assign переносит в другое пространство.'} + + + +
+ + +
+ +
+ + +
+ + + + + +
+
+ ) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index c46c49e..6a79b2d 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -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(path: string, options: RequestInit = {}): Promise 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('/api/spaces'), + + createSpace: (body: { name: string; slug?: string }) => + fetchApi('/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) }), diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index bb5807d..4fc23cd 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -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' diff --git a/apps/web/src/lib/space.ts b/apps/web/src/lib/space.ts new file mode 100644 index 0000000..8661237 --- /dev/null +++ b/apps/web/src/lib/space.ts @@ -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 */ + } +} diff --git a/apps/web/src/queries/snapshot.ts b/apps/web/src/queries/snapshot.ts index 6af5906..e2b1258 100644 --- a/apps/web/src/queries/snapshot.ts +++ b/apps/web/src/queries/snapshot.ts @@ -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, } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1a5efd8..0c732a0 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, diff --git a/apps/web/src/routes/_auth/spaces.tsx b/apps/web/src/routes/_auth/spaces.tsx new file mode 100644 index 0000000..d4e36ce --- /dev/null +++ b/apps/web/src/routes/_auth/spaces.tsx @@ -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 ( + + + + void membersQuery.refetch()} + empty={Boolean(membersQuery.data && membersQuery.data.length === 0)} + emptyTitle="Нет участников" + emptyDescription="Добавьте userId из auth-portal" + > + {(members) => ( +
+
+
+ + setUserId(e.target.value)} + placeholder="uuid пользователя" + /> +
+
+ + +
+ +
+ + + + + User ID + Роль + + + + + {members.map((m) => ( + + {m.userId} + {m.role} + + {m.role !== 'owner' ? ( + + ) : null} + + + ))} + +
+
+ )} +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 7dc4b09..2f76640 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -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(EMPTY_FORM) const [filters, setFilters] = useState(buildDefaultVpsFilters()) const [selectedIds, setSelectedIds] = useState([]) + const [accessVps, setAccessVps] = useState(null) + const [accessOpen, setAccessOpen] = useState(false) useEffect(() => { if (!health) return @@ -414,9 +417,14 @@ function VpsPage() { header: 'Статус', icon: CircleDotIcon, cell: (v) => ( - - {vpsStatusLabel(v.status)} - +
+ {v.access === 'shared' ? ( + Общий + ) : null} + + {vpsStatusLabel(v.status)} + +
), }, { @@ -490,10 +498,25 @@ function VpsPage() { className: 'w-24 text-right', cell: (v) => ( 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' ? ( + + ) : null + } /> ), }, @@ -661,6 +684,11 @@ function VpsPage() { submitting={createMutation.isPending || updateMutation.isPending} /> ) : null} + ) } diff --git a/apps/web/src/types/entities.ts b/apps/web/src/types/entities.ts index 82e5797..5953472 100644 --- a/apps/web/src/types/entities.ts +++ b/apps/web/src/types/entities.ts @@ -83,6 +83,9 @@ export interface Vps { paidUntil?: string notes?: string customData?: string | Record + access?: 'owned' | 'shared' + grantPermission?: 'read' | 'write' + spaceId?: string } export interface Payment { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cd3fd41..a040d02 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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, diff --git a/packages/db/src/repositories/audit-log.ts b/packages/db/src/repositories/audit-log.ts index 280de4c..aa402af 100644 --- a/packages/db/src/repositories/audit-log.ts +++ b/packages/db/src/repositories/audit-log.ts @@ -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 + 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() diff --git a/packages/db/src/repositories/balance-ledger.ts b/packages/db/src/repositories/balance-ledger.ts index b9a6cc1..f28f417 100644 --- a/packages/db/src/repositories/balance-ledger.ts +++ b/packages/db/src/repositories/balance-ledger.ts @@ -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) { 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 | 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 }, } diff --git a/packages/db/src/repositories/notifications.ts b/packages/db/src/repositories/notifications.ts index 4a5a184..54f9e8d 100644 --- a/packages/db/src/repositories/notifications.ts +++ b/packages/db/src/repositories/notifications.ts @@ -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() } diff --git a/packages/db/src/repositories/payments.ts b/packages/db/src/repositories/payments.ts index 719b223..10c8d7b 100644 --- a/packages/db/src/repositories/payments.ts +++ b/packages/db/src/repositories/payments.ts @@ -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) { 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 | 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 }, } diff --git a/packages/db/src/repositories/projects.ts b/packages/db/src/repositories/projects.ts index 17200b5..7c3afa3 100644 --- a/packages/db/src/repositories/projects.ts +++ b/packages/db/src/repositories/projects.ts @@ -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 }, } diff --git a/packages/db/src/repositories/provider-accounts.ts b/packages/db/src/repositories/provider-accounts.ts index 77dd993..60d99eb 100644 --- a/packages/db/src/repositories/provider-accounts.ts +++ b/packages/db/src/repositories/provider-accounts.ts @@ -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 }, diff --git a/packages/db/src/repositories/providers.ts b/packages/db/src/repositories/providers.ts index 2a1acd8..b9f6037 100644 --- a/packages/db/src/repositories/providers.ts +++ b/packages/db/src/repositories/providers.ts @@ -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 & { @@ -22,18 +23,29 @@ function normalize(input: Partial) { 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 }, } diff --git a/packages/db/src/repositories/settings.ts b/packages/db/src/repositories/settings.ts index 2433af1..a680919 100644 --- a/packages/db/src/repositories/settings.ts +++ b/packages/db/src/repositories/settings.ts @@ -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)! + }, } diff --git a/packages/db/src/repositories/snapshot.ts b/packages/db/src/repositories/snapshot.ts index e2fee18..8ea2b31 100644 --- a/packages/db/src/repositories/snapshot.ts +++ b/packages/db/src/repositories/snapshot.ts @@ -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 + spaceId: string + vps: VpsDto[] serverProjects: ReturnType providers: ReturnType providerAccounts: ReturnType @@ -21,11 +24,36 @@ export interface Snapshot { tariffSyncOptions: ReturnType syncLog: ReturnType vpsDomains: ReturnType + vpsGrants: ReturnType +} + +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, } diff --git a/packages/db/src/repositories/spaces.ts b/packages/db/src/repositories/spaces.ts new file mode 100644 index 0000000..67b13d1 --- /dev/null +++ b/packages/db/src/repositories/spaces.ts @@ -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 = { + 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()) + }, +} diff --git a/packages/db/src/repositories/sync-log.ts b/packages/db/src/repositories/sync-log.ts index 3facd59..714de56 100644 --- a/packages/db/src/repositories/sync-log.ts +++ b/packages/db/src/repositories/sync-log.ts @@ -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() diff --git a/packages/db/src/repositories/tariffs.ts b/packages/db/src/repositories/tariffs.ts index 647c0d9..2f8f0ba 100644 --- a/packages/db/src/repositories/tariffs.ts +++ b/packages/db/src/repositories/tariffs.ts @@ -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() } }, } diff --git a/packages/db/src/repositories/vps-domains.ts b/packages/db/src/repositories/vps-domains.ts index a97e36c..3a82375 100644 --- a/packages/db/src/repositories/vps-domains.ts +++ b/packages/db/src/repositories/vps-domains.ts @@ -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() }, diff --git a/packages/db/src/repositories/vps.ts b/packages/db/src/repositories/vps.ts index d60b245..f4ffc79 100644 --- a/packages/db/src/repositories/vps.ts +++ b/packages/db/src/repositories/vps.ts @@ -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 ({ ...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) { diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 2b793fe..5536f01 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -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 } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7c25609..c241969 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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'), diff --git a/packages/db/src/space-context.ts b/packages/db/src/space-context.ts new file mode 100644 index 0000000..82bfb73 --- /dev/null +++ b/packages/db/src/space-context.ts @@ -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(spaceId: string, fn: () => T): T { + return storage.run({ spaceId }, fn) +} + +export async function runWithSpaceAsync( + spaceId: string, + fn: () => Promise, +): Promise { + 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}` +} diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index 0ce2a14..0f84610 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -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) }