Soft-deleted boards stay editable in other windows; server accepts reads/writes; clients are not notified #83

Open
opened 2026-04-27 14:32:41 +00:00 by AhmedHanafy725 · 3 comments
Member

Bug

Deleting a board from the home page is a server-side soft delete (sets deleted_at). The home page's board.list filter hides it correctly, but every other layer keeps treating the board as live:

  • The server keeps returning the deleted board from board.get and accepts every subsequent object.list / create / update / delete, comment.*, connector.*, etc., because those handlers don't check the parent board's deleted_at.
  • A user who already has the board open in another window (whether through the direct /board/<id> URL or a /s/<token> share link) is never told the board was deleted. Their editor keeps working and every edit silently persists on the soft-deleted board.
  • Result: the deleter thinks the board is gone (it disappeared from their list), but other sessions edit it indefinitely. The orphaned edits are invisible from the home page yet still occupy DB rows and consume sync bandwidth.

Reproduction

  1. Open a board in window A and create a /s/<token> editor share. Open the share URL in window B.
  2. In window A (or from the home page in any tab) click the trash icon on the board card and confirm Delete.
  3. Observe in window A: the board is gone from the home grid. Good.
  4. Switch to window B. The board is still loaded; create / move / edit objects. They appear locally, sync to the server with no error, and survive a page reload of window B (the data persists on the soft-deleted board).
  5. Refresh the home page anywhere: the board does not reappear, but the orphaned edits keep accumulating in the DB.

Root cause

Server (crates/hero_whiteboard_server/):

  • handlers/board.rs:104-112 -- delete() calls queries::soft_delete_board(...), which UPDATE boards SET deleted_at = ?1 WHERE id = ?2 (db/queries.rs:183). No cascade, no notification.
  • db/queries.rs:91 get_board -- the SELECT statement has no deleted_at IS NULL predicate, so board.get returns soft-deleted boards as if they were live.
  • handlers/object.rs:50 (and the other CRUD methods in the same file) -- never check the parent board's deleted_at; they query/insert/update by board_id directly.
  • handlers/comment.rs, handlers/connector.rs, handlers/share.rs -- same story, no parent-board check.
  • crates/hero_whiteboard_ui/src/ws.rs -- the WebSocket relay fans out per-board client messages but has no server-originated event for board lifecycle (no board.deleted broadcast).

Client (crates/hero_whiteboard_ui/):

  • The board page assumes the board exists for the lifetime of the session. There is no handler for a board.deleted event, no periodic re-validation of the board's live status, and no UX path for the editor to gracefully shut down when the underlying board is gone.
  • The /s/<token> share page reuses the same board page; same blind spot applies.

Affected files (expected)

  • crates/hero_whiteboard_server/src/db/queries.rs -- add deleted_at IS NULL filtering to get_board; add a small helper (e.g. is_board_deleted(conn, board_id) or have get_board itself return NotFound for soft-deleted rows).
  • crates/hero_whiteboard_server/src/handlers/board.rs -- have get and update reject soft-deleted boards with a NotFound / Gone error.
  • crates/hero_whiteboard_server/src/handlers/object.rs (and comment.rs, connector.rs, share.rs) -- reject writes against a soft-deleted parent board.
  • crates/hero_whiteboard_ui/src/ws.rs -- broadcast a board.deleted event (with board_id) to all subscribers when the deletion succeeds. Either pipe this from the server's board.delete RPC handler or have the UI's RPC proxy emit it after a successful delete RPC.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js -- handle the board.deleted event: stop the local sync loop, mark the board read-only, and show a non-dismissable notice.
  • crates/hero_whiteboard_ui/templates/web/board.html -- add a small board-deleted-notice overlay (themed via var(--wb-...)) shown when the broadcast arrives. Single "Back to home" button.

No schema changes required (the deleted_at column already exists).

Expected behavior

  • After board.delete returns successfully:
    • board.get for the same id returns a not found / gone error (use the same error path the UI already handles for unknown boards).
    • object.list / create / update / delete, comment.*, connector.*, and share.* for that board id return an error and do not mutate state. The error message should be clear (e.g. "board has been deleted").
    • All open WebSocket subscribers for that board receive a { type: "board.deleted", board_id } message.
  • When the client (board page or share page) receives the board.deleted event:
    • It immediately stops the local sync loop and marks the canvas read-only (no more object.update calls).
    • It shows a centered, themed notice: This board has been deleted. plus a Back to home button.
    • Pending in-flight RPCs that were already queued are allowed to fail silently (they will because the server now rejects them).
  • Pre-existing soft-deleted boards in the DB keep behaving as deleted (no need to migrate).

Acceptance criteria

  • board.delete is followed by board.get returning a not-found-style error (same shape the UI's RPC code already handles).
  • After delete, object.list / create / update / delete, comment.create / update / delete, connector.create / update / delete, share.create / list / get / delete for that board_id all return an error.
  • After delete, the WebSocket subscribers for that board receive a board.deleted event with the board_id.
  • In the board / share-page client, the board.deleted event stops the local sync loop, marks the canvas read-only, and shows a centered themed notice with a Back to home button.
  • No more silent persistence of orphaned edits to soft-deleted boards.
  • Behavior in the deleter's window is unchanged (they're already on the home page after the delete modal closes).
  • Tests: add a server-side integration test that asserts board.delete followed by object.create on the same board_id returns an error.
  • No regression in the existing board.list filter (it already excludes soft-deleted; keep it that way).

Notes

  • The notice's UX should match the rest of the app's modal styling (var(--wb-...)); reuse the patterns established by the delete-board-modal, share-board-modal, etc.
  • The board.deleted broadcast is the safest UX. If implementing the broadcast is too invasive in this pass, the client can fall back to detecting the deletion lazily: any RPC error that matches the new "board has been deleted" shape triggers the same read-only + notice flow. Both layers are valuable; broadcast is preferred so window B reacts immediately rather than at the next sync attempt.
  • Soft delete is intentional and should stay (so the data can be undeleted by an admin); the fix is to make every read/write path respect deleted_at IS NULL.
## Bug Deleting a board from the home page is a server-side **soft delete** (sets `deleted_at`). The home page's `board.list` filter hides it correctly, but every other layer keeps treating the board as live: - The server keeps returning the deleted board from `board.get` and accepts every subsequent `object.list / create / update / delete`, `comment.*`, `connector.*`, etc., because those handlers don't check the parent board's `deleted_at`. - A user who already has the board open in another window (whether through the direct `/board/<id>` URL or a `/s/<token>` share link) is never told the board was deleted. Their editor keeps working and every edit silently persists on the soft-deleted board. - Result: the deleter thinks the board is gone (it disappeared from their list), but other sessions edit it indefinitely. The orphaned edits are invisible from the home page yet still occupy DB rows and consume sync bandwidth. ## Reproduction 1. Open a board in window A and create a `/s/<token>` editor share. Open the share URL in window B. 2. In window A (or from the home page in any tab) click the trash icon on the board card and confirm Delete. 3. Observe in window A: the board is gone from the home grid. Good. 4. Switch to window B. The board is still loaded; create / move / edit objects. They appear locally, sync to the server with no error, and survive a page reload of window B (the data persists on the soft-deleted board). 5. Refresh the home page anywhere: the board does not reappear, but the orphaned edits keep accumulating in the DB. ## Root cause Server (`crates/hero_whiteboard_server/`): - `handlers/board.rs:104-112` -- `delete()` calls `queries::soft_delete_board(...)`, which `UPDATE boards SET deleted_at = ?1 WHERE id = ?2` (`db/queries.rs:183`). No cascade, no notification. - `db/queries.rs:91 get_board` -- the SELECT statement has no `deleted_at IS NULL` predicate, so `board.get` returns soft-deleted boards as if they were live. - `handlers/object.rs:50` (and the other CRUD methods in the same file) -- never check the parent board's `deleted_at`; they query/insert/update by `board_id` directly. - `handlers/comment.rs`, `handlers/connector.rs`, `handlers/share.rs` -- same story, no parent-board check. - `crates/hero_whiteboard_ui/src/ws.rs` -- the WebSocket relay fans out per-board client messages but has no server-originated event for board lifecycle (no `board.deleted` broadcast). Client (`crates/hero_whiteboard_ui/`): - The board page assumes the board exists for the lifetime of the session. There is no handler for a `board.deleted` event, no periodic re-validation of the board's live status, and no UX path for the editor to gracefully shut down when the underlying board is gone. - The `/s/<token>` share page reuses the same board page; same blind spot applies. ## Affected files (expected) - `crates/hero_whiteboard_server/src/db/queries.rs` -- add `deleted_at IS NULL` filtering to `get_board`; add a small helper (e.g. `is_board_deleted(conn, board_id)` or have `get_board` itself return `NotFound` for soft-deleted rows). - `crates/hero_whiteboard_server/src/handlers/board.rs` -- have `get` and `update` reject soft-deleted boards with a `NotFound` / `Gone` error. - `crates/hero_whiteboard_server/src/handlers/object.rs` (and `comment.rs`, `connector.rs`, `share.rs`) -- reject writes against a soft-deleted parent board. - `crates/hero_whiteboard_ui/src/ws.rs` -- broadcast a `board.deleted` event (with `board_id`) to all subscribers when the deletion succeeds. Either pipe this from the server's `board.delete` RPC handler or have the UI's RPC proxy emit it after a successful delete RPC. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` -- handle the `board.deleted` event: stop the local sync loop, mark the board read-only, and show a non-dismissable notice. - `crates/hero_whiteboard_ui/templates/web/board.html` -- add a small `board-deleted-notice` overlay (themed via `var(--wb-...)`) shown when the broadcast arrives. Single "Back to home" button. No schema changes required (the `deleted_at` column already exists). ## Expected behavior - After `board.delete` returns successfully: - `board.get` for the same id returns a `not found` / `gone` error (use the same error path the UI already handles for unknown boards). - `object.list / create / update / delete`, `comment.*`, `connector.*`, and `share.*` for that board id return an error and do **not** mutate state. The error message should be clear (e.g. `"board has been deleted"`). - All open WebSocket subscribers for that board receive a `{ type: "board.deleted", board_id }` message. - When the client (board page or share page) receives the `board.deleted` event: - It immediately stops the local sync loop and marks the canvas read-only (no more `object.update` calls). - It shows a centered, themed notice: `This board has been deleted.` plus a `Back to home` button. - Pending in-flight RPCs that were already queued are allowed to fail silently (they will because the server now rejects them). - Pre-existing soft-deleted boards in the DB keep behaving as deleted (no need to migrate). ## Acceptance criteria - [ ] `board.delete` is followed by `board.get` returning a not-found-style error (same shape the UI's RPC code already handles). - [ ] After delete, `object.list / create / update / delete`, `comment.create / update / delete`, `connector.create / update / delete`, `share.create / list / get / delete` for that `board_id` all return an error. - [ ] After delete, the WebSocket subscribers for that board receive a `board.deleted` event with the `board_id`. - [ ] In the board / share-page client, the `board.deleted` event stops the local sync loop, marks the canvas read-only, and shows a centered themed notice with a `Back to home` button. - [ ] No more silent persistence of orphaned edits to soft-deleted boards. - [ ] Behavior in the deleter's window is unchanged (they're already on the home page after the delete modal closes). - [ ] Tests: add a server-side integration test that asserts `board.delete` followed by `object.create` on the same `board_id` returns an error. - [ ] No regression in the existing `board.list` filter (it already excludes soft-deleted; keep it that way). ## Notes - The notice's UX should match the rest of the app's modal styling (`var(--wb-...)`); reuse the patterns established by the `delete-board-modal`, `share-board-modal`, etc. - The `board.deleted` broadcast is the safest UX. If implementing the broadcast is too invasive in this pass, the client can fall back to detecting the deletion lazily: any RPC error that matches the new "board has been deleted" shape triggers the same read-only + notice flow. Both layers are valuable; broadcast is preferred so window B reacts immediately rather than at the next sync attempt. - Soft delete is intentional and should stay (so the data can be undeleted by an admin); the fix is to make every read/write path respect `deleted_at IS NULL`.
Author
Member

Implementation Spec for Issue #83

Objective

Make board soft-deletes effective end-to-end: the server rejects every read/write against a soft-deleted board, the UI broadcasts a board.deleted WebSocket event after a successful board.delete RPC, and the board / share page handles that event by stopping sync, marking the canvas read-only, and showing a centered themed notice.

Requirements

  • After board.delete:
    • board.get for the same id returns a not-found-style error.
    • object.list / create / update / delete / batch_update, comment.create / update / resolve / delete, connector.create / update / delete, share.create reject the call when the parent board is soft-deleted.
    • All open WebSocket subscribers for that board receive a { type: "board.deleted", board_id: <number> } message originating from the server (not from a peer client).
  • The board page (and the /s/<token> share page that reuses it) handles board.deleted by:
    • Stopping the local sync loop (no more outbound object.update RPCs / WS broadcasts).
    • Marking the canvas read-only (suppress further user-driven mutations as a defense against in-flight inputs).
    • Showing a centered themed overlay with the message This board has been deleted. and a Back to home button.
  • Soft-deleted rows in the DB stay; only access semantics change.
  • No regression in the existing board.list filter or in the rest of the app.
  • Tests: at least one server-side unit/integration test that asserts board.delete followed by object.create on the same board_id returns an error.

Files to Modify

  • crates/hero_whiteboard_server/src/db/queries.rs — gate get_board on deleted_at IS NULL; add a is_board_live(conn, board_id) -> rusqlite::Result<bool> helper.
  • crates/hero_whiteboard_server/src/handlers/object.rs — guard create / update / delete / batch_update against deleted parent boards.
  • crates/hero_whiteboard_server/src/handlers/comment.rs, connector.rs, share.rs — same guard for write paths.
  • crates/hero_whiteboard_server/src/handlers/board.rsupdate rejects soft-deleted boards too.
  • crates/hero_whiteboard_server/tests/ (or an existing integration test file) — add the regression test.
  • crates/hero_whiteboard_ui/src/ws.rs — make BroadcastMsg::sender_id reachable for server-originated broadcasts (sender_id 0 so it's never a real connection); add a small helper pub async fn broadcast_to_board(channels: &BoardChannels, board_id: &str, text: String).
  • crates/hero_whiteboard_ui/src/routes.rs — in rpc_proxy, sniff the request body for method == "board.delete", capture params.id, and after a 200 response with result.deleted > 0, call the new broadcast helper with {type:"board.deleted", board_id}.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js — handle msg.type === 'board.deleted': set a boardDeleted flag, stop the WS loop, prevent future RPCs (early-return in onUpdate, onCreate, onDelete), call a UI hook to show the overlay.
  • crates/hero_whiteboard_ui/templates/web/board.html — add a centered themed <div id="board-deleted-overlay"> (matching the rest of the page's modals) with the message and the Back to home button. Wire a small script function to show it.

No new DB migrations — deleted_at already exists.

Implementation Plan

Step 1: Server-side gating (queries + handlers + test)

Files: crates/hero_whiteboard_server/src/db/queries.rs, crates/hero_whiteboard_server/src/handlers/board.rs, handlers/object.rs, handlers/comment.rs, handlers/connector.rs, handlers/share.rs, plus a test file.

  1. In db/queries.rs:

    • Change get_board to add AND deleted_at IS NULL so the SELECT returns no rows for soft-deleted boards. The handler will see rusqlite::Error::QueryReturnedNoRows and bubble it up via ?. To keep the user-facing message consistent, wrap it in anyhow::anyhow!("board not found or deleted") at the handler layer.
    • Add helper:
      pub fn is_board_live(conn: &Connection, board_id: u64) -> rusqlite::Result<bool> {
          let mut stmt = conn.prepare(
              "SELECT 1 FROM boards WHERE id = ?1 AND deleted_at IS NULL",
          )?;
          let mut rows = stmt.query(rusqlite::params![board_id])?;
          Ok(rows.next()?.is_some())
      }
      
  2. In each write handler, before mutating:

    • For create-style methods that already take board_id, call queries::is_board_live(&db, board_id) and bail!("board has been deleted") if false.
    • For update/delete/resolve/batch_update that don't take board_id directly, fetch the row first (get_object, get_comment, get_connector, etc.), then check is_board_live against the row's board_id.
    • Use anyhow::bail!("board has been deleted") so the message is consistent and easy for the client to detect.
  3. In handlers/board.rs:

    • Have get and update do the same is_board_live check (or rely on the new get_board filter for get -- consistent error wording either way).
  4. Add a test crates/hero_whiteboard_server/tests/soft_delete.rs (or extend an existing one) that:

    • Creates a workspace + board.
    • Calls board.delete.
    • Calls object.create for the same board_id and asserts the call returns an Err containing "deleted".
    • Calls board.get and asserts it errors.

Dependencies: none. Self-contained server-side change.

Step 2: WS broadcast for board.deleted after a successful delete

Files: crates/hero_whiteboard_ui/src/ws.rs, crates/hero_whiteboard_ui/src/routes.rs.

  1. In ws.rs:

    • BroadcastMsg::sender_id is currently a u64 taken from NEXT_CONN_ID (starts at 1). Use 0 as a sentinel for "server-originated, never echo to anybody" -- since real conn ids start at 1, no client connection will match sender_id == 0, so all subscribers (including the originating tab) will receive it.
    • Add a helper:
      pub async fn broadcast_to_board(channels: &BoardChannels, board_id: &str, text: String) {
          let map = channels.read().await;
          if let Some(tx) = map.get(board_id) {
              let _ = tx.send(BroadcastMsg { sender_id: 0, text });
          }
      }
      
    • No change to existing handler logic; sender_id 0 already isn't matched by if msg.sender_id == conn_id.
  2. In routes.rs::rpc_proxy:

    • Before forwarding to the server, parse the body bytes once via serde_json::from_slice::<serde_json::Value> and capture (method, params.id). If JSON parse fails or method != "board.delete", skip the capture (proxy stays a pass-through for everything else).
    • After the proxy receives a 200 response, parse the response bytes once and check body.result.deleted (looking for a positive integer). On match, call ws::broadcast_to_board(&state.channels, &board_id_str, json!({ "type": "board.deleted", "board_id": board_id_u64 }).to_string()).await.
    • Failure parsing the response should not affect the proxy's success path -- log and move on.

Dependencies: Step 1 (so the server actually performs the delete; otherwise we'd broadcast on no-ops). For incremental development the steps can run in either order, but the test assertions in Step 1 don't depend on Step 2.

Step 3: Client handler + overlay

Files: crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js, crates/hero_whiteboard_ui/templates/web/board.html.

  1. In sync.js:

    • Add a module-scope var boardDeleted = false;.
    • In handleWsMessage(msg), before the type switch (or as a new branch), handle msg.type === 'board.deleted':
      if (msg.type === 'board.deleted') {
          boardDeleted = true;
          try { if (ws) ws.close(); } catch (e) {}
          try { if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } } catch (e) {}
          if (typeof showBoardDeletedNotice === 'function') showBoardDeletedNotice();
          return;
      }
      
    • In onCreate, onUpdate, onDelete, and the WS reconnect path, early-return when boardDeleted is true.
    • In connectWebSocket, also early-return if boardDeleted is true (so reconnect attempts don't fire after a deletion).
  2. In board.html:

    • Inside {% block content %}, add:
      <!-- Board deleted overlay -->
      <div id="board-deleted-overlay"
          style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;">
          <div style="background:var(--wb-surface);border:1px solid var(--wb-border);border-radius:12px;padding:24px;width:400px;max-width:90vw;text-align:center;">
              <h3 style="margin:0 0 12px;font-size:16px;">Board deleted</h3>
              <p style="margin:0 0 16px;font-size:14px;color:var(--wb-text-muted);">This board has been deleted by another user.</p>
              <a class="btn btn-sm btn-primary" href="{{ base_path }}/">Back to home</a>
          </div>
      </div>
      
    • Inside {% block scripts %}, add:
      function showBoardDeletedNotice() {
          var el = document.getElementById('board-deleted-overlay');
          if (el) el.style.display = 'flex';
      }
      
      Defined in the global scope so sync.js's typeof showBoardDeletedNotice === 'function' check finds it.

Dependencies: Step 2 (the broadcast format must match {type, board_id}).

Acceptance Criteria

  • board.delete is followed by board.get returning a not-found-style error.
  • After delete, object.list / create / update / delete / batch_update, comment.create / update / resolve / delete, connector.create / update / delete, share.create for that board_id all return an error mentioning the board has been deleted.
  • After delete, all WebSocket subscribers for that board receive a board.deleted event with the board_id.
  • The board / share page handles board.deleted by stopping the WS loop, suppressing further sync RPCs, and showing the centered themed overlay with Back to home.
  • Behaviour in the deleter's tab is unchanged (they're already on the home page after Delete).
  • At least one new server-side test asserts board.delete then object.create returns an error.
  • cargo test --workspace --lib passes; cargo clippy --workspace -- -D warnings clean.
  • No regression in board.list (still filters soft-deleted).

Notes

  • BroadcastMsg::sender_id == 0 is a sentinel for server-originated messages; existing client connections start at id 1, so no echo-skip collision.
  • The server's existing get_board callers (e.g. update) will start failing for soft-deleted rows after Step 1. Sweep callers; the only legitimate caller that currently exists is board.update and its expected behaviour is also "reject" -- so the change is safe.
  • The client overlay is a hard read-only signal. Pending in-flight RPCs may still race in (and will fail server-side); that's fine -- the user just sees the overlay either way.
  • If the broadcast machinery is too risky to land in this pass, the client can still pick up the deletion lazily by treating any RPC error matching "deleted" as a board.deleted event. This is a useful belt-and-suspenders fallback worth wiring even with the broadcast in place.
  • Don't change board.delete's shape (still soft-delete, still returns {deleted: 1}) -- only the surrounding access semantics.
## Implementation Spec for Issue #83 ### Objective Make board soft-deletes effective end-to-end: the server rejects every read/write against a soft-deleted board, the UI broadcasts a `board.deleted` WebSocket event after a successful `board.delete` RPC, and the board / share page handles that event by stopping sync, marking the canvas read-only, and showing a centered themed notice. ### Requirements - After `board.delete`: - `board.get` for the same id returns a not-found-style error. - `object.list / create / update / delete / batch_update`, `comment.create / update / resolve / delete`, `connector.create / update / delete`, `share.create` reject the call when the parent board is soft-deleted. - All open WebSocket subscribers for that board receive a `{ type: "board.deleted", board_id: <number> }` message originating from the server (not from a peer client). - The board page (and the `/s/<token>` share page that reuses it) handles `board.deleted` by: - Stopping the local sync loop (no more outbound `object.update` RPCs / WS broadcasts). - Marking the canvas read-only (suppress further user-driven mutations as a defense against in-flight inputs). - Showing a centered themed overlay with the message `This board has been deleted.` and a `Back to home` button. - Soft-deleted rows in the DB stay; only access semantics change. - No regression in the existing `board.list` filter or in the rest of the app. - Tests: at least one server-side unit/integration test that asserts `board.delete` followed by `object.create` on the same `board_id` returns an error. ### Files to Modify - `crates/hero_whiteboard_server/src/db/queries.rs` — gate `get_board` on `deleted_at IS NULL`; add a `is_board_live(conn, board_id) -> rusqlite::Result<bool>` helper. - `crates/hero_whiteboard_server/src/handlers/object.rs` — guard `create / update / delete / batch_update` against deleted parent boards. - `crates/hero_whiteboard_server/src/handlers/comment.rs`, `connector.rs`, `share.rs` — same guard for write paths. - `crates/hero_whiteboard_server/src/handlers/board.rs` — `update` rejects soft-deleted boards too. - `crates/hero_whiteboard_server/tests/` (or an existing integration test file) — add the regression test. - `crates/hero_whiteboard_ui/src/ws.rs` — make `BroadcastMsg::sender_id` reachable for server-originated broadcasts (sender_id `0` so it's never a real connection); add a small helper `pub async fn broadcast_to_board(channels: &BoardChannels, board_id: &str, text: String)`. - `crates/hero_whiteboard_ui/src/routes.rs` — in `rpc_proxy`, sniff the request body for `method == "board.delete"`, capture `params.id`, and after a 200 response with `result.deleted > 0`, call the new broadcast helper with `{type:"board.deleted", board_id}`. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` — handle `msg.type === 'board.deleted'`: set a `boardDeleted` flag, stop the WS loop, prevent future RPCs (early-return in `onUpdate`, `onCreate`, `onDelete`), call a UI hook to show the overlay. - `crates/hero_whiteboard_ui/templates/web/board.html` — add a centered themed `<div id="board-deleted-overlay">` (matching the rest of the page's modals) with the message and the `Back to home` button. Wire a small script function to show it. No new DB migrations — `deleted_at` already exists. ### Implementation Plan #### Step 1: Server-side gating (queries + handlers + test) Files: `crates/hero_whiteboard_server/src/db/queries.rs`, `crates/hero_whiteboard_server/src/handlers/board.rs`, `handlers/object.rs`, `handlers/comment.rs`, `handlers/connector.rs`, `handlers/share.rs`, plus a test file. 1. In `db/queries.rs`: - Change `get_board` to add `AND deleted_at IS NULL` so the SELECT returns no rows for soft-deleted boards. The handler will see `rusqlite::Error::QueryReturnedNoRows` and bubble it up via `?`. To keep the user-facing message consistent, wrap it in `anyhow::anyhow!("board not found or deleted")` at the handler layer. - Add helper: ```rust pub fn is_board_live(conn: &Connection, board_id: u64) -> rusqlite::Result<bool> { let mut stmt = conn.prepare( "SELECT 1 FROM boards WHERE id = ?1 AND deleted_at IS NULL", )?; let mut rows = stmt.query(rusqlite::params![board_id])?; Ok(rows.next()?.is_some()) } ``` 2. In each write handler, before mutating: - For `create`-style methods that already take `board_id`, call `queries::is_board_live(&db, board_id)` and `bail!("board has been deleted")` if false. - For `update`/`delete`/`resolve`/`batch_update` that don't take `board_id` directly, fetch the row first (`get_object`, `get_comment`, `get_connector`, etc.), then check `is_board_live` against the row's `board_id`. - Use `anyhow::bail!("board has been deleted")` so the message is consistent and easy for the client to detect. 3. In `handlers/board.rs`: - Have `get` and `update` do the same `is_board_live` check (or rely on the new `get_board` filter for `get` -- consistent error wording either way). 4. Add a test `crates/hero_whiteboard_server/tests/soft_delete.rs` (or extend an existing one) that: - Creates a workspace + board. - Calls `board.delete`. - Calls `object.create` for the same `board_id` and asserts the call returns an `Err` containing `"deleted"`. - Calls `board.get` and asserts it errors. Dependencies: none. Self-contained server-side change. #### Step 2: WS broadcast for `board.deleted` after a successful delete Files: `crates/hero_whiteboard_ui/src/ws.rs`, `crates/hero_whiteboard_ui/src/routes.rs`. 1. In `ws.rs`: - `BroadcastMsg::sender_id` is currently a `u64` taken from `NEXT_CONN_ID` (starts at 1). Use `0` as a sentinel for "server-originated, never echo to anybody" -- since real conn ids start at 1, no client connection will match `sender_id == 0`, so all subscribers (including the originating tab) will receive it. - Add a helper: ```rust pub async fn broadcast_to_board(channels: &BoardChannels, board_id: &str, text: String) { let map = channels.read().await; if let Some(tx) = map.get(board_id) { let _ = tx.send(BroadcastMsg { sender_id: 0, text }); } } ``` - No change to existing handler logic; `sender_id` 0 already isn't matched by `if msg.sender_id == conn_id`. 2. In `routes.rs::rpc_proxy`: - Before forwarding to the server, parse the body bytes once via `serde_json::from_slice::<serde_json::Value>` and capture `(method, params.id)`. If JSON parse fails or method != `"board.delete"`, skip the capture (proxy stays a pass-through for everything else). - After the proxy receives a 200 response, parse the response bytes once and check `body.result.deleted` (looking for a positive integer). On match, call `ws::broadcast_to_board(&state.channels, &board_id_str, json!({ "type": "board.deleted", "board_id": board_id_u64 }).to_string()).await`. - Failure parsing the response should not affect the proxy's success path -- log and move on. Dependencies: Step 1 (so the server actually performs the delete; otherwise we'd broadcast on no-ops). For incremental development the steps can run in either order, but the test assertions in Step 1 don't depend on Step 2. #### Step 3: Client handler + overlay Files: `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js`, `crates/hero_whiteboard_ui/templates/web/board.html`. 1. In `sync.js`: - Add a module-scope `var boardDeleted = false;`. - In `handleWsMessage(msg)`, before the type switch (or as a new branch), handle `msg.type === 'board.deleted'`: ```js if (msg.type === 'board.deleted') { boardDeleted = true; try { if (ws) ws.close(); } catch (e) {} try { if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } } catch (e) {} if (typeof showBoardDeletedNotice === 'function') showBoardDeletedNotice(); return; } ``` - In `onCreate`, `onUpdate`, `onDelete`, and the WS reconnect path, early-return when `boardDeleted` is true. - In `connectWebSocket`, also early-return if `boardDeleted` is true (so reconnect attempts don't fire after a deletion). 2. In `board.html`: - Inside `{% block content %}`, add: ```html <!-- Board deleted overlay --> <div id="board-deleted-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;"> <div style="background:var(--wb-surface);border:1px solid var(--wb-border);border-radius:12px;padding:24px;width:400px;max-width:90vw;text-align:center;"> <h3 style="margin:0 0 12px;font-size:16px;">Board deleted</h3> <p style="margin:0 0 16px;font-size:14px;color:var(--wb-text-muted);">This board has been deleted by another user.</p> <a class="btn btn-sm btn-primary" href="{{ base_path }}/">Back to home</a> </div> </div> ``` - Inside `{% block scripts %}`, add: ```js function showBoardDeletedNotice() { var el = document.getElementById('board-deleted-overlay'); if (el) el.style.display = 'flex'; } ``` Defined in the global scope so `sync.js`'s `typeof showBoardDeletedNotice === 'function'` check finds it. Dependencies: Step 2 (the broadcast format must match `{type, board_id}`). ### Acceptance Criteria - [ ] `board.delete` is followed by `board.get` returning a not-found-style error. - [ ] After delete, `object.list / create / update / delete / batch_update`, `comment.create / update / resolve / delete`, `connector.create / update / delete`, `share.create` for that `board_id` all return an error mentioning the board has been deleted. - [ ] After delete, all WebSocket subscribers for that board receive a `board.deleted` event with the `board_id`. - [ ] The board / share page handles `board.deleted` by stopping the WS loop, suppressing further sync RPCs, and showing the centered themed overlay with `Back to home`. - [ ] Behaviour in the deleter's tab is unchanged (they're already on the home page after Delete). - [ ] At least one new server-side test asserts `board.delete` then `object.create` returns an error. - [ ] `cargo test --workspace --lib` passes; `cargo clippy --workspace -- -D warnings` clean. - [ ] No regression in `board.list` (still filters soft-deleted). ### Notes - `BroadcastMsg::sender_id == 0` is a sentinel for server-originated messages; existing client connections start at id 1, so no echo-skip collision. - The server's existing `get_board` callers (e.g. `update`) will start failing for soft-deleted rows after Step 1. Sweep callers; the only legitimate caller that currently exists is `board.update` and its expected behaviour is also "reject" -- so the change is safe. - The client overlay is a hard read-only signal. Pending in-flight RPCs may still race in (and will fail server-side); that's fine -- the user just sees the overlay either way. - If the broadcast machinery is too risky to land in this pass, the client can still pick up the deletion lazily by treating any RPC error matching `"deleted"` as a `board.deleted` event. This is a useful belt-and-suspenders fallback worth wiring even with the broadcast in place. - Don't change `board.delete`'s shape (still soft-delete, still returns `{deleted: 1}`) -- only the surrounding access semantics.
Author
Member

Test Results

  • cargo test --workspace: all tests pass, including the new handlers::object::tests::create_after_delete_returns_error regression test.
  • cargo clippy --workspace -- -D warnings: clean.
  • node --check sync.js: parses cleanly.

The new test seeds a workspace + board in an in-memory SQLite, asserts object.create succeeds while the board is live, calls board.delete, then asserts the next object.create errors with a message containing "deleted".

Manual verification recommended:

  1. Open a board in window A and a /s/<token> editor share in window B.
  2. From window A's home page, delete the board.
  3. Confirm window B receives the broadcast: the Board deleted overlay appears, the canvas freezes, and edits stop syncing.
  4. Confirm window B's WebSocket does not reconnect after the deletion.
  5. Confirm clicking Back to home navigates out cleanly.
## Test Results - `cargo test --workspace`: all tests pass, including the new `handlers::object::tests::create_after_delete_returns_error` regression test. - `cargo clippy --workspace -- -D warnings`: clean. - `node --check sync.js`: parses cleanly. The new test seeds a workspace + board in an in-memory SQLite, asserts `object.create` succeeds while the board is live, calls `board.delete`, then asserts the next `object.create` errors with a message containing `"deleted"`. Manual verification recommended: 1. Open a board in window A and a `/s/<token>` editor share in window B. 2. From window A's home page, delete the board. 3. Confirm window B receives the broadcast: the `Board deleted` overlay appears, the canvas freezes, and edits stop syncing. 4. Confirm window B's WebSocket does not reconnect after the deletion. 5. Confirm clicking `Back to home` navigates out cleanly.
Author
Member

Implementation Summary

10 files changed, +272 / -12.

Server gating (crates/hero_whiteboard_server/)

  • db/mod.rs: extracted the migration list into a private helper and added a #[cfg(test)] open_memory_db() so unit tests can run against an in-memory DB without touching ~/hero/var/data.
  • db/queries.rs: get_board now filters deleted_at IS NULL. Added three live-board predicates: is_board_live(board_id), is_comment_board_live(comment_id), is_connector_board_live(connector_id).
  • handlers/object.rs: create, list, update, delete, batch_update all reject when the parent board is soft-deleted. Added a new tests module with create_after_delete_returns_error (passes).
  • handlers/comment.rs: create, list, update, resolve, delete reject deleted-board operations.
  • handlers/connector.rs: create, list, update, delete reject deleted-board operations.
  • handlers/share.rs: create rejects new shares against a deleted board.
  • handlers/board.rs::update: gated implicitly via the new get_board filter (errors with QueryReturnedNoRows).

All write paths return anyhow::bail!("board has been deleted") so the message is consistent.

Server-originated WS broadcast (crates/hero_whiteboard_ui/)

  • src/ws.rs: broadcast_to_board(channels, board_id, text) helper. Uses sender_id: 0 as a server-originated sentinel (real connection ids start at 1, so the per-client echo-skip never filters server-originated messages out).
  • src/routes.rs::rpc_proxy: sniffs the request body for method == "board.delete", captures params.id, and after a 200 response with result.deleted > 0, broadcasts {"type":"board.deleted","board_id":<id>} to the matching channel.

Client handler + overlay (crates/hero_whiteboard_ui/)

  • static/web/js/whiteboard/sync.js: added a boardDeleted flag. handleWsMessage handles board.deleted before the localUserId echo-skip — closes the WS, clears the reconnect timer, drops pending updates, and calls window.showBoardDeletedNotice(). connectWebSocket, onCreate, onUpdate, onDelete all early-return when boardDeleted is true.
  • templates/web/board.html: added a centered themed board-deleted-overlay (var(--wb-...)) with Back to home, plus the global window.showBoardDeletedNotice() helper.

Verification

  • cargo test --workspace: all green, including the new create_after_delete_returns_error.
  • cargo clippy --workspace -- -D warnings: clean.
  • node --check sync.js: parses cleanly.

Notes / caveats

  • Soft-delete still works the same way at the DB level (the deleted_at column is set; data isn't dropped). Only the access semantics change.
  • The broadcast lives in the UI's RPC proxy, not the server, because the WebSocket relay also lives in the UI process. BoardChannels is shared via AppState.
  • The board page no longer reconnects WebSockets after a deletion -- previously the per-client ws.onclose handler would unconditionally schedule a reconnect.
  • The notice overlay uses var(--wb-...) so it matches dark + light themes.
  • The fix scope is end-to-end (Rust + JS), but every change is small and contained: each handler gets a 3-line guard, the UI proxy gets a single sniff/broadcast block, and the client gets one new flag with four early-returns plus one keyboard-free overlay.
## Implementation Summary 10 files changed, +272 / -12. ### Server gating (`crates/hero_whiteboard_server/`) - `db/mod.rs`: extracted the migration list into a private helper and added a `#[cfg(test)] open_memory_db()` so unit tests can run against an in-memory DB without touching `~/hero/var/data`. - `db/queries.rs`: `get_board` now filters `deleted_at IS NULL`. Added three live-board predicates: `is_board_live(board_id)`, `is_comment_board_live(comment_id)`, `is_connector_board_live(connector_id)`. - `handlers/object.rs`: `create`, `list`, `update`, `delete`, `batch_update` all reject when the parent board is soft-deleted. Added a new `tests` module with `create_after_delete_returns_error` (passes). - `handlers/comment.rs`: `create`, `list`, `update`, `resolve`, `delete` reject deleted-board operations. - `handlers/connector.rs`: `create`, `list`, `update`, `delete` reject deleted-board operations. - `handlers/share.rs`: `create` rejects new shares against a deleted board. - `handlers/board.rs::update`: gated implicitly via the new `get_board` filter (errors with `QueryReturnedNoRows`). All write paths return `anyhow::bail!("board has been deleted")` so the message is consistent. ### Server-originated WS broadcast (`crates/hero_whiteboard_ui/`) - `src/ws.rs`: `broadcast_to_board(channels, board_id, text)` helper. Uses `sender_id: 0` as a server-originated sentinel (real connection ids start at 1, so the per-client echo-skip never filters server-originated messages out). - `src/routes.rs::rpc_proxy`: sniffs the request body for `method == "board.delete"`, captures `params.id`, and after a 200 response with `result.deleted > 0`, broadcasts `{"type":"board.deleted","board_id":<id>}` to the matching channel. ### Client handler + overlay (`crates/hero_whiteboard_ui/`) - `static/web/js/whiteboard/sync.js`: added a `boardDeleted` flag. `handleWsMessage` handles `board.deleted` before the localUserId echo-skip — closes the WS, clears the reconnect timer, drops pending updates, and calls `window.showBoardDeletedNotice()`. `connectWebSocket`, `onCreate`, `onUpdate`, `onDelete` all early-return when `boardDeleted` is true. - `templates/web/board.html`: added a centered themed `board-deleted-overlay` (`var(--wb-...)`) with `Back to home`, plus the global `window.showBoardDeletedNotice()` helper. ### Verification - `cargo test --workspace`: all green, including the new `create_after_delete_returns_error`. - `cargo clippy --workspace -- -D warnings`: clean. - `node --check sync.js`: parses cleanly. ### Notes / caveats - Soft-delete still works the same way at the DB level (the `deleted_at` column is set; data isn't dropped). Only the access semantics change. - The broadcast lives in the UI's RPC proxy, not the server, because the WebSocket relay also lives in the UI process. `BoardChannels` is shared via `AppState`. - The board page no longer reconnects WebSockets after a deletion -- previously the per-client `ws.onclose` handler would unconditionally schedule a reconnect. - The notice overlay uses `var(--wb-...)` so it matches dark + light themes. - The fix scope is end-to-end (Rust + JS), but every change is small and contained: each handler gets a 3-line guard, the UI proxy gets a single sniff/broadcast block, and the client gets one new flag with four early-returns plus one keyboard-free overlay.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#83
No description provided.