- Shipped
- August 10, 2026 at 2:13 PM UTC
- Author
- Kamo
- Commit
- 41c76a7
Contacts were scoped by organisation alone. listBooks(orgId) was findByOrganizationId(orgId), and the contact list query opened WHERE c.contactBook.organization.id = :orgId with no owner predicate, so every member saw — and could bulk-delete, move and export — every contact any colleague had ever added. CardDAV was owner-scoped the whole time (listBooksByUserId -> findByOwnerId), so a member's phone and the web console disagreed about what that account owned. Scope is now organisation AND owner on every read. Owner alone would carry a member's books across every organisation they belong to; organisation alone is the bug being replaced. - ContactQueryStore.whereClause() is a static, testable JPQL fragment, so the owner predicate lives in exactly one place rather than being re-derived per call site. - findInOrg -> findInScope(ids, orgId, ownerId): the guard behind every bulk delete, move, export and group-membership change. - Group listing moves into the query store, owner-scoped; the org-wide reader on ContactService had no remaining callers and is removed rather than left as an unscoped entry point. - Guards become bookBelongsToMember / groupBelongsToMember / contactBelongsToMember. The ownerless organisation-default book belongs to nobody and 404s for everyone. - ensurePersonalBook() provisions "My Contacts" on first read, so a member with no book is not left with an empty picker and a create that cannot succeed. listBooks is therefore no longer readOnly. Also fixes **************** which passed a user id into listBooks(orgId) and so matched it against ORGANIZATION_ID — it returned nothing. It now uses the owner-scoped listing it always meant. Data already sitting in shared books is migrated by ContactBookOwnerSplitMigration in KamoInitializerService.