Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
74af90270f
commit
670b5a4468
19 files changed
+1131
-167
No files matched your search
@@ -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())
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user