diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index acc8d9f..876b0eb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -30,6 +30,7 @@ import { startScheduler } from './services/scheduler.js' import { authPlugin } from './plugins/auth.js' import { spacePlugin } from './plugins/space.js' import { spacesRoutes } from './routes/spaces.js' +import { portalUsersRoutes } from './routes/portal-users.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { app.get('/health', async () => ({ ok: true })) await app.register(spacesRoutes) + await app.register(portalUsersRoutes) await app.register(dataRoutes) await app.register(vpsRoutes) await app.register(providersRoutes) diff --git a/apps/api/src/routes/backup.ts b/apps/api/src/routes/backup.ts index 178964f..1a72188 100644 --- a/apps/api/src/routes/backup.ts +++ b/apps/api/src/routes/backup.ts @@ -1,20 +1,21 @@ import type { FastifyPluginAsync } from 'fastify' -import { desc } from 'drizzle-orm' import { existsSync } from 'node:fs' import { - getDb, getDbPath, readDatabaseFileBuffer, reloadDatabaseFromBuffer, - schema, } from '@cfdm/db' -import { getSnapshot } from '@cfdm/db/repositories/snapshot' +import { spacesRepository } from '@cfdm/db/repositories/spaces' import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js' +import { + FULL_BACKUP_VERSION, + getFullBackupSnapshot, + importFullBackup, + type FullBackupPayload, +} from '../services/backup-full.js' import { restartScheduler } from '../services/scheduler.js' -const BACKUP_VERSION = 1 - /** Лимит тела для импорта бэкапа (Fastify default = 1 MiB → 413). */ function backupBodyLimitBytes(): number { const raw = process.env.BACKUP_BODY_LIMIT_BYTES @@ -37,18 +38,7 @@ export const backupRoutes: FastifyPluginAsync = async (app) => { ) app.get('/api/backup/json', async (_req, reply) => { - const syncLog = getDb() - .select() - .from(schema.syncLog) - .orderBy(desc(schema.syncLog.startedAt)) - .limit(500) - .all() - const snapshot = { - backupVersion: BACKUP_VERSION, - exportedAt: new Date().toISOString(), - ...getSnapshot(), - syncLog, - } + const snapshot = getFullBackupSnapshot() reply.header('Content-Type', 'application/json; charset=utf-8') reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"') return reply.send(JSON.stringify(snapshot, null, 2)) @@ -74,7 +64,14 @@ export const backupRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } }) } try { - importJsonSnapshot(payload as BackupPayload) + const data = payload as FullBackupPayload + if (data.tables && typeof data.tables === 'object') { + importFullBackup(data) + } else { + importJsonSnapshot(payload as BackupPayload) + } + void FULL_BACKUP_VERSION + spacesRepository.getMain() restartScheduler() return { ok: true } } catch (err) { @@ -95,6 +92,7 @@ export const backupRoutes: FastifyPluginAsync = async (app) => { } try { reloadDatabaseFromBuffer(buf) + spacesRepository.getMain() restartScheduler() return { ok: true } } catch (err) { diff --git a/apps/api/src/routes/portal-users.ts b/apps/api/src/routes/portal-users.ts new file mode 100644 index 0000000..425fe45 --- /dev/null +++ b/apps/api/src/routes/portal-users.ts @@ -0,0 +1,52 @@ +import type { FastifyPluginAsync } from 'fastify' + +/** + * Proxy portal directory users so the SPA searches by name/email + * without calling auth-portal CORS from the browser. + */ +export const portalUsersRoutes: FastifyPluginAsync = async (app) => { + app.get<{ Querystring: { q?: string } }>('/api/portal-users', async (req, reply) => { + const portalUrl = app.authConfig?.portalUrl + if (!portalUrl) { + return reply.code(503).send({ + error: { code: 'UNAVAILABLE', message: 'AUTH_PORTAL_URL не настроен' }, + }) + } + + const auth = req.headers.authorization + if (!auth) { + return reply.code(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' }, + }) + } + + const q = String(req.query.q ?? '').trim() + const url = new URL('/api/v1/directory/users', portalUrl) + if (q) url.searchParams.set('q', q) + + try { + const res = await fetch(url, { + headers: { Authorization: auth, Accept: 'application/json' }, + }) + if (!res.ok) { + const text = await res.text() + return reply.code(res.status).send({ + error: { + code: 'PORTAL_ERROR', + message: text || `Portal HTTP ${res.status}`, + }, + }) + } + const data = (await res.json()) as { id: string; email: string; name: string }[] + return Array.isArray(data) ? data : [] + } catch (err) { + req.log.error(err) + return reply.code(502).send({ + error: { + code: 'BAD_GATEWAY', + message: err instanceof Error ? err.message : 'Ошибка portal', + }, + }) + } + }) +} diff --git a/apps/api/src/routes/spaces.test.ts b/apps/api/src/routes/spaces.test.ts index 09ac373..7036271 100644 --- a/apps/api/src/routes/spaces.test.ts +++ b/apps/api/src/routes/spaces.test.ts @@ -103,4 +103,112 @@ describe('spaces API', () => { expect(body.permission).toBe('read') await app.close() }) + + it('soft-delete → restore; soft → purge; purge active/main → 400', async () => { + const personal = spacesRepository.create({ + id: 'space-user-trash', + name: 'Trashable', + slug: 'trashable', + kind: 'personal', + ownerUserId: 'u-trash', + }) + runWithSpace(personal.id, () => + vpsRepository.create({ + ip: '9.9.9.9', + providerId: 'prov-1', + providerAccountId: 'acc-1', + status: 'active', + }), + ) + + const app = await buildApp() + + const soft = await app.inject({ + method: 'DELETE', + url: `/api/spaces/${personal.id}`, + }) + expect(soft.statusCode).toBe(200) + expect((soft.json() as { deletedAt: string }).deletedAt).toBeTruthy() + + const activeList = await app.inject({ method: 'GET', url: '/api/spaces' }) + expect( + (activeList.json() as { id: string }[]).some((s) => s.id === personal.id), + ).toBe(false) + + const trashList = await app.inject({ + method: 'GET', + url: '/api/spaces?deleted=1', + }) + expect( + (trashList.json() as { id: string }[]).some((s) => s.id === personal.id), + ).toBe(true) + + const restore = await app.inject({ + method: 'POST', + url: `/api/spaces/${personal.id}/restore`, + }) + expect(restore.statusCode).toBe(200) + expect((restore.json() as { deletedAt: string | null }).deletedAt).toBeFalsy() + + // soft again then purge + await app.inject({ method: 'DELETE', url: `/api/spaces/${personal.id}` }) + + const purgeActive = await app.inject({ + method: 'DELETE', + url: `/api/spaces/${MAIN_SPACE_ID}/purge`, + }) + expect(purgeActive.statusCode).toBe(400) + + const purgeBeforeTrash = await app.inject({ + method: 'DELETE', + url: `/api/spaces/${personal.id}`, + }) + // already soft-deleted — soft again returns existing + expect(purgeBeforeTrash.statusCode).toBe(200) + + const stillActive = spacesRepository.create({ + id: 'space-user-active-purge', + name: 'Active', + slug: 'active-purge', + kind: 'personal', + ownerUserId: 'u-a', + }) + const purgeActiveSpace = await app.inject({ + method: 'DELETE', + url: `/api/spaces/${stillActive.id}/purge`, + }) + expect(purgeActiveSpace.statusCode).toBe(400) + + const purged = await app.inject({ + method: 'DELETE', + url: `/api/spaces/${personal.id}/purge`, + }) + expect(purged.statusCode).toBe(204) + expect(spacesRepository.getAny(personal.id)).toBeUndefined() + expect(runWithSpace(personal.id, () => vpsRepository.list()).length).toBe(0) + + await app.close() + }) + + it('transfers ownership', async () => { + const personal = spacesRepository.create({ + id: 'space-user-xfer', + name: 'Xfer', + slug: 'xfer', + kind: 'personal', + ownerUserId: 'owner-a', + }) + const app = await buildApp() + const res = await app.inject({ + method: 'POST', + url: `/api/spaces/${personal.id}/transfer-ownership`, + payload: { newOwnerUserId: 'owner-b' }, + }) + expect(res.statusCode).toBe(200) + const body = res.json() as { ownerUserId: string } + expect(body.ownerUserId).toBe('owner-b') + expect(spacesRepository.getMember(personal.id, 'owner-a')?.role).toBe('admin') + expect(spacesRepository.getMember(personal.id, 'owner-b')?.role).toBe('owner') + await app.close() + }) }) diff --git a/apps/api/src/routes/spaces.ts b/apps/api/src/routes/spaces.ts index fbd016e..5c7254e 100644 --- a/apps/api/src/routes/spaces.ts +++ b/apps/api/src/routes/spaces.ts @@ -18,13 +18,29 @@ function isSpacesAdmin(request: { authUser?: { isAdmin?: boolean; permissions: s return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin') } +function canManageDeletedSpace( + space: { ownerUserId: string | null; id: string }, + userId: string | undefined, + admin: boolean, +): boolean { + if (admin) return true + if (!userId) return true + if (space.ownerUserId === userId) return true + const member = spacesRepository.getMember(space.id, userId) + return member?.role === 'owner' +} + export const spacesRoutes: FastifyPluginAsync = async (app) => { - app.get('/api/spaces', async (req) => { + app.get<{ Querystring: { deleted?: string } }>('/api/spaces', async (req) => { const user = req.authUser + const deletedOnly = req.query.deleted === '1' || req.query.deleted === 'true' if (!user) { + if (deletedOnly) { + return spacesRepository.listDeleted().map((s) => ({ ...s, role: 'owner' })) + } return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' })) } - return spacesRepository.listForUser(user.id, isSpacesAdmin(req)) + return spacesRepository.listForUser(user.id, isSpacesAdmin(req), { deletedOnly }) }) app.post('/api/spaces', async (req, reply) => { @@ -65,8 +81,114 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => { return { ...space, role: member?.role ?? 'viewer' } }) + /** Soft-delete → корзина */ + app.delete<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => { + const space = spacesRepository.getAny(req.params.id) + if (!space) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + if (space.kind === 'main' || space.id === MAIN_SPACE_ID) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'Нельзя удалить основное пространство' }, + }) + } + if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) { + return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Только владелец' } }) + } + const updated = spacesRepository.softDelete(req.params.id) + if (!updated) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'Не удалось удалить' }, + }) + } + return updated + }) + + app.post<{ Params: { id: string } }>('/api/spaces/:id/restore', async (req, reply) => { + const space = spacesRepository.getAny(req.params.id) + if (!space) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + if (!space.deletedAt) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'Пространство не в корзине' }, + }) + } + if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) { + return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } }) + } + return spacesRepository.restore(req.params.id) + }) + + /** Hard purge — only soft-deleted */ + app.delete<{ Params: { id: string } }>('/api/spaces/:id/purge', async (req, reply) => { + const space = spacesRepository.getAny(req.params.id) + if (!space) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + if (space.kind === 'main' || space.id === MAIN_SPACE_ID) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'Нельзя удалить основное пространство' }, + }) + } + if (!space.deletedAt) { + return reply.code(400).send({ + error: { + code: 'VALIDATION', + message: 'Сначала переместите пространство в корзину', + }, + }) + } + if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) { + return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } }) + } + const ok = spacesRepository.purge(req.params.id) + if (!ok) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'Не удалось удалить навсегда' }, + }) + } + return reply.code(204).send() + }) + + app.post<{ Params: { id: string } }>( + '/api/spaces/:id/transfer-ownership', + 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 + const admin = isSpacesAdmin(req) + if (user) { + const member = spacesRepository.getMember(space.id, user.id) + if (!admin && member?.role !== 'owner') { + return reply.code(403).send({ + error: { + code: 'FORBIDDEN', + message: 'Только владелец может передать владение', + }, + }) + } + } + const body = req.body as { newOwnerUserId?: string } + const newOwnerUserId = String(body.newOwnerUserId ?? '').trim() + if (!newOwnerUserId) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'newOwnerUserId обязателен' }, + }) + } + const updated = spacesRepository.transferOwnership(req.params.id, newOwnerUserId) + if (!updated) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: 'Не удалось передать владение' }, + }) + } + return updated + }, + ) + 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 } @@ -159,7 +281,6 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => { }, ) - /** 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) => { @@ -202,7 +323,10 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => { 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)) { + 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) @@ -210,7 +334,6 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => { }, ) - /** Assign (move) VPS to target space */ app.post<{ Params: { id: string; vpsId: string } }>( '/api/spaces/:id/vps/:vpsId/assign', async (req, reply) => { @@ -235,8 +358,7 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => { }) } vpsGrantsRepository.deleteByVps(req.params.vpsId) - const moved = vpsRepository.assignToSpace(req.params.vpsId, toSpaceId) - return moved + return vpsRepository.assignToSpace(req.params.vpsId, toSpaceId) }, ) diff --git a/apps/api/src/services/backup-full.ts b/apps/api/src/services/backup-full.ts new file mode 100644 index 0000000..2868098 --- /dev/null +++ b/apps/api/src/services/backup-full.ts @@ -0,0 +1,130 @@ +import { getDb, getSqlite, schema } from '@cfdm/db' +import { + spacesRepository, + vpsGrantsRepository, +} from '@cfdm/db/repositories/spaces' + +const TABLE_ORDER_DELETE = [ + 'vps_grants', + 'notification_log', + 'notification_state', + 'vps_health_checks', + 'audit_log', + 'sync_log', + 'active_tariffs', + 'tariff_sync_options', + 'topology_diagrams', + 'vps_domains', + 'balance_ledger', + 'payments', + 'vps', + 'provider_accounts', + 'server_projects', + 'providers', + 'settings', + 'space_members', + 'spaces', +] as const + +const TABLE_ORDER_INSERT = [...TABLE_ORDER_DELETE].reverse() + +export const FULL_BACKUP_VERSION = 2 + +export type FullBackupPayload = { + backupVersion?: number + exportedAt?: string + tables?: Record[]> + /** Legacy v1 fields — ignored when tables present */ + [key: string]: unknown +} + +function selectAll(table: string): Record[] { + const sqlite = getSqlite() + try { + return sqlite.prepare(`SELECT * FROM ${table}`).all() as Record[] + } catch { + return [] + } +} + +/** Full multi-space dump for backup export. */ +export function getFullBackupSnapshot(): FullBackupPayload { + const tables: Record[]> = {} + for (const table of TABLE_ORDER_INSERT) { + tables[table] = selectAll(table) + } + return { + backupVersion: FULL_BACKUP_VERSION, + exportedAt: new Date().toISOString(), + tables, + } +} + +function quoteIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"` +} + +function insertRows(table: string, rows: Record[]): void { + if (!rows.length) return + const sqlite = getSqlite() + const cols = Object.keys(rows[0]!) + const placeholders = cols.map(() => '?').join(', ') + const colList = cols.map(quoteIdent).join(', ') + const stmt = sqlite.prepare( + `INSERT INTO ${table} (${colList}) VALUES (${placeholders})`, + ) + for (const row of rows) { + stmt.run(...cols.map((c) => row[c] ?? null)) + } +} + +/** + * Full overwrite import (v2 tables dump). + * Falls back to legacy importJsonSnapshot when `tables` missing. + */ +export function importFullBackup(data: FullBackupPayload): void { + const tables = data.tables + if (!tables || typeof tables !== 'object') { + throw new Error('FULL_BACKUP_REQUIRED') + } + + const sqlite = getSqlite() + sqlite.exec('BEGIN') + try { + sqlite.pragma('foreign_keys = OFF') + for (const table of TABLE_ORDER_DELETE) { + try { + sqlite.exec(`DELETE FROM ${table}`) + } catch { + /* missing table */ + } + } + + for (const table of TABLE_ORDER_INSERT) { + const rows = Array.isArray(tables[table]) ? tables[table]! : [] + insertRows(table, rows) + } + + sqlite.pragma('foreign_keys = ON') + sqlite.exec('COMMIT') + } catch (err) { + sqlite.exec('ROLLBACK') + sqlite.pragma('foreign_keys = ON') + throw err + } + + // Ensure main space exists after import + spacesRepository.getMain() +} + +export function listAllSpaceMembers(): typeof schema.spaceMembers.$inferSelect[] { + return getDb().select().from(schema.spaceMembers).all() +} + +export function listAllSpaces() { + return spacesRepository.listAll({ includeDeleted: true }) +} + +export function listAllGrants() { + return vpsGrantsRepository.listAll() +} diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx index c419cf9..7e36cde 100644 --- a/apps/web/src/components/confirm-dialog.tsx +++ b/apps/web/src/components/confirm-dialog.tsx @@ -1,3 +1,5 @@ +import { useState, type ReactElement, type ReactNode } from 'react' + import { AlertDialog, AlertDialogAction, @@ -9,7 +11,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@cfdm/ui/components/alert-dialog' -import type { ReactElement, ReactNode } from 'react' interface ConfirmDialogProps { trigger: ReactElement @@ -18,7 +19,10 @@ interface ConfirmDialogProps { confirmLabel?: string cancelLabel?: string destructive?: boolean + /** Called after dialog closes (next tick) — safe for file pickers */ onConfirm: () => void + open?: boolean + onOpenChange?: (open: boolean) => void } export function ConfirmDialog({ @@ -29,9 +33,28 @@ export function ConfirmDialog({ cancelLabel = 'Отмена', destructive, onConfirm, + open: openProp, + onOpenChange: onOpenChangeProp, }: ConfirmDialogProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false) + const isControlled = openProp !== undefined + const open = isControlled ? openProp : uncontrolledOpen + + function setOpen(next: boolean) { + if (!isControlled) setUncontrolledOpen(next) + onOpenChangeProp?.(next) + } + + function handleConfirm() { + setOpen(false) + // Close AlertDialog before opening native file picker / async work + queueMicrotask(() => { + onConfirm() + }) + } + return ( - + @@ -42,7 +65,7 @@ export function ConfirmDialog({ {cancelLabel} {confirmLabel} diff --git a/apps/web/src/components/layout/space-switcher.tsx b/apps/web/src/components/layout/space-switcher.tsx index c216782..48bc9a7 100644 --- a/apps/web/src/components/layout/space-switcher.tsx +++ b/apps/web/src/components/layout/space-switcher.tsx @@ -1,5 +1,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { CheckIcon, ChevronsUpDownIcon, PlusIcon, UsersIcon } from 'lucide-react' +import { + CheckIcon, + ChevronsUpDownIcon, + PlusIcon, + Trash2Icon, + UsersIcon, +} from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -29,10 +35,13 @@ import { useSidebar, } from '@cfdm/ui/components/sidebar' +import { ConfirmDialog } from '@/components/confirm-dialog' import { api } from '@/lib/api-client' import { useSpaceId, type SpaceDto } from '@/lib/space' import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot' +const MAIN_KIND = 'main' + export function SpaceSwitcher() { const { isMobile } = useSidebar() const qc = useQueryClient() @@ -49,6 +58,14 @@ export function SpaceSwitcher() { } }, [spaceId, spaces, setSpaceId]) + // If current space disappeared (soft-deleted), fall back to first active + useEffect(() => { + if (spaceId && spaces.length > 0 && !spaces.some((s) => s.id === spaceId)) { + const next = spaces.find((s) => s.kind === MAIN_KIND) ?? spaces[0] + if (next) setSpaceId(next.id) + } + }, [spaceId, spaces, setSpaceId]) + function selectSpace(space: SpaceDto) { if (space.id === currentId) return setSpaceId(space.id) @@ -77,9 +94,27 @@ export function SpaceSwitcher() { } } + async function handleSoftDelete(space: SpaceDto) { + try { + await api.softDeleteSpace(space.id) + if (spaceId === space.id) { + const next = + spaces.find((s) => s.id !== space.id && s.kind === MAIN_KIND) ?? + spaces.find((s) => s.id !== space.id) + if (next) setSpaceId(next.id) + } + await qc.invalidateQueries({ queryKey: spacesKeys.all }) + await qc.invalidateQueries({ queryKey: spacesKeys.deleted }) + await qc.invalidateQueries({ queryKey: snapshotKeys.all }) + toast.success('Пространство перемещено в корзину') + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Не удалось удалить') + } + } + if (spaces.length === 0) { return ( -
Пространства…
+
Пространства…
) } @@ -97,7 +132,7 @@ export function SpaceSwitcher() { } >
@@ -106,7 +141,7 @@ export function SpaceSwitcher() { {current?.name ?? 'Пространство'} - + {current?.kind === 'main' ? 'Основное' : 'Личное'} {current?.role ? ` · ${current.role}` : ''} @@ -122,10 +157,7 @@ export function SpaceSwitcher() { Пространства {spaces.map((s) => ( - selectSpace(s)} - > + selectSpace(s)}> {s.name} {s.id === current?.id ? ( @@ -140,6 +172,24 @@ export function SpaceSwitcher() { Создать пространство + {current && current.kind !== MAIN_KIND ? ( + void handleSoftDelete(current)} + trigger={ + e.preventDefault()} + className="text-destructive" + > + + Удалить «{current.name}» + + } + /> + ) : null} diff --git a/apps/web/src/components/reui-kit/settings-card.tsx b/apps/web/src/components/reui-kit/settings-card.tsx index e58b67b..a02fb41 100644 --- a/apps/web/src/components/reui-kit/settings-card.tsx +++ b/apps/web/src/components/reui-kit/settings-card.tsx @@ -34,8 +34,10 @@ export function SettingsCard({ }: SettingsCardProps) { return ( - - + + {title} {description ? {description} : null} @@ -44,7 +46,10 @@ export function SettingsCard({ {footer ? ( {footer} diff --git a/apps/web/src/components/setting-row.tsx b/apps/web/src/components/setting-row.tsx index 473135b..5adfa0e 100644 --- a/apps/web/src/components/setting-row.tsx +++ b/apps/web/src/components/setting-row.tsx @@ -6,7 +6,6 @@ import { FieldContent, FieldDescription, FieldLabel, - FieldSeparator, FieldTitle, } from '@cfdm/ui/components/field' @@ -23,7 +22,10 @@ export interface SettingRowProps { titleAddon?: ReactNode } -/** Compact settings row — preview https://reui.io/preview/base/settings-3 */ +/** + * Compact settings row — preview https://reui.io/preview/base/settings-3 · settings-2 + * Hairline divider via border-b (no FieldSeparator h-5). + */ export function SettingRow({ title, description, @@ -37,49 +39,49 @@ export function SettingRow({ titleAddon, }: SettingRowProps) { return ( - <> - -
-
- {labelFor ? ( - {title} - ) : ( - {title} - )} - {titleAddon} -
- - {description ? ( - {description} - ) : null} + +
+
+ {labelFor ? ( + {title} + ) : ( + {title} + )} + {titleAddon}
- {description} + ) : null} +
+ + +
-
- {children} -
- - - - {!last ? : null} - + {children} +
+
+
) } diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index c021d87..b093cc3 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -206,7 +206,10 @@ export const api = { return res.blob() }, - fetchSpaces: () => fetchApi('/api/spaces'), + fetchSpaces: (opts?: { deleted?: boolean }) => + fetchApi( + opts?.deleted ? '/api/spaces?deleted=1' : '/api/spaces', + ), createSpace: (body: { name: string; slug?: string }) => fetchApi('/api/spaces', { @@ -214,6 +217,37 @@ export const api = { body: JSON.stringify(body), }), + softDeleteSpace: (spaceId: string) => + fetchApi( + `/api/spaces/${encodeURIComponent(spaceId)}`, + { method: 'DELETE' }, + ), + + restoreSpace: (spaceId: string) => + fetchApi( + `/api/spaces/${encodeURIComponent(spaceId)}/restore`, + { method: 'POST', body: '{}' }, + ), + + purgeSpace: (spaceId: string) => + fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/purge`, { + method: 'DELETE', + }), + + transferSpaceOwnership: (spaceId: string, newOwnerUserId: string) => + fetchApi( + `/api/spaces/${encodeURIComponent(spaceId)}/transfer-ownership`, + { + method: 'POST', + body: JSON.stringify({ newOwnerUserId }), + }, + ), + + searchPortalUsers: (q: string) => + fetchApi<{ id: string; email: string; name: string }[]>( + `/api/portal-users?q=${encodeURIComponent(q)}`, + ), + fetchSpaceMembers: (spaceId: string) => fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>( `/api/spaces/${encodeURIComponent(spaceId)}/members`, diff --git a/apps/web/src/lib/space.ts b/apps/web/src/lib/space.ts index 53eeb1a..09d870d 100644 --- a/apps/web/src/lib/space.ts +++ b/apps/web/src/lib/space.ts @@ -18,6 +18,7 @@ export type SpaceDto = { kind: string ownerUserId: string | null createdAt: string + deletedAt?: string | null role?: string } diff --git a/apps/web/src/queries/snapshot.ts b/apps/web/src/queries/snapshot.ts index 170e94a..287fa5d 100644 --- a/apps/web/src/queries/snapshot.ts +++ b/apps/web/src/queries/snapshot.ts @@ -19,6 +19,7 @@ export const snapshotQueryOptions = (spaceId?: string | null) => { export const spacesKeys = { all: ['spaces'] as const, + deleted: ['spaces', 'deleted'] as const, members: (spaceId: string) => ['spaces', spaceId, 'members'] as const, } @@ -28,6 +29,12 @@ export const spacesQueryOptions = () => ({ staleTime: 60_000, }) +export const deletedSpacesQueryOptions = () => ({ + queryKey: spacesKeys.deleted, + queryFn: () => api.fetchSpaces({ deleted: true }), + staleTime: 30_000, +}) + export const ratesKeys = { all: ['rates'] as const, } diff --git a/apps/web/src/routes/_auth/settings/index.tsx b/apps/web/src/routes/_auth/settings/index.tsx index 610a588..8ea557c 100644 --- a/apps/web/src/routes/_auth/settings/index.tsx +++ b/apps/web/src/routes/_auth/settings/index.tsx @@ -5,8 +5,11 @@ import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' import { DownloadIcon, UploadIcon } from 'lucide-react' import { useCallback } from 'react' -import { snapshotQueryOptions } from '@/queries/snapshot' +import { snapshotQueryOptions, spacesKeys } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' +import { useSpaceId } from '@/lib/space' + +const MAIN_SPACE_ID = 'space-main' import { QueryState } from '@/components/query-state' import { ConfirmDialog } from '@/components/confirm-dialog' import { LoadingButton } from '@/components/loading-button' @@ -47,6 +50,7 @@ function SettingsSkeleton() { function SettingsGeneralPage() { const queryClient = useQueryClient() + const { setSpaceId } = useSpaceId() const { data: snapshot, current, isLoading, isError, error, refetch } = useSettingsSnapshot() const patchMut = useSettingsPatch({ successMessage: 'Настройки интерфейса сохранены' }) @@ -59,10 +63,17 @@ function SettingsGeneralPage() { values: formValues, }) + async function afterBackupImport() { + setSpaceId(MAIN_SPACE_ID) + await queryClient.invalidateQueries({ queryKey: spacesKeys.all }) + await queryClient.invalidateQueries({ queryKey: spacesKeys.deleted }) + await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) + } + const importJsonMut = useMutation({ mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) + onSuccess: async () => { + await afterBackupImport() toast.success('Импорт JSON выполнен') }, onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'), @@ -70,8 +81,8 @@ function SettingsGeneralPage() { const importDbMut = useMutation({ mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) + onSuccess: async () => { + await afterBackupImport() toast.success('Импорт SQLite выполнен') }, onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'), diff --git a/apps/web/src/routes/_auth/spaces.tsx b/apps/web/src/routes/_auth/spaces.tsx index e4bce75..16e476c 100644 --- a/apps/web/src/routes/_auth/spaces.tsx +++ b/apps/web/src/routes/_auth/spaces.tsx @@ -1,10 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useMemo, 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, @@ -22,12 +21,20 @@ import { TableRow, } from '@cfdm/ui/components/table' +import { AutoCompleteInput } from '@/components/auto-complete-input' +import { ConfirmDialog } from '@/components/confirm-dialog' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' +import { SettingsCard } from '@/components/reui-kit/settings-card' import { api } from '@/lib/api-client' import { useSpaceId } from '@/lib/space' -import { spacesKeys, spacesQueryOptions } from '@/queries/snapshot' +import { + deletedSpacesQueryOptions, + spacesKeys, + spacesQueryOptions, + snapshotKeys, +} from '@/queries/snapshot' export const Route = createFileRoute('/_auth/spaces')({ component: SpacesPage, @@ -40,12 +47,17 @@ type MemberRow = { createdAt: string } +type PortalUser = { id: string; email: string; name: string } + function SpacesPage() { const qc = useQueryClient() const { spaceId } = useSpaceId() const { data: spaces = [] } = useQuery(spacesQueryOptions()) + const { data: trash = [] } = useQuery(deletedSpacesQueryOptions()) const current = spaces.find((s) => s.id === spaceId) ?? spaces[0] const currentId = current?.id ?? '' + const myRole = current?.role ?? 'viewer' + const canAdmin = myRole === 'owner' || myRole === 'admin' const membersQuery = useQuery({ queryKey: spacesKeys.members(currentId), @@ -53,14 +65,75 @@ function SpacesPage() { enabled: Boolean(currentId), }) - const [userId, setUserId] = useState('') + const [userQuery, setUserQuery] = useState('') + const [selectedUserId, setSelectedUserId] = useState('') const [role, setRole] = useState('member') + const [portalUsers, setPortalUsers] = useState([]) + const [memberLabels, setMemberLabels] = useState>({}) + const [transferUserId, setTransferUserId] = useState('') + const [transferQuery, setTransferQuery] = useState('') + + useEffect(() => { + const q = userQuery.trim() || transferQuery.trim() + const t = setTimeout(() => { + void api + .searchPortalUsers(q) + .then(setPortalUsers) + .catch(() => setPortalUsers([])) + }, q ? 250 : 0) + return () => clearTimeout(t) + }, [userQuery, transferQuery]) + + // Resolve names for current members (search by userId) + useEffect(() => { + const members = membersQuery.data + if (!members?.length) return + let cancelled = false + void (async () => { + const next: Record = {} + await Promise.all( + members.map(async (m) => { + try { + const found = await api.searchPortalUsers(m.userId) + const u = found.find((x) => x.id === m.userId) ?? found[0] + if (u && u.id === m.userId) { + next[m.userId] = `${u.name} · ${u.email}` + } + } catch { + /* ignore */ + } + }), + ) + if (!cancelled) setMemberLabels((prev) => ({ ...prev, ...next })) + })() + return () => { + cancelled = true + } + }, [membersQuery.data]) + + const userOptions = useMemo( + () => + portalUsers.map((u) => ({ + value: u.id, + label: `${u.name} · ${u.email}`, + })), + [portalUsers], + ) + + const userLabelById = useMemo(() => { + const map = new Map(Object.entries(memberLabels)) + for (const u of portalUsers) { + map.set(u.id, `${u.name} · ${u.email}`) + } + return map + }, [portalUsers, memberLabels]) const addMutation = useMutation({ mutationFn: () => - api.addSpaceMember(currentId, { userId: userId.trim(), role }), + api.addSpaceMember(currentId, { userId: selectedUserId.trim(), role }), onSuccess: async () => { - setUserId('') + setSelectedUserId('') + setUserQuery('') toast.success('Участник добавлен') await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) }) }, @@ -70,12 +143,44 @@ function SpacesPage() { const removeMutation = useMutation({ mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid), onSuccess: async () => { - toast.success('Участник удалён') + toast.success('Доступ отозван') await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) }) }, onError: (e: Error) => toast.error(e.message), }) + const transferMutation = useMutation({ + mutationFn: () => api.transferSpaceOwnership(currentId, transferUserId.trim()), + onSuccess: async () => { + setTransferUserId('') + setTransferQuery('') + toast.success('Владение передано') + await qc.invalidateQueries({ queryKey: spacesKeys.all }) + await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const restoreMutation = useMutation({ + mutationFn: (id: string) => api.restoreSpace(id), + onSuccess: async () => { + toast.success('Пространство восстановлено') + await qc.invalidateQueries({ queryKey: spacesKeys.all }) + await qc.invalidateQueries({ queryKey: spacesKeys.deleted }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const purgeMutation = useMutation({ + mutationFn: (id: string) => api.purgeSpace(id), + onSuccess: async () => { + toast.success('Пространство удалено навсегда') + await qc.invalidateQueries({ queryKey: spacesKeys.deleted }) + await qc.invalidateQueries({ queryKey: snapshotKeys.all }) + }, + 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 пользователя" - /> -
-
- - -
- +
+ {myRole === 'owner' ? ( + transferMutation.mutate()} + trigger={ + + } + /> + } + > +
+ + { + const match = userOptions.find( + (o) => o.value === v || o.label === v, + ) + if (match) { + setTransferUserId(match.value) + setTransferQuery(match.label) + } else { + setTransferUserId('') + setTransferQuery(v) + } + }} + options={userOptions} + placeholder="Имя или email…" + allowFreeText={false} + emptyText="Пользователи не найдены" + />
+
+ ) : null} + void membersQuery.refetch()} + empty={Boolean(membersQuery.data && membersQuery.data.length === 0)} + emptyTitle="Нет участников" + emptyDescription="Добавьте пользователя по имени или email" + > + {(members) => ( + + {canAdmin ? ( +
+
+ + { + const match = userOptions.find( + (o) => o.value === v || o.label === v, + ) + if (match) { + setSelectedUserId(match.value) + setUserQuery(match.label) + } else { + setSelectedUserId('') + setUserQuery(v) + } + }} + options={userOptions} + placeholder="Имя или email…" + allowFreeText={false} + emptyText="Пользователи не найдены" + /> +
+
+ + +
+ +
+ ) : null} + + + + + Пользователь + Роль + + + + + {members.map((m) => ( + + +
+ + {userLabelById.get(m.userId) ?? m.userId} + + {!userLabelById.has(m.userId) ? ( + + {m.userId} + + ) : null} +
+
+ {m.role} + + {canAdmin && m.role !== 'owner' ? ( + removeMutation.mutate(m.userId)} + trigger={ + + } + /> + ) : null} + +
+ ))} +
+
+
+ )} +
+ + + {trash.length === 0 ? ( +

Корзина пуста

+ ) : ( - User ID - Роль - + Название + Удалено + - {members.map((m) => ( - - {m.userId} - {m.role} + {trash.map((s) => ( + + {s.name} + + {s.deletedAt + ? new Date(s.deletedAt).toLocaleString('ru-RU') + : '—'} + - {m.role !== 'owner' ? ( +
- ) : null} + purgeMutation.mutate(s.id)} + trigger={ + + } + /> +
))}
-
- )} - + )} + +
) } diff --git a/packages/db/src/repositories/spaces.ts b/packages/db/src/repositories/spaces.ts index 67b13d1..68b1d4b 100644 --- a/packages/db/src/repositories/spaces.ts +++ b/packages/db/src/repositories/spaces.ts @@ -1,5 +1,5 @@ -import { and, asc, eq } from 'drizzle-orm' -import { getDb, schema } from '../index.js' +import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' +import { getDb, getSqlite, schema } from '../index.js' import { generateId } from './utils.js' import { MAIN_SPACE_ID, @@ -22,20 +22,81 @@ const ROLE_RANK: Record = { owner: 4, } +/** Tables with spaceId column — purge order (children first). */ +const SPACE_DATA_TABLES = [ + 'vps_grants', + 'notification_log', + 'notification_state', + 'vps_health_checks', + 'audit_log', + 'sync_log', + 'active_tariffs', + 'tariff_sync_options', + 'topology_diagrams', + 'vps_domains', + 'balance_ledger', + 'payments', + 'vps', + 'provider_accounts', + 'server_projects', + 'providers', + 'settings', + 'space_members', +] as const + export function roleAtLeast(role: string, min: SpaceRole): boolean { return (ROLE_RANK[role as SpaceRole] ?? 0) >= ROLE_RANK[min] } +export function isSpaceActive(space: Pick): boolean { + return space.deletedAt == null || space.deletedAt === '' +} + function nowIso(): string { return new Date().toISOString() } export const spacesRepository = { - listAll(): SpaceRow[] { - return getDb().select().from(schema.spaces).orderBy(asc(schema.spaces.name)).all() + listAll(opts?: { includeDeleted?: boolean }): SpaceRow[] { + const db = getDb() + if (opts?.includeDeleted) { + return db.select().from(schema.spaces).orderBy(asc(schema.spaces.name)).all() + } + return db + .select() + .from(schema.spaces) + .where(isNull(schema.spaces.deletedAt)) + .orderBy(asc(schema.spaces.name)) + .all() }, - listForUser(userId: string, isAdmin = false): (SpaceRow & { role: string })[] { + listDeleted(): SpaceRow[] { + return getDb() + .select() + .from(schema.spaces) + .where(isNotNull(schema.spaces.deletedAt)) + .orderBy(asc(schema.spaces.name)) + .all() + }, + + listForUser( + userId: string, + isAdmin = false, + opts?: { deletedOnly?: boolean }, + ): (SpaceRow & { role: string })[] { + if (opts?.deletedOnly) { + const deleted = this.listDeleted() + return deleted + .filter((s) => { + if (isAdmin) return true + return Boolean(this.getMember(s.id, userId)) + }) + .map((s) => { + const m = this.getMember(s.id, userId) + return { ...s, role: m?.role ?? (isAdmin ? 'admin' : 'viewer') } + }) + } + if (isAdmin) { return this.listAll().map((s) => { const m = this.getMember(s.id, userId) @@ -51,17 +112,25 @@ export const spacesRepository = { const out: (SpaceRow & { role: string })[] = [] for (const m of members) { const space = this.get(m.spaceId) - if (space) out.push({ ...space, role: m.role }) + if (space && isSpaceActive(space)) out.push({ ...space, role: m.role }) } return out.sort((a, b) => a.name.localeCompare(b.name)) }, - get(id: string): SpaceRow | undefined { + /** Includes soft-deleted */ + getAny(id: string): SpaceRow | undefined { return getDb().select().from(schema.spaces).where(eq(schema.spaces.id, id)).get() }, + /** Active only */ + get(id: string): SpaceRow | undefined { + const row = this.getAny(id) + if (!row || !isSpaceActive(row)) return undefined + return row + }, + getMain(): SpaceRow { - let row = this.get(MAIN_SPACE_ID) + let row = this.getAny(MAIN_SPACE_ID) if (!row) { row = this.create({ id: MAIN_SPACE_ID, @@ -70,6 +139,10 @@ export const spacesRepository = { kind: 'main', ownerUserId: process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null, }) + } else if (!isSpaceActive(row)) { + // Main must never stay soft-deleted + this.restore(MAIN_SPACE_ID) + row = this.getAny(MAIN_SPACE_ID)! } return row }, @@ -92,6 +165,7 @@ export const spacesRepository = { kind: input.kind ?? 'personal', ownerUserId: input.ownerUserId ?? null, createdAt, + deletedAt: null, }) .run() @@ -106,7 +180,6 @@ export const spacesRepository = { .run() } - // Seed settings for the space const settingsId = settingsIdForSpace(id) const existingSettings = db .select() @@ -125,15 +198,15 @@ export const spacesRepository = { .run() } - return this.get(id)! + return this.getAny(id)! }, update( id: string, input: Partial<{ name: string; slug: string; ownerUserId: string | null }>, ): SpaceRow | undefined { - const existing = this.get(id) - if (!existing) return undefined + const existing = this.getAny(id) + if (!existing || !isSpaceActive(existing)) return undefined getDb() .update(schema.spaces) .set({ @@ -147,10 +220,98 @@ export const spacesRepository = { return this.get(id) }, + softDelete(id: string): SpaceRow | undefined { + const existing = this.getAny(id) + if (!existing) return undefined + if (existing.kind === 'main' || id === MAIN_SPACE_ID) return undefined + if (!isSpaceActive(existing)) return existing + getDb() + .update(schema.spaces) + .set({ deletedAt: nowIso() }) + .where(eq(schema.spaces.id, id)) + .run() + return this.getAny(id) + }, + + restore(id: string): SpaceRow | undefined { + const existing = this.getAny(id) + if (!existing) return undefined + if (isSpaceActive(existing)) return existing + getDb() + .update(schema.spaces) + .set({ deletedAt: null }) + .where(eq(schema.spaces.id, id)) + .run() + return this.get(id) + }, + + /** + * Hard purge — only soft-deleted non-main spaces. + * Cascade deletes all space-scoped rows. + */ + purge(id: string): boolean { + const existing = this.getAny(id) + if (!existing) return false + if (existing.kind === 'main' || id === MAIN_SPACE_ID) return false + if (isSpaceActive(existing)) return false + + const sqlite = getSqlite() + sqlite.exec('BEGIN') + try { + // Grants referencing this space (from or to) + sqlite + .prepare( + `DELETE FROM vps_grants WHERE fromSpaceId = ? OR toSpaceId = ?`, + ) + .run(id, id) + + for (const table of SPACE_DATA_TABLES) { + if (table === 'vps_grants' || table === 'space_members') continue + try { + sqlite.prepare(`DELETE FROM ${table} WHERE spaceId = ?`).run(id) + } catch { + /* table may not exist in older DBs */ + } + } + + sqlite.prepare(`DELETE FROM space_members WHERE spaceId = ?`).run(id) + sqlite.prepare(`DELETE FROM spaces WHERE id = ?`).run(id) + sqlite.exec('COMMIT') + return true + } catch (err) { + sqlite.exec('ROLLBACK') + throw err + } + }, + + transferOwnership( + spaceId: string, + newOwnerUserId: string, + ): SpaceRow | undefined { + const space = this.get(spaceId) + if (!space) return undefined + const oldOwnerId = space.ownerUserId + + if (oldOwnerId && oldOwnerId !== newOwnerUserId) { + this.updateMember(spaceId, oldOwnerId, 'admin') + } + this.addMember(spaceId, newOwnerUserId, 'owner') + getDb() + .update(schema.spaces) + .set({ ownerUserId: newOwnerUserId }) + .where(eq(schema.spaces.id, spaceId)) + .run() + return this.get(spaceId) + }, + ensurePersonalSpace(userId: string, name?: string): SpaceRow { const id = `space-user-${userId}` - const existing = this.get(id) + const existing = this.getAny(id) if (existing) { + if (!isSpaceActive(existing)) { + // Soft-deleted personal — do not recreate same id; fall back to main + return this.getMain() + } const member = this.getMember(id, userId) if (!member) { this.addMember(id, userId, 'owner') @@ -251,6 +412,8 @@ export const spacesRepository = { }, canAccess(spaceId: string, userId: string, isAdmin = false): boolean { + const space = this.get(spaceId) + if (!space) return false if (isAdmin) return true return Boolean(this.getMember(spaceId, userId)) }, @@ -261,6 +424,7 @@ export const spacesRepository = { min: SpaceRole, isAdmin = false, ): SpaceMemberRow | null { + if (!this.get(spaceId) && !isAdmin) return null if (isAdmin) { return ( this.getMember(spaceId, userId) ?? { @@ -294,6 +458,10 @@ export const vpsGrantsRepository = { .all() }, + listAll(): VpsGrantRow[] { + return getDb().select().from(schema.vpsGrants).all() + }, + get(id: string): VpsGrantRow | undefined { return getDb().select().from(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).get() }, @@ -359,7 +527,6 @@ export const vpsGrantsRepository = { 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/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 8c4ca56..a9febe4 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -13,7 +13,8 @@ const CORE_TABLE_MIGRATIONS: string[] = [ slug TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'personal', ownerUserId TEXT, - createdAt TEXT NOT NULL + createdAt TEXT NOT NULL, + deletedAt TEXT )`, `CREATE TABLE IF NOT EXISTS space_members ( spaceId TEXT NOT NULL REFERENCES spaces(id), @@ -324,6 +325,7 @@ const COLUMN_MIGRATIONS: string[] = [ `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 spaces ADD COLUMN deletedAt TEXT`, `ALTER TABLE sync_log ADD COLUMN spaceId TEXT`, `ALTER TABLE sync_log ADD COLUMN summary TEXT`, `ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c1d6549..67526aa 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -8,6 +8,8 @@ export const spaces = sqliteTable('spaces', { kind: text('kind').notNull().default('personal'), ownerUserId: text('ownerUserId'), createdAt: text('createdAt').notNull(), + /** Soft-delete (корзина); null = active */ + deletedAt: text('deletedAt'), }) export const spaceMembers = sqliteTable( diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index 0f84610..6b992c8 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -8,7 +8,8 @@ CREATE TABLE IF NOT EXISTS spaces ( slug TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'personal', ownerUserId TEXT, - createdAt TEXT NOT NULL + createdAt TEXT NOT NULL, + deletedAt TEXT ); CREATE TABLE IF NOT EXISTS space_members (