Skip to content

Share a whole workspace, not one board at a time - #28

Merged
boubou666 merged 3 commits into
mainfrom
organizations
Aug 8, 2026
Merged

boubou666 merged 3 commits into
mainfrom
organizations

Conversation

@boubou666

@boubou666 boubou666 commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Sharing hands out one board. An organization hands out everything in it, now and later: you are invited once, and every board the team makes afterwards is already yours to open. That is the whole difference, and it is why this is a second grant of access rather than a loop over the first.

The vocabulary is deliberately the board's — an owner, and members who are editor or viewer — and your role in the organization is your role on its boards, so board_role() stays the one question everything asks. It just has two places to look now, and the stronger grant wins: being made an editor of one board is not undone by being a viewer of the team, and joining the team does not quietly downgrade a board someone handed you.

Owning the organization resolves to editor on its boards, not owner. A board has exactly one owner and it is whoever made it — two rows answering owner would put freeze_board_owner and board_people in disagreement about who that is. What owning the organization additionally buys is written into the policies that grant it.

The load-bearing pieces

freeze_board_org. Being an org editor is what lets you write boards you do not own — so without a trigger, that same grant lets you write the column that says whose board it is, carrying the team's work into an organization you control, past a policy that only ever sees the row being proposed. RLS cannot see the old row, so it is a trigger, like freeze_board_owner. Inside it, coalesce(org_role(...), '') not in (...) rather than a bare not in: null not in (...) is null, so the one case the check exists for is the one it would have waved through.

Creating one takes a real account. Every visitor is signed in anonymously, and an organization owned by a browser session is one nobody can get back into. The gate reads auth.users.is_anonymous, not the JWT claim — a claim is a copy of that column from whenever the token was minted, so a guest who has just registered would be refused until it refreshed. Being invited needs no account, exactly as a board link does — which is why sweep_anonymous_users had to learn about membership, or a guest invited on Monday was deleted the following week.

Deleting an organization is on delete set null. The boards in it are several people's work; they fall back to whoever created them and everyone else loses sight of them, which is the part the deletion was actually about. The confirm says so.

Three things that follow from having teams at all

Scoped, paginated lists

list() had no where clause on purpose — RLS decides what you can see, and a filter in the client would be a second, weaker copy of the policies. That stayed cheap while "every board you can see" meant your own plus a handful shared with you. One invite to a team with five hundred boards makes every load of your personal list fetch five hundred rows to render three.

Scoping has to come before paging: cutting the page in Postgres and filtering it in the browser gives pages of unpredictable size, and no way to tell "that is all of them" from "this page happened to be entirely somebody's team". So the scope is a security_invoker view — the policies stay the enforcement, and the view adds only the personal column and the absence of doc, which a list never wanted and now cannot ask for by mistake.

Paging is keyset. .range() is shorter and wrong: boards are ordered by last save, so anyone saving while you read shifts rows across the boundary. updated_at alone is not a total order either, so id breaks ties — in the sort, in the cursor comparison, and in the index. The cursor carries the timestamp exactly as Postgres gave it; Date.parse rounds microseconds away into a cursor that straddles its own boundary.

The badge changed with it. The record used to be pruned to whatever was listed, which bounded it for free — against a page that would forget every board below the fold. It is capped instead. What it still does not do is add what it announces: being listed is not being looked at, and a reconcile that recorded the boards it badged would clear them on the next refresh, before anyone had read them.

Handing an organization over

organizations_update still tests owner_id = auth.uid() in both halves, and there is a test asserting the naive update keeps being refused — if it ever starts working, the rules below stop being rules. Transfer is three changes that must land together, with rules a with check could not express: the recipient must already be a member, and must be a real account. So it is a SECURITY DEFINER function, which is where those rules can be read.

An optional second owner

One owner is one person who can be ill, or on a plane, or gone. co_owner_id is a column rather than an 'owner' role, because the shape is the rule — a role makes "how many owners" an open question.

org_role() answers owner for both, so running the organization is shared; the four acts on its own row — rename, delete, hand over, appoint — stay with owner_id. There is always exactly one account that cannot be locked out by the other. The appointment never touches their membership row, which is what makes appointing and removing one statement each — and why the two must end together, so a trigger clears the appointment when they stop being a member.

Two fixes the scoping exposed

  • rls.test.js emptied public.boards before every test. DATABASE_URL naming the hosted project is a documented workflow, and that would empty real people's workspaces the first time someone followed it. Now scoped to the run's own fixture users.
  • One of its assertions counted every invite in the database rather than the board's — green only because the previous statement had wiped the table.

Verified

Against a real local Supabase stack, not a stand-in:

  • npm test — 676/676, line coverage 98.1% (gate 95%)
  • npm run test:db — 141/141 policy tests
  • npm run build — clean

Not built yet

Written up in the README rather than left implied: an organization cannot outlive its first owner on its own (owner_id is on delete cascade, co_owner_id is on delete set null — promoting somebody automatically is a decision the schema has no business making unasked); the personal scope has no index behind it, which is an argument rather than a measurement; and nothing re-reads a list while you are looking at it, which paging makes sharper.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA

Summary by CodeRabbit

  • New Features

    • Added organization workspaces with member roles, co-ownership, invitations, ownership transfers, and management controls.
    • Create, rename, delete, join, leave, and switch between personal and organization workspaces.
    • Move boards between personal and organization locations.
    • Organization members can access shared boards without appearing in board-specific rosters.
    • Added paginated board lists with “Load more” support.
    • Added organization invitation links and a dedicated join flow.
    • Improved board discovery across multiple pages without incorrectly showing new-item indicators.
  • Documentation

    • Documented organization routing, membership behavior, security policies, and known limitations.

Sharing hands out one board. An organization hands out everything in it,
now and later: you are invited once, and every board the team makes
afterwards is already yours to open. That is the whole difference, and it
is why this is a second grant of access rather than a loop over the first.

The vocabulary is deliberately the board's — an owner, and members who are
editor or viewer — and your role in the organization *is* your role on its
boards, so board_role() stays the one question everything asks. It just has
two places to look now, and the stronger grant wins. Owning the
organization resolves to editor on its boards, not owner: a board has
exactly one owner and it is whoever made it.

Creating one takes a real account. Every visitor is signed in anonymously,
and an organization owned by a browser session is one nobody can get back
into — which is also why sweep_anonymous_users had to learn about
membership, or a guest invited on Monday was deleted the following week.
Being invited needs no account, exactly as a board link does.

freeze_board_org is the load-bearing piece. Being an org editor is what
lets you write boards you do not own, and without a trigger that same grant
would let you write the column saying whose board it is — carrying the
team's work into an organization you control, past a policy that only ever
sees the row being proposed.

Then three things that follow from having teams at all:

Scoped, paginated lists. list() had no where clause on purpose, and stayed
cheap while "every board you can see" meant your own plus a handful. One
invite to a team with five hundred boards makes every load of your personal
list fetch five hundred rows to render three. Scoping has to come before
paging — cutting the page in Postgres and filtering it in the browser gives
pages of unpredictable size — so the scope is a security_invoker view,
leaving the policies as the enforcement, and paging is keyset with an id
tiebreak so a save mid-read cannot shift rows across a boundary.

Handing an organization over. organizations_update still refuses the naive
update, and there is a test that it keeps refusing it. Transfer is three
changes that must land together, with rules a with check could not express,
so it is a definer function where those rules can be read.

An optional second owner. One owner is one person who can be ill, or gone.
org_role() answers owner for both, so running the organization is shared;
the four acts on its own row — rename, delete, hand over, appoint — stay
with owner_id. There is always exactly one account that cannot be locked
out by the other.

Also fixes two things the scoping exposed: rls.test.js emptied
public.boards before every test, which against the hosted project that
DATABASE_URL is documented to accept would empty real workspaces; and one
of its assertions counted every invite in the database rather than the
board's, so it was green only because the previous statement had wiped the
table.

676 tests, 141 policy tests, 98.1% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6542ef59-bb83-46f0-949a-15c9e9c86c3c

📥 Commits

Reviewing files that changed from the base of the PR and between a58dcf7 and 7f7499e.

📒 Files selected for processing (1)
  • test/db/rls.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/db/rls.test.js

📝 Walkthrough

Walkthrough

The change adds organization management, organization-scoped board access, invitation flows, co-owner and transfer operations, cursor-based board pagination, updated board tracking, and related database, UI, repository, documentation, and integration tests.

Changes

Organizations and access control

Layer / File(s) Summary
Organization data and access controls
supabase/migrations/*, test/db/*
Adds organizations, memberships, invitations, inherited board roles, co-owner support, ownership transfer, board placement rules, summary views, and organization-aware anonymous-user cleanup.
Organization services and application flows
src/platform/*, src/routes/*, src/components/*, src/shell/*, src/styles/shell.css, test/browser/organizations.test.js, test/node/organizations.test.js
Adds organization APIs, invitation redemption, membership management, scoped board lists, board movement, sharing notices, and organization controls.

Paginated board listing

Layer / File(s) Summary
Paginated repositories and board tracking
src/platform/storage.js, src/platform/supabase-repository.js, src/platform/adopt.js, src/platform/seen-boards.js, test/node/*, test/browser/boards.test.js
Changes repository listing to { boards, cursor }, adds scope filtering, stable keyset pagination, board movement, full adoption traversal, and capped seen-board tracking.
Compatibility, documentation, and validation
README.md, test/browser/app.test.js, test/browser/shell.test.js, test/node/sharing.test.js, test/coverage.js, test/db/*
Documents organization and pagination behavior and updates assertions and database coverage for the changed repository and organization contracts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • boubou666/peerthink#3: Evolves the board repository list() contract toward paginated { boards, cursor } results.
  • boubou666/peerthink#4: Shares the React shell, routing, board-list, and storage changes extended here.
  • boubou666/peerthink#5: Provides related Supabase board-sharing foundations extended with organization membership and scoped access.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: organization-based workspace sharing across multiple boards.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch organizations

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (8)
supabase/migrations/20260808180000_organization_co_owner.sql (1)

48-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Restate the grants for the replaced org_role().

create or replace function keeps the privileges of the previous definition, so this works. However, 20260808120100_sweep_spares_org_members.sql (lines 53-58) states the convention that a replaced function restates its grant so the privilege is a property of the file. organization_people() follows that convention at lines 300-301 of this file; org_role() does not.

♻️ Restate the grant next to the definition
   end;
 $$;
 
+revoke execute on function public.org_role(text) from public, anon;
+grant execute on function public.org_role(text) to authenticated;
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260808180000_organization_co_owner.sql` around lines 48
- 66, Add the explicit grant for public.org_role immediately after its CREATE OR
REPLACE FUNCTION definition, matching the grant convention used by
organization_people() and the prior org_role migration. Preserve the existing
function implementation and grant the same role/privilege as the replaced
definition.
test/node/seen-boards.test.js (1)

66-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

State the failure in the assertion message.

The message prints only when the assertion fails, so it should name the fault. Here the set is expected to be empty, and 'and came back announced' reads as a description of the expected state. The other messages in this file name the fault.

✏️ Clearer failure message
-    assert.deepEqual([...seen.reconcile(['a', 'b', 'c'])], [], 'and came back announced');
+    assert.deepEqual(
+      [...seen.reconcile(['a', 'b', 'c'])],
+      [],
+      'a board absent from one page was announced when it was listed again',
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/seen-boards.test.js` at line 66, Update the failure message on the
reconcile assertion in the seen-board test to state that previously announced
boards came back announced, matching the fault-focused wording used by the other
assertions in the file.
src/routes/JoinOrgPage.jsx (1)

27-33: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Handle exceptions from organizations.redeem.

redeem converts returned RPC errors to null, but it does not catch exceptions from client.rpc. If the promise rejects, set failed when the component is still live.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/JoinOrgPage.jsx` around lines 27 - 33, Update the
organizations.redeem promise chain in JoinOrgPage to catch rejected client.rpc
exceptions and call setFailed(true) only when the component is still live, while
preserving the existing orgId navigation and null-result handling.
src/platform/organizations.js (1)

194-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Freeze NOBODY or return a fresh array.

people() returns the module-level NOBODY array on failure. Every failing call shares one array instance. A caller that sorts or pushes in place would then change what every later failure returns. The current consumer in src/components/OrganizationDialog.jsx only reads it, so this is defensive rather than a live defect.

♻️ Proposed change
-const NOBODY = [];
+const NOBODY = Object.freeze([]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/organizations.js` around lines 194 - 199, Update the failure
path in people() to avoid exposing the mutable module-level NOBODY array: either
freeze NOBODY at its definition or return a new empty array for each error or
missing-data result. Preserve the existing mapped data behavior for successful
organization_people responses.
test/db/rls.test.js (2)

608-622: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the stale comment: transfer now exists.

The comment states "There is no transfer, and the with check is what makes that true". The same file adds describe('transferring it', …) at Line 1143, which exercises public.transfer_organization. The assertion is still correct — the naive update stays refused — but the prose contradicts the feature added in this PR. The equivalent test at Line 1151 already words this correctly.

📝 Proposed comment fix
     /**
-     * There is no transfer, and the `with check` is what makes that true
-     * rather than the absence of a button. An owner who could write another
-     * id into the column would be handing over every board in the
-     * organization and every outstanding invite along with it.
+     * Handing the organization on goes through `transfer_organization`, never
+     * through this column. The `with check` is what makes that true rather
+     * than the absence of a button: an owner who could write another id here
+     * would be handing over every board in the organization and every
+     * outstanding invite along with it, with none of the function's rules.
      */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/rls.test.js` around lines 608 - 622, Update the comment above the
“and cannot hand it to somebody else” test to remove the claim that no transfer
exists, while preserving the explanation that the WITH CHECK policy rejects the
direct owner_id update and prevents handing over the organization and invites.
Match the wording used by the equivalent test near the transferring it coverage.

1274-1291: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

carol is not removed if an assertion fails.

The cleanup at Line 1290 runs only when every assertion above it passes. If the transfer assertion at Line 1287 fails, carol stays in auth.users and her organization survives, because beforeEach scopes its deletes to alice, bob and gus. The next run then sees leftover fixture rows. Register carol for teardown instead, or wrap the cleanup in try/finally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/rls.test.js` around lines 1274 - 1291, Ensure the temporary carol
fixture is always cleaned up even when an assertion fails: update the test
around transfer and coOwnerAfter to wrap the setup/assertions in try/finally,
deleting carol in the finally block, or register carol with the test teardown
mechanism. Preserve the existing assertions and cleanup behavior.
src/styles/shell.css (1)

602-602: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Define a shared destructive colour token. src/styles/canvas.css has no danger token. Add one to both theme blocks, then replace #d83a52 in this rule and the other destructive states in src/styles/shell.css with var(--danger).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/styles/shell.css` at line 602, Define a shared --danger colour token in
both theme blocks of canvas.css, then update the .org-actions delete hover rule
and all other destructive states in shell.css to use var(--danger) instead of
hardcoded `#d83a52`.
src/platform/supabase-repository.js (1)

189-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Merge the two adjacent doc blocks.

save now has two consecutive JSDoc blocks. Only the second one is attached to the function, so the update-then-insert rationale above it is left as a detached comment. Fold the orgId paragraph into the existing block.

♻️ Proposed change
-     */
-    /**
-     * `orgId` says where a board being created belongs, and is used on the
+     *
+     * `orgId` says where a board being created belongs, and is used on the
      * insert path alone. It is deliberately not part of `patch`: an update
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/supabase-repository.js` around lines 189 - 196, Merge the
standalone JSDoc block describing orgId into the existing JSDoc block
immediately above save. Keep the rationale intact and ensure save has one
contiguous documentation block containing both the update-then-insert behavior
and orgId semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/OrganizationDialog.jsx`:
- Around line 320-330: Update the button label in the isPrimary/co-owner branch
of OrganizationDialog to say “Take the appointment back” instead of “Step down,”
matching the README terminology and the setCoOwner(null) action.
- Around line 353-363: Update the member-row controls in OrganizationDialog so
Remove is hidden only when the displayed person is the current caller, while
remaining available for the primary owner to remove a co-owner. Add or expose a
Leave this organization control for the second-owner view, ensuring it invokes
the membership-leave behavior rather than the appointment-clearing Step down
action.

In `@src/routes/BoardListPage.jsx`:
- Around line 611-629: Update the OrganizationDialog onChanged prop in
BoardListPage so it invokes the busy-aware reloadOrgs handler instead of passing
the bare refresh function. Preserve the existing onGone navigation and reload
behavior, ensuring all organization reads use the page’s busy state.
- Around line 216-232: Update loadMore to validate its response against the
currently active scope using a ref or liveness token rather than the captured
scope value. Ignore stale success updates, including board and cursor changes
and reconcile, and ignore stale error updates when navigation has changed scope;
retain normal loading cleanup for the active request.

In `@supabase/migrations/20260808120000_organizations.sql`:
- Around line 225-253: Update public.freeze_board_org to return new when
new.org_id is null and old.org_id no longer exists, allowing ON DELETE SET NULL
to detach boards during organization deletion while preserving the existing
authorization checks for other organization changes.

In `@test/browser/organizations.test.js`:
- Around line 106-117: Replace the fixed 60 ms sleep in the choose helper with
an observable readiness wait, while leaving each caller responsible for its
specific post-change condition. In inviteLink, add an explicit wait for the role
change to update the target link before reading [data-org-url], preserving the
existing URL assertion.
- Around line 354-372: Remove the first orphaned documentation block describing
handing an organization over from the consecutive blocks near the second-owner
test, and place it immediately above the handover test associated with the
outgoing owner’s screen. Keep the second-owner documentation block above its
corresponding test.

In `@test/node/organizations.test.js`:
- Around line 80-85: Update the teardown around the after hook and the test
board-creation paths: either track every board id created with newId('b') and
remove those boards, including boards owned by fixture accounts, or revise the
comment to accurately state that organization deletion leaves the boards behind
intentionally. Ensure the implementation and comment describe the same cleanup
behavior.

In `@test/node/supabase-repository.test.js`:
- Around line 206-215: Update the “boards saved in the same instant are still
partitioned” test to assert both that all six unique IDs were seen and that
mine.length equals 6, matching the duplicate-detection pattern used near line
197. Keep the existing Set assertion and failure message context.

---

Nitpick comments:
In `@src/platform/organizations.js`:
- Around line 194-199: Update the failure path in people() to avoid exposing the
mutable module-level NOBODY array: either freeze NOBODY at its definition or
return a new empty array for each error or missing-data result. Preserve the
existing mapped data behavior for successful organization_people responses.

In `@src/platform/supabase-repository.js`:
- Around line 189-196: Merge the standalone JSDoc block describing orgId into
the existing JSDoc block immediately above save. Keep the rationale intact and
ensure save has one contiguous documentation block containing both the
update-then-insert behavior and orgId semantics.

In `@src/routes/JoinOrgPage.jsx`:
- Around line 27-33: Update the organizations.redeem promise chain in
JoinOrgPage to catch rejected client.rpc exceptions and call setFailed(true)
only when the component is still live, while preserving the existing orgId
navigation and null-result handling.

In `@src/styles/shell.css`:
- Line 602: Define a shared --danger colour token in both theme blocks of
canvas.css, then update the .org-actions delete hover rule and all other
destructive states in shell.css to use var(--danger) instead of hardcoded
`#d83a52`.

In `@supabase/migrations/20260808180000_organization_co_owner.sql`:
- Around line 48-66: Add the explicit grant for public.org_role immediately
after its CREATE OR REPLACE FUNCTION definition, matching the grant convention
used by organization_people() and the prior org_role migration. Preserve the
existing function implementation and grant the same role/privilege as the
replaced definition.

In `@test/db/rls.test.js`:
- Around line 608-622: Update the comment above the “and cannot hand it to
somebody else” test to remove the claim that no transfer exists, while
preserving the explanation that the WITH CHECK policy rejects the direct
owner_id update and prevents handing over the organization and invites. Match
the wording used by the equivalent test near the transferring it coverage.
- Around line 1274-1291: Ensure the temporary carol fixture is always cleaned up
even when an assertion fails: update the test around transfer and coOwnerAfter
to wrap the setup/assertions in try/finally, deleting carol in the finally
block, or register carol with the test teardown mechanism. Preserve the existing
assertions and cleanup behavior.

In `@test/node/seen-boards.test.js`:
- Line 66: Update the failure message on the reconcile assertion in the
seen-board test to state that previously announced boards came back announced,
matching the fault-focused wording used by the other assertions in the file.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3fc7e38b-5dad-4f35-a70b-f0139128ab88

📥 Commits

Reviewing files that changed from the base of the PR and between 7badead and fb50462.

📒 Files selected for processing (33)
  • README.md
  • src/App.jsx
  • src/components/OrganizationDialog.jsx
  • src/components/ShareDialog.jsx
  • src/platform/adopt.js
  • src/platform/organizations.js
  • src/platform/seen-boards.js
  • src/platform/storage.js
  • src/platform/supabase-repository.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/routes/JoinOrgPage.jsx
  • src/shell/organizations.js
  • src/shell/sharing.js
  • src/styles/shell.css
  • supabase/migrations/20260808120000_organizations.sql
  • supabase/migrations/20260808120100_sweep_spares_org_members.sql
  • supabase/migrations/20260808140000_board_summaries.sql
  • supabase/migrations/20260808160000_transfer_organization.sql
  • supabase/migrations/20260808180000_organization_co_owner.sql
  • test/browser/app.test.js
  • test/browser/boards.test.js
  • test/browser/organizations.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/db/rls.test.js
  • test/db/sweep.test.js
  • test/node/adopt.test.js
  • test/node/organizations.test.js
  • test/node/seen-boards.test.js
  • test/node/sharing.test.js
  • test/node/storage.test.js
  • test/node/supabase-repository.test.js

Comment thread src/components/OrganizationDialog.jsx
Comment thread src/components/OrganizationDialog.jsx Outdated
Comment thread src/routes/BoardListPage.jsx
Comment thread src/routes/BoardListPage.jsx
Comment thread supabase/migrations/20260808120000_organizations.sql
Comment thread test/browser/organizations.test.js
Comment thread test/browser/organizations.test.js Outdated
Comment thread test/node/organizations.test.js
Comment thread test/node/supabase-repository.test.js
Eight findings from CodeRabbit. Seven were right; one was half right in a
more interesting way than it looked.

The Critical one said `on delete set null` would fire `freeze_board_org` for
each detached board and be refused, because `org_role()` can no longer see
the organization being deleted. The visibility half is exactly right — I
proved it by hardening the test with a `coalesce` and watching both deletion
paths start raising. The conclusion was wrong: it works today only because
`null or null` makes the `if` not fire, which is not a rule anybody wrote
down. So the cascade now returns early and says why, the identity test is
`coalesce`d to deny by default, and two tests cover the paths that were
uncovered — deleting an organization holding somebody else's board, and the
owner's account going, which reaches those boards through two cascades with
no session at all.

The rest:

- `loadMore` closed over `scope` from its own render, so its guard compared a
  value with itself. A late page could append a team's boards under the
  personal list and install that team's cursor. Guarded by a ref instead.
- `onChanged` passed the bare `refresh`, so the re-read after a rename or an
  appointment raised no `busy` — the exact failure the comment three hundred
  lines above it describes, and one I had already fixed once for `onGone`.
- "Step down" sat on somebody else's row and only the primary owner could
  see it, so it named an action its reader was not taking.
- `Remove` showed on the second owner's own row: it worked, and took them out
  of the organization under a label about somebody else. Hidden on your own
  row, and the leave control now exists in the view they actually get.
- A fixed 60ms sleep in `choose` stood in for a readiness condition. Replaced
  — with both halves, because moving a board out of the scope on screen
  unmounts the very select being waited on.
- The tie-breaking pagination test asserted `new Set(mine).size === 6` under
  a message promising "exactly once", which cannot see a board served twice.
- A teardown comment promised board cleanup that did not exist.
- A doc block describing the transfer test had ended up above the second-owner
  one when I inserted it.

676 tests, 143 policy tests, 98.1% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
@boubou666

Copy link
Copy Markdown
Owner Author

Thanks — seven of the eight landed. Notes on the one that did not, and on two where the fix differed from the suggestion.

freeze_board_org and the on delete set null cascade — half right, and the interesting half. The visibility claim is correct: org_role() genuinely cannot see the organization during the cascade. I confirmed it by hardening the identity test with a coalesce and watching both deletion paths start raising.

The conclusion was wrong, though — nothing was broken. It works because old.owner_id = auth.uid() or org_role(...) = 'owner' evaluates to null for a board somebody else owns, and plpgsql treats if not (null) as false. So it passes by three-valued logic rather than by anything written down, which is worse than either being right or being broken.

Fixed as suggested, plus the consequence: the cascade returns early and says why, and the identity test is now coalesced to deny by default — safe because the cascade already returned. Two tests cover what nothing covered: deleting an organization holding somebody else's board, and the owner's account going, which reaches those boards through two cascades with auth.uid() null. The second one asserts the behaviour a future coalesce would break, since that hardening is exactly what someone would reach for next.

The choose sleep — taken, with both halves of the condition. Waiting only for el.value === value hangs: moving a board out of the scope on screen unmounts its card, so that <select> never settles on the value, it disappears. !el || el.value === value — either outcome is React having handled the change. Caught it by the test timing out at 8s.

Board cleanup in test/node/organizations.test.js — took the cleanup rather than the comment correction, since sharing.test.js already sets that precedent. Cheaper than it looked: ids are recorded in newId (every board id in the file goes through it, and several saves are meant to fail), and both suite accounts are asked for every id — they own all but one between them, and remove on somebody else's board matches nothing rather than erroring.

676 tests, 143 policy tests, 98.1% coverage.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/db/rls.test.js`:
- Around line 1342-1352: Update the comment above the account-deletion cascade
assertions to explain that freeze_board_org explicitly permits
organization-deletion cascades, while the identity check uses coalesce to deny
null identities otherwise. Remove the three-valued-logic rationale and keep all
assertions unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a2a287e9-0125-4f05-af37-d2813a0164ea

📥 Commits

Reviewing files that changed from the base of the PR and between fb50462 and a58dcf7.

📒 Files selected for processing (7)
  • src/components/OrganizationDialog.jsx
  • src/routes/BoardListPage.jsx
  • supabase/migrations/20260808120000_organizations.sql
  • test/browser/organizations.test.js
  • test/db/rls.test.js
  • test/node/organizations.test.js
  • test/node/supabase-repository.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/node/supabase-repository.test.js
  • test/node/organizations.test.js
  • test/browser/organizations.test.js
  • supabase/migrations/20260808120000_organizations.sql

Comment thread test/db/rls.test.js Outdated
The comment above the account-deletion cascade test described how that path
worked *before* the fix it sits next to: three-valued logic letting a null
identity through. The trigger now has an explicit branch for the cascade and
a `coalesce` denying a null caller otherwise, so the comment named a
mechanism the code no longer uses — which is the failure mode comments have.

Only the comment changes; the assertions are the same and still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
@boubou666
boubou666 merged commit 2f29ee9 into main Aug 8, 2026
3 checks passed
@boubou666
boubou666 deleted the organizations branch August 8, 2026 01:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants