Skip to content

feature: Deck boards can be owned by a circle#7899

Draft
jospoortvliet wants to merge 23 commits intomainfrom
review/circle-owned-boards
Draft

feature: Deck boards can be owned by a circle#7899
jospoortvliet wants to merge 23 commits intomainfrom
review/circle-owned-boards

Conversation

@jospoortvliet
Copy link
Copy Markdown
Member

@jospoortvliet jospoortvliet commented Apr 30, 2026

Summary

This PR implements circle (team) ownership of Deck boards, analogous to how Collectives uses circles as owners. It allows boards to be transferred to a Nextcloud team, after which all circle members automatically receive full owner-level permissions.

Fixes #7747 (adds validation that the new owner exists before transferring).
(that was a side-effect...)

Once this is merged, I would want to implement creating a deck board from Circles...
nextcloud/circles#2409

What changes

  • Database: New owner_type column on oc_deck_boards (SMALLINT, default 0 = user). Migration Version11002Date20260429000000.
  • BoardMapper: Resolves circle owners via CirclesService::getCircle(), lists circle-owned boards alongside user-owned and shared boards in findAllForUser() and findBoardIds().
  • PermissionService: userIsBoardOwner() checks circle membership for circle-owned boards; findUsers() expands circle members instead of treating the owner ID as a user UID.
  • BoardService: transferBoardOwnership() validates the target (user must exist; circle must exist and Circles app must be enabled) before making any DB change. Skips the "add previous owner as ACL participant" step and content remap when transferring to a circle.
  • BoardController: Accepts newOwnerType (0 or 7) in the transferOwner REST endpoint; returns HTTP 400 for invalid values.
  • OCC command: New --to-circle flag on deck:transfer-ownership to treat <newOwner> as a circle ID.
  • Frontend:
    • Board list (BoardItem.vue): Shows a team icon instead of an avatar for circle-owned boards.
    • Sharing sidebar (SharingTabSidebar.vue): Shows "Team" label for circle-owned boards; adds a "Transfer ownership" action button on each ACL participant row (visible to board owner for user-owned boards, or to managers for circle-owned boards).
    • Vuex store: transferOwnership action forwards newOwnerType to the REST endpoint.
  • Tests: Unit tests added for BoardTest, PermissionServiceTest, and BoardServiceTest covering the new paths.

How to use

Via OCC (CLI):

# Transfer a user-owned board to a team
php occ deck:transfer-ownership <currentOwnerUid> <circleId> <boardId> --to-team

# Transfer all boards from a user to a team
php occ deck:transfer-ownership <currentOwnerUid> <circleId> --to-team

NOTES:

  • --to-team is optional. Deck will detect if the userID is a team. A userID will not often be the same as a circle id.
  • This PR does not allow assigning a Deck board to a group, only to a team.
  • All team

Via UI:
Open a board → Sharing tab → click the "⋯" menu next to any participant → "Transfer ownership".

Notes

  • Creation is always "create as user, then transfer" — no special creation UI is added.
  • Circle members receive full owner-level permissions (read/edit/manage/share) automatically via userIsBoardOwner().
  • Card content (assignedUsers, createdBy) is not remapped when transferring to a circle, since a circle is not a user account. Cards can be assigned to circles, but remapping automatically seemed a bit too strong.
  • The Circles app must be enabled for team/circle ownership to function; the --to-team flag returns an error otherwise.
  • After you transferred ownership to a team, you can transfer it back to you from the ... menu on the team ownership entry on top of the list.
  • After transferring ownership to a team, the team is kept as a member. This to avoid that when a team member transfers ownership to somebody else, the team loses access. This is perhaps not the semantically most logical approach, as it can be confusing. It allows setting specific rights that will apply if/when a team loses access, and the second team membership can also be removed. But it is already possible to have team members have individual access, even if they are in a team or group the board is shared with. The reason this approach was taken is because it was a simpler fix than, upon transferring ownership away from a circle, making the circle a member again was much more complicated. We could change our mind on this and change it, but I would do that in a separate PR as this one has gotten super big already...
  • when a Team that owns a board is deleted, so are the board the Team owns. This is the same behavior as with users who own a board.

🤖 Generated with AI - Claude, codex, chatGPT and human reading and review and testing back and forth.

issues

  • if you are in a team that owns a board, you can't transfer ownership to yourself in the UI
  • if you give ownership to a team, you can still share a board with that same team a second time.
  • no known issues atm

Lint issues

file & line number error description proposed solution or explanation why it's invalid
TransferOwnership.php Undefined type OCP\IUserManager Invalid. This is a known analyzer/stub issue in this workspace: OCP symbols are not fully resolved by the current diagnostics provider. The import and usage are correct for Nextcloud app code. No code change needed.
PermissionService.php Undefined type OCP\Cache\CappedMemoryCache Invalid. Same OCP type-resolution problem. The class is valid in Nextcloud runtime. No code change needed.
PermissionService.php Undefined type OCP\IUserManager (and similar OCP interfaces around constructor lines) Invalid. These are framework interfaces available in Nextcloud, but missing in local analyzer resolution. No code change needed.
PermissionService.php Undefined exception types DoesNotExistException / MultipleObjectsReturnedException Invalid. These imports are correct and widely used in this codebase. This is analyzer namespace resolution noise. No code change needed.
PermissionService.php Possible undefined variable cacheKey Likely invalid for runtime in this flow, but this is the only warning worth hardening if you want cleaner static analysis. Optional small fix: initialize cacheKey before the access-token branch or set only when access token is null.
PermissionService.php ArrayAccess/type mismatch on CappedMemoryCache Invalid. CappedMemoryCache supports offset access in Nextcloud; analyzer stubs here do not model it well. No code change needed.
PermissionService.php Undefined type OCA\Circles\Model\Member Invalid. Circles type resolution is missing in current analyzer context; code is valid in app runtime. No code change needed.
ParticipantCleanupListener.php Undefined type OCP\EventDispatcher\IEventListener Invalid. Event interfaces/events are valid Nextcloud APIs; analyzer cannot resolve full platform symbols. No code change needed.
ParticipantCleanupListener.php Undefined method delete on mapper Invalid. Method exists via mapper inheritance chain (QBMapper path), but analyzer misses it. No code change needed.
ImportExportTest.php Undefined type Test\TestCase and PHPUnit helper methods Invalid. This is test-environment/stub configuration noise, not a PR regression. No code change needed.
ImportExportTest.php Undefined OCP\Server and related symbols in tests Invalid. Same missing framework/test stubs in diagnostics provider. No code change needed.

Result summary: I do not see any clearly valid new lint/check regressions introduced by this PR from the current diagnostics output. The reported failures are overwhelmingly analyzer/stub-resolution issues in this workspace. The only optional cleanup candidate is the cacheKey warning in PermissionService if you want to silence stricter static analysis.

Testing that was done

from the CLI:
  • transferred a board from a userid to a circle ID:
php occ deck:transfer-ownership john LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I 3
Detected team target: treating LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I as team Super cool team.
Transfer board test board 1 from john to team Super cool team
Do you really want to continue? (y/n) y
Board test board 1 transferred to team Super cool team
  • transferred a board from a circle ID to a user ID:
php occ deck:transfer-ownership LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I john 3
Transfer board test board 1 from LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I to john
Do you really want to continue? (y/n) y
Board test board 1 transferred to john
  • transfer a board from a circle ID to a user ID but add --to-team:
occ deck:transfer-ownership LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I admin 3 --to-team
Transfer board test board 1 from LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I to team admin
Do you really want to continue? (y/n) y
Circle not found: admin
  • transfer a board from a user ID to a different user ID but add --to-team
occ deck:transfer-ownership admin john 3 --to-team
Transfer board test board 1 from admin to team john
Do you really want to continue? (y/n) y
Circle not found: john
  • transfer from one team to another (automatically recognizing the teams as such)
occ deck:transfer-ownership LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I 3SWW28JZfNngAICiZITmkoXkcNu8k9G 3
Detected team target: treating 3SWW28JZfNngAICiZITmkoXkcNu8k9G as team Deck test team.
Transfer board test board 1 from LQAVNwMabN1pl2ZxqRkPpj53pSA9X2I to team Deck test team
Do you really want to continue? (y/n) y
Board test board 1 transferred to team Deck test team
user interface
  • can you transfer a board to a team?
    • Screenshot 2026-05-01 at 15 11 34
  • If you are in the team that owns a board, can you transfer ownership to other users or circles?
    • Screenshot 2026-05-01 at 15 13 19
  • If you are in a circle that owns a board, can you transfer ownership to yourself?
    • Screenshot 2026-05-02 at 14 40 21
  • use an actually existing icon for 'transfer ownership'
    • Screenshot 2026-05-03 at 14 14 42
  • Transfer ownership keeps the team also as member of the board. This to ensure that if ownership is transferred away the team still has access - which can be controlled by removing them.
    • Screenshot 2026-05-04 at 13 15 13
  • and a fair bit of randomly clicking around...
  • no errors in the server logs
  • no errors in the JS console

Add an owner_type column to deck_boards (SMALLINT, default 0) that mirrors
the existing Acl::PERMISSION_TYPE_* constants. A value of 0 means the owner
is a user (preserving all existing behaviour); 7 means the owner is a
circle/team.

- DB migration Version11002Date20260429000000 adds the column idempotently
- Board entity gains $ownerType property, type registration, docblock
  accessors, and automatic serialisation into API responses as ownerType
- BoardMapper: add owner_type to every explicit SELECT column list so the
  field is populated when entities are hydrated from those queries
  (SELECT * queries already include it automatically)
- BoardTest: update all jsonSerialize assertions to expect ownerType: 0

No functional changes in this commit; subsequent steps will wire up
permission checks, transfer logic, and the UI.

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
mapOwner(): when owner_type = PERMISSION_TYPE_CIRCLE (7), resolve the
owner string as a circle ID via CirclesService and return a Circle object,
matching the behaviour already used in mapAcl() for circle ACL entries.
The federated-user and plain-user paths are unchanged.

findAllByCircleOwner(): new method that finds boards where owner_type = 7
and owner is a circle the requesting user belongs to. Follows the same
filter-parameter contract as the other findAllBy* methods; sets shared = 0
(user is effectively an owner, not just a collaborator).

findAllForUser(): includes findAllByCircleOwner() results in the merged
board list alongside the existing user, group, and circle-share sources.

findBoardIds(): adds a third query segment for circle-owned boards,
reusing the $circles list already fetched for the circle-share segment.

transferOwnership(): adds an optional $newOwnerType parameter (default
PERMISSION_TYPE_USER, placed after $boardId to preserve backward
compatibility) and stores it as owner_type in the UPDATE, so a future
transfer to a circle atomically sets both owner and owner_type.

No functional change for existing user-owned boards; all new paths either
return empty results (no circles app / user in no circles) or are blocked
by the as-yet-unchanged PermissionService (step 4).

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
… boards

userIsBoardOwner(): when the board's owner_type is PERMISSION_TYPE_CIRCLE,
delegate to CirclesService::isUserInCircle() instead of comparing the owner
string directly to the user ID.  Because getPermissions() and
matchPermissions() both gate every permission on userIsBoardOwner(), this
single change gives every circle member full read/edit/manage/share access
to a circle-owned board with no further changes to the permission stack.

findUsers(): for circle-owned boards the owner field holds a circle ID, not
a user ID, so the existing "add board owner as a User" path would create a
dangling entry.  It is replaced by an expansion of the owning circle's
inherited members, reusing the same Member::LEVEL_MEMBER + getUserType()===1
guard already present for circle ACL entries below.

Tests: add testUserIsBoardOwnerCircleMember covering the member→true and
non-member→false cases for a circle-owned board.

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
transferBoardOwnership() gains a newOwnerType parameter (default PERMISSION_TYPE_USER, backward-compatible). Validates new owner before any DB change: userExists() for user targets, CirclesService::getCircle() for circle targets, throwing BadRequestException on failure (also fixes the silent corruption bug when transferring to a non-existent user).

For circle transfers: correct ACL type used in deleteParticipantFromBoard, content remap is skipped (card owners cannot map to a circle), previous user owner receives a back-fill ACL entry unless changeContent=true.

transferOwnership() (bulk OCC path) gains the same newOwnerType parameter and switches to findAllByOwner so it works for both user-owned and circle-owned boards. CirclesService added to the constructor for circle validation.

Tests: transfer to circle, to missing user, to missing circle.

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
BoardController::transferOwner() now accepts an optional newOwnerType parameter (0=user, 7=circle). Unknown values return HTTP 400. The validated type is forwarded to BoardService::transferBoardOwnership().

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
New --to-circle option treats the newOwner argument as a circle ID. The command labels output accordingly, wraps the transfer in an error handler so invalid circle IDs print a clean message, and forwards PERMISSION_TYPE_CIRCLE to the service layer. Error messages are now surfaced for both single-board and bulk transfers.

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
…ip button

SharingTabSidebar: when board.ownerType === 7, render a team icon instead of NcAvatar for the owner row and label it Team. The hidden Owner NcActionCheckbox is replaced by a NcActionButton labelled Transfer ownership. For user-owned boards it appears only for user ACL entries when the current user is the owner (unchanged). For circle-owned boards it appears for any ACL entry when canManage is true. Confirmation dialog and success/error messages include the target label (team name or user ID). newOwnerType is forwarded through the Vuex transferOwnership action to the PUT payload.

BoardItem: guard NcAvatar with v-if board.ownerType !== 7 and show a team icon div for circle-owned boards, preventing a lookup of a circle ID as a user avatar.

AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7)
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
The circle member type check `getUserType() !== 1` uses a raw integer
where a named constant is available and already imported.  The existing
ACL-circle expansion path already uses the named constant `Member::LEVEL_MEMBER`
right next to the same condition, making the inconsistency obvious.

Replace both occurrences (circle-owned board owner expansion added in this
feature, and the pre-existing ACL-share expansion) with `Member::TYPE_USER`.
Now that owner_type distinguishes user owners (0) from circle owners (7),
queries that look up boards by user ID should explicitly exclude circle-owned
boards from the user-owner path.

Without this guard, findBoardIds and findAllByUser would accidentally return
a circle-owned board if the circle's single ID happened to match the user ID
string - impossible today (circle IDs are UUIDs, user IDs are logins) but
semantically wrong and a latent bug.  Being explicit also makes the intent
clear to future readers.
findAllForUser now calls both findAllByCircles() and findAllByCircleOwner(),
each of which independently calls getUserCircles().  Without caching,
every board-list request launches two Circles API sessions (getFederatedUser,
startSession, getCircles) for the same user in the same PHP process.

Add a $userCirclesCache keyed by userId, mirroring the existing
$userCircleCache already used by isUserInCircle.  The cache is per-object
(per-request in a normal Nextcloud HTTP context), so stale data is not
a concern.
…stants.js

The feature introduced comparisons like `ownerType !== 7`, `ownerType === 7`,
and `newOwnerType === 7` in three different files (SharingTabSidebar.vue,
BoardItem.vue, main.js), spreading the magic number 7 (PERMISSION_TYPE_CIRCLE)
through the frontend.  The same file already defined SOURCE_TO_SHARE_TYPE with
`circles: 7` locally, duplicating the constant yet again.

Introduce src/helpers/constants.js that exports named constants mirroring the
PHP Acl::PERMISSION_TYPE_* values, and move SOURCE_TO_SHARE_TYPE there as well.
All three files now import and use the named constants; the local
SOURCE_TO_SHARE_TYPE definition in SharingTabSidebar.vue is removed.
findAllByOwner() already queries WHERE owner = $owner, so every board in
the returned collection is guaranteed to have getOwner() === $owner.
The inner guard is always true and adds noise without benefit.
Doctrine DBAL defaults to unsigned=false for integer columns; spelling it out
adds noise without conveying intent and may imply the choice was deliberate
rather than incidental.
Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
canTransferTo was guarded with acl.type === PERMISSION_TYPE_USER, which
silently excluded circle entries (type=7) from ever seeing the Transfer
ownership button.  The board backend already accepts PERMISSION_TYPE_CIRCLE
as a valid newOwnerType, so the UI restriction had no purpose.

Split the eligibility check into two separate concerns:
1. canBeOwnershipTarget: only user and circle participants are valid new
   owners (groups, remotes, etc. are not).
2. Permission to perform the transfer: current user must be the board owner
   (user-owned board) or have manage rights (circle-owned board).

This means the Transfer ownership button now appears in the ... menu for
both user and team/circle participants, as long as the current user has
the right to initiate the transfer.
- allow transfer ownership to team targets from UI and OCC with team-first wording
- rename OCC flag to --to-team (drop --to-circle) and auto-detect team IDs safely
- validate transfer target up front in transferOwnership to fail fast on invalid users/teams
- show team display names (not circle IDs) in transfer confirmations and OCC output
- extend board share notifications to team members
- extend card assignment notifications to team members and add team-specific notification text
- mark team assignment notifications as processed on unassign
- keep internal backend semantics based on circle IDs, with clarifying comments
* Show that removing a user who has access rights through a group or team membership only removes these ACL's, not their entire access. Comes with tests.
* Add transfer-ownership-to-self button for user who is in a circle that owns a board

Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
just like with users when they transfer ownership.
This means that when removing ownership, the team is
still in the list of sharees.

Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
following the same behavior for users
* make sure findAllByOwner is explicit about the type of ownership
* Move 'PERMISSION_OWNER' to a sane spot
* Fix the annotateAclRetainedAccess missing the owning circle's ACL entry

Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
… initial share and improve performance.

Signed-off-by: Jos Poortvliet <jospoortvliet@gmail.com>
@jospoortvliet jospoortvliet force-pushed the review/circle-owned-boards branch from 768599f to 55bbcdc Compare May 4, 2026 00:37
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

🐢 Performance warning.
It looks like the query count of the integration tests increased with this PR.
Database query count is now 96886 was 93102 (+4.06%)
Please check your code again. If you added a new test this can be expected and the base value in tests/integration/base-query-count.txt can be increased.

jospoortvliet and others added 2 commits May 4, 2026 14:53
- NotificationHelper: alphabetise imports (CirclesService before ConfigService)
- Notifier: remove extra indentation level from isTeamAssignment block
- CirclesService: reformat constructor to multi-line with trailing comma
- BoardService: remove trailing whitespace on PERMISSION_OWNER line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- stub.phpstub: add getUserId() to Member stub (was missing, caused UndefinedMethod)
- TransferOwnership: remove redundant double-check of $circle !== null;
  restructure to single if/else so psalm tracks $circleExists cleanly
- TransferOwnership: suppress RedundantCondition on the ownerCircleExists and
  circleExists guards — psalm traces probeCircle() stub as non-nullable and
  loses the exception path
- CirclesService: suppress RedundantCondition on $userId !== null guard in
  clearUserCircleCache — prior guard makes it implied but explicit is clearer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

🐢 Performance warning.
It looks like the query count of the integration tests increased with this PR.
Database query count is now 96887 was 93102 (+4.06%)
Please check your code again. If you added a new test this can be expected and the base value in tests/integration/base-query-count.txt can be increased.

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.

Board ownership transfer to non-existent user

1 participant