feat(orgs): add members to an org, and discover and join public ones - #72
Open
Adron wants to merge 1 commit into
Open
feat(orgs): add members to an org, and discover and join public ones#72Adron wants to merge 1 commit into
Adron wants to merge 1 commit into
Conversation
`addOrganizationMember`, `organizations` and `joinOrganization` had lived in `APIClient` with no caller (#52), so on iOS you could neither grow an org you own nor find one to join. The gap was the two *reads* they needed: without a candidate search there is nobody to add, and without a directory there is nothing to join. Both land in a new `APIClient+Organizations.swift` per the transport-seam convention; the existing writes are now wired up unchanged. - `organizationUsers(id:search:excludeMembers:limit:offset:)` — `GET /api/organizations/{id}/users`. Its `pagination` block is `{limit, offset, hasMore}` with `total` at the top level, so it does *not* fit the shared `Pagination` type (decoding it as one throws); `OrganizationUsersResponse` mirrors `WatcherCandidatesResponse` instead - `publicOrganizations(limit:offset:)` — `GET /api/organizations?public=true`. The bare directory call folds in the viewer's own *private* orgs, which already have their own list; `public=true` returns joinable orgs only and merges `userRole` so a row they already belong to renders as a membership rather than a Join the backend would reject - Add-member sheet in `OrganizationMembersView`: debounced search, role picker, then `addOrganizationMember`; the list reloads on success. The route is owner-only while `canManage` also admits admins, so an admin learns of the gate by hitting it — a 403 retires the affordance instead of surfacing an error they can't act on. That 403 arrives with an `error` body, so it is `APIError.forbidden`, *not* `.status(403)`; both are caught - `canManage(_:)` keeps the per-member rules and now delegates the role test to a shared `canManageMembers`, which also gates the new control - Discover scope in `OrganizationsListView`: paged public directory with member counts, Join on public orgs the viewer isn't in, and the viewer's role badge where they already are. Joining refreshes both lists Fixes a real encoding bug the tests caught: `.urlQueryAllowed` permits `&`, `=` and `+`, so searching `"ada l&ve"` reached the backend as `search=ada l` plus a stray `ve` parameter. Query *values* now encode with those delimiters stripped (`+` included — the backend reads params via `URLSearchParams`, which decodes a literal `+` as a space), while the commas joining `excludeMembers` stay literal because the backend splits on them. Note: joining grants the `member` role, and `POST /api/messages` requires owner/admin to post as an org, so a joined org correctly does not appear in ComposeView's author picker until the role is raised. Issue #52's acceptance criterion to the contrary would produce a 403 on post; `postableOrgs` is left as is. Verified: build + full unit suite green (964 tests, 0 failures), E2E skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FoJJQNJdpyhgbtgkUFJvwH
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #52.
addOrganizationMember,organizationsandjoinOrganizationalready existed inAPIClientbut were called from nowhere, so on iOS you could neither grow an org you own nor discover or join one. What was missing was the two reads those writes needed: without a candidate search there is nobody to add, and without a directory there is nothing to join. Both go in a newServices/APIClient+Organizations.swiftper the transport-seam convention; the three existing writes are wired up unchanged.What's included
New endpoints —
APIClient+Organizations.swift(bothGET; the writes they feed already usedpostCamel, which is what these camelCase routes want)organizationUsers(id:search:excludeMembers:limit:offset:)—GET /api/organizations/{id}/users. Itspaginationblock is{limit, offset, hasMore}withtotalat the top level, so it does not fit the sharedPaginationtype — decoding it as one throws.OrganizationUsersResponsemirrors the same quirk already documented onWatcherCandidatesResponse.publicOrganizations(limit:offset:)—GET /api/organizations?public=true. Deviation worth a look: the issue says to back Discover withorganizations(limit:offset:), but that bare call folds in the viewer's own private orgs, which already have their own list, and would page a directory containing rows nobody can join.public=trueis what the web'sgetPublicOrganizationsuses: joinable orgs only, withuserRolemerged so a row the viewer already belongs to renders as a membership instead of a Join the backend would reject.Add member —
OrganizationMembersViewAddWatcherView) with a role picker, thenaddOrganizationMember; the members list reloads on success, no manual refresh.canManagealso admits admins — so an admin only discovers the gate by hitting it. A 403 retires the affordance rather than surfacing an error they can do nothing about. That 403 arrives with anerrorbody, so the transport maps it toAPIError.forbidden, not.status(403); both are caught.canManage(_:)keeps its per-member rules and now delegates the role test to a sharedcanManageMembers, which also gates the new control — one rule, two call sites.Discover —
OrganizationsListViewhasMore, house pattern fromFollowListView), shows member counts and descriptions, and renders the viewer's role badge where they're already a member.POST /api/user/organizationsrejects private orgs outright and conflicts on an existing membership. Joining refreshes both lists.Bug found by the new tests
.urlQueryAllowedpermits&,=and+, so a search for"ada l&ve"reached the backend assearch=ada lplus a strayveparameter — the term was silently truncated. Query values now encode with those delimiters stripped (+included: the backend reads params viaURLSearchParams, which decodes a literal+as a space), while the commas joiningexcludeMembersstay literal because the backend splits on them. The same latent flaw exists insearchWatcherCandidates; left alone here to keep this PR scoped — worth a follow-up.Testing
xcodebuild ... -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests test— 964 tests, 0 failures. Build clean.APIClientOrganizationDirectoryTestscovering all three request/response shapes: the search path, paging defaults,&/+encoding, comma-joinedexcludeMembers, thepagination-without-totaldecode, the non-owner 403 mapping to.forbidden; the add-member camelCase body (userId/role) and its 201 + 409; the join camelCase body (organizationId) and the private-org 403; and the directory'spublic=truequery, decode anduserRolemerge..swiftfiles registered inproject.pbxprojvia thexcodeprojgem.One acceptance criterion I did not implement, deliberately
The issue expects a joined org to become selectable as a posting target in
ComposeView. Joining grants thememberrole, andPOST /api/messagesrequires owner or admin to post as an org (app/api/messages/route.ts:role: { in: ['owner','admin'] }) — it returns 403 otherwise. LooseningpostableOrgsto match the criterion would put orgs in the picker that fail on send, sopostableOrgsis left as is: a joined org does flow intouserOrganizations()and appears under "Mine", and becomes postable if and when its role is raised.🤖 Generated with Claude Code