Skip to content

feat(orgs): add members to an org, and discover and join public ones - #72

Open
Adron wants to merge 1 commit into
mainfrom
feat/org-members-and-discovery
Open

feat(orgs): add members to an org, and discover and join public ones#72
Adron wants to merge 1 commit into
mainfrom
feat/org-members-and-discovery

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Closes #52.

addOrganizationMember, organizations and joinOrganization already existed in APIClient but 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 new Services/APIClient+Organizations.swift per the transport-seam convention; the three existing writes are wired up unchanged.

What's included

New endpoints — APIClient+Organizations.swift (both GET; the writes they feed already used postCamel, which is what these camelCase routes want)

  • 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 the same quirk already documented on WatcherCandidatesResponse.
  • publicOrganizations(limit:offset:)GET /api/organizations?public=true. Deviation worth a look: the issue says to back Discover with organizations(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=true is what the web's getPublicOrganizations uses: joinable orgs only, with userRole merged so a row the viewer already belongs to renders as a membership instead of a Join the backend would reject.

Add member — OrganizationMembersView

  • Owner-gated search sheet (300ms debounce, matching AddWatcherView) with a role picker, then addOrganizationMember; the members list reloads on success, no manual refresh.
  • The search route is owner-only, while canManage also 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 an error body, so the transport maps it to APIError.forbidden, not .status(403); both are caught.
  • canManage(_:) keeps its per-member rules and now delegates the role test to a shared canManageMembers, which also gates the new control — one rule, two call sites.

Discover — OrganizationsListView

  • Mine / Discover segmented scope; the directory pages on scroll (hasMore, house pattern from FollowListView), shows member counts and descriptions, and renders the viewer's role badge where they're already a member.
  • Join only on public orgs the viewer isn't in — POST /api/user/organizations rejects private orgs outright and conflicts on an existing membership. Joining refreshes both lists.

Bug found by the new tests

.urlQueryAllowed permits &, = and +, so a search for "ada l&ve" reached the backend as search=ada l plus a stray ve parameter — the term was silently truncated. 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. The same latent flaw exists in searchWatcherCandidates; left alone here to keep this PR scoped — worth a follow-up.

Testing

  • xcodebuild ... -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests test964 tests, 0 failures. Build clean.
  • 22 new tests in APIClientOrganizationDirectoryTests covering all three request/response shapes: the search path, paging defaults, &/+ encoding, comma-joined excludeMembers, the pagination-without-total decode, 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's public=true query, decode and userRole merge.
  • Both new .swift files registered in project.pbxproj via the xcodeproj gem.
  • E2E not run (read-only suite, shared live account).

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 the member role, and POST /api/messages requires owner or admin to post as an org (app/api/messages/route.ts: role: { in: ['owner','admin'] }) — it returns 403 otherwise. Loosening postableOrgs to match the criterion would put orgs in the picker that fail on send, so postableOrgs is left as is: a joined org does flow into userOrganizations() and appears under "Mine", and becomes postable if and when its role is raised.

🤖 Generated with Claude Code

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

W4: Add members to an organization; browse and join public organizations

1 participant