Skip to content

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

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

feature: Deck boards can be owned by a circle#7899
jospoortvliet wants to merge 17 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).

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

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 circle ownership to function; the --to-team flag returns an error otherwise.

🤖 Generated with AI - claude, codex, and quite some human 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

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
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