Two boards can have the same name in the same workspace #84

Open
opened 2026-04-28 08:22:42 +00:00 by AhmedHanafy725 · 3 comments
Member

Bug

The board-create flow places no constraint on board names within a workspace. A user can create two (or N) boards with identical names under the same workspace_id and the home page will show both as separate cards with no way to tell them apart visually.

Reproduction

  1. Open the home page, pick a workspace (or stay in All Workspaces and pick one in the modal).
  2. Click New Board, enter Sprint planning, Create.
  3. Click New Board again in the same workspace, enter Sprint planning, Create.

Observed: both boards are created successfully and both appear in the list.

Expected: the second create fails with a clear error (A board named "Sprint planning" already exists in this workspace), shown inline in the New Board modal so the user can adjust the name without losing their other input.

The same issue applies to board.update (renaming a board to collide with another live board in the same workspace) and to duplicateBoard (which currently appends (copy) once -- duplicating twice produces two Foo (copy) boards).

Root cause

  • crates/hero_whiteboard_server/src/db/queries.rs::create_board (around line 70) and update_board -- neither checks for an existing live board with the same (workspace_id, name).
  • crates/hero_whiteboard_server/src/migrations/ -- the boards table has no unique index on (workspace_id, name).
  • crates/hero_whiteboard_server/src/handlers/board.rs -- create and update don't pre-validate uniqueness.
  • crates/hero_whiteboard_ui/templates/web/home.html -- the New Board modal and the rename modal don't validate either; they call the RPC blindly.

Affected files (expected)

  • crates/hero_whiteboard_server/src/migrations/006_unique_board_name.sql (new) -- partial unique index that ignores soft-deleted rows.
  • crates/hero_whiteboard_server/src/db/mod.rs -- register the new migration.
  • crates/hero_whiteboard_server/src/db/queries.rs -- a small helper board_name_exists(conn, workspace_id, name, except_id) -> bool for an explicit pre-flight check.
  • crates/hero_whiteboard_server/src/handlers/board.rs -- create and update reject duplicate names with "A board named \"<name>\" already exists in this workspace".
  • crates/hero_whiteboard_server/tests (or inline) -- regression test that two creates with the same (workspace_id, name) errors on the second.
  • crates/hero_whiteboard_ui/templates/web/home.html -- show the server's error message inline in the New Board modal (and the rename modal) instead of an alert/silent failure.

No SDK / openrpc edits required; the error path is already JSON-RPC-error-shape.

Expected behavior

  • Two live boards in the same workspace cannot share the same name.
  • Soft-deleted boards do not count towards the constraint, so a user can re-create a board with the same name as one they previously deleted (matches the soft-delete semantics elsewhere in the app).
  • Comparison is case-sensitive (matches what the UI already shows -- the user can rename later if they want a different casing).
  • A board can be renamed to its current name (idempotent self-update).
  • The error surfaces as a clear inline message in the New Board / Rename modal, not as a silent retry or a browser alert.
  • Duplicate detection is enforced at the DB layer (partial unique index) AND pre-validated at the handler layer so the user gets a friendly message instead of a UNIQUE-constraint-violation surfacing.

Acceptance criteria

  • A migration adds a partial unique index on boards (workspace_id, name) WHERE deleted_at IS NULL.
  • board.create rejects an attempt to create a second board with the same (workspace_id, name) as a live board, with the message A board named "<name>" already exists in this workspace.
  • board.update rejects renaming a board to a name already taken by another live board in the same workspace.
  • Renaming a board to its existing name does not error (no-op as far as the constraint is concerned).
  • Soft-deleted boards do not count -- after board.delete, a new board with that name in the same workspace can be created.
  • A regression test asserts the duplicate-create rejection.
  • The home-page New Board modal surfaces the server error inline (#new-board-error-style) and keeps the modal open for retry; same for the rename modal.
  • No regression in board.list, board.get, or any other handler.
  • cargo test --workspace passes; cargo clippy --workspace -- -D warnings clean.
  • If existing rows in production already violate the new constraint, the migration must still apply -- include a one-shot rename pass in the migration (e.g. append (1), (2), ...) before creating the index. Document this behavior.

Notes

  • The partial unique index WHERE deleted_at IS NULL matches the soft-delete semantics already enforced elsewhere (get_board, is_board_live, etc.).
  • Use sqlite_master or a plain SELECT in the migration to detect collisions before creating the index, rather than letting the index creation itself fail on a populated DB.
  • For the rename modal, reuse the same inline-error pattern as the new-board modal so the styling is consistent.
  • Don't change the public RPC shape; only the error message string.
## Bug The board-create flow places no constraint on board names within a workspace. A user can create two (or N) boards with identical names under the same `workspace_id` and the home page will show both as separate cards with no way to tell them apart visually. ## Reproduction 1. Open the home page, pick a workspace (or stay in `All Workspaces` and pick one in the modal). 2. Click `New Board`, enter `Sprint planning`, Create. 3. Click `New Board` again in the same workspace, enter `Sprint planning`, Create. Observed: both boards are created successfully and both appear in the list. Expected: the second create fails with a clear error (`A board named "Sprint planning" already exists in this workspace`), shown inline in the New Board modal so the user can adjust the name without losing their other input. The same issue applies to `board.update` (renaming a board to collide with another live board in the same workspace) and to `duplicateBoard` (which currently appends ` (copy)` once -- duplicating twice produces two `Foo (copy)` boards). ## Root cause - `crates/hero_whiteboard_server/src/db/queries.rs::create_board` (around line 70) and `update_board` -- neither checks for an existing live board with the same `(workspace_id, name)`. - `crates/hero_whiteboard_server/src/migrations/` -- the boards table has no unique index on `(workspace_id, name)`. - `crates/hero_whiteboard_server/src/handlers/board.rs` -- `create` and `update` don't pre-validate uniqueness. - `crates/hero_whiteboard_ui/templates/web/home.html` -- the New Board modal and the rename modal don't validate either; they call the RPC blindly. ## Affected files (expected) - `crates/hero_whiteboard_server/src/migrations/006_unique_board_name.sql` (new) -- partial unique index that ignores soft-deleted rows. - `crates/hero_whiteboard_server/src/db/mod.rs` -- register the new migration. - `crates/hero_whiteboard_server/src/db/queries.rs` -- a small helper `board_name_exists(conn, workspace_id, name, except_id) -> bool` for an explicit pre-flight check. - `crates/hero_whiteboard_server/src/handlers/board.rs` -- `create` and `update` reject duplicate names with `"A board named \"<name>\" already exists in this workspace"`. - `crates/hero_whiteboard_server/tests` (or inline) -- regression test that two creates with the same `(workspace_id, name)` errors on the second. - `crates/hero_whiteboard_ui/templates/web/home.html` -- show the server's error message inline in the New Board modal (and the rename modal) instead of an `alert`/silent failure. No SDK / openrpc edits required; the error path is already JSON-RPC-error-shape. ## Expected behavior - Two **live** boards in the same workspace cannot share the same `name`. - Soft-deleted boards do **not** count towards the constraint, so a user can re-create a board with the same name as one they previously deleted (matches the soft-delete semantics elsewhere in the app). - Comparison is case-sensitive (matches what the UI already shows -- the user can rename later if they want a different casing). - A board can be renamed to its current name (idempotent self-update). - The error surfaces as a clear inline message in the New Board / Rename modal, not as a silent retry or a browser `alert`. - Duplicate detection is enforced at the DB layer (partial unique index) AND pre-validated at the handler layer so the user gets a friendly message instead of a UNIQUE-constraint-violation surfacing. ## Acceptance criteria - [ ] A migration adds a partial unique index on `boards (workspace_id, name) WHERE deleted_at IS NULL`. - [ ] `board.create` rejects an attempt to create a second board with the same `(workspace_id, name)` as a live board, with the message `A board named "<name>" already exists in this workspace`. - [ ] `board.update` rejects renaming a board to a name already taken by another live board in the same workspace. - [ ] Renaming a board to its existing name does not error (no-op as far as the constraint is concerned). - [ ] Soft-deleted boards do not count -- after `board.delete`, a new board with that name in the same workspace can be created. - [ ] A regression test asserts the duplicate-create rejection. - [ ] The home-page New Board modal surfaces the server error inline (`#new-board-error`-style) and keeps the modal open for retry; same for the rename modal. - [ ] No regression in `board.list`, `board.get`, or any other handler. - [ ] `cargo test --workspace` passes; `cargo clippy --workspace -- -D warnings` clean. - [ ] If existing rows in production already violate the new constraint, the migration must still apply -- include a one-shot rename pass in the migration (e.g. append ` (1)`, ` (2)`, ...) before creating the index. Document this behavior. ## Notes - The partial unique index `WHERE deleted_at IS NULL` matches the soft-delete semantics already enforced elsewhere (`get_board`, `is_board_live`, etc.). - Use `sqlite_master` or a plain SELECT in the migration to detect collisions before creating the index, rather than letting the index creation itself fail on a populated DB. - For the rename modal, reuse the same inline-error pattern as the new-board modal so the styling is consistent. - Don't change the public RPC shape; only the error message string.
Author
Member

Implementation Spec for Issue #84

Objective

Enforce that two live boards in the same workspace cannot share the same name. Reject duplicate (workspace_id, name) at the server (DB index + handler pre-check), and surface the rejection inline in the New Board / Rename modals on the home page.

Requirements

  • A migration adds a partial unique index on boards (workspace_id, name) WHERE deleted_at IS NULL.
  • The migration de-dupes any pre-existing collisions before creating the index, so it can apply on a populated DB.
  • board.create rejects duplicates with A board named "<name>" already exists in this workspace.
  • board.update rejects renames that collide with another live board in the same workspace; renaming a board to its current name is a no-op (no error).
  • Soft-deleted boards do not count toward uniqueness (matches is_board_live semantics).
  • Comparison is case-sensitive.
  • The home page's New Board modal and Rename modal show the error inline (no alert) and stay open for retry.
  • Diff stays out of the SDK / openrpc; only the error string changes.
  • A regression test asserts the duplicate-create rejection.

Files to Modify / Create

  • crates/hero_whiteboard_server/src/migrations/006_unique_board_name.sql (new) — dedupe step (rename collisions to "<name> (N)" until unique) + partial unique index.
  • crates/hero_whiteboard_server/src/db/mod.rs — register migration 006.
  • crates/hero_whiteboard_server/src/db/queries.rsboard_name_taken(conn, workspace_id, name, except_id) -> rusqlite::Result<bool> helper.
  • crates/hero_whiteboard_server/src/handlers/board.rscreate and update pre-flight check using the helper; consistent error message.
  • crates/hero_whiteboard_server/src/handlers/object.rs — extend the existing tests module with a duplicate-name regression test (or add a separate tests module in board.rs).
  • crates/hero_whiteboard_ui/templates/web/home.html — show the server's error message inline in the New Board modal and the Rename modal.

Implementation Plan

Step 1: Migration + queries helper

Files: crates/hero_whiteboard_server/src/migrations/006_unique_board_name.sql (new), crates/hero_whiteboard_server/src/db/mod.rs, crates/hero_whiteboard_server/src/db/queries.rs.

  1. Create 006_unique_board_name.sql:

    • Dedupe any pre-existing collisions among live boards. Use a window function (SQLite supports ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)) to assign unique suffixes to all but the oldest row in each (workspace_id, name) group:
      -- Append " (N)" to duplicate live-board names so the partial unique
      -- index below can be created on a populated DB. The earliest row
      -- (lowest id) in each group keeps its original name.
      WITH dups AS (
          SELECT id,
                 name || ' (' ||
                     (ROW_NUMBER() OVER (PARTITION BY workspace_id, name ORDER BY id) - 1)
                     || ')' AS new_name
          FROM boards
          WHERE deleted_at IS NULL
      )
      UPDATE boards
      SET name = (SELECT new_name FROM dups WHERE dups.id = boards.id)
      WHERE id IN (
          SELECT id FROM dups
          WHERE id IN (
              SELECT id FROM boards b2
              WHERE b2.deleted_at IS NULL
                AND EXISTS (
                    SELECT 1 FROM boards b3
                    WHERE b3.workspace_id = b2.workspace_id
                      AND b3.name = b2.name
                      AND b3.deleted_at IS NULL
                      AND b3.id <> b2.id
                )
          )
      );
      
      CREATE UNIQUE INDEX IF NOT EXISTS idx_boards_workspace_name_live
          ON boards (workspace_id, name)
          WHERE deleted_at IS NULL;
      
    • The dedupe writes "<name> (1)", "<name> (2)", ... to the duplicates while leaving the oldest one alone. After this runs, every (workspace_id, name) pair among live rows is unique, so the index creation succeeds.
  2. In db/mod.rs, append M::up(include_str!("../migrations/006_unique_board_name.sql")) to the migration list.

  3. In db/queries.rs, add the helper:

    /// True if a *live* board with this `(workspace_id, name)` exists, ignoring
    /// `except_id` (so a rename can compare against itself without flagging a
    /// false collision). Soft-deleted boards never count.
    pub fn board_name_taken(
        conn: &Connection,
        workspace_id: u64,
        name: &str,
        except_id: Option<u64>,
    ) -> rusqlite::Result<bool> {
        let mut stmt = conn.prepare(
            "SELECT 1 FROM boards
             WHERE workspace_id = ?1
               AND name = ?2
               AND deleted_at IS NULL
               AND (?3 IS NULL OR id != ?3)",
        )?;
        let mut rows = stmt.query(rusqlite::params![
            workspace_id,
            name,
            except_id.map(|v| v as i64)
        ])?;
        Ok(rows.next()?.is_some())
    }
    

Dependencies: none. Self-contained.

Step 2: Handler pre-flight check + regression test

Files: crates/hero_whiteboard_server/src/handlers/board.rs, crates/hero_whiteboard_server/src/handlers/object.rs (test module).

  1. In handlers/board.rs::create, after building the Board struct and before queries::create_board, do:
    let db = state.db.lock().unwrap();
    if queries::board_name_taken(&db, workspace_id, &board.name, None)? {
        anyhow::bail!(
            "A board named \"{}\" already exists in this workspace",
            board.name
        );
    }
    
  2. In handlers/board.rs::update, after fetching the existing board (and applying the optional name field), do:
    if board.name != *previous_name && queries::board_name_taken(&db, board.workspace_id, &board.name, Some(board.id))? {
        anyhow::bail!(
            "A board named \"{}\" already exists in this workspace",
            board.name
        );
    }
    
    previous_name is just the value of board.name before the optional update is applied — capture it before the if let Some(v) = params["name"].as_str() { board.name = v.to_string(); } block. The board.name != previous_name short-circuit is what makes "rename to current name" idempotent.
  3. Extend the existing handlers::object::tests module (or add an inline tests module in board.rs) with a duplicate_board_name_rejected test that:
    • Creates a workspace and a board named Sprint planning.
    • Calls board.create with the same name and asserts the call errors with the expected message.
    • Calls board.delete on the original, then asserts a fresh board.create with the same name now succeeds (soft-delete does not block reuse).

Dependencies: Step 1.

Step 3: UI inline error

Files: crates/hero_whiteboard_ui/templates/web/home.html.

  1. The New Board modal already has a #new-board-error region (created earlier). Make submitNewBoard's catch path show the error inline instead of the existing alert:
    } catch (e) {
        var err = document.getElementById('new-board-error');
        err.textContent = (e && e.message) ? e.message : ('Failed to create board: ' + e);
        err.style.display = 'block';
    }
    
    (If #new-board-error doesn't already exist on this branch, add a small <div> matching the New Workspace modal's pattern.)
  2. The Rename modal does not have an inline error region today. Add one (#rename-error) styled the same way as the other modals' error regions. Update saveRename to show the error inline on failure and keep the modal open. Reset both regions on modal-open.

Dependencies: Step 2 (so the error string is the one we surface).

Acceptance Criteria

  • Migration 006 applies cleanly on a fresh DB and on a populated DB with pre-existing collisions; collisions are renamed, the partial unique index is created.
  • board.create with a duplicate (workspace_id, name) returns the message A board named "<name>" already exists in this workspace.
  • board.update rejects renames that collide with another live board in the same workspace.
  • Renaming a board to its current name is a no-op (no error).
  • After board.delete, the same name can be reused for a new board in the same workspace.
  • Regression test for the duplicate-create rejection passes.
  • New Board modal: server error shows inline; modal stays open.
  • Rename modal: server error shows inline; modal stays open.
  • cargo test --workspace passes.
  • cargo clippy --workspace -- -D warnings clean.
  • No regression in board.list, board.get, share/object/comment/connector handlers.

Notes

  • The dedupe pass in the migration is idempotent — running it twice yields the same names because EXISTS (...) is false on the second pass.
  • The partial unique index uses WHERE deleted_at IS NULL so the same name can be re-used after a soft delete.
  • The handler-level pre-check is a UX layer: it gives a clear message before the DB raises a UNIQUE constraint error. The DB index is the safety net for race conditions.
  • Don't case-fold the comparison; the user can rename later if they want a different casing — and the existing UI already surfaces the case as typed.
## Implementation Spec for Issue #84 ### Objective Enforce that two **live** boards in the same workspace cannot share the same `name`. Reject duplicate `(workspace_id, name)` at the server (DB index + handler pre-check), and surface the rejection inline in the New Board / Rename modals on the home page. ### Requirements - A migration adds a partial unique index on `boards (workspace_id, name) WHERE deleted_at IS NULL`. - The migration de-dupes any pre-existing collisions before creating the index, so it can apply on a populated DB. - `board.create` rejects duplicates with `A board named "<name>" already exists in this workspace`. - `board.update` rejects renames that collide with another *live* board in the same workspace; renaming a board to its current name is a no-op (no error). - Soft-deleted boards do not count toward uniqueness (matches `is_board_live` semantics). - Comparison is case-sensitive. - The home page's New Board modal and Rename modal show the error inline (no `alert`) and stay open for retry. - Diff stays out of the SDK / openrpc; only the error string changes. - A regression test asserts the duplicate-create rejection. ### Files to Modify / Create - `crates/hero_whiteboard_server/src/migrations/006_unique_board_name.sql` (new) — dedupe step (rename collisions to `"<name> (N)"` until unique) + partial unique index. - `crates/hero_whiteboard_server/src/db/mod.rs` — register migration 006. - `crates/hero_whiteboard_server/src/db/queries.rs` — `board_name_taken(conn, workspace_id, name, except_id) -> rusqlite::Result<bool>` helper. - `crates/hero_whiteboard_server/src/handlers/board.rs` — `create` and `update` pre-flight check using the helper; consistent error message. - `crates/hero_whiteboard_server/src/handlers/object.rs` — extend the existing `tests` module with a duplicate-name regression test (or add a separate `tests` module in `board.rs`). - `crates/hero_whiteboard_ui/templates/web/home.html` — show the server's error message inline in the New Board modal and the Rename modal. ### Implementation Plan #### Step 1: Migration + queries helper Files: `crates/hero_whiteboard_server/src/migrations/006_unique_board_name.sql` (new), `crates/hero_whiteboard_server/src/db/mod.rs`, `crates/hero_whiteboard_server/src/db/queries.rs`. 1. Create `006_unique_board_name.sql`: - Dedupe any pre-existing collisions among live boards. Use a window function (SQLite supports `ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)`) to assign unique suffixes to all but the oldest row in each `(workspace_id, name)` group: ```sql -- Append " (N)" to duplicate live-board names so the partial unique -- index below can be created on a populated DB. The earliest row -- (lowest id) in each group keeps its original name. WITH dups AS ( SELECT id, name || ' (' || (ROW_NUMBER() OVER (PARTITION BY workspace_id, name ORDER BY id) - 1) || ')' AS new_name FROM boards WHERE deleted_at IS NULL ) UPDATE boards SET name = (SELECT new_name FROM dups WHERE dups.id = boards.id) WHERE id IN ( SELECT id FROM dups WHERE id IN ( SELECT id FROM boards b2 WHERE b2.deleted_at IS NULL AND EXISTS ( SELECT 1 FROM boards b3 WHERE b3.workspace_id = b2.workspace_id AND b3.name = b2.name AND b3.deleted_at IS NULL AND b3.id <> b2.id ) ) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_boards_workspace_name_live ON boards (workspace_id, name) WHERE deleted_at IS NULL; ``` - The dedupe writes `"<name> (1)"`, `"<name> (2)"`, ... to the duplicates while leaving the oldest one alone. After this runs, every `(workspace_id, name)` pair among live rows is unique, so the index creation succeeds. 2. In `db/mod.rs`, append `M::up(include_str!("../migrations/006_unique_board_name.sql"))` to the migration list. 3. In `db/queries.rs`, add the helper: ```rust /// True if a *live* board with this `(workspace_id, name)` exists, ignoring /// `except_id` (so a rename can compare against itself without flagging a /// false collision). Soft-deleted boards never count. pub fn board_name_taken( conn: &Connection, workspace_id: u64, name: &str, except_id: Option<u64>, ) -> rusqlite::Result<bool> { let mut stmt = conn.prepare( "SELECT 1 FROM boards WHERE workspace_id = ?1 AND name = ?2 AND deleted_at IS NULL AND (?3 IS NULL OR id != ?3)", )?; let mut rows = stmt.query(rusqlite::params![ workspace_id, name, except_id.map(|v| v as i64) ])?; Ok(rows.next()?.is_some()) } ``` Dependencies: none. Self-contained. #### Step 2: Handler pre-flight check + regression test Files: `crates/hero_whiteboard_server/src/handlers/board.rs`, `crates/hero_whiteboard_server/src/handlers/object.rs` (test module). 1. In `handlers/board.rs::create`, after building the `Board` struct and before `queries::create_board`, do: ```rust let db = state.db.lock().unwrap(); if queries::board_name_taken(&db, workspace_id, &board.name, None)? { anyhow::bail!( "A board named \"{}\" already exists in this workspace", board.name ); } ``` 2. In `handlers/board.rs::update`, after fetching the existing board (and applying the optional name field), do: ```rust if board.name != *previous_name && queries::board_name_taken(&db, board.workspace_id, &board.name, Some(board.id))? { anyhow::bail!( "A board named \"{}\" already exists in this workspace", board.name ); } ``` `previous_name` is just the value of `board.name` *before* the optional update is applied — capture it before the `if let Some(v) = params["name"].as_str() { board.name = v.to_string(); }` block. The `board.name != previous_name` short-circuit is what makes "rename to current name" idempotent. 3. Extend the existing `handlers::object::tests` module (or add an inline `tests` module in `board.rs`) with a `duplicate_board_name_rejected` test that: - Creates a workspace and a board named `Sprint planning`. - Calls `board.create` with the same name and asserts the call errors with the expected message. - Calls `board.delete` on the original, then asserts a fresh `board.create` with the same name now succeeds (soft-delete does not block reuse). Dependencies: Step 1. #### Step 3: UI inline error Files: `crates/hero_whiteboard_ui/templates/web/home.html`. 1. The New Board modal already has a `#new-board-error` region (created earlier). Make `submitNewBoard`'s catch path show the error inline instead of the existing `alert`: ```js } catch (e) { var err = document.getElementById('new-board-error'); err.textContent = (e && e.message) ? e.message : ('Failed to create board: ' + e); err.style.display = 'block'; } ``` (If `#new-board-error` doesn't already exist on this branch, add a small `<div>` matching the New Workspace modal's pattern.) 2. The Rename modal does not have an inline error region today. Add one (`#rename-error`) styled the same way as the other modals' error regions. Update `saveRename` to show the error inline on failure and keep the modal open. Reset both regions on modal-open. Dependencies: Step 2 (so the error string is the one we surface). ### Acceptance Criteria - [ ] Migration 006 applies cleanly on a fresh DB and on a populated DB with pre-existing collisions; collisions are renamed, the partial unique index is created. - [ ] `board.create` with a duplicate `(workspace_id, name)` returns the message `A board named "<name>" already exists in this workspace`. - [ ] `board.update` rejects renames that collide with another live board in the same workspace. - [ ] Renaming a board to its current name is a no-op (no error). - [ ] After `board.delete`, the same name can be reused for a new board in the same workspace. - [ ] Regression test for the duplicate-create rejection passes. - [ ] New Board modal: server error shows inline; modal stays open. - [ ] Rename modal: server error shows inline; modal stays open. - [ ] `cargo test --workspace` passes. - [ ] `cargo clippy --workspace -- -D warnings` clean. - [ ] No regression in `board.list`, `board.get`, share/object/comment/connector handlers. ### Notes - The dedupe pass in the migration is idempotent — running it twice yields the same names because `EXISTS (...)` is false on the second pass. - The partial unique index uses `WHERE deleted_at IS NULL` so the same name can be re-used after a soft delete. - The handler-level pre-check is a UX layer: it gives a clear message before the DB raises a `UNIQUE` constraint error. The DB index is the safety net for race conditions. - Don't case-fold the comparison; the user can rename later if they want a different casing — and the existing UI already surfaces the case as typed.
Author
Member

Test Results

  • cargo test --workspace: all green, including:
    • handlers::object::tests::create_after_delete_returns_error (existing).
    • handlers::object::tests::duplicate_board_name_rejected (new) — asserts: first create succeeds, duplicate fails with "already exists", name becomes reusable after board.delete.
  • cargo clippy --workspace -- -D warnings: clean.
  • cargo check --workspace: clean.

Manual verification:

  1. Create a board Sprint planning in workspace A. Try to create another Sprint planning in workspace A — modal stays open with the inline error A board named "Sprint planning" already exists in this workspace.
  2. Create Sprint planning in workspace B — succeeds (unique per workspace).
  3. Rename board X to a name another live board in the same workspace already uses — modal stays open with the same error.
  4. Rename board X to its current name — succeeds (no-op).
  5. Delete the original Sprint planning, then create a new one with the same name — succeeds (soft-delete doesn't block reuse).
## Test Results - `cargo test --workspace`: all green, including: - `handlers::object::tests::create_after_delete_returns_error` (existing). - `handlers::object::tests::duplicate_board_name_rejected` (new) — asserts: first create succeeds, duplicate fails with `"already exists"`, name becomes reusable after `board.delete`. - `cargo clippy --workspace -- -D warnings`: clean. - `cargo check --workspace`: clean. Manual verification: 1. Create a board `Sprint planning` in workspace A. Try to create another `Sprint planning` in workspace A — modal stays open with the inline error `A board named "Sprint planning" already exists in this workspace`. 2. Create `Sprint planning` in workspace B — succeeds (unique per workspace). 3. Rename board X to a name another live board in the same workspace already uses — modal stays open with the same error. 4. Rename board X to its current name — succeeds (no-op). 5. Delete the original `Sprint planning`, then create a new one with the same name — succeeds (soft-delete doesn't block reuse).
Author
Member

Implementation Summary

6 files changed (5 modified + 1 new migration), +125 / -6.

Migration + queries (crates/hero_whiteboard_server/)

  • migrations/006_unique_board_name.sql (new): de-dupes any pre-existing collisions among live rows by appending " (1)", " (2)", ... to all but the oldest in each (workspace_id, name) group, then creates a partial unique index idx_boards_workspace_name_live ON boards (workspace_id, name) WHERE deleted_at IS NULL. Idempotent.
  • db/mod.rs: registered the new migration.
  • db/queries.rs: added board_name_taken(conn, workspace_id, name, except_id) helper.

Handlers (crates/hero_whiteboard_server/src/handlers/board.rs)

  • create: pre-flight check via board_name_taken(...); rejects duplicates with A board named "<name>" already exists in this workspace.
  • update: snapshots the board's name before applying the optional name field, then runs the same check with except_id = Some(board.id) so renaming to the current name is a no-op.

Tests

  • handlers/object.rs::tests::duplicate_board_name_rejected (new): asserts the duplicate-create error and that the same name becomes reusable after board.delete.

UI (crates/hero_whiteboard_ui/templates/web/home.html)

  • New Board modal: added #new-board-error inline region. submitNewBoard now surfaces every error path (workspace name missing, workspace creation failure, no workspace selected, RPC failure including duplicate-name) inline instead of via window.alert.
  • Rename modal: added #rename-error inline region; saveRename now shows server errors inline; modal stays open for retry. Empty-name validation is inline too.
  • Both modals reset their error region on open.

Verification

  • cargo test --workspace: all green; new test passes.
  • cargo clippy --workspace -- -D warnings: clean.
  • cargo check --workspace: clean.

Notes / caveats

  • The DB index is the safety net; the handler-level pre-check is the UX layer that gives the user a friendly message before the index would raise a generic UNIQUE-constraint error.
  • Soft-deleted rows are exempt from the constraint (matches is_board_live semantics elsewhere in the app).
  • Comparison is case-sensitive — re-naming with different casing is permitted.
  • Renaming a board to its current name is a no-op; the check is gated by board.name != previous_name.
## Implementation Summary 6 files changed (5 modified + 1 new migration), +125 / -6. ### Migration + queries (`crates/hero_whiteboard_server/`) - `migrations/006_unique_board_name.sql` (new): de-dupes any pre-existing collisions among live rows by appending `" (1)"`, `" (2)"`, ... to all but the oldest in each `(workspace_id, name)` group, then creates a partial unique index `idx_boards_workspace_name_live ON boards (workspace_id, name) WHERE deleted_at IS NULL`. Idempotent. - `db/mod.rs`: registered the new migration. - `db/queries.rs`: added `board_name_taken(conn, workspace_id, name, except_id)` helper. ### Handlers (`crates/hero_whiteboard_server/src/handlers/board.rs`) - `create`: pre-flight check via `board_name_taken(...)`; rejects duplicates with `A board named "<name>" already exists in this workspace`. - `update`: snapshots the board's name before applying the optional `name` field, then runs the same check with `except_id = Some(board.id)` so renaming to the current name is a no-op. ### Tests - `handlers/object.rs::tests::duplicate_board_name_rejected` (new): asserts the duplicate-create error and that the same name becomes reusable after `board.delete`. ### UI (`crates/hero_whiteboard_ui/templates/web/home.html`) - New Board modal: added `#new-board-error` inline region. `submitNewBoard` now surfaces every error path (workspace name missing, workspace creation failure, no workspace selected, RPC failure including duplicate-name) inline instead of via `window.alert`. - Rename modal: added `#rename-error` inline region; `saveRename` now shows server errors inline; modal stays open for retry. Empty-name validation is inline too. - Both modals reset their error region on open. ### Verification - `cargo test --workspace`: all green; new test passes. - `cargo clippy --workspace -- -D warnings`: clean. - `cargo check --workspace`: clean. ### Notes / caveats - The DB index is the safety net; the handler-level pre-check is the UX layer that gives the user a friendly message before the index would raise a generic UNIQUE-constraint error. - Soft-deleted rows are exempt from the constraint (matches `is_board_live` semantics elsewhere in the app). - Comparison is case-sensitive — re-naming with different casing is permitted. - Renaming a board to its current name is a no-op; the check is gated by `board.name != previous_name`.
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#84
No description provided.