From 0d14db97a6c8596b6134360c654f5a9f0b93dcab Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 09:22:21 -0500 Subject: [PATCH] feat(admin): Delete Organization + Workspace with cascade (#36) Platform admins can now cleanly remove a customer org (account ends) or a stray workspace from the UI, instead of raw SQL that risks orphaning resources. The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices, content, playlists, ...) are NO ACTION and must be purged before the workspace. Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade / deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses the purgeWorkspaces extraction). Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts + workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id. UI: an Organizations section in Admin listing every org/workspace with a type-the-name confirmation before the irreversible delete. Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53. --- frontend/js/api.js | 3 + .../js/components/type-to-confirm-modal.js | 75 ++++++++++++++++++ frontend/js/i18n/en.js | 18 +++++ frontend/js/views/admin.js | 78 +++++++++++++++++++ server/lib/user-deletion.js | 72 +++++++++++++---- server/routes/admin.js | 54 +++++++++++++ server/test/admin-users.test.js | 38 +++++++++ server/test/user-deletion.test.js | 41 ++++++++++ 8 files changed, 362 insertions(+), 17 deletions(-) create mode 100644 frontend/js/components/type-to-confirm-modal.js diff --git a/frontend/js/api.js b/frontend/js/api.js index 04d9ed0..f5c38a1 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -178,6 +178,9 @@ export const api = { // workspaceId, role, mustChangePassword } adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }), adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }), + adminListOrgs: () => request('/admin/orgs'), + adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }), + adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }), // Instance-level default branding (#15, platform admin). adminGetBranding: () => request('/admin/branding'), diff --git a/frontend/js/components/type-to-confirm-modal.js b/frontend/js/components/type-to-confirm-modal.js new file mode 100644 index 0000000..ef8da1f --- /dev/null +++ b/frontend/js/components/type-to-confirm-modal.js @@ -0,0 +1,75 @@ +import { t } from '../i18n.js'; + +// Reusable destructive-confirmation modal (#36). The primary (danger) button stays +// disabled until the user types `expected` exactly — guards irreversible deletes +// (delete org / workspace). opts: +// title, body (HTML allowed - caller escapes), expected (string to type), +// confirmLabel, onConfirm: async () => any (throw to show an inline error) +export function openTypeToConfirmModal(opts = {}) { + const { title, body = '', expected, confirmLabel, onConfirm } = opts; + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const input = overlay.querySelector('#ttcInput'); + const confirmBtn = overlay.querySelector('#ttcConfirm'); + const errorEl = overlay.querySelector('#ttcError'); + input.focus(); + + const matches = () => input.value.trim() === String(expected); + input.addEventListener('input', () => { confirmBtn.disabled = !matches(); }); + + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function onKey(e) { + if (e.key === 'Escape') close(); + else if (e.key === 'Enter' && matches()) confirm(); + } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelectorAll('[data-ttc-close]').forEach(b => b.addEventListener('click', close)); + + async function confirm() { + if (!matches()) return; + errorEl.style.display = 'none'; + confirmBtn.disabled = true; + confirmBtn.textContent = t('common.deleting'); + try { + await onConfirm?.(); + close(); + } catch (err) { + confirmBtn.disabled = false; + confirmBtn.textContent = confirmLabel || t('common.delete'); + errorEl.textContent = err?.message || t('confirm_delete.failed'); + errorEl.style.display = 'block'; + } + } + confirmBtn.addEventListener('click', confirm); +} + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 0597981..efadc6e 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -26,7 +26,10 @@ export default { 'common.edit': 'Edit', 'common.done': 'Done', 'common.saving': 'Saving...', + 'common.deleting': 'Deleting...', 'common.loading': 'Loading...', + 'confirm_delete.type_label': 'Type "{name}" to confirm', + 'confirm_delete.failed': 'Action failed', 'common.connected': 'Connected', 'common.disconnected': 'Disconnected', 'common.never': 'Never', @@ -796,6 +799,21 @@ export default { 'admin.create_org.success': 'Organization "{name}" created', 'admin.create_org.err_empty': 'Organization name cannot be empty', 'admin.create_org.err_failed': 'Could not create organization', + 'admin.orgs.title': 'Organizations', + 'admin.orgs.desc': 'Every organization and its workspaces. Deleting cascades all devices, content, playlists and memberships — this is irreversible.', + 'admin.orgs.empty': 'No organizations yet.', + 'admin.orgs.owner': 'Owner', + 'admin.orgs.workspaces': 'workspaces', + 'admin.orgs.devices': 'devices', + 'admin.orgs.members': 'members', + 'admin.orgs.delete_org': 'Delete org', + 'admin.orgs.delete_ws': 'Delete', + 'admin.orgs.delete_org_title': 'Delete organization', + 'admin.orgs.delete_org_body': 'This permanently deletes {name} and all of its workspaces, devices, content, playlists and memberships. This cannot be undone.', + 'admin.orgs.delete_ws_title': 'Delete workspace', + 'admin.orgs.delete_ws_body': 'This permanently deletes workspace {name} and all of its devices, content and playlists. The organization is kept. This cannot be undone.', + 'admin.orgs.org_deleted': 'Organization "{name}" deleted', + 'admin.orgs.ws_deleted': 'Workspace "{name}" deleted', 'admin.access_denied': 'Access Denied', 'admin.access_denied_desc': 'Platform admin access required.', 'admin.all_users': 'All Users', diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index cc183a3..19061b4 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -5,6 +5,7 @@ import { t } from '../i18n.js'; import { openAddUserModal } from '../components/workspace-members-add-user-modal.js'; import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js'; import { openCreateOrgModal } from '../components/admin-create-org-modal.js'; +import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js'; // Reuse the members view's server-error -> friendly-string mapper (handles the // 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a // second mapper. @@ -70,6 +71,12 @@ export async function render(container) {

${t('common.loading')}

+
+

${t('admin.orgs.title')}

+

${t('admin.orgs.desc')}

+

${t('common.loading')}

+
+

${t('admin.branding.title')}

${t('admin.branding.desc')}

@@ -111,12 +118,83 @@ export async function render(container) { }); loadUsers(); + loadOrgs(); loadBranding(); loadPlans(); loadSystem(); } +// #36: list organizations with owner + resource counts; platform admin can +// cascade-delete an org or an individual workspace (type-the-name confirm). +async function loadOrgs() { + const el = document.getElementById('orgsTable'); + if (!el) return; + let orgs; + try { + orgs = await api.adminListOrgs(); + } catch (err) { + el.innerHTML = `

${esc(err.message || 'Failed to load organizations')}

`; + return; + } + if (!orgs.length) { + el.innerHTML = `

${t('admin.orgs.empty')}

`; + return; + } + el.innerHTML = orgs.map(o => { + const wsRows = (o.workspaces || []).map(w => ` +
+
${esc(w.name)} + · ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')} +
+ +
`).join(''); + return ` +
+
+
+
${esc(o.name)}
+
+ ${t('admin.orgs.owner')}: ${esc(o.owner_email || '—')} · + ${o.workspace_count} ${t('admin.orgs.workspaces')} · ${o.device_count} ${t('admin.orgs.devices')} · ${o.member_count} ${t('admin.orgs.members')} +
+
+ +
+ ${wsRows} +
`; + }).join(''); + + el.querySelectorAll('[data-del-org]').forEach(btn => btn.addEventListener('click', () => { + const id = btn.dataset.delOrg, name = btn.dataset.orgName; + openTypeToConfirmModal({ + title: t('admin.orgs.delete_org_title'), + body: t('admin.orgs.delete_org_body', { name: esc(name) }), + expected: name, + confirmLabel: t('admin.orgs.delete_org'), + onConfirm: async () => { + await api.adminDeleteOrg(id); + showToast(t('admin.orgs.org_deleted', { name }), 'success'); + loadOrgs(); loadUsers(); + }, + }); + })); + el.querySelectorAll('[data-del-ws]').forEach(btn => btn.addEventListener('click', () => { + const id = btn.dataset.delWs, name = btn.dataset.wsName; + openTypeToConfirmModal({ + title: t('admin.orgs.delete_ws_title'), + body: t('admin.orgs.delete_ws_body', { name: esc(name) }), + expected: name, + confirmLabel: t('admin.orgs.delete_ws'), + onConfirm: async () => { + await api.adminDeleteWorkspace(id); + showToast(t('admin.orgs.ws_deleted', { name }), 'success'); + loadOrgs(); + }, + }); + })); +} + // #15: instance-level default branding form (platform default; every workspace // without its own white-label inherits this, as does the login page). async function loadBranding() { diff --git a/server/lib/user-deletion.js b/server/lib/user-deletion.js index 2bedc6a..83c82bb 100644 --- a/server/lib/user-deletion.js +++ b/server/lib/user-deletion.js @@ -54,6 +54,55 @@ const REASSIGN_USER_TABLES = [ 'playlists', 'schedules', 'video_walls', 'device_groups', 'kiosk_pages', 'white_labels', 'alert_configs', ]; +const inClause = n => Array.from({ length: n }, () => '?').join(','); +function tablesPresent(db) { + return new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name)); +} + +// Delete the given workspaces and every tenant resource inside them. The +// workspace-scoped tables are NO ACTION (won't cascade from the workspace), so +// we delete them explicitly first; their CASCADE children (playlist_items, +// telemetry, assignments, layout_zones, *_devices) and workspace_members/invites +// clean themselves up. MUST run inside a transaction with defer_foreign_keys=ON. +// `have` is the set of existing table names (tablesPresent()). +function purgeWorkspaces(db, wsIds, have) { + if (!wsIds.length) return; + const wph = inClause(wsIds.length); + if (have.has('devices')) { + const devIds = db.prepare(`SELECT id FROM devices WHERE workspace_id IN (${wph})`).all(...wsIds).map(r => r.id); + if (devIds.length) { + const dph = inClause(devIds.length); + for (const lt of DEVICE_LOG_TABLES) if (have.has(lt)) db.prepare(`DELETE FROM ${lt} WHERE device_id IN (${dph})`).run(...devIds); + } + } + for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds); + if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds); + db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites +} + +// #36: cascade-delete a single workspace (and all its tenant resources). The +// parent org is left intact. Platform-admin action; callers gate authorization. +function deleteWorkspaceCascade(db, { workspaceId }) { + db.transaction(() => { + db.pragma('defer_foreign_keys = ON'); + purgeWorkspaces(db, [workspaceId], tablesPresent(db)); + })(); +} + +// #36: cascade-delete an organization - all its workspaces + tenant resources, +// then the org itself (cascades organization_members). Member USERS are NOT +// deleted (they may belong to other orgs); they simply lose this membership. +function deleteOrgCascade(db, { orgId }) { + db.transaction(() => { + db.pragma('defer_foreign_keys = ON'); + const have = tablesPresent(db); + const wsIds = db.prepare('SELECT id FROM workspaces WHERE organization_id = ?').all(orgId).map(r => r.id); + purgeWorkspaces(db, wsIds, have); + if (have.has('activity_log')) db.prepare('UPDATE activity_log SET organization_id = NULL WHERE organization_id = ?').run(orgId); + db.prepare('DELETE FROM organizations WHERE id = ?').run(orgId); // cascades organization_members + })(); +} + function listOwnedOrgsWithSharing(db, userId) { let orgs = []; try { orgs = db.prepare('SELECT id FROM organizations WHERE owner_user_id = ?').all(userId); } @@ -84,8 +133,7 @@ function deleteUserCascade(db, { targetId, actingAdminId }) { } const soloOrgIds = owned.map(o => o.id); - const have = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name)); - const inClause = n => Array.from({ length: n }, () => '?').join(','); + const have = tablesPresent(db); const run = db.transaction(() => { // FK checks deferred to COMMIT: order of our deletes no longer matters, only @@ -97,20 +145,7 @@ function deleteUserCascade(db, { targetId, actingAdminId }) { const wsIds = db.prepare( `SELECT id FROM workspaces WHERE organization_id IN (${inClause(soloOrgIds.length)})` ).all(...soloOrgIds).map(r => r.id); - - if (wsIds.length) { - const wph = inClause(wsIds.length); - if (have.has('devices')) { - const devIds = db.prepare(`SELECT id FROM devices WHERE workspace_id IN (${wph})`).all(...wsIds).map(r => r.id); - if (devIds.length) { - const dph = inClause(devIds.length); - for (const lt of DEVICE_LOG_TABLES) if (have.has(lt)) db.prepare(`DELETE FROM ${lt} WHERE device_id IN (${dph})`).run(...devIds); - } - } - for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds); - if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds); - db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites - } + purgeWorkspaces(db, wsIds, have); const oph = inClause(soloOrgIds.length); if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET organization_id = NULL WHERE organization_id IN (${oph})`).run(...soloOrgIds); @@ -146,4 +181,7 @@ function deleteUserCascade(db, { targetId, actingAdminId }) { run(); } -module.exports = { deleteUserCascade, OrgHasOtherMembersError, listOwnedOrgsWithSharing }; +module.exports = { + deleteUserCascade, OrgHasOtherMembersError, listOwnedOrgsWithSharing, + deleteWorkspaceCascade, deleteOrgCascade, +}; diff --git a/server/routes/admin.js b/server/routes/admin.js index c2325e1..f33873b 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -6,6 +6,7 @@ const { db } = require('../db/database'); const { canAdminWorkspace } = require('../lib/permissions'); const { requirePlatformAdmin } = require('../middleware/auth'); const { logActivity, getClientIp } = require('../services/activity'); +const { deleteWorkspaceCascade, deleteOrgCascade } = require('../lib/user-deletion'); const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require('../lib/branding'); // Admin-provisioned user creation (#10). Operates on a target workspace @@ -133,6 +134,59 @@ router.post('/orgs', requirePlatformAdmin, (req, res) => { res.status(201).json({ id: orgId, name, owner_user_id: ownerId, workspace_id: wsId, workspace_name: 'Default' }); }); +// GET /api/admin/orgs - list every organization with owner + resource counts and +// its workspaces (#36, drives the Organizations admin section). Platform-admin only. +router.get('/orgs', requirePlatformAdmin, (req, res) => { + const orgs = db.prepare(` + SELECT o.id, o.name, o.created_at, u.email AS owner_email, u.name AS owner_name, + (SELECT COUNT(*) FROM organization_members m WHERE m.organization_id = o.id) AS member_count, + (SELECT COUNT(*) FROM workspaces w WHERE w.organization_id = o.id) AS workspace_count, + (SELECT COUNT(*) FROM devices d JOIN workspaces w ON w.id = d.workspace_id WHERE w.organization_id = o.id) AS device_count + FROM organizations o + LEFT JOIN users u ON u.id = o.owner_user_id + ORDER BY o.created_at DESC + `).all(); + const wsByOrg = {}; + for (const w of db.prepare(` + SELECT w.id, w.name, w.organization_id, + (SELECT COUNT(*) FROM devices d WHERE d.workspace_id = w.id) AS device_count, + (SELECT COUNT(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count + FROM workspaces w ORDER BY w.created_at + `).all()) { + (wsByOrg[w.organization_id] = wsByOrg[w.organization_id] || []).push(w); + } + res.json(orgs.map(o => ({ ...o, workspaces: wsByOrg[o.id] || [] }))); +}); + +// DELETE /api/admin/orgs/:id - cascade-delete an org and everything in it (#36). +// Platform-admin only. The frontend requires a type-the-name confirmation; this +// is irreversible. Uses the shared cascade helper so no tenant resource is orphaned. +router.delete('/orgs/:id', requirePlatformAdmin, (req, res) => { + const org = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.params.id); + if (!org) return res.status(404).json({ error: 'Organization not found' }); + try { + deleteOrgCascade(db, { orgId: org.id }); + } catch (e) { + return res.status(500).json({ error: 'Failed to delete organization' }); + } + logActivity(req.user.id, 'admin_delete_org', `org: ${org.name} (${org.id})`, null, getClientIp(req), null); + res.json({ deleted: true, id: org.id }); +}); + +// DELETE /api/admin/workspaces/:id - cascade-delete a single workspace + its +// tenant resources (#36); the parent org is left intact. Platform-admin only. +router.delete('/workspaces/:id', requirePlatformAdmin, (req, res) => { + const ws = db.prepare('SELECT id, name, organization_id FROM workspaces WHERE id = ?').get(req.params.id); + if (!ws) return res.status(404).json({ error: 'Workspace not found' }); + try { + deleteWorkspaceCascade(db, { workspaceId: ws.id }); + } catch (e) { + return res.status(500).json({ error: 'Failed to delete workspace' }); + } + logActivity(req.user.id, 'admin_delete_workspace', `workspace: ${ws.name} (${ws.id})`, null, getClientIp(req), null); + res.json({ deleted: true, id: ws.id }); +}); + // PUT /api/admin/users/:id/workspace - move/assign a SINGLE-workspace user to a // different workspace (platform Users admin page). Platform-admin only: this is // a cross-org, platform-level action (requirePlatformAdmin excludes diff --git a/server/test/admin-users.test.js b/server/test/admin-users.test.js index 2ce1a83..4a690bf 100644 --- a/server/test/admin-users.test.js +++ b/server/test/admin-users.test.js @@ -82,6 +82,7 @@ db.exec(` details TEXT, ip_address TEXT, workspace_id TEXT, + organization_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); `); @@ -381,3 +382,40 @@ test('create org: non-admin and operator denied (403)', async () => { assert.equal((await post('/api/admin/orgs', tokens.operator, { name: 'Y' })).status, 403); assert.equal(db.prepare('SELECT COUNT(*) c FROM organizations').get().c, before, 'no org created by denied callers'); }); + +// --- #36: DELETE /api/admin/orgs/:id and /workspaces/:id --- +const delReq = (pathname, token) => fetch(base + pathname, { + method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, +}); + +test('platform_admin deletes an org (200): org + workspace + members removed', async () => { + db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-del','Del Org','u-admin')").run(); + db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES ('org-del','u-admin','org_owner')").run(); + db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-del','org-del','Del WS')").run(); + db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-del','u-admin','workspace_admin')").run(); + + const res = await delReq('/api/admin/orgs/org-del', tokens.admin); + assert.equal(res.status, 200); + assert.equal(db.prepare("SELECT COUNT(*) c FROM organizations WHERE id='org-del'").get().c, 0); + assert.equal(db.prepare("SELECT COUNT(*) c FROM workspaces WHERE id='ws-del'").get().c, 0); + // member-table FK cascade is verified against the real cascaded FKs in + // user-deletion.test.js (this minimal harness models no FKs). + assert.ok(db.prepare("SELECT 1 FROM activity_log WHERE action='admin_delete_org'").get(), 'audited'); +}); + +test('delete org: 404 unknown; 403 for non-admin + operator (no delete)', async () => { + assert.equal((await delReq('/api/admin/orgs/nope', tokens.admin)).status, 404); + db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-keep','Keep','u-admin')").run(); + assert.equal((await delReq('/api/admin/orgs/org-keep', tokens.regular)).status, 403); + assert.equal((await delReq('/api/admin/orgs/org-keep', tokens.operator)).status, 403); + assert.equal(db.prepare("SELECT COUNT(*) c FROM organizations WHERE id='org-keep'").get().c, 1, 'denied callers did not delete'); +}); + +test('platform_admin deletes a workspace (200): ws gone, parent org intact', async () => { + db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-wd','WD','u-admin')").run(); + db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-wd','org-wd','WD WS')").run(); + const res = await delReq('/api/admin/workspaces/ws-wd', tokens.admin); + assert.equal(res.status, 200); + assert.equal(db.prepare("SELECT COUNT(*) c FROM workspaces WHERE id='ws-wd'").get().c, 0); + assert.equal(db.prepare("SELECT COUNT(*) c FROM organizations WHERE id='org-wd'").get().c, 1, 'org intact'); +}); diff --git a/server/test/user-deletion.test.js b/server/test/user-deletion.test.js index fba6fe8..1a05b08 100644 --- a/server/test/user-deletion.test.js +++ b/server/test/user-deletion.test.js @@ -192,3 +192,44 @@ test('missing user -> 404', async () => { const res = await del('does-not-exist', tokens.admin); assert.equal(res.status, 404); }); + +// --- #36: deleteOrgCascade / deleteWorkspaceCascade (shared cascade helpers) --- +const { deleteOrgCascade, deleteWorkspaceCascade } = require('../lib/user-deletion'); + +test('deleteOrgCascade: org + all workspaces/resources/members gone; member users kept', () => { + user('u-cust'); + db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('orgD','Cust','u-cust')").run(); + db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES ('orgD','u-cust','org_owner')").run(); + db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('wsD1','orgD','D1'),('wsD2','orgD','D2')").run(); + db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('wsD1','u-cust','workspace_admin')").run(); + db.prepare("INSERT INTO devices (id, workspace_id) VALUES ('dvD','wsD1')").run(); + db.prepare("INSERT INTO content (id, workspace_id) VALUES ('ctD','wsD2')").run(); + db.prepare("INSERT INTO playlists (id, user_id, workspace_id) VALUES ('plD','u-cust','wsD1')").run(); + + deleteOrgCascade(db, { orgId: 'orgD' }); + + assert.equal(exists('organizations','orgD'), false, 'org gone'); + assert.equal(exists('workspaces','wsD1') || exists('workspaces','wsD2'), false, 'workspaces gone'); + assert.equal(exists('devices','dvD'), false, 'device gone'); + assert.equal(exists('content','ctD'), false, 'content gone'); + assert.equal(exists('playlists','plD'), false, 'playlist gone'); + assert.equal(db.prepare("SELECT COUNT(*) c FROM organization_members WHERE organization_id='orgD'").get().c, 0, 'org members cascaded'); + assert.equal(exists('users','u-cust'), true, 'member user is NOT deleted'); +}); + +test('deleteWorkspaceCascade: one workspace + resources gone; org + sibling intact', () => { + user('u-cust2'); + db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('orgW','W','u-cust2')").run(); + db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('wsW1','orgW','W1'),('wsW2','orgW','W2')").run(); + db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('wsW1','u-cust2','workspace_admin')").run(); + db.prepare("INSERT INTO devices (id, workspace_id) VALUES ('dvW','wsW1'),('dvW2','wsW2')").run(); + + deleteWorkspaceCascade(db, { workspaceId: 'wsW1' }); + + assert.equal(exists('workspaces','wsW1'), false, 'target ws gone'); + assert.equal(exists('devices','dvW'), false, 'target ws device gone'); + assert.equal(db.prepare("SELECT COUNT(*) c FROM workspace_members WHERE workspace_id='wsW1'").get().c, 0, 'members cascaded'); + assert.equal(exists('organizations','orgW'), true, 'org intact'); + assert.equal(exists('workspaces','wsW2'), true, 'sibling ws intact'); + assert.equal(exists('devices','dvW2'), true, 'sibling device intact'); +});