Workspaces: a workspace deleted from admin still appears in the user's New Board picker, and selecting it fails with "FOREIGN KEY constraint failed" #191

Open
opened 2026-05-14 12:29:11 +00:00 by eslamnawara · 3 comments
Member

Summary

Deleting a workspace from the admin interface does not remove it from
the user-facing workspace list. The deleted workspace remains selectable
in the New Board dialog's Workspace dropdown until reload, and attempting to
create a board with that workspace selected fails with the raw database
error FOREIGN KEY constraint failed surfaced directly in the UI.

Steps to reproduce

  1. As an admin, delete a workspace that a user has open in their browser
    (or that exists in their cached workspace list).
  2. As that user, open the boards page and click + New Board.
  3. In the New Board dialog, select the deleted workspace from the
    Workspace dropdown.
  4. Enter a name and click Create.

Expected

  • The deleted workspace should not appear in the user's Workspace
    dropdown — it should be removed (or marked unavailable) as soon as
    the user's view is refreshed, ideally pushed live.
  • If a user somehow does target a workspace that no longer exists, the
    server should respond with a clear, user-friendly error such as
    "This workspace no longer exists. Please pick another." — never a
    raw FOREIGN KEY constraint failed message.

Actual

  • The deleted workspace stays in the dropdown.
  • Submitting New Board with that workspace selected returns
    FOREIGN KEY constraint failed, rendered in red inside the dialog.
  • The user has no way to recover other than picking a different
    workspace or reloading the page.
    image
## Summary Deleting a workspace from the admin interface does not remove it from the user-facing workspace list. The deleted workspace remains selectable in the `New Board` dialog's `Workspace` dropdown until reload, and attempting to create a board with that workspace selected fails with the raw database error `FOREIGN KEY constraint failed` surfaced directly in the UI. ## Steps to reproduce 1. As an admin, delete a workspace that a user has open in their browser (or that exists in their cached workspace list). 2. As that user, open the boards page and click `+ New Board`. 3. In the `New Board` dialog, select the deleted workspace from the `Workspace` dropdown. 4. Enter a name and click `Create`. ## Expected - The deleted workspace should not appear in the user's `Workspace` dropdown — it should be removed (or marked unavailable) as soon as the user's view is refreshed, ideally pushed live. - If a user somehow does target a workspace that no longer exists, the server should respond with a clear, user-friendly error such as "This workspace no longer exists. Please pick another." — never a raw `FOREIGN KEY constraint failed` message. ## Actual - The deleted workspace stays in the dropdown. - Submitting `New Board` with that workspace selected returns `FOREIGN KEY constraint failed`, rendered in red inside the dialog. - The user has no way to recover other than picking a different workspace or reloading the page. ![image](/attachments/ca6cc6b5-ff3c-43a4-93ed-0729b0374858)
Member

Implementation Spec for Issue #191

Objective

Stop the end-user whiteboard home page from showing a deleted workspace in its "New Board" dropdown, and prevent the raw FOREIGN KEY constraint failed SQLite error from ever surfacing. When a workspace was deleted out from under the open page, show a friendly notice and re-sync the dropdown.

Root cause

  1. Stale cache, no broadcast. loadWorkspaces() in home.html populates workspacesCache once at page load and isn't re-invoked when the New Board modal opens. The end-user home page has no WebSocket connection — sync.js only opens one for a specific board (/ws/{board_id}), so admin-side workspace.delete doesn't propagate here.
  2. Raw SQL error bubble. handlers::board::create (handlers/board.rs:27-62) inserts directly; if the workspace was hard-deleted the rusqlite FK error becomes e.to_string() → JSON-RPC message: "FOREIGN KEY constraint failed". submitNewBoard (home.html:534-536) renders that raw string into #new-board-error.

Files to Modify

  • crates/hero_whiteboard_server/src/handlers/board.rs — workspace-existence precheck in create with a stable friendly error message.
  • crates/hero_whiteboard_server/src/handlers/object.rs — regression test in the existing #[cfg(test)] mod tests.
  • crates/hero_whiteboard_admin/templates/web/home.html — re-fetch workspaces every time the New Board modal opens; recover gracefully when board.create reports "workspace has been deleted".

Implementation Plan

Step 1: Server-side precheck + friendly error

File: crates/hero_whiteboard_server/src/handlers/board.rs

In create, after let db = state.db.lock().unwrap(); and before the existing board_name_taken check, insert:

// Verify the workspace still exists. A stable, recognizable message
// lets the UI distinguish "workspace vanished out from under you"
// from generic create failures and recover the dropdown.
match queries::get_workspace(&db, workspace_id) {
    Ok(_) => {}
    Err(rusqlite::Error::QueryReturnedNoRows) => {
        anyhow::bail!("workspace has been deleted");
    }
    Err(e) => return Err(e.into()),
}

Mirrors the existing "board has been deleted" convention in board.rs:72-78.

Dependencies: none.

Step 2: Regression test

File: crates/hero_whiteboard_server/src/handlers/object.rs — inside the existing #[cfg(test)] mod tests, after workspace_delete_returns_cascaded_board_ids:

#[test]
fn board_create_after_workspace_delete_returns_friendly_error() {
    let state = make_state();
    let workspace_id = seed_workspace(&state);

    handlers::workspace::delete(&state, json!({"id": workspace_id}))
        .expect("workspace.delete must succeed");

    let err = handlers::board::create(
        &state,
        json!({"workspace_id": workspace_id, "name": "alpha"}),
    )
    .expect_err("create against deleted workspace must error");
    let msg = err.to_string();
    assert!(msg.contains("workspace has been deleted"),
        "expected friendly workspace-deleted error, got: {msg}");
    assert!(!msg.contains("FOREIGN KEY"),
        "raw SQLite FK message must not leak, got: {msg}");
}

Dependencies: Step 1.

Step 3: Re-fetch workspaces on modal open

File: crates/hero_whiteboard_admin/templates/web/home.html

  • Change function openNewBoardModal() { (~line 440) to async function openNewBoardModal() {.

  • Right after the early field-init block (just before reading workspacesCache), add:

    try { await loadWorkspaces(); } catch (e) { /* surfaced inside loadWorkspaces */ }
    

The onclick="openNewBoardModal()" callers don't need a change — calling an async function from onclick returns a discarded promise, same pattern as submitNewBoard already in the file.

Side benefit: loadWorkspaces() also refreshes the top #workspace-select filter.

Dependencies: none.

Step 4: Recover when board.create reports the friendly error

File: crates/hero_whiteboard_admin/templates/web/home.html

Replace the existing submitNewBoard catch block (lines 534-536) with:

} catch (e) {
    var emsg = (e && e.message) ? e.message : '';
    if (emsg.indexOf('workspace has been deleted') !== -1) {
        try { await loadWorkspaces(); } catch (e2) {}
        var sel = document.getElementById('new-board-ws-select');
        if (sel) {
            sel.innerHTML = '';
            for (var i = 0; i < workspacesCache.length; i++) {
                var ws = workspacesCache[i];
                var opt = document.createElement('option');
                opt.value = ws.id;
                opt.textContent = ws.name || ws.id;
                sel.appendChild(opt);
            }
            var newOpt = document.createElement('option');
            newOpt.value = '__new__';
            newOpt.textContent = '+ New workspace...';
            sel.appendChild(newOpt);
            onNewBoardWorkspaceChange();
        }
        showErr('That workspace no longer exists. Pick another or create a new one.');
        return;
    }
    showErr(emsg || 'Failed to create board.');
}

Modal stays open so the user can retry without losing the typed board name.

Dependencies: Step 1 (message string is the contract).

Acceptance Criteria

  • cargo test -p hero_whiteboard_server passes, including the new test.
  • board.create against a deleted workspace returns JSON-RPC error with message: "workspace has been deleted" and no FOREIGN KEY substring.
  • Opening the New Board modal after a workspace was deleted in another tab no longer shows that workspace in the dropdown.
  • If the workspace is deleted between modal-open and Create-click, the inline error reads "That workspace no longer exists. Pick another or create a new one.", the dropdown is repopulated, no raw SQLite text appears.
  • Happy path unchanged: create with a valid workspace still works as before.

Notes

  • Why a precheck instead of catching the FK error. rusqlite exposes ErrorCode::ConstraintViolation but matching FK vs UNIQUE vs CHECK requires the extended code (SQLITE_CONSTRAINT_FOREIGNKEY = 787) and a message string that's locale-flaky. A precheck inside the same lock costs one indexed lookup, is race-free against the connection's own writes, and yields one friendly message — symmetric with board.get translating QueryReturnedNoRows to "board has been deleted".
  • Why not broadcast workspace.deleted over WebSockets. The end-user WS is per-board (/ws/{board_id}); the home page has no WS connection. Adding a workspace channel would need new server plumbing for one rare race. Re-fetching workspace.list on modal open eliminates the stale-dropdown case for ~all real usage; Step 4 handles the residual race recoverably.
  • Existing board.deleted broadcast unchanged. The RPC proxy's workspace.delete sniffer already broadcasts board.deleted to every open editor for boards inside the cascaded workspace. Users editing a doomed board still see the existing showBoardDeletedNotice overlay. This fix only targets the home page's New Board dialog.
  • No migration, schema, or openrpc.json change.
## Implementation Spec for Issue #191 ### Objective Stop the end-user whiteboard home page from showing a deleted workspace in its "New Board" dropdown, and prevent the raw `FOREIGN KEY constraint failed` SQLite error from ever surfacing. When a workspace was deleted out from under the open page, show a friendly notice and re-sync the dropdown. ### Root cause 1. **Stale cache, no broadcast.** `loadWorkspaces()` in `home.html` populates `workspacesCache` once at page load and isn't re-invoked when the New Board modal opens. The end-user home page has no WebSocket connection — `sync.js` only opens one for a specific board (`/ws/{board_id}`), so admin-side `workspace.delete` doesn't propagate here. 2. **Raw SQL error bubble.** `handlers::board::create` (`handlers/board.rs:27-62`) inserts directly; if the workspace was hard-deleted the rusqlite FK error becomes `e.to_string()` → JSON-RPC `message: "FOREIGN KEY constraint failed"`. `submitNewBoard` (`home.html:534-536`) renders that raw string into `#new-board-error`. ### Files to Modify - `crates/hero_whiteboard_server/src/handlers/board.rs` — workspace-existence precheck in `create` with a stable friendly error message. - `crates/hero_whiteboard_server/src/handlers/object.rs` — regression test in the existing `#[cfg(test)] mod tests`. - `crates/hero_whiteboard_admin/templates/web/home.html` — re-fetch workspaces every time the New Board modal opens; recover gracefully when `board.create` reports "workspace has been deleted". ### Implementation Plan #### Step 1: Server-side precheck + friendly error File: `crates/hero_whiteboard_server/src/handlers/board.rs` In `create`, after `let db = state.db.lock().unwrap();` and before the existing `board_name_taken` check, insert: ```rust // Verify the workspace still exists. A stable, recognizable message // lets the UI distinguish "workspace vanished out from under you" // from generic create failures and recover the dropdown. match queries::get_workspace(&db, workspace_id) { Ok(_) => {} Err(rusqlite::Error::QueryReturnedNoRows) => { anyhow::bail!("workspace has been deleted"); } Err(e) => return Err(e.into()), } ``` Mirrors the existing `"board has been deleted"` convention in `board.rs:72-78`. Dependencies: none. #### Step 2: Regression test File: `crates/hero_whiteboard_server/src/handlers/object.rs` — inside the existing `#[cfg(test)] mod tests`, after `workspace_delete_returns_cascaded_board_ids`: ```rust #[test] fn board_create_after_workspace_delete_returns_friendly_error() { let state = make_state(); let workspace_id = seed_workspace(&state); handlers::workspace::delete(&state, json!({"id": workspace_id})) .expect("workspace.delete must succeed"); let err = handlers::board::create( &state, json!({"workspace_id": workspace_id, "name": "alpha"}), ) .expect_err("create against deleted workspace must error"); let msg = err.to_string(); assert!(msg.contains("workspace has been deleted"), "expected friendly workspace-deleted error, got: {msg}"); assert!(!msg.contains("FOREIGN KEY"), "raw SQLite FK message must not leak, got: {msg}"); } ``` Dependencies: Step 1. #### Step 3: Re-fetch workspaces on modal open File: `crates/hero_whiteboard_admin/templates/web/home.html` - Change `function openNewBoardModal() {` (~line 440) to `async function openNewBoardModal() {`. - Right after the early field-init block (just before reading `workspacesCache`), add: ```javascript try { await loadWorkspaces(); } catch (e) { /* surfaced inside loadWorkspaces */ } ``` The `onclick="openNewBoardModal()"` callers don't need a change — calling an `async` function from `onclick` returns a discarded promise, same pattern as `submitNewBoard` already in the file. Side benefit: `loadWorkspaces()` also refreshes the top `#workspace-select` filter. Dependencies: none. #### Step 4: Recover when `board.create` reports the friendly error File: `crates/hero_whiteboard_admin/templates/web/home.html` Replace the existing `submitNewBoard` `catch` block (lines 534-536) with: ```javascript } catch (e) { var emsg = (e && e.message) ? e.message : ''; if (emsg.indexOf('workspace has been deleted') !== -1) { try { await loadWorkspaces(); } catch (e2) {} var sel = document.getElementById('new-board-ws-select'); if (sel) { sel.innerHTML = ''; for (var i = 0; i < workspacesCache.length; i++) { var ws = workspacesCache[i]; var opt = document.createElement('option'); opt.value = ws.id; opt.textContent = ws.name || ws.id; sel.appendChild(opt); } var newOpt = document.createElement('option'); newOpt.value = '__new__'; newOpt.textContent = '+ New workspace...'; sel.appendChild(newOpt); onNewBoardWorkspaceChange(); } showErr('That workspace no longer exists. Pick another or create a new one.'); return; } showErr(emsg || 'Failed to create board.'); } ``` Modal stays open so the user can retry without losing the typed board name. Dependencies: Step 1 (message string is the contract). ### Acceptance Criteria - [ ] `cargo test -p hero_whiteboard_server` passes, including the new test. - [ ] `board.create` against a deleted workspace returns JSON-RPC error with `message: "workspace has been deleted"` and no `FOREIGN KEY` substring. - [ ] Opening the New Board modal after a workspace was deleted in another tab no longer shows that workspace in the dropdown. - [ ] If the workspace is deleted between modal-open and Create-click, the inline error reads "That workspace no longer exists. Pick another or create a new one.", the dropdown is repopulated, no raw SQLite text appears. - [ ] Happy path unchanged: create with a valid workspace still works as before. ### Notes - **Why a precheck instead of catching the FK error.** rusqlite exposes `ErrorCode::ConstraintViolation` but matching FK vs UNIQUE vs CHECK requires the extended code (`SQLITE_CONSTRAINT_FOREIGNKEY = 787`) and a message string that's locale-flaky. A precheck inside the same lock costs one indexed lookup, is race-free against the connection's own writes, and yields one friendly message — symmetric with `board.get` translating `QueryReturnedNoRows` to `"board has been deleted"`. - **Why not broadcast `workspace.deleted` over WebSockets.** The end-user WS is per-board (`/ws/{board_id}`); the home page has no WS connection. Adding a workspace channel would need new server plumbing for one rare race. Re-fetching `workspace.list` on modal open eliminates the stale-dropdown case for ~all real usage; Step 4 handles the residual race recoverably. - **Existing `board.deleted` broadcast unchanged.** The RPC proxy's `workspace.delete` sniffer already broadcasts `board.deleted` to every open editor for boards inside the cascaded workspace. Users editing a doomed board still see the existing `showBoardDeletedNotice` overlay. This fix only targets the *home page's New Board dialog*. - No migration, schema, or openrpc.json change.
Member

Test Results

cargo test -p hero_whiteboard_server
running 7 tests
test handlers::object::tests::create_after_delete_returns_error ... ok
test handlers::object::tests::locked_default_is_false_and_round_trips_get_list ... ok
test handlers::object::tests::locked_round_trips_through_create_and_update ... ok
test handlers::object::tests::duplicate_board_name_rejected ... ok
test handlers::object::tests::batch_update_persists_locked ... ok
test handlers::object::tests::board_create_after_workspace_delete_returns_friendly_error ... ok
test handlers::object::tests::workspace_delete_returns_cascaded_board_ids ... ok

test result: ok. 7 passed; 0 failed; 0 ignored
  • Total: 7
  • Passed: 7
  • Failed: 0

The new regression test (board_create_after_workspace_delete_returns_friendly_error) asserts both that the friendly "workspace has been deleted" message is returned AND that no "FOREIGN KEY" substring leaks.

Other gates:

  • cargo fmt --check — pass
  • cargo clippy --workspace --all-targets -- -D warnings — pass
## Test Results ``` cargo test -p hero_whiteboard_server running 7 tests test handlers::object::tests::create_after_delete_returns_error ... ok test handlers::object::tests::locked_default_is_false_and_round_trips_get_list ... ok test handlers::object::tests::locked_round_trips_through_create_and_update ... ok test handlers::object::tests::duplicate_board_name_rejected ... ok test handlers::object::tests::batch_update_persists_locked ... ok test handlers::object::tests::board_create_after_workspace_delete_returns_friendly_error ... ok test handlers::object::tests::workspace_delete_returns_cascaded_board_ids ... ok test result: ok. 7 passed; 0 failed; 0 ignored ``` - Total: 7 - Passed: 7 - Failed: 0 The new regression test (`board_create_after_workspace_delete_returns_friendly_error`) asserts both that the friendly `"workspace has been deleted"` message is returned AND that no `"FOREIGN KEY"` substring leaks. Other gates: - `cargo fmt --check` — pass - `cargo clippy --workspace --all-targets -- -D warnings` — pass
Member

Implementation Summary

Changes

  • crates/hero_whiteboard_server/src/handlers/board.rscreate now prechecks workspace existence (mirroring board.get's "board has been deleted" translation). FK violations no longer reach the wire as raw SQLite strings; instead the handler returns a stable "workspace has been deleted" message.
  • crates/hero_whiteboard_server/src/handlers/object.rs — added regression test board_create_after_workspace_delete_returns_friendly_error that asserts both the friendly message and the absence of any "FOREIGN KEY" substring.
  • crates/hero_whiteboard_admin/templates/web/home.html:
    • openNewBoardModal is now async and awaits loadWorkspaces() before populating the dropdown — admin-deleted workspaces disappear on the next modal open.
    • submitNewBoard's catch path recognises the friendly server error, re-fetches workspaces, rebuilds the dropdown, and surfaces "That workspace no longer exists. Pick another or create a new one." in the existing inline error slot. The modal stays open so the user can retry without losing the typed board name.

Test Results

cargo test -p hero_whiteboard_server — 7 passed / 0 failed, including the new regression test.
cargo fmt --check — pass. cargo clippy --workspace --all-targets -- -D warnings — pass.

Behaviour after fix

  • Admin deletes a workspace → end-user reopens the New Board dialog → the deleted workspace is gone from the dropdown.
  • If the workspace is deleted between dialog open and Create click (race), the inline error reads "That workspace no longer exists. Pick another or create a new one." and the dropdown is repopulated. No raw FOREIGN KEY constraint failed text appears anywhere.
  • Happy path unchanged.

Notes

  • No schema, openrpc.json, or migration change.
  • The end-user home page has no WebSocket connection (the existing WS in sync.js is per-board), so the fix relies on re-fetching workspace.list on dialog open plus recoverable error handling — no new server channel needed.
  • The existing board.deleted broadcasts from workspace.delete continue to fire for users actively editing a board in the deleted workspace.
## Implementation Summary ### Changes - `crates/hero_whiteboard_server/src/handlers/board.rs` — `create` now prechecks workspace existence (mirroring `board.get`'s `"board has been deleted"` translation). FK violations no longer reach the wire as raw SQLite strings; instead the handler returns a stable `"workspace has been deleted"` message. - `crates/hero_whiteboard_server/src/handlers/object.rs` — added regression test `board_create_after_workspace_delete_returns_friendly_error` that asserts both the friendly message and the absence of any `"FOREIGN KEY"` substring. - `crates/hero_whiteboard_admin/templates/web/home.html`: - `openNewBoardModal` is now `async` and `await`s `loadWorkspaces()` before populating the dropdown — admin-deleted workspaces disappear on the next modal open. - `submitNewBoard`'s catch path recognises the friendly server error, re-fetches workspaces, rebuilds the dropdown, and surfaces "That workspace no longer exists. Pick another or create a new one." in the existing inline error slot. The modal stays open so the user can retry without losing the typed board name. ### Test Results `cargo test -p hero_whiteboard_server` — 7 passed / 0 failed, including the new regression test. `cargo fmt --check` — pass. `cargo clippy --workspace --all-targets -- -D warnings` — pass. ### Behaviour after fix - Admin deletes a workspace → end-user reopens the New Board dialog → the deleted workspace is gone from the dropdown. - If the workspace is deleted between dialog open and Create click (race), the inline error reads "That workspace no longer exists. Pick another or create a new one." and the dropdown is repopulated. No raw `FOREIGN KEY constraint failed` text appears anywhere. - Happy path unchanged. ### Notes - No schema, openrpc.json, or migration change. - The end-user home page has no WebSocket connection (the existing WS in `sync.js` is per-board), so the fix relies on re-fetching `workspace.list` on dialog open plus recoverable error handling — no new server channel needed. - The existing `board.deleted` broadcasts from `workspace.delete` continue to fire for users actively editing a board in the deleted workspace.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#191
No description provided.