feat(organizations): join and leave, with a last-owner guard (#81) - #109
Merged
Merged
Conversation
Android could browse and create organizations but never join or leave one.
Both contracts were captured against the live API before implementing:
- join is POST /api/user/organizations with {"organizationId": "..."} -> 201
{message, membership}; 404 for an unknown org, 409 when already a member,
403 for a private org.
- there is no dedicated leave route: leaving is DELETE
/api/organizations/{id}/members/{userId} with your own user id -> 200.
- membership is reported as `role` on the index and `userRole` on the detail
endpoint, and is simply absent/null when you are not a member, so the
existing index already lists joinable public organizations.
- a sole owner is refused with 400 "Cannot remove the last owner".
The index card now offers Join for public organizations the user has not
joined and shows their role otherwise; the detail screen shows a join prompt
for non-members (and skips the members request, which is members-only and
403s) and a Leave entry with a confirmation for members. The only owner is
told why they cannot leave before any request is made, and the server's
rejection is surfaced with the same explanation as a backstop rather than a
generic error. Leaving pops back to the index; the repository re-reads the
organization so the Room cache reflects the new membership, dropping a
private organization that is no longer visible.
Tests: repository round-trips asserting the real paths and bodies for join
and leave (including the 409, the last-owner 400, and the missing-session
guard), error-message mapping, view-model membership/join/leave behaviour,
and Compose coverage for member vs non-member rendering.
Closes #81
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.
Closes #81. Part of epic #80.
The contract was captured live, because the spec gives no summary for any of it
POST /api/user/organizationswith{"organizationId":"<id>"}→201 {"message":"Joined organization successfully","membership":{…}}. Observed errors: 404not_found, 409conflict("User is already a member of this organization"), and 403 for aprivate org per the help centre. So this endpoint is join, not create-under-user — which the
issue listed as an open question.
/api/user/organizationshas only
get/postin the spec. Leaving is expressed as removing your own membership:DELETE /api/organizations/{id}/members/{yourUserId}→200 {"message":"Member removed…"}.Verified with a full join → leave round trip, confirming
memberCount/userRolereturned totheir original values with no residue.
400 {"error":"Cannot remove the last owner","code":"bad_request"}— and"Cannot demote the last owner"for the PUT. The probe org was deleted; live state verified clean.Three findings that changed the design:
GET /api/organizations— which the indexalready calls — already returns public organizations the user is not in; non-member rows simply
carry no
role/userRolekey. So membership is signalled by the absence of the role, and joinwires into the existing index and detail screens.
GET /api/organizations/{id}reports membership asuserRole(explicitlynullfor anon-member) and never as
role. The existing DTO read onlyrole, so the detail screen's rolewas previously always null — fixed here.
GET /api/organizations/{id}/membersis members-only (403 "You must be a member…"). The detailscreen now skips that request for non-members instead of showing a permission error.
Behaviour
non-member orgs offer nothing.
instead of member management; members get Leave organization in the overflow behind a
confirmation. Leaving pops back to the index.
role == OWNERand exactly one owner in theloaded member list), explains why they cannot leave, and offers no destructive action. If the
member list never loaded, the server's 400 maps to the same explanation rather than a generic
error.
count — and drops a private org that is no longer visible once left.
Verification
./gradlew :app:assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL, 990 tests repo-wide, 0failures, of which 60 in
:feature:organizations(was 38). Includes join body/path + cache,409 conflict, leave path using the signed-in id, eviction of a no-longer-visible org, the last-owner
400 leaving the cache untouched, a missing-session guard that makes no HTTP call, and
userRolemembership mapping. 9 Compose tests compile, not executed (no emulator).
Risks / follow-ups
SessionStore.userId(written byDefaultAuthRepositoryat login), becausethe API has no "leave" or
members/meroute. A session predating that write fails with a clear"we couldn't confirm who you're signed in as — sign in again" message and makes no request.
Worth confirming whether a
DELETE /api/user/organizations/{id}exists or is planned.known, gating those is natural — but that is roles/visibility work, i.e. Organizations: roles and public/private visibility surfaced correctly #83.
isSystem: true, everyone belongs) and will show a Leave entry.isSystemis not modelled or special-cased; if the server refuses, the message surfaces. Afollow-up could hide Leave for system orgs.
account that can see public orgs but has none cached. Left as-is to keep the change focused.