diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index c773506..67a7e10 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -36,6 +36,21 @@ export function getDb(): Database.Database { return db; } +export function deleteOrganization(db: Database.Database, orgId: string): void { + const tx = db.transaction(() => { + // Instance events do not cascade, so remove them before their instances. + db.prepare( + 'DELETE FROM instance_events WHERE instance_id IN (SELECT id FROM instances WHERE org_id = ?)', + ).run(orgId); + db.prepare('DELETE FROM instances WHERE org_id = ?').run(orgId); + db.prepare('DELETE FROM org_invitations WHERE org_id = ?').run(orgId); + db.prepare('DELETE FROM org_members WHERE org_id = ?').run(orgId); + db.prepare('DELETE FROM organizations WHERE id = ?').run(orgId); + }); + + tx(); +} + // Fresh-install schema. Arc Launchpad creates its SQLite database on first // run; there is no upgrade path from older databases, so this is a single // flat set of CREATE TABLE statements rather than an incremental migration diff --git a/src/lib/server/orgDeletion.test.ts b/src/lib/server/orgDeletion.test.ts new file mode 100644 index 0000000..acd9cef --- /dev/null +++ b/src/lib/server/orgDeletion.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { deleteOrganization, getDb } from './db'; + +describe('organization deletion', () => { + it('deletes instances and their retained data before the organization', () => { + const db = getDb(); + db.exec('DELETE FROM instance_events; DELETE FROM instances; DELETE FROM org_members; DELETE FROM organizations; DELETE FROM users;'); + db.prepare('INSERT INTO users (id, email) VALUES (?, ?)').run('user-1', 'user@example.test'); + db.prepare('INSERT INTO organizations (id, name, owner_user_id) VALUES (?, ?, ?)').run('org-1', 'Test org', 'user-1'); + db.prepare('INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?)').run('org-1', 'user-1', 'owner'); + db.prepare('INSERT INTO instances (id, org_id, resource_id, admin_token) VALUES (?, ?, ?, ?)').run( + 'instance-1', + 'org-1', + 'resource-1', + 'secret-token', + ); + db.prepare('INSERT INTO instance_events (instance_id, event_type) VALUES (?, ?)').run('instance-1', 'connected'); + db.prepare('INSERT INTO org_invitations (id, org_id, email, token, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)').run( + 'invite-1', + 'org-1', + 'invitee@example.test', + 'invite-token', + 'user-1', + '2099-01-01T00:00:00.000Z', + ); + + deleteOrganization(db, 'org-1'); + + expect(db.prepare('SELECT 1 FROM organizations WHERE id = ?').get('org-1')).toBeUndefined(); + expect(db.prepare('SELECT 1 FROM org_members WHERE org_id = ?').get('org-1')).toBeUndefined(); + expect(db.prepare('SELECT 1 FROM org_invitations WHERE org_id = ?').get('org-1')).toBeUndefined(); + expect(db.prepare('SELECT 1 FROM instances WHERE org_id = ?').get('org-1')).toBeUndefined(); + expect(db.prepare('SELECT 1 FROM instance_events WHERE instance_id = ?').get('instance-1')).toBeUndefined(); + expect(db.prepare('SELECT 1 FROM instances WHERE admin_token = ?').get('secret-token')).toBeUndefined(); + }); +}); diff --git a/src/routes/api/v1/admin/orgs/[org_id]/+server.ts b/src/routes/api/v1/admin/orgs/[org_id]/+server.ts index 57a3e9c..18059ca 100644 --- a/src/routes/api/v1/admin/orgs/[org_id]/+server.ts +++ b/src/routes/api/v1/admin/orgs/[org_id]/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getDb } from '$lib/server/db'; +import { deleteOrganization, getDb } from '$lib/server/db'; import { logOperatorAction } from '$lib/server/audit'; export const PATCH: RequestHandler = async ({ locals, params, request }) => { @@ -40,13 +40,7 @@ export const DELETE: RequestHandler = async ({ locals, params }) => { // Removing an org drops its instance connections, members, and pending invites. // Connections are just stored URLs/tokens — nothing external to tear down. - const tx = db.transaction(() => { - db.prepare("UPDATE instances SET deleted_at = datetime('now'), status = 'deleted' WHERE org_id = ? AND deleted_at IS NULL").run(params.org_id); - db.prepare('DELETE FROM org_invitations WHERE org_id = ?').run(params.org_id); - db.prepare('DELETE FROM org_members WHERE org_id = ?').run(params.org_id); - db.prepare('DELETE FROM organizations WHERE id = ?').run(params.org_id); - }); - tx(); + deleteOrganization(db, params.org_id); logOperatorAction(locals.user.id, 'delete_org', 'org', params.org_id, { name: org.name });