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.
This commit is contained in:
ScreenTinker 2026-06-09 09:22:21 -05:00
parent 36d1578794
commit 0d14db97a6
8 changed files with 362 additions and 17 deletions

View file

@ -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'),

View file

@ -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 = `
<div class="modal">
<div class="modal-header">
<h3>${esc(title || '')}</h3>
<button class="btn-icon" type="button" data-ttc-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div style="font-size:13px;line-height:1.5;margin-bottom:12px">${body}</div>
<div class="form-group">
<label for="ttcInput">${t('confirm_delete.type_label', { name: esc(expected) })}</label>
<input id="ttcInput" type="text" class="input" autocomplete="off" style="width:100%">
</div>
<div id="ttcError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-ttc-close>${t('common.cancel')}</button>
<button class="btn btn-danger" type="button" id="ttcConfirm" disabled>${esc(confirmLabel || t('common.delete'))}</button>
</div>
</div>
`;
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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -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 <b>{name}</b> 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 <b>{name}</b> 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',

View file

@ -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) {
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.orgs.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.orgs.desc')}</p>
<div id="orgsTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.branding.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.branding.desc')}</p>
@ -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 = `<p style="color:var(--danger)">${esc(err.message || 'Failed to load organizations')}</p>`;
return;
}
if (!orgs.length) {
el.innerHTML = `<p style="color:var(--text-muted)">${t('admin.orgs.empty')}</p>`;
return;
}
el.innerHTML = orgs.map(o => {
const wsRows = (o.workspaces || []).map(w => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-top:1px solid var(--border)">
<div style="font-size:13px">${esc(w.name)}
<span style="color:var(--text-muted);font-size:11px">· ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}</span>
</div>
<button class="btn btn-danger btn-sm" data-del-ws="${esc(w.id)}" data-ws-name="${esc(w.name)}">${t('admin.orgs.delete_ws')}</button>
</div>`).join('');
return `
<div style="border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-secondary)">
<div>
<div style="font-weight:600">${esc(o.name)}</div>
<div style="color:var(--text-muted);font-size:11px">
${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')}
</div>
</div>
<button class="btn btn-danger btn-sm" data-del-org="${esc(o.id)}" data-org-name="${esc(o.name)}">${t('admin.orgs.delete_org')}</button>
</div>
${wsRows}
</div>`;
}).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() {

View file

@ -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,
};

View file

@ -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

View file

@ -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');
});

View file

@ -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');
});