Skip to content

feat(organizations): join and leave, with a last-owner guard (#81) - #109

Merged
Adron merged 1 commit into
parity/queuefrom
issue/81-org-join-leave
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/81-org-join-leave

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #81. Part of epic #80.

The contract was captured live, because the spec gives no summary for any of it

  • Join = POST /api/user/organizations with {"organizationId":"<id>"} →
    201 {"message":"Joined organization successfully","membership":{…}}. Observed errors: 404
    not_found, 409 conflict ("User is already a member of this organization"), and 403 for a
    private org per the help centre. So this endpoint is join, not create-under-user — which the
    issue listed as an open question.
  • There is no dedicated leave endpoint. Not in the help centre, and /api/user/organizations
    has only get/post in 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/userRole returned to
    their original values with no residue.
  • Last remaining owner: probed with a throwaway org (sole owner), which returned
    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:

  • Browsing public organizations needs no new screen. GET /api/organizations — which the index
    already calls — already returns public organizations the user is not in; non-member rows simply
    carry no role/userRole key. So membership is signalled by the absence of the role, and join
    wires into the existing index and detail screens.
  • GET /api/organizations/{id} reports membership as userRole (explicitly null for a
    non-member) and never as role. The existing DTO read only role, so the detail screen's role
    was previously always null
    — fixed here.
  • GET /api/organizations/{id}/members is members-only (403 "You must be a member…"). The detail
    screen now skips that request for non-members instead of showing a permission error.

Behaviour

  • Index card: Join for public orgs not joined, the role label when a member — never both. Private
    non-member orgs offer nothing.
  • Detail: non-members get a join prompt (or a "members only / ask an owner" note for private orgs)
    instead of member management; members get Leave organization in the overflow behind a
    confirmation. Leaving pops back to the index.
  • Last owner: the dialog detects it client-side (role == OWNER and exactly one owner in the
    loaded 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.
  • The repository re-reads the org after join/leave, so the Room cache carries the new role and member
    count — and drops a private org that is no longer visible once left.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 990 tests repo-wide, 0
failures
, 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 userRole
membership mapping. 9 Compose tests compile, not executed (no emulator).

Risks / follow-ups

  1. Leave depends on SessionStore.userId (written by DefaultAuthRepository at login), because
    the API has no "leave" or members/me route. 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.
  2. The overflow still shows Edit/Delete to non-members (pre-existing). Now that membership is
    known, gating those is natural — but that is roles/visibility work, i.e. Organizations: roles and public/private visibility surfaced correctly #83.
  3. "The Public" is a system org (isSystem: true, everyone belongs) and will show a Leave entry.
    isSystem is not modelled or special-cased; if the server refuses, the message surfaces. A
    follow-up could hide Leave for system orgs.
  4. The index's empty-state copy ("Tap New to create your first one") is now slightly off for an
    account that can see public orgs but has none cached. Left as-is to keep the change focused.

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
@Adron
Adron merged commit 68b3a36 into parity/queue Sep 16, 2026
1 check passed
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.

1 participant