feat(orgs): delete, leave, suspend, My Organizations, and org LinkedIn (G25) - #67
Merged
Merged
Conversation
…n (G25) Closes the organization lifecycle and shared-LinkedIn gaps from issue #54. Delivered - DELETE /api/organizations/{id}, gated owner-only client-side. - Leave an org. There is no /leave route — leaving is removing yourself from the members collection, which is what the web client does. - Suspend / restore a member. Also not a route: it is `active` on the member-update body, sent with the member's existing role so a suspend cannot silently change it. - My Organizations showing role, joined date and member count, with per-row Leave, owner-only Delete behind a confirmation that names the org and says it can't be undone, and a browse-and-join sheet. - The four org-LinkedIn routes: status, sync-pages, assignments, credential — plus the browser org-authorize handoff. - Last-owner protection and the "nobody leaves The Public" rule encoded as OrgOwnershipRules and enforced before any network call. Defects found and fixed on the way - GET /api/user/organizations is a Bearer route, not session-only. Decision 0001 had it wrong and the kit shipped auth: .session. Raw curl with only a Bearer header returns 200, and openapi.json marks it x-auth-type: sync-token. - The members listing could never decode. Live rows key on `id`, not `userId`, and date with `joinedAt`, not `createdAt`; the DTO required `userId`, so every real response failed with keyNotFound and the roster never rendered. Same family as the G21 defect. - LinkedInTargetDTO had the wrong kind taxonomy (personal/org instead of personal/orgPage/personalPage), so org-page targets decoded as unknown and lost their page identity. That made the composer's "Posting as …" line actively wrong: an assigned member was told they were posting to their own profile while the server published to the company page. Verification, all read-only The test account is shared with concurrent sessions, so nothing was exercised as a write. Verbs came from OPTIONS and GET /api/openapi.json; the join body was confirmed from the shipped web bundle and the OpenAPI schema independently. /help/api/organizations documents GETs on sync-pages and assignments that answer 405 live — the OpenAPI document is right and the help page is stale. Left explicitly unverified rather than guessed: the connected shape of linkedin/status (no reachable org has a credential — both the observed and documented shapes decode), and the 201 bodies of join and sync-pages, which are ignored via sendVoid plus a re-read instead of decoding a shape nobody has seen. Gate: build SUCCEEDED; App 792, Kit 412, Domain 829, Persistence 140, all 0 failures; live ContractTests 4/0; zero `import InterlinedKit` in App/Features, App/Navigation, App/MenuCommands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAaFauZruiXx6zFS3nKhxA
This was referenced Sep 13, 2026
Open
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 the organization lifecycle and shared-LinkedIn gaps from #54 (G25): delete an org, leave one, suspend/restore a member, My Organizations as its own surface with join, and the four org-LinkedIn routes. Last-owner protection and the "nobody leaves The Public" rule are enforced client-side before any network call, so the user gets a specific sentence instead of a bare server rejection.
Three of the four verbs the issue asked for turned out not to be routes at all, and the recon uncovered three already-shipped defects sitting underneath the feature — including one that made the composer tell users something untrue.
Route findings (all verified read-only)
The test account is shared with 7 concurrent sessions, so not a single write was issued. Verbs came from
OPTIONSandGET /api/openapi.json; request bodies were confirmed against the shipped web client's own JS bundle and the OpenAPI schemas independently.POST /api/user/organizationsis the join path, body{"organizationId": …}— confirmed twice, not guessed. There is no/api/organizations/{id}/join(404).DELETE /api/organizations/{id}/members/{yourUserId}, which is exactly what the web client does.active: falseon the member-update body, sent alongside the member's existing role so a suspend cannot silently change it./help/api/organizationsis stale on two rows. It documentsGETonlinkedin/sync-pagesandlinkedin/assignments; both answer 405 live and neither appears in the OpenAPI document. Solinkedin/statusis the only readable org-LinkedIn route, and the discovered pages and current assignments have to come back through it.PUT linkedin/assignmentstakes one{userId, pageId}pair, per the OpenAPI body schema — not the "assignment map" the help page describes. Where the two sources disagree, OpenAPI has now won twice.orgScopesEnabledre-confirmed (theDefects found and fixed on the way
GET /api/user/organizationsis a Bearer route, not session-only. Decision 0001 recorded it as session-only and the kit shippedauth: .session. A rawcurlcarrying onlyAuthorization: Bearer— no cookie jar — returns HTTP 200, and the OpenAPI document marks itx-auth-type: sync-token. The same spec correctly reportssessionfor/api/user/engagement, which this repo independently confirmed, so the field is trustworthy in both directions.GET /api/organizations/{id}/memberskeys rows byid, notuserId, and dates themjoinedAt, notcreatedAt.OrganizationMemberDTOrequireduserId, so every real response failed withkeyNotFoundand the roster never rendered — the same silent-decode family as the G21 link-metadata bug. The existing kit test used a fabricated{"userId": …}fixture, which is why it stayed green. Rows also carry the member's identity, so the roster now renders names instead of raw UUIDs and needs no second lookup.LinkedInTargetDTO.kindhad the wrong taxonomy. The real tokens arepersonal/orgPage/personalPage(withpageId/personalPageId/linkedInPageId/logoUrl); the client modelledpersonal/org, so org-page targets decoded as unknown and lost their page identity. That made the composer's "Posting as …" line actively wrong: per/help/organizationsan assigned member's default LinkedIn destination is the company page, so the line named their personal profile while the server published to a company page. Fixed viaLinkedInPostingTargets.defaultDestination, and the company-page case now says so in as many words.Changes
Organizations.delete+ the four org-LinkedIn builders;User.joinOrganization(one additive builder) and the one-line auth correction onUser.organizations;OrgLinkedInDTO.swift; correctedOrganizationMemberDTO(tolerant of both wire spellings) andLinkedInTargetDTO; membership-context fields onOrganizationDTO/UserOrganizationDTO.OrgServicing: delete, leave, suspend, and the four org-LinkedIn verbs;OrgLifecycleError+OrgOwnershipRulesas pure, testable predicates;OrgLinkedIn.swiftmodels;UserServicing.joinOrganization;Organization.isSystem/slug/memberCount;OrgMemberidentity +isSuspended.isSystem/slug/memberCountand member identity round-trip through SwiftData, so the no-leave rule holds on a cache-first paint before any network read confirms it.Deliberately not built
Two shapes could not be confirmed read-only, and are flagged in the code rather than guessed at: the connected shape of
linkedin/status(no org reachable from the test account has a credential — the decoder accepts both the observed{credential, role}and the documented{connected, expiresAt, pages}), and the 201 bodies ofjoinandsync-pages(both ignored viasendVoidplus a re-read of a shape that has been seen, rather than decoding an unseen one). WhetherpageId: nilclears an assignment is the one inference made; it is called out inUpdateOrgLinkedInAssignmentRequest.Verification
Full E2E gate, re-run after merging
origin/dev(which brought in #37):xcodebuild … build→** BUILD SUCCEEDED **xcodebuild … test→Executed 800 tests, with 0 failures (0 unexpected)→** TEST SUCCEEDED **swift test --package-path Packages/InterlinedKit→Executed 412 tests, with 0 failures (0 unexpected)swift test --package-path Packages/InterlinedDomain→Executed 829 tests, with 0 failures (0 unexpected)swift test --package-path Packages/InterlinedPersistence→Executed 140 tests, with 0 failures (0 unexpected)grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands→ 0 hits (Decision 0003)ContractTests→ ran,Executed 4 tests, with 0 failures (0 unexpected)BDD quartets ship with every behavior change: happy (leave a non-system org, delete an owned org, suspend and restore, assign a page), invalid (last owner tries to leave / be demoted / be suspended; anyone tries to leave "The Public"; non-owner delete; member tries to manage org LinkedIn — each asserting no service call was made), upstream-failure (sync-pages fails → assignments UI keeps the stale page list, error surfaced separately; every optimistic mutation rolls back), and boundary (one owner + one member; a member with and without a page assignment; empty rosters; partial rosters that must not block).
Closes #54.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FAaFauZruiXx6zFS3nKhxA