Soft-deleted boards stay editable in other windows; server accepts reads/writes; clients are not notified #83
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#83
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Bug
Deleting a board from the home page is a server-side soft delete (sets
deleted_at). The home page'sboard.listfilter hides it correctly, but every other layer keeps treating the board as live:board.getand accepts every subsequentobject.list / create / update / delete,comment.*,connector.*, etc., because those handlers don't check the parent board'sdeleted_at./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.Reproduction
/s/<token>editor share. Open the share URL in window B.Root cause
Server (
crates/hero_whiteboard_server/):handlers/board.rs:104-112--delete()callsqueries::soft_delete_board(...), whichUPDATE 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 nodeleted_at IS NULLpredicate, soboard.getreturns 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'sdeleted_at; they query/insert/update byboard_iddirectly.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 (noboard.deletedbroadcast).Client (
crates/hero_whiteboard_ui/):board.deletedevent, 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./s/<token>share page reuses the same board page; same blind spot applies.Affected files (expected)
crates/hero_whiteboard_server/src/db/queries.rs-- adddeleted_at IS NULLfiltering toget_board; add a small helper (e.g.is_board_deleted(conn, board_id)or haveget_boarditself returnNotFoundfor soft-deleted rows).crates/hero_whiteboard_server/src/handlers/board.rs-- havegetandupdatereject soft-deleted boards with aNotFound/Goneerror.crates/hero_whiteboard_server/src/handlers/object.rs(andcomment.rs,connector.rs,share.rs) -- reject writes against a soft-deleted parent board.crates/hero_whiteboard_ui/src/ws.rs-- broadcast aboard.deletedevent (withboard_id) to all subscribers when the deletion succeeds. Either pipe this from the server'sboard.deleteRPC 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 theboard.deletedevent: 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 smallboard-deleted-noticeoverlay (themed viavar(--wb-...)) shown when the broadcast arrives. Single "Back to home" button.No schema changes required (the
deleted_atcolumn already exists).Expected behavior
board.deletereturns successfully:board.getfor the same id returns anot found/goneerror (use the same error path the UI already handles for unknown boards).object.list / create / update / delete,comment.*,connector.*, andshare.*for that board id return an error and do not mutate state. The error message should be clear (e.g."board has been deleted").{ type: "board.deleted", board_id }message.board.deletedevent:object.updatecalls).This board has been deleted.plus aBack to homebutton.Acceptance criteria
board.deleteis followed byboard.getreturning a not-found-style error (same shape the UI's RPC code already handles).object.list / create / update / delete,comment.create / update / delete,connector.create / update / delete,share.create / list / get / deletefor thatboard_idall return an error.board.deletedevent with theboard_id.board.deletedevent stops the local sync loop, marks the canvas read-only, and shows a centered themed notice with aBack to homebutton.board.deletefollowed byobject.createon the sameboard_idreturns an error.board.listfilter (it already excludes soft-deleted; keep it that way).Notes
var(--wb-...)); reuse the patterns established by thedelete-board-modal,share-board-modal, etc.board.deletedbroadcast 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.deleted_at IS NULL.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.deletedWebSocket event after a successfulboard.deleteRPC, and the board / share page handles that event by stopping sync, marking the canvas read-only, and showing a centered themed notice.Requirements
board.delete:board.getfor 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.createreject the call when the parent board is soft-deleted.{ type: "board.deleted", board_id: <number> }message originating from the server (not from a peer client)./s/<token>share page that reuses it) handlesboard.deletedby:object.updateRPCs / WS broadcasts).This board has been deleted.and aBack to homebutton.board.listfilter or in the rest of the app.board.deletefollowed byobject.createon the sameboard_idreturns an error.Files to Modify
crates/hero_whiteboard_server/src/db/queries.rs— gateget_boardondeleted_at IS NULL; add ais_board_live(conn, board_id) -> rusqlite::Result<bool>helper.crates/hero_whiteboard_server/src/handlers/object.rs— guardcreate / update / delete / batch_updateagainst 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—updaterejects 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— makeBroadcastMsg::sender_idreachable for server-originated broadcasts (sender_id0so it's never a real connection); add a small helperpub async fn broadcast_to_board(channels: &BoardChannels, board_id: &str, text: String).crates/hero_whiteboard_ui/src/routes.rs— inrpc_proxy, sniff the request body formethod == "board.delete", captureparams.id, and after a 200 response withresult.deleted > 0, call the new broadcast helper with{type:"board.deleted", board_id}.crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js— handlemsg.type === 'board.deleted': set aboardDeletedflag, stop the WS loop, prevent future RPCs (early-return inonUpdate,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 theBack to homebutton. Wire a small script function to show it.No new DB migrations —
deleted_atalready 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.In
db/queries.rs:get_boardto addAND deleted_at IS NULLso the SELECT returns no rows for soft-deleted boards. The handler will seerusqlite::Error::QueryReturnedNoRowsand bubble it up via?. To keep the user-facing message consistent, wrap it inanyhow::anyhow!("board not found or deleted")at the handler layer.In each write handler, before mutating:
create-style methods that already takeboard_id, callqueries::is_board_live(&db, board_id)andbail!("board has been deleted")if false.update/delete/resolve/batch_updatethat don't takeboard_iddirectly, fetch the row first (get_object,get_comment,get_connector, etc.), then checkis_board_liveagainst the row'sboard_id.anyhow::bail!("board has been deleted")so the message is consistent and easy for the client to detect.In
handlers/board.rs:getandupdatedo the sameis_board_livecheck (or rely on the newget_boardfilter forget-- consistent error wording either way).Add a test
crates/hero_whiteboard_server/tests/soft_delete.rs(or extend an existing one) that:board.delete.object.createfor the sameboard_idand asserts the call returns anErrcontaining"deleted".board.getand asserts it errors.Dependencies: none. Self-contained server-side change.
Step 2: WS broadcast for
board.deletedafter a successful deleteFiles:
crates/hero_whiteboard_ui/src/ws.rs,crates/hero_whiteboard_ui/src/routes.rs.In
ws.rs:BroadcastMsg::sender_idis currently au64taken fromNEXT_CONN_ID(starts at 1). Use0as a sentinel for "server-originated, never echo to anybody" -- since real conn ids start at 1, no client connection will matchsender_id == 0, so all subscribers (including the originating tab) will receive it.sender_id0 already isn't matched byif msg.sender_id == conn_id.In
routes.rs::rpc_proxy: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).body.result.deleted(looking for a positive integer). On match, callws::broadcast_to_board(&state.channels, &board_id_str, json!({ "type": "board.deleted", "board_id": board_id_u64 }).to_string()).await.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.In
sync.js:var boardDeleted = false;.handleWsMessage(msg), before the type switch (or as a new branch), handlemsg.type === 'board.deleted':onCreate,onUpdate,onDelete, and the WS reconnect path, early-return whenboardDeletedis true.connectWebSocket, also early-return ifboardDeletedis true (so reconnect attempts don't fire after a deletion).In
board.html:{% block content %}, add:{% block scripts %}, add: Defined in the global scope sosync.js'stypeof showBoardDeletedNotice === 'function'check finds it.Dependencies: Step 2 (the broadcast format must match
{type, board_id}).Acceptance Criteria
board.deleteis followed byboard.getreturning a not-found-style error.object.list / create / update / delete / batch_update,comment.create / update / resolve / delete,connector.create / update / delete,share.createfor thatboard_idall return an error mentioning the board has been deleted.board.deletedevent with theboard_id.board.deletedby stopping the WS loop, suppressing further sync RPCs, and showing the centered themed overlay withBack to home.board.deletethenobject.createreturns an error.cargo test --workspace --libpasses;cargo clippy --workspace -- -D warningsclean.board.list(still filters soft-deleted).Notes
BroadcastMsg::sender_id == 0is a sentinel for server-originated messages; existing client connections start at id 1, so no echo-skip collision.get_boardcallers (e.g.update) will start failing for soft-deleted rows after Step 1. Sweep callers; the only legitimate caller that currently exists isboard.updateand its expected behaviour is also "reject" -- so the change is safe."deleted"as aboard.deletedevent. This is a useful belt-and-suspenders fallback worth wiring even with the broadcast in place.board.delete's shape (still soft-delete, still returns{deleted: 1}) -- only the surrounding access semantics.Test Results
cargo test --workspace: all tests pass, including the newhandlers::object::tests::create_after_delete_returns_errorregression 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.createsucceeds while the board is live, callsboard.delete, then asserts the nextobject.createerrors with a message containing"deleted".Manual verification recommended:
/s/<token>editor share in window B.Board deletedoverlay appears, the canvas freezes, and edits stop syncing.Back to homenavigates out cleanly.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_boardnow filtersdeleted_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_updateall reject when the parent board is soft-deleted. Added a newtestsmodule withcreate_after_delete_returns_error(passes).handlers/comment.rs:create,list,update,resolve,deletereject deleted-board operations.handlers/connector.rs:create,list,update,deletereject deleted-board operations.handlers/share.rs:createrejects new shares against a deleted board.handlers/board.rs::update: gated implicitly via the newget_boardfilter (errors withQueryReturnedNoRows).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. Usessender_id: 0as 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 formethod == "board.delete", capturesparams.id, and after a 200 response withresult.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 aboardDeletedflag.handleWsMessagehandlesboard.deletedbefore the localUserId echo-skip — closes the WS, clears the reconnect timer, drops pending updates, and callswindow.showBoardDeletedNotice().connectWebSocket,onCreate,onUpdate,onDeleteall early-return whenboardDeletedis true.templates/web/board.html: added a centered themedboard-deleted-overlay(var(--wb-...)) withBack to home, plus the globalwindow.showBoardDeletedNotice()helper.Verification
cargo test --workspace: all green, including the newcreate_after_delete_returns_error.cargo clippy --workspace -- -D warnings: clean.node --check sync.js: parses cleanly.Notes / caveats
deleted_atcolumn is set; data isn't dropped). Only the access semantics change.BoardChannelsis shared viaAppState.ws.onclosehandler would unconditionally schedule a reconnect.var(--wb-...)so it matches dark + light themes.