feat(spaces): корзина, hard purge, transfer и полный backup
Docker / build (push) Failing after 20s

Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-19 12:50:18 +07:00
co-authored by Cursor
parent 74af90270f
commit 670b5a4468
19 changed files with 1131 additions and 167 deletions
+181 -14
View File
@@ -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<SpaceRole, number> = {
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<SpaceRow, 'deletedAt'>): 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())
},
+3 -1
View File
@@ -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`,
+2
View File
@@ -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(
+2 -1
View File
@@ -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 (