Skip to content

feat(orgs): delete, leave, suspend, My Organizations, and org LinkedIn (G25) - #67

Merged
Adron merged 2 commits into
devfrom
feat/orgs-lifecycle-g25
Sep 13, 2026
Merged

Adron merged 2 commits into
devfrom
feat/orgs-lifecycle-g25

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

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 OPTIONS and GET /api/openapi.json; request bodies were confirmed against the shipped web client's own JS bundle and the OpenAPI schemas independently.

  • POST /api/user/organizations is the join path, body {"organizationId": …} — confirmed twice, not guessed. There is no /api/organizations/{id}/join (404).
  • There is no leave route. Leaving is DELETE /api/organizations/{id}/members/{yourUserId}, which is exactly what the web client does.
  • Suspension is not a route. It is active: false on the member-update body, sent alongside the member's existing role so a suspend cannot silently change it.
  • /help/api/organizations is stale on two rows. It documents GET on linkedin/sync-pages and linkedin/assignments; both answer 405 live and neither appears in the OpenAPI document. So linkedin/status is the only readable org-LinkedIn route, and the discovered pages and current assignments have to come back through it.
  • PUT linkedin/assignments takes 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.
  • orgScopesEnabled re-confirmed (the ⚠️ on the issue): all four org-LinkedIn routes are deployed and Bearer-reachable.

Defects found and fixed on the way

  1. GET /api/user/organizations is a Bearer route, not session-only. Decision 0001 recorded it as session-only and the kit shipped auth: .session. A raw curl carrying only Authorization: Bearer — no cookie jar — returns HTTP 200, and the OpenAPI document marks it x-auth-type: sync-token. The same spec correctly reports session for /api/user/engagement, which this repo independently confirmed, so the field is trustworthy in both directions.
  2. The members listing could never decode. GET /api/organizations/{id}/members keys rows by id, not userId, and dates them joinedAt, not createdAt. OrganizationMemberDTO required userId, so every real response failed with keyNotFound and 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.
  3. LinkedInTargetDTO.kind had the wrong taxonomy. The real tokens are personal / orgPage / personalPage (with pageId / personalPageId / linkedInPageId / logoUrl); the client modelled personal / 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/organizations an 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 via LinkedInPostingTargets.defaultDestination, and the company-page case now says so in as many words.

Changes

  • KitOrganizations.delete + the four org-LinkedIn builders; User.joinOrganization (one additive builder) and the one-line auth correction on User.organizations; OrgLinkedInDTO.swift; corrected OrganizationMemberDTO (tolerant of both wire spellings) and LinkedInTargetDTO; membership-context fields on OrganizationDTO / UserOrganizationDTO.
  • DomainOrgServicing: delete, leave, suspend, and the four org-LinkedIn verbs; OrgLifecycleError + OrgOwnershipRules as pure, testable predicates; OrgLinkedIn.swift models; UserServicing.joinOrganization; Organization.isSystem / slug / memberCount; OrgMember identity + isSuspended.
  • PersistenceisSystem / slug / memberCount and member identity round-trip through SwiftData, so the no-leave rule holds on a cache-first paint before any network read confirms it.
  • App — My Organizations rows with role, joined date and member count; per-row Leave; owner-only Delete behind a confirmation that names the org and states it is not reversible; a browse-and-join sheet; suspend/restore in the members table with last-owner gating; an owner/admin org-LinkedIn section (connect, sync, assign, disconnect) whose disconnect confirmation names how many members it affects; the composer destination fix.

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 of join and sync-pages (both ignored via sendVoid plus a re-read of a shape that has been seen, rather than decoding an unseen one). Whether pageId: nil clears an assignment is the one inference made; it is called out in UpdateOrgLinkedInAssignmentRequest.

Verification

Full E2E gate, re-run after merging origin/dev (which brought in #37):

  • xcodebuild … build** BUILD SUCCEEDED **
  • xcodebuild … testExecuted 800 tests, with 0 failures (0 unexpected)** TEST SUCCEEDED **
  • swift test --package-path Packages/InterlinedKitExecuted 412 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedDomainExecuted 829 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedPersistenceExecuted 140 tests, with 0 failures (0 unexpected)
  • grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands0 hits (Decision 0003)
  • Env-gated live ContractTestsran, 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

Adron and others added 2 commits September 9, 2026 10:04
…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
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