diff --git a/App/Features/Compose/ComposerViewModel.swift b/App/Features/Compose/ComposerViewModel.swift index 8bf25a6..debb5f6 100644 --- a/App/Features/Compose/ComposerViewModel.swift +++ b/App/Features/Compose/ComposerViewModel.swift @@ -147,11 +147,29 @@ final class ComposerViewModel { /// a connect hint — mirrors the Bluesky/Mastodon NW-4 pattern. private(set) var linkedInNotConfigured: Bool = false - /// The primary LinkedIn destination the post will publish to (the personal - /// profile, falling back to the first available target). `nil` until targets - /// are loaded. - var linkedInPersonalTarget: LinkedInTarget? { - linkedInTargets.first { $0.kind == .personal } ?? linkedInTargets.first + /// The LinkedIn destination this post will actually publish to + /// (work-consolidation.md G25). + /// + /// **This is not always the personal profile.** Per `/help/organizations`, + /// a member who has been assigned an organization company page gets that + /// page as their *default* destination: enabling the LinkedIn toggle + /// without picking a target publishes to the company page, not to them. + /// + /// The previous implementation looked up the personal target first, so an + /// assigned member was told "Posting as " while the server + /// published to a company page. The precedence now matches the server's. + var linkedInEffectiveTarget: LinkedInTarget? { + LinkedInPostingTargets( + targets: linkedInTargets, + orgScopeMissing: linkedInOrgScopeMissing + ).defaultDestination + } + + /// Whether the resolved destination is a company page rather than the + /// user's own profile. The view uses this to say so explicitly instead of + /// leaving the distinction to a bare name. + var linkedInPostsToCompanyPage: Bool { + linkedInEffectiveTarget?.isCompanyPage ?? false } // MARK: - Derived gating diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index fda629b..6038421 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -380,11 +380,20 @@ struct ComposerWindowView: View { )) { Text("LinkedIn") } .disabled(!viewModel.canUseSubscriberFeatures) - if viewModel.crossPostToLinkedIn, let target = viewModel.linkedInPersonalTarget { - Text("Posting as \(target.label)") + // G25: name the destination the server will actually publish to. + // For a member with an org page assignment that is the company + // page, not their own profile — so the company-page case says so + // in as many words rather than showing a bare name the user would + // reasonably read as their own. + if viewModel.crossPostToLinkedIn, let target = viewModel.linkedInEffectiveTarget { + Text(viewModel.linkedInPostsToCompanyPage + ? "Posting to the \(target.label) company page" + : "Posting as \(target.label)") .font(.ilMono(10)) .foregroundStyle(.secondary) - .accessibilityLabel("Posting to LinkedIn as \(target.label)") + .accessibilityLabel(viewModel.linkedInPostsToCompanyPage + ? "Posting to LinkedIn as the \(target.label) company page" + : "Posting to LinkedIn as \(target.label)") } if viewModel.crossPostToLinkedIn, viewModel.linkedInOrgScopeMissing { diff --git a/App/Features/Organizations/OrgLinkedInViewModel.swift b/App/Features/Organizations/OrgLinkedInViewModel.swift new file mode 100644 index 0000000..7a2089b --- /dev/null +++ b/App/Features/Organizations/OrgLinkedInViewModel.swift @@ -0,0 +1,208 @@ +// OrgLinkedInViewModel +// +// Drives the organization's shared-LinkedIn section (work-consolidation.md +// G25): credential status, the discovered company pages, per-member page +// assignments, and disconnect. +// +// The route shape drives the design here. `linkedin/status` is the only +// readable route of the four — `GET` on `sync-pages` and on `assignments` +// answers 405 — so the page list and the assignment map both arrive on the +// status response, and every write is followed by a status re-read rather +// than a targeted refetch. +// +// The upstream-failure rule the issue calls out is enforced in `syncPages()`: +// a failed sync must leave the previously-loaded page list on screen so the +// assignment UI stays usable, with the error surfaced separately. +// +// Reads through `OrgServicing` only — no direct API access — so unit tests +// substitute a stub. `@Observable` so SwiftUI re-renders on every state change. +// +// Per decision 0003, the view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class OrgLinkedInViewModel { + + // MARK: - Dependencies + + private let orgs: OrgServicing + private let orgId: String + + /// The caller's role from the membership list. Used as the gate before the + /// status read lands (and as a fallback if status omits `role`). + private let membershipRole: OrgRole + + // MARK: - Observable state + + /// The org's shared-LinkedIn state. `nil` until the first load. + private(set) var status: OrgLinkedInStatus? + + private(set) var isLoading: Bool = false + private(set) var loadError: Error? + + /// True while a sync / assign / disconnect round-trip is in flight. + private(set) var isWorking: Bool = false + + /// The most recent write error. Separate from `loadError` so a failed + /// write never blanks the page list the assignment UI needs. + private(set) var actionError: Error? + + /// True when the last `syncPages()` failed while a page list was already + /// on screen. The list is deliberately kept — the view shows this as a + /// "couldn't refresh" hint rather than emptying the assignment UI. + private(set) var syncFailedWithStalePages: Bool = false + + // MARK: - Init + + init(orgService: OrgServicing, orgId: String, membershipRole: OrgRole) { + self.orgs = orgService + self.orgId = orgId + self.membershipRole = membershipRole + } + + // MARK: - Derived state + + /// The company pages currently known for the org. + var pages: [OrgLinkedInPage] { status?.pages ?? [] } + + /// Whether the org has a shared credential connected. + var isConnected: Bool { status?.isConnected ?? false } + + /// Whether the signed-in user may connect, sync, assign, and disconnect. + /// + /// Prefers the role the status route reports (it is authoritative for this + /// org) and falls back to the membership role before the first load, so + /// the section does not flash the wrong affordances on appear. + var canManage: Bool { + if let status, status.callerRole != nil { return status.callerCanManage } + switch membershipRole { + case .owner, .admin: return true + case .member, .other: return false + } + } + + /// The page assigned to `userId`, if any. + func assignedPage(for userId: String) -> OrgLinkedInPage? { + status?.assignedPage(for: userId) + } + + // MARK: - Loading + + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + status = try await orgs.linkedInStatus(of: orgId) + loadError = nil + } catch { + loadError = error + } + } + + // MARK: - Sync pages + + /// Re-discovers the org's LinkedIn company pages. + /// + /// On failure with pages already loaded, the existing list is left intact + /// and `syncFailedWithStalePages` is set — the assignment UI keeps working + /// against the stale list, which is what the issue asks for. + /// + /// - Returns: the error on failure, `nil` on success / no-op. + @discardableResult + func syncPages() async -> Error? { + guard !isWorking else { return nil } + isWorking = true + syncFailedWithStalePages = false + defer { isWorking = false } + do { + status = try await orgs.syncLinkedInPages(of: orgId, callerRole: effectiveRole) + actionError = nil + return nil + } catch { + actionError = error + // Keep whatever pages are already on screen; only note that the + // refresh failed. + syncFailedWithStalePages = !pages.isEmpty + return error + } + } + + // MARK: - Assign a page + + /// Assigns `userId` to `pageId`, or clears their assignment when `pageId` + /// is nil, then re-reads status so the rendered assignments match the + /// server. + /// + /// - Returns: the error on failure, `nil` on success / no-op. + @discardableResult + func assign(userId: String, pageId: String?) async -> Error? { + guard !isWorking else { return nil } + isWorking = true + defer { isWorking = false } + do { + try await orgs.assignLinkedInPage( + in: orgId, + userId: userId, + pageId: pageId, + callerRole: effectiveRole + ) + // No read route for assignments — status is where they come back. + status = try await orgs.linkedInStatus(of: orgId) + actionError = nil + return nil + } catch { + actionError = error + return error + } + } + + // MARK: - Disconnect + + /// Disconnects the org's shared credential. + /// + /// Destructive beyond this screen: every assigned member silently falls + /// back to their personal LinkedIn identity, so the view must confirm and + /// say so before calling this. + /// + /// - Returns: the error on failure, `nil` on success / no-op. + @discardableResult + func disconnect() async -> Error? { + guard !isWorking else { return nil } + isWorking = true + defer { isWorking = false } + do { + try await orgs.disconnectLinkedIn(from: orgId, callerRole: effectiveRole) + // Re-read rather than assuming: the server also clears assignments. + status = try await orgs.linkedInStatus(of: orgId) + actionError = nil + return nil + } catch { + actionError = error + return error + } + } + + /// How many members would lose their assigned page if the credential were + /// disconnected right now. The confirmation names this count, because the + /// consequence lands on people other than the person clicking. + var assignedMemberCount: Int { + (status?.assignments ?? []).filter { $0.pageId != nil }.count + } + + /// The browser URL that starts the org's LinkedIn OAuth flow, or `nil` if + /// it cannot be built. Connecting is a redirect flow, not an API call. + var authorizeURL: URL? { + orgs.linkedInAuthorizeURL(organizationId: orgId) + } + + /// The role the service-side gate should see: the status route's answer + /// when it has one, else the membership role. + private var effectiveRole: OrgRole? { + status?.callerRole ?? membershipRole + } +} diff --git a/App/Features/Organizations/OrgMembersViewModel.swift b/App/Features/Organizations/OrgMembersViewModel.swift index 7c9b79c..98d27ea 100644 --- a/App/Features/Organizations/OrgMembersViewModel.swift +++ b/App/Features/Organizations/OrgMembersViewModel.swift @@ -119,17 +119,35 @@ final class OrgMembersViewModel { guard let index = members.firstIndex(where: { $0.userId == member.userId }) else { return nil } guard members[index].role != newRole else { return nil } + // Last-owner protection (G25): demoting the only owner strands the + // org. Rejected here so the user gets the specific sentence instead of + // a generic 400 from the server. + if let violation = OrgOwnershipRules.validateRoleChange( + member: members[index], + to: newRole, + members: members + ) { + actionError = violation + return violation + } + let snapshot = members pendingOperations.insert(member.userId) defer { pendingOperations.remove(member.userId) } - // Optimistic: paint the new role immediately. + // Optimistic: paint the new role immediately. Identity is carried + // across explicitly — the roster row now renders a name and avatar, + // and rebuilding without them would blank the row mid-flight. members[index] = OrgMember( userId: member.userId, membershipId: member.membershipId, role: newRole, active: member.active, - createdAt: member.createdAt + createdAt: member.createdAt, + username: member.username, + displayName: member.displayName, + avatarURL: member.avatarURL, + emailVerified: member.emailVerified ) do { @@ -139,9 +157,21 @@ final class OrgMembersViewModel { role: newRole, active: nil ) - // Trust the server's authoritative return value, not the local copy. + // Trust the server's authoritative role, but keep the identity: + // the membership envelope this returns carries no username or + // avatar, so adopting it wholesale would blank the row. if let idx = members.firstIndex(where: { $0.userId == updated.userId }) { - members[idx] = updated + members[idx] = OrgMember( + userId: updated.userId, + membershipId: updated.membershipId, + role: updated.role, + active: updated.active, + createdAt: updated.createdAt ?? member.createdAt, + username: member.username, + displayName: member.displayName, + avatarURL: member.avatarURL, + emailVerified: member.emailVerified + ) } actionError = nil return nil @@ -214,6 +244,12 @@ final class OrgMembersViewModel { guard !pendingOperations.contains(member.userId) else { return nil } guard members.contains(where: { $0.userId == member.userId }) else { return nil } + // Last-owner protection (G25) — same rule as demotion. + if let violation = OrgOwnershipRules.validateRemoval(member: member, members: members) { + actionError = violation + return violation + } + let snapshot = members pendingOperations.insert(member.userId) defer { pendingOperations.remove(member.userId) } @@ -231,6 +267,90 @@ final class OrgMembersViewModel { } } + // MARK: - Suspend / restore (optimistic, work-consolidation.md G25) + + /// Suspends or restores a member's access without removing them from the + /// organization (`/help/organizations` — "suspend their access"). + /// + /// There is no suspend route: suspension is `active` on the member-update + /// body, which is why this goes through `OrgServicing.setMemberSuspended` + /// rather than a bespoke call. The service re-sends the member's current + /// role so a suspend cannot silently change it. + /// + /// Optimistic, like every other mutation here: flip the row, call the + /// service, replace with the server's authoritative membership on success, + /// restore the snapshot on failure. + /// + /// - Returns: the error if rejected / failed, `nil` on success or no-op. + @discardableResult + func setSuspended(_ member: OrgMember, suspended: Bool) async -> Error? { + guard !pendingOperations.contains(member.userId) else { return nil } + guard let index = members.firstIndex(where: { $0.userId == member.userId }) else { return nil } + guard members[index].isSuspended != suspended else { return nil } + + // Suspending the last owner leaves the org with no usable owner. + if let violation = OrgOwnershipRules.validateSuspension( + member: members[index], + suspended: suspended, + members: members + ) { + actionError = violation + return violation + } + + let snapshot = members + pendingOperations.insert(member.userId) + defer { pendingOperations.remove(member.userId) } + + // Optimistic: paint the new access state immediately. + members[index] = OrgMember( + userId: member.userId, + membershipId: member.membershipId, + role: member.role, + active: !suspended, + createdAt: member.createdAt, + username: member.username, + displayName: member.displayName, + avatarURL: member.avatarURL, + emailVerified: member.emailVerified + ) + + do { + let updated = try await orgs.setMemberSuspended( + in: orgId, + member: member, + suspended: suspended, + members: snapshot + ) + if let idx = members.firstIndex(where: { $0.userId == updated.userId }) { + members[idx] = updated + } + actionError = nil + return nil + } catch { + members = snapshot + actionError = error + return error + } + } + + /// Whether the roster's rules currently allow suspending `member`. Drives + /// the control's enabled state so the UI does not offer an action that is + /// guaranteed to be rejected. + func canSuspend(_ member: OrgMember) -> Bool { + OrgOwnershipRules.validateSuspension(member: member, suspended: true, members: members) == nil + } + + /// Whether the roster's rules currently allow removing `member`. + func canRemove(_ member: OrgMember) -> Bool { + OrgOwnershipRules.validateRemoval(member: member, members: members) == nil + } + + /// Whether `member` can be moved to `role` under the last-owner rule. + func canChangeRole(of member: OrgMember, to role: OrgRole) -> Bool { + OrgOwnershipRules.validateRoleChange(member: member, to: role, members: members) == nil + } + // MARK: - Handle lookup (NW-6) /// Looks up a user by exact handle. Populates `foundUser` on success; diff --git a/App/Features/Organizations/OrganizationDetailView.swift b/App/Features/Organizations/OrganizationDetailView.swift index a9ec6fe..864c287 100644 --- a/App/Features/Organizations/OrganizationDetailView.swift +++ b/App/Features/Organizations/OrganizationDetailView.swift @@ -19,11 +19,12 @@ struct OrganizationDetailView: View { @State private var detailViewModel: OrganizationDetailViewModel? @State private var membersViewModel: OrgMembersViewModel? + @State private var linkedInViewModel: OrgLinkedInViewModel? var body: some View { Group { - if let detailViewModel, let membersViewModel { - content(detail: detailViewModel, members: membersViewModel) + if let detailViewModel, let membersViewModel, let linkedInViewModel { + content(detail: detailViewModel, members: membersViewModel, linkedIn: linkedInViewModel) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -42,24 +43,38 @@ struct OrganizationDetailView: View { userService: environment.userService, orgId: membership.organization.id ) + let linkedIn = OrgLinkedInViewModel( + orgService: environment.orgService, + orgId: membership.organization.id, + membershipRole: membership.role + ) detailViewModel = detail membersViewModel = members + linkedInViewModel = linkedIn async let d: Void = detail.load() async let m: Void = members.load(reset: true) - _ = await (d, m) + async let l: Void = linkedIn.load() + _ = await (d, m, l) } } @ViewBuilder private func content( detail: OrganizationDetailViewModel, - members: OrgMembersViewModel + members: OrgMembersViewModel, + linkedIn: OrgLinkedInViewModel ) -> some View { ScrollView { VStack(alignment: .leading, spacing: 24) { OrganizationEditSection(viewModel: detail) Divider() - MemberRosterSection(viewModel: members, canManage: membership.role.canManageMembers) + MemberRosterSection( + viewModel: members, + linkedIn: linkedIn, + canManage: membership.role.canManageMembers + ) + Divider() + OrgLinkedInSection(viewModel: linkedIn, members: members) } .padding(20) } @@ -124,6 +139,7 @@ private struct OrganizationEditSection: View { private struct MemberRosterSection: View { @Bindable var viewModel: OrgMembersViewModel + @Bindable var linkedIn: OrgLinkedInViewModel let canManage: Bool @State private var newMemberUserId: String = "" @@ -152,7 +168,11 @@ private struct MemberRosterSection: View { member: member, canManage: canManage, isPending: viewModel.pendingOperations.contains(member.userId), + canSuspend: viewModel.canSuspend(member), + canRemove: viewModel.canRemove(member), + assignedPageName: linkedIn.assignedPage(for: member.userId)?.name, onChangeRole: { role in await viewModel.changeRole(of: member, to: role) }, + onSetSuspended: { suspended in await viewModel.setSuspended(member, suspended: suspended) }, onRemove: { await viewModel.removeMember(member) } ) .onAppear { @@ -229,7 +249,16 @@ private struct MemberRow: View { let member: OrgMember let canManage: Bool let isPending: Bool + /// False when the last-owner rule forbids suspending this member. + let canSuspend: Bool + /// False when the last-owner rule forbids removing this member. + let canRemove: Bool + /// The org LinkedIn company page assigned to this member, if any. Shown + /// on the row because an assignment changes where the member's ordinary + /// LinkedIn cross-posts land. + let assignedPageName: String? let onChangeRole: (OrgRole) async -> Void + let onSetSuspended: (Bool) async -> Void let onRemove: () async -> Void var body: some View { @@ -238,16 +267,40 @@ private struct MemberRow: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) - .foregroundStyle(ILColor.primary.opacity(0.6)) + .foregroundStyle(ILColor.primary.opacity(member.isSuspended ? 0.25 : 0.6)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(member.userId) - .font(.ilBody()) - .lineLimit(1) - .truncationMode(.middle) - Text(member.role.displayName) - .font(.ilMono(10)) - .foregroundStyle(.secondary) + HStack(spacing: 6) { + // The roster now carries real identity, so show the name + // rather than the raw user id. + Text(member.displayLabel) + .font(.ilBody()) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(member.isSuspended ? .secondary : .primary) + if member.isSuspended { + Text("Suspended") + .font(.ilMono(9)) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.18), in: Capsule()) + } + } + HStack(spacing: 6) { + Text(member.role.displayName) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + if let username = member.username { + Text("@\(username)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + if let assignedPageName { + Text("posts as \(assignedPageName)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } } Spacer() if isPending { @@ -272,7 +325,26 @@ private struct MemberRow: View { .labelsHidden() .frame(width: 110) .disabled(isPending) - .accessibilityLabel("Role for \(member.userId)") + .accessibilityLabel("Role for \(member.displayLabel)") + + // Suspend / restore: keeps the member in the org but revokes + // access (`/help/organizations`). Restoring is never blocked; + // suspending the last owner is. + Button { + Task { await onSetSuspended(!member.isSuspended) } + } label: { + Image(systemName: member.isSuspended ? "play.circle" : "pause.circle") + } + .buttonStyle(.borderless) + .disabled(isPending || (!member.isSuspended && !canSuspend)) + .help(member.isSuspended + ? "Restore this member's access" + : (canSuspend + ? "Suspend this member's access" + : "The last owner can't be suspended")) + .accessibilityLabel(member.isSuspended + ? "Restore access for \(member.displayLabel)" + : "Suspend access for \(member.displayLabel)") Button(role: .destructive) { Task { await onRemove() } @@ -280,8 +352,11 @@ private struct MemberRow: View { Image(systemName: "trash") } .buttonStyle(.borderless) - .disabled(isPending) - .accessibilityLabel("Remove \(member.userId) from organization") + .disabled(isPending || !canRemove) + .help(canRemove + ? "Remove from organization" + : "The last owner can't be removed") + .accessibilityLabel("Remove \(member.displayLabel) from organization") } } .padding(.vertical, 4) @@ -292,6 +367,197 @@ private struct MemberRow: View { } } +// MARK: - OrgLinkedInSection + +/// The organization's shared LinkedIn credential, its company pages, and the +/// per-member page assignments (work-consolidation.md G25). +/// +/// Visible to everyone (a member should be able to see that the org has a +/// shared credential and which page they post as), but only owners and admins +/// get the connect / sync / assign / disconnect controls. +private struct OrgLinkedInSection: View { + + @Bindable var viewModel: OrgLinkedInViewModel + @Bindable var members: OrgMembersViewModel + + @Environment(\.openURL) private var openURL + + @State private var showDisconnectConfirmation: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("LinkedIn company pages") + .font(.ilSubtitle()) + + Text("Members assigned a company page cross-post to that page using the organization's shared LinkedIn connection. Members without an assignment post as themselves.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if viewModel.isLoading, viewModel.status == nil { + ProgressView().controlSize(.small) + } else if let error = viewModel.loadError, viewModel.status == nil { + OrgErrorState(error: error, retry: { await viewModel.load() }) + .frame(height: 160) + } else { + statusRow + if let error = viewModel.actionError { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + } + if viewModel.syncFailedWithStalePages { + // Upstream-failure rule: keep the page list usable and say + // it might be out of date rather than emptying it. + Text("Couldn't refresh the page list — showing the pages from the last successful sync.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + if viewModel.isConnected { + pageList + if viewModel.canManage { + assignmentEditor + } + } + } + } + .confirmationDialog( + "Disconnect the organization's LinkedIn?", + isPresented: $showDisconnectConfirmation, + titleVisibility: .visible + ) { + Button("Disconnect", role: .destructive) { + Task { await viewModel.disconnect() } + } + Button("Cancel", role: .cancel) {} + } message: { + // The consequence lands on other people, so name the count. + Text(viewModel.assignedMemberCount == 0 + ? "The shared connection is removed and this organization's company pages become unavailable for cross-posting." + : "\(viewModel.assignedMemberCount) assigned \(viewModel.assignedMemberCount == 1 ? "member" : "members") will fall back to posting on their personal LinkedIn. Page assignments are cleared and can't be restored without reconnecting.") + } + } + + private var statusRow: some View { + HStack(spacing: 8) { + Image(systemName: viewModel.isConnected ? "checkmark.seal.fill" : "xmark.seal") + .foregroundStyle(viewModel.isConnected ? Color.green : Color.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.isConnected ? "Connected" : "Not connected") + .font(.ilBody()) + if let expiry = viewModel.status?.expiresAt { + Text("Credential expires \(expiry.formatted(date: .abbreviated, time: .omitted))") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + Spacer() + if viewModel.isWorking { + ProgressView().controlSize(.small) + } + if viewModel.canManage { + if viewModel.isConnected { + Button("Sync pages") { + Task { await viewModel.syncPages() } + } + .buttonStyle(.bordered) + .disabled(viewModel.isWorking) + + Button("Disconnect…", role: .destructive) { + showDisconnectConfirmation = true + } + .buttonStyle(.bordered) + .disabled(viewModel.isWorking) + } else { + // Connecting is a browser OAuth redirect, not an API call. + Button("Connect LinkedIn") { openAuthorize() } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isWorking) + } + } + } + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var pageList: some View { + if viewModel.pages.isEmpty { + Text("No company pages yet. Sync to pull the pages this connection administers.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.pages) { page in + HStack(spacing: 6) { + Image(systemName: "building.2") + .imageScale(.small) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(page.name) + .font(.ilMono(10)) + if let synced = page.lastSyncedAt { + Text("synced \(synced.formatted(date: .abbreviated, time: .omitted))") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private var assignmentEditor: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Page assignments") + .font(.ilSubtitle()) + .fontWeight(.medium) + if members.members.isEmpty { + Text("Load the member roster to assign pages.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } else { + ForEach(members.members) { member in + HStack { + Text(member.displayLabel) + .font(.ilMono(10)) + .lineLimit(1) + Spacer() + Picker("Page", selection: Binding( + get: { viewModel.assignedPage(for: member.userId)?.id ?? "" }, + set: { pageId in + Task { + await viewModel.assign( + userId: member.userId, + // "" is the no-assignment sentinel; the + // wire wants a null page reference. + pageId: pageId.isEmpty ? nil : pageId + ) + } + } + )) { + Text("Personal LinkedIn").tag("") + ForEach(viewModel.pages) { page in + Text(page.name).tag(page.id) + } + } + .labelsHidden() + .frame(width: 200) + .disabled(viewModel.isWorking) + .accessibilityLabel("LinkedIn page for \(member.displayLabel)") + } + } + } + } + .padding(.top, 4) + } + + private func openAuthorize() { + guard let url = viewModel.authorizeURL else { return } + openURL(url) + } +} + // MARK: - OrgRole management gate extension OrgRole { diff --git a/App/Features/Organizations/OrganizationsListViewModel.swift b/App/Features/Organizations/OrganizationsListViewModel.swift index c3b27c9..c28e0f5 100644 --- a/App/Features/Organizations/OrganizationsListViewModel.swift +++ b/App/Features/Organizations/OrganizationsListViewModel.swift @@ -67,13 +67,39 @@ final class OrganizationsListViewModel { /// its submit button and a double-tap can't double-create. private(set) var isCreating: Bool = false + /// True while a join / leave / delete round-trip is in flight, keyed by + /// org id — drives per-row spinners and stops a double-tap. + private(set) var pendingOperations: Set = [] + + /// The most recent lifecycle-action error (join / leave / delete). Kept + /// separate from `loadError` and `createError` so a failed action never + /// blanks the list. + private(set) var actionError: Error? + + /// Public organizations available to join, loaded on demand by `browse()`. + /// Empty until then. + private(set) var browsableOrganizations: [Organization] = [] + private(set) var isBrowsing: Bool = false + private(set) var browseError: Error? + // MARK: - Init - init(orgService: OrgServicing, userService: UserServicing) { + /// - Parameters: + /// - orgService: the organizations surface. + /// - userService: the membership surface (list + join). + /// - currentUserId: the signed-in user's id. Required to leave an org, + /// because leaving is removing *yourself* from the members collection. + /// `nil` when no session has resolved; leave is then refused with a + /// specific message rather than being silently unavailable. + init(orgService: OrgServicing, userService: UserServicing, currentUserId: String? = nil) { self.orgs = orgService self.user = userService + self.currentUserId = currentUserId } + /// The signed-in user's id, for the leave path. + private let currentUserId: String? + // MARK: - Intents /// Loads the signed-in user's organizations, stale-while-revalidate @@ -167,6 +193,164 @@ final class OrganizationsListViewModel { return nil } } + + // MARK: - Join (work-consolidation.md G25) + + /// Loads the public organizations the user could join. + /// + /// Filters out orgs the user already belongs to, so the browse sheet never + /// offers a Join button that would fail. + func browse() async { + guard !isBrowsing else { return } + isBrowsing = true + browseError = nil + defer { isBrowsing = false } + do { + let page = try await orgs.organizations( + isPublic: true, + userId: nil, + limit: 50, + offset: 0 + ) + let joined = Set(memberships.map(\.organization.id)) + browsableOrganizations = page.organizations.filter { !joined.contains($0.id) } + } catch { + browseError = error + } + } + + /// Joins a public organization and refreshes the membership list from the + /// server's answer. + /// + /// Joining is free for every account — only creating an org is + /// subscriber-gated — so there is deliberately no entitlement check here. + /// + /// - Returns: the error on failure, `nil` on success / no-op. + @discardableResult + func join(organizationId: String) async -> Error? { + let trimmed = organizationId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + actionError = OrganizationsListError.emptyOrganizationId + return OrganizationsListError.emptyOrganizationId + } + guard !pendingOperations.contains(trimmed) else { return nil } + pendingOperations.insert(trimmed) + defer { pendingOperations.remove(trimmed) } + + do { + // The service re-reads the membership list, so the row appears + // with the server's own role and joined-at rather than a guess. + memberships = try await user.joinOrganization(id: trimmed) + browsableOrganizations.removeAll { $0.id == trimmed } + actionError = nil + lastRefreshedAt = Date() + return nil + } catch { + actionError = error + return error + } + } + + // MARK: - Leave (optimistic) + + /// Leaves an organization. + /// + /// Enforces the two client-side rules before the call: the system org + /// ("The Public") can never be left, and the last owner cannot leave until + /// another owner exists. The last-owner half needs the org's roster, which + /// this list does not hold — `members` is passed empty, so that rule is + /// enforced by the server and surfaced as its error. The system-org rule is + /// decided entirely on the row, which is why `isSystem` is cached. + /// + /// Optimistic: drop the row, call the service, restore on failure. + /// + /// - Returns: the error if rejected / failed, `nil` on success. + @discardableResult + func leave(_ membership: UserOrganization) async -> Error? { + let orgId = membership.organization.id + guard !pendingOperations.contains(orgId) else { return nil } + guard let currentUserId, !currentUserId.isEmpty else { + actionError = OrgLifecycleError.unknownCurrentUser + return OrgLifecycleError.unknownCurrentUser + } + // Decided on the row, before anything else — this one never needs the + // network to know the answer. + guard membership.isLeavable else { + let violation = OrgLifecycleError.cannotLeaveSystemOrganization( + name: membership.organization.name + ) + actionError = violation + return violation + } + + let snapshot = memberships + pendingOperations.insert(orgId) + defer { pendingOperations.remove(orgId) } + + memberships.removeAll { $0.organization.id == orgId } + + do { + try await orgs.leave( + organization: membership.organization, + userId: currentUserId, + // The roster is not loaded on this surface; the last-owner + // rule is the server's to enforce here. + members: [] + ) + actionError = nil + return nil + } catch { + memberships = snapshot + actionError = error + return error + } + } + + // MARK: - Delete (optimistic) + + /// Deletes an organization the caller owns. **Not reversible** — the view + /// is responsible for confirming with the user, naming the org, before + /// this is called. + /// + /// Optimistic: drop the row, call the service, restore on failure. + /// + /// - Returns: the error if rejected / failed, `nil` on success. + @discardableResult + func delete(_ membership: UserOrganization) async -> Error? { + let orgId = membership.organization.id + guard !pendingOperations.contains(orgId) else { return nil } + + let snapshot = memberships + pendingOperations.insert(orgId) + defer { pendingOperations.remove(orgId) } + + memberships.removeAll { $0.organization.id == orgId } + + do { + // The owner check lives in the service so it holds for every + // caller, not just this screen. + try await orgs.delete(id: orgId, callerRole: membership.role) + actionError = nil + return nil + } catch { + memberships = snapshot + actionError = error + return error + } + } + + /// Whether the signed-in user may delete this org — owner only. Drives + /// whether the Delete control renders at all (ownership-gated UI is + /// hidden, not disabled). + func canDelete(_ membership: UserOrganization) -> Bool { + membership.role == .owner + } + + /// Whether the Leave control should render for this row. Hidden for the + /// system org, which nobody can leave, and when no session has resolved. + func canLeave(_ membership: UserOrganization) -> Bool { + currentUserId?.isEmpty == false && membership.isLeavable + } } // MARK: - OrganizationsListError @@ -174,11 +358,14 @@ final class OrganizationsListViewModel { /// Client-side validation failures surfaced before any network call. enum OrganizationsListError: LocalizedError, Equatable { case emptyName + case emptyOrganizationId var errorDescription: String? { switch self { case .emptyName: return "Enter a name for the organization." + case .emptyOrganizationId: + return "Pick an organization to join." } } } diff --git a/App/Features/Organizations/OrganizationsRootView.swift b/App/Features/Organizations/OrganizationsRootView.swift index c33b12e..4b1b98d 100644 --- a/App/Features/Organizations/OrganizationsRootView.swift +++ b/App/Features/Organizations/OrganizationsRootView.swift @@ -31,6 +31,12 @@ struct OrganizationsRootView: View { @State private var viewModel: OrganizationsListViewModel? @State private var selection: UserOrganization? @State private var showCreateSheet: Bool = false + @State private var showJoinSheet: Bool = false + /// The membership awaiting a Leave confirmation, if any. + @State private var pendingLeave: UserOrganization? + /// The membership awaiting a Delete confirmation, if any. Delete is not + /// reversible, so it gets its own confirmation that names the org. + @State private var pendingDelete: UserOrganization? var body: some View { NavigationStack { @@ -53,6 +59,12 @@ struct OrganizationsRootView: View { .controlSize(.small) .help("Refreshing organizations…") } + Button { + showJoinSheet = true + } label: { + Label("Browse Organizations", systemImage: "magnifyingglass") + } + .help("Find a public organization to join") Button { showCreateSheet = true } label: { @@ -75,7 +87,10 @@ struct OrganizationsRootView: View { guard environment.currentUserStore.currentUserID != nil else { return } let vm = OrganizationsListViewModel( orgService: environment.orgService, - userService: environment.userService + userService: environment.userService, + // Leaving an org is removing yourself from its members, + // so the list needs to know who "yourself" is. + currentUserId: environment.currentUserStore.currentUserID ) viewModel = vm await vm.load() @@ -85,6 +100,11 @@ struct OrganizationsRootView: View { await vm.load() } } + .sheet(isPresented: $showJoinSheet) { + if let viewModel { + BrowseOrganizationsSheet(listViewModel: viewModel) + } + } .sheet(isPresented: $showCreateSheet) { if let viewModel, let environment { CreateOrganizationSheet(listViewModel: viewModel) { created in @@ -116,7 +136,24 @@ struct OrganizationsRootView: View { } else { List(viewModel.memberships, selection: $selection) { membership in NavigationLink(value: membership) { - OrgRowView(membership: membership) + OrgRowView( + membership: membership, + isPending: viewModel.pendingOperations.contains(membership.organization.id) + ) + } + .contextMenu { + // Ownership-gated actions are hidden, not disabled, when + // they don't apply (PLAN.md §6 M2 rule). + if viewModel.canLeave(membership) { + Button("Leave \(membership.organization.name)") { + pendingLeave = membership + } + } + if viewModel.canDelete(membership) { + Button("Delete \(membership.organization.name)…", role: .destructive) { + pendingDelete = membership + } + } } } .listStyle(.inset) @@ -124,6 +161,54 @@ struct OrganizationsRootView: View { OrganizationDetailView(membership: membership) } .refreshable { await viewModel.load() } + .overlay(alignment: .bottom) { + if let error = viewModel.actionError { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + .padding(8) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) + .padding(8) + .accessibilityLabel("Organization action failed: \(error.localizedDescription)") + } + } + .confirmationDialog( + "Leave \(pendingLeave?.organization.name ?? "this organization")?", + isPresented: Binding( + get: { pendingLeave != nil }, + set: { if !$0 { pendingLeave = nil } } + ), + titleVisibility: .visible + ) { + Button("Leave", role: .destructive) { + if let membership = pendingLeave { + pendingLeave = nil + Task { await viewModel.leave(membership) } + } + } + Button("Cancel", role: .cancel) { pendingLeave = nil } + } message: { + Text("You'll lose access to this organization's shared content. You can rejoin later if it's public.") + } + .confirmationDialog( + "Delete \(pendingDelete?.organization.name ?? "this organization")?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete Organization", role: .destructive) { + if let membership = pendingDelete { + pendingDelete = nil + Task { await viewModel.delete(membership) } + } + } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } message: { + // Name the org and say plainly that it cannot be undone. + Text("This permanently deletes \(pendingDelete?.organization.name ?? "the organization") and removes every member. This can't be undone.") + } } } @@ -147,10 +232,13 @@ struct OrganizationsRootView: View { private struct OrgRowView: View { let membership: UserOrganization + var isPending: Bool = false var body: some View { HStack(spacing: 12) { - Image(systemName: "building.2.crop.circle.fill") + Image(systemName: membership.organization.isSystem + ? "globe" + : "building.2.crop.circle.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) @@ -164,6 +252,16 @@ private struct OrgRowView: View { Text(membership.role.displayName) .font(.ilMono(10)) .foregroundStyle(.secondary) + if let count = membership.organization.memberCount { + Text(count == 1 ? "1 member" : "\(count) members") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + if let joined = membership.joinedAt { + Text("joined \(joined.formatted(date: .abbreviated, time: .omitted))") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } if membership.organization.isPublic { Text("Public") .font(.ilMono(9)) @@ -171,9 +269,22 @@ private struct OrgRowView: View { .padding(.vertical, 1) .background(Color.secondary.opacity(0.15), in: Capsule()) } + if membership.organization.isSystem { + // Explains up front why this row has no Leave action. + Text("System") + .font(.ilMono(9)) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.15), in: Capsule()) + } } } Spacer() + if isPending { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Updating organization") + } } .padding(.vertical, 4) .accessibilityElement(children: .combine) @@ -181,8 +292,94 @@ private struct OrgRowView: View { } private var rowAccessibilityLabel: String { - let visibility = membership.organization.isPublic ? ", public" : "" - return "\(membership.organization.name), \(membership.role.displayName)\(visibility)" + var parts = [membership.organization.name, membership.role.displayName] + if let count = membership.organization.memberCount { + parts.append(count == 1 ? "1 member" : "\(count) members") + } + if let joined = membership.joinedAt { + parts.append("joined \(joined.formatted(date: .abbreviated, time: .omitted))") + } + if membership.organization.isPublic { parts.append("public") } + if membership.organization.isSystem { parts.append("system organization, can't be left") } + return parts.joined(separator: ", ") + } +} + +// MARK: - BrowseOrganizationsSheet + +/// Find and join a public organization. +/// +/// Joining is free for every account — only *creating* an org is +/// subscriber-gated — so nothing here is entitlement-gated. +private struct BrowseOrganizationsSheet: View { + + @Bindable var listViewModel: OrganizationsListViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Browse Organizations") + .font(.ilSubtitle()) + .padding([.top, .horizontal], 20) + + Group { + if listViewModel.isBrowsing, listViewModel.browsableOrganizations.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = listViewModel.browseError, + listViewModel.browsableOrganizations.isEmpty { + OrgErrorState(error: error, retry: { await listViewModel.browse() }) + } else if listViewModel.browsableOrganizations.isEmpty { + Text("You already belong to every public organization.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(listViewModel.browsableOrganizations) { org in + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(org.name) + .font(.ilBody()) + if let description = org.description, !description.isEmpty { + Text(description) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer() + if listViewModel.pendingOperations.contains(org.id) { + ProgressView().controlSize(.small) + } else { + Button("Join") { + Task { await listViewModel.join(organizationId: org.id) } + } + .buttonStyle(.bordered) + } + } + .padding(.vertical, 4) + } + .listStyle(.inset) + } + } + .frame(height: 320) + + if let error = listViewModel.actionError { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 20) + } + + HStack { + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + } + .padding(20) + } + .frame(width: 460) + .task { await listViewModel.browse() } } } diff --git a/AppTests/ComposerViewModelTests.swift b/AppTests/ComposerViewModelTests.swift index 8f50a5d..4ddc1cd 100644 --- a/AppTests/ComposerViewModelTests.swift +++ b/AppTests/ComposerViewModelTests.swift @@ -948,7 +948,7 @@ final class ComposerViewModelTests: XCTestCase { await viewModel.setLinkedInEnabled(true) XCTAssertTrue(viewModel.crossPostToLinkedIn) - XCTAssertEqual(viewModel.linkedInPersonalTarget?.label, "Ada Lovelace") + XCTAssertEqual(viewModel.linkedInEffectiveTarget?.label, "Ada Lovelace") XCTAssertFalse(viewModel.linkedInNotConfigured) XCTAssertFalse(viewModel.linkedInOrgScopeMissing) } @@ -997,7 +997,95 @@ final class ComposerViewModelTests: XCTestCase { XCTAssertTrue(viewModel.crossPostToLinkedIn) XCTAssertFalse(viewModel.linkedInNotConfigured) - XCTAssertNil(viewModel.linkedInPersonalTarget) + XCTAssertNil(viewModel.linkedInEffectiveTarget) + } + + // MARK: - LinkedIn destination truthfulness (work-consolidation.md G25) + // + // /help/organizations: a member with an org page assignment gets that page + // as their DEFAULT destination — enabling the toggle without picking a + // target publishes to the company page, not to them. The readiness line + // has to say so, or it tells the user something untrue. + + func test_givenOrgPageAssignment_whenEnablingLinkedIn_thenNamesTheCompanyPage() async { + // Happy path: the assigned company page wins over the personal profile. + let service = StubLinkedInService(result: .success( + LinkedInPostingTargets( + targets: [ + LinkedInTarget(kind: .personal, label: "Ada Lovelace", isEnabled: true), + LinkedInTarget(kind: .orgPage, label: "Acme Corp", isEnabled: true, pageRecordId: "page-1") + ], + orgScopeMissing: false + ) + )) + let viewModel = ComposerViewModel( + messages: StubMessagesService(), eventBus: ComposerEventBus(), + mode: .newPost, linkedIn: service + ) + + await viewModel.setLinkedInEnabled(true) + + XCTAssertEqual(viewModel.linkedInEffectiveTarget?.label, "Acme Corp") + XCTAssertTrue( + viewModel.linkedInPostsToCompanyPage, + "An assigned member must not be told they're posting as themselves" + ) + } + + func test_givenDisabledOrgPage_whenEnablingLinkedIn_thenNamesThePersonalProfile() async { + // Invalid-for-this-purpose input: a switched-off page is not where the + // post lands, so naming it would be the same lie in reverse. + let service = StubLinkedInService(result: .success( + LinkedInPostingTargets( + targets: [ + LinkedInTarget(kind: .personal, label: "Ada Lovelace", isEnabled: true), + LinkedInTarget(kind: .orgPage, label: "Acme Corp", isEnabled: false, pageRecordId: "page-1") + ], + orgScopeMissing: false + ) + )) + let viewModel = ComposerViewModel( + messages: StubMessagesService(), eventBus: ComposerEventBus(), + mode: .newPost, linkedIn: service + ) + + await viewModel.setLinkedInEnabled(true) + + XCTAssertEqual(viewModel.linkedInEffectiveTarget?.label, "Ada Lovelace") + XCTAssertFalse(viewModel.linkedInPostsToCompanyPage) + } + + func test_givenOnlyAPersonalPage_whenEnablingLinkedIn_thenStillFlagsACompanyPage() async { + // Boundary: a personal company page is still a company page, not the + // user's own profile — the distinction the line has to make. + let service = StubLinkedInService(result: .success( + LinkedInPostingTargets( + targets: [ + LinkedInTarget(kind: .personalPage, label: "Ada's Studio", isEnabled: true, pageRecordId: "page-2") + ], + orgScopeMissing: false + ) + )) + let viewModel = ComposerViewModel( + messages: StubMessagesService(), eventBus: ComposerEventBus(), + mode: .newPost, linkedIn: service + ) + + await viewModel.setLinkedInEnabled(true) + + XCTAssertEqual(viewModel.linkedInEffectiveTarget?.label, "Ada's Studio") + XCTAssertTrue(viewModel.linkedInPostsToCompanyPage) + } + + func test_givenNoTargetsLoaded_whenReadingDestination_thenClaimsNothing() async { + // Boundary: with nothing loaded the line renders nothing rather than + // guessing a destination. + let viewModel = ComposerViewModel( + messages: StubMessagesService(), eventBus: ComposerEventBus(), mode: .newPost + ) + + XCTAssertNil(viewModel.linkedInEffectiveTarget) + XCTAssertFalse(viewModel.linkedInPostsToCompanyPage) } func test_givenNoService_whenEnablingLinkedIn_thenTogglesPlainlyWithoutTargets() async { diff --git a/AppTests/OrgLinkedInViewModelTests.swift b/AppTests/OrgLinkedInViewModelTests.swift new file mode 100644 index 0000000..d3f9cf0 --- /dev/null +++ b/AppTests/OrgLinkedInViewModelTests.swift @@ -0,0 +1,315 @@ +// OrgLinkedInViewModelTests +// +// BDD-named tests for the organization LinkedIn section (work-consolidation.md +// G25): credential status, page sync, per-member page assignment, disconnect. +// +// The upstream-failure case the issue calls out explicitly gets its own test: +// when `sync-pages` fails, the previously-loaded page list must stay on screen +// so the assignment UI remains usable, with the error surfaced separately. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class OrgLinkedInViewModelTests: XCTestCase { + + private func makeViewModel( + role: OrgRole = .owner, + orgId: String = "o1" + ) -> (OrgLinkedInViewModel, StubOrgService) { + let orgs = StubOrgService() + let vm = OrgLinkedInViewModel(orgService: orgs, orgId: orgId, membershipRole: role) + return (vm, orgs) + } + + private func page(_ id: String, _ name: String) -> OrgLinkedInPage { + OrgLinkedInPage(id: id, name: name) + } + + private func connected( + pages: [OrgLinkedInPage] = [], + assignments: [OrgLinkedInAssignment] = [], + role: OrgRole = .owner + ) -> OrgLinkedInStatus { + OrgLinkedInStatus( + isConnected: true, + callerRole: role, + pages: pages, + assignments: assignments + ) + } + + // MARK: - load + + func test_givenConnectedOrg_whenLoading_thenRendersPagesAndAssignments() async { + // Happy path. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected( + pages: [page("p1", "Acme Corp")], + assignments: [OrgLinkedInAssignment(userId: "u2", pageId: "p1")] + )) + + await vm.load() + + XCTAssertTrue(vm.isConnected) + XCTAssertEqual(vm.pages.map(\.name), ["Acme Corp"]) + XCTAssertEqual(vm.assignedPage(for: "u2")?.name, "Acme Corp") + XCTAssertNil(vm.assignedPage(for: "u3")) + XCTAssertTrue(vm.canManage) + } + + func test_givenDisconnectedOrg_whenLoading_thenReportsNotConnected() async { + // Boundary: the live shape on a tenant with no org credential. + let (vm, orgs) = makeViewModel(role: .member) + await orgs.enqueueLinkedInStatus(success: OrgLinkedInStatus( + isConnected: false, + callerRole: .member + )) + + await vm.load() + + XCTAssertFalse(vm.isConnected) + XCTAssertTrue(vm.pages.isEmpty) + XCTAssertFalse(vm.canManage, "A plain member gets no management controls") + } + + func test_givenStatusFails_whenLoading_thenSurfacesLoadError() async { + // Upstream API failure. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(failure: URLError(.badServerResponse)) + + await vm.load() + + XCTAssertNotNil(vm.loadError) + XCTAssertNil(vm.status) + } + + func test_givenMemberRole_whenNotYetLoaded_thenGatesFromMembershipRole() async { + // Boundary: before the status read lands, the membership role decides, + // so the section does not flash controls a member can't use. + let (memberVM, _) = makeViewModel(role: .member) + let (adminVM, _) = makeViewModel(role: .admin) + let (unknownVM, _) = makeViewModel(role: .other("guest")) + + XCTAssertFalse(memberVM.canManage) + XCTAssertTrue(adminVM.canManage) + XCTAssertFalse(unknownVM.canManage) + } + + // MARK: - sync pages + + func test_givenOwner_whenSyncingPages_thenReplacesThePageList() async { + // Happy path. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + await vm.load() + await orgs.enqueueSyncPages(success: connected( + pages: [page("p1", "Acme Corp"), page("p2", "Acme Labs")] + )) + + let error = await vm.syncPages() + + XCTAssertNil(error) + XCTAssertEqual(vm.pages.map(\.name), ["Acme Corp", "Acme Labs"]) + XCTAssertFalse(vm.syncFailedWithStalePages) + } + + func test_givenSyncFails_whenSyncingPages_thenKeepsStalePagesUsable() async { + // Upstream API failure — the case the issue names: "sync-pages fails → + // assignments UI stays usable with the stale page list, error surfaced." + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + await vm.load() + await orgs.enqueueSyncPages(failure: URLError(.badServerResponse)) + + let error = await vm.syncPages() + + XCTAssertNotNil(error) + XCTAssertEqual(vm.pages.map(\.name), ["Acme Corp"], "Stale pages stay on screen") + XCTAssertTrue(vm.syncFailedWithStalePages) + XCTAssertNotNil(vm.actionError) + } + + func test_givenSyncFailsWithNoPagesYet_whenSyncing_thenDoesNotClaimStalePages() async { + // Boundary: nothing to keep, so the "showing stale pages" hint must + // not appear. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [])) + await vm.load() + await orgs.enqueueSyncPages(failure: URLError(.badServerResponse)) + + await vm.syncPages() + + XCTAssertFalse(vm.syncFailedWithStalePages) + XCTAssertTrue(vm.pages.isEmpty) + } + + func test_givenMember_whenSyncingPages_thenRejectedByTheRoleGate() async { + // Invalid input: sync is owner/admin only. + let (vm, orgs) = makeViewModel(role: .member) + await orgs.enqueueLinkedInStatus(success: OrgLinkedInStatus(isConnected: true, callerRole: .member)) + await vm.load() + + let error = await vm.syncPages() + + XCTAssertEqual(error as? OrgLifecycleError, .linkedInRequiresOwnerOrAdmin) + } + + // MARK: - assign + + func test_givenOwner_whenAssigningPage_thenRereadsStatusForTheNewAssignment() async { + // Happy path. There is no read route for assignments, so the view model + // re-reads status rather than trusting a local mutation. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + await vm.load() + await orgs.enqueueAssignPageSuccess() + await orgs.enqueueLinkedInStatus(success: connected( + pages: [page("p1", "Acme Corp")], + assignments: [OrgLinkedInAssignment(userId: "u2", pageId: "p1")] + )) + + let error = await vm.assign(userId: "u2", pageId: "p1") + + XCTAssertNil(error) + XCTAssertEqual(vm.assignedPage(for: "u2")?.id, "p1") + let recorded = await orgs.recorded + XCTAssertTrue(recorded.contains { + $0.kind == .assignLinkedInPage(orgId: "o1", userId: "u2", pageId: "p1", callerRole: "owner") + }) + } + + func test_givenNilPageId_whenAssigning_thenClearsTheAssignment() async { + // Boundary: clearing sends a nil page reference and the member falls + // back to their personal LinkedIn. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected( + pages: [page("p1", "Acme Corp")], + assignments: [OrgLinkedInAssignment(userId: "u2", pageId: "p1")] + )) + await vm.load() + await orgs.enqueueAssignPageSuccess() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + + await vm.assign(userId: "u2", pageId: nil) + + XCTAssertNil(vm.assignedPage(for: "u2")) + let recorded = await orgs.recorded + XCTAssertTrue(recorded.contains { + $0.kind == .assignLinkedInPage(orgId: "o1", userId: "u2", pageId: nil, callerRole: "owner") + }) + } + + func test_givenMember_whenAssigningPage_thenRejectedByTheRoleGate() async { + // Invalid input: assignment is owner/admin only. + let (vm, orgs) = makeViewModel(role: .member) + await orgs.enqueueLinkedInStatus(success: OrgLinkedInStatus(isConnected: true, callerRole: .member)) + await vm.load() + + let error = await vm.assign(userId: "u2", pageId: "p1") + + XCTAssertEqual(error as? OrgLifecycleError, .linkedInRequiresOwnerOrAdmin) + } + + func test_givenAssignFails_whenAssigning_thenSurfacesErrorAndKeepsPages() async { + // Upstream API failure: the page list is untouched so the editor stays + // usable and the user can retry. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + await vm.load() + await orgs.enqueueAssignPage(failure: URLError(.badServerResponse)) + + let error = await vm.assign(userId: "u2", pageId: "p1") + + XCTAssertNotNil(error) + XCTAssertNotNil(vm.actionError) + XCTAssertEqual(vm.pages.count, 1) + } + + // MARK: - disconnect + + func test_givenOwner_whenDisconnecting_thenRereadsStatusAsDisconnected() async { + // Happy path. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected( + pages: [page("p1", "Acme Corp")], + assignments: [OrgLinkedInAssignment(userId: "u2", pageId: "p1")] + )) + await vm.load() + await orgs.enqueueDisconnectSuccess() + await orgs.enqueueLinkedInStatus(success: OrgLinkedInStatus(isConnected: false, callerRole: .owner)) + + let error = await vm.disconnect() + + XCTAssertNil(error) + XCTAssertFalse(vm.isConnected) + XCTAssertTrue(vm.pages.isEmpty) + } + + func test_givenAssignedMembers_whenAboutToDisconnect_thenCountsWhoIsAffected() async { + // The confirmation names this count, because the consequence lands on + // people other than the person clicking. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected( + pages: [page("p1", "Acme Corp")], + assignments: [ + OrgLinkedInAssignment(userId: "u2", pageId: "p1"), + OrgLinkedInAssignment(userId: "u3", pageId: "p1"), + // An assignment with no page is not an affected member. + OrgLinkedInAssignment(userId: "u4", pageId: nil) + ] + )) + await vm.load() + + XCTAssertEqual(vm.assignedMemberCount, 2) + } + + func test_givenNoAssignments_whenCountingAffectedMembers_thenZero() async { + // Boundary: nobody is affected, so the confirmation says so instead of + // naming a count. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + await vm.load() + + XCTAssertEqual(vm.assignedMemberCount, 0) + } + + func test_givenMember_whenDisconnecting_thenRejectedByTheRoleGate() async { + // Invalid input: disconnect is owner/admin only. + let (vm, orgs) = makeViewModel(role: .member) + await orgs.enqueueLinkedInStatus(success: OrgLinkedInStatus(isConnected: true, callerRole: .member)) + await vm.load() + + let error = await vm.disconnect() + + XCTAssertEqual(error as? OrgLifecycleError, .linkedInRequiresOwnerOrAdmin) + } + + func test_givenDisconnectFails_whenDisconnecting_thenStaysConnectedAndSurfacesError() async { + // Upstream API failure: the section must not paint "disconnected" for + // a disconnect that did not happen. + let (vm, orgs) = makeViewModel() + await orgs.enqueueLinkedInStatus(success: connected(pages: [page("p1", "Acme Corp")])) + await vm.load() + await orgs.enqueueDisconnect(failure: URLError(.badServerResponse)) + + let error = await vm.disconnect() + + XCTAssertNotNil(error) + XCTAssertTrue(vm.isConnected) + XCTAssertNotNil(vm.actionError) + } + + // MARK: - authorize URL + + func test_givenOrgId_whenReadingAuthorizeURL_thenTargetsTheOrgAuthorizeRoute() { + // Connecting is a browser redirect flow, not an API call. + let (vm, _) = makeViewModel() + + let url = vm.authorizeURL + + XCTAssertEqual(url?.path, "/api/auth/linkedin/org-authorize") + XCTAssertEqual(url?.query, "organizationId=o1") + } +} diff --git a/AppTests/OrgMembersViewModelTests.swift b/AppTests/OrgMembersViewModelTests.swift index 4ec815a..6cf5d1f 100644 --- a/AppTests/OrgMembersViewModelTests.swift +++ b/AppTests/OrgMembersViewModelTests.swift @@ -263,4 +263,211 @@ final class OrgMembersViewModelTests: XCTestCase { XCTAssertNil(vm.foundUser) XCTAssertEqual(vm.actionError as? OrgMembersError, .emptyHandle) } + + // MARK: - Suspend / restore (work-consolidation.md G25) + + func test_givenActiveMember_whenSuspending_thenOptimisticallyMarksSuspended() async { + // Happy path: the row flips, the service is called, and the server's + // authoritative membership replaces the optimistic copy. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2")], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + await orgs.enqueueSetSuspended(success: OrgMember( + userId: "u2", membershipId: "m-u2", role: .member, active: false + )) + + let error = await vm.setSuspended(vm.members[1], suspended: true) + + XCTAssertNil(error) + XCTAssertTrue(vm.members[1].isSuspended) + let recorded = await orgs.recorded + XCTAssertTrue(recorded.contains { $0.kind == .setMemberSuspended(orgId: "o1", userId: "u2", suspended: true) }) + } + + func test_givenSuspendedMember_whenRestoring_thenMarksActiveAgain() async { + // Happy path in reverse — restoring is never gated. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [OrgMember(userId: "u2", role: .member, active: false)], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + await orgs.enqueueSetSuspended(success: OrgMember(userId: "u2", role: .member, active: true)) + + let error = await vm.setSuspended(vm.members[0], suspended: false) + + XCTAssertNil(error) + XCTAssertFalse(vm.members[0].isSuspended) + } + + func test_givenLastOwner_whenSuspending_thenRejectedBeforeAnyServiceCall() async { + // Invalid input: suspending the only owner strands the org. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2")], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + let callsBefore = await orgs.recorded.count + + let error = await vm.setSuspended(vm.members[0], suspended: true) + + XCTAssertEqual(error as? OrgLifecycleError, .lastOwnerCannotBeSuspended) + XCTAssertFalse(vm.members[0].isSuspended, "The row must not flip on a rejected suspend") + let callsAfter = await orgs.recorded.count + XCTAssertEqual(callsAfter, callsBefore, "A rejected suspend must not hit the service") + } + + func test_givenSuspendFails_whenSuspending_thenRollsBackAndSurfacesError() async { + // Upstream API failure: the optimistic flip is reverted. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2")], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + await orgs.enqueueSetSuspended(failure: URLError(.badServerResponse)) + + let error = await vm.setSuspended(vm.members[1], suspended: true) + + XCTAssertNotNil(error) + XCTAssertFalse(vm.members[1].isSuspended, "Optimistic state rolled back") + XCTAssertNotNil(vm.actionError) + } + + func test_givenMemberAlreadyInTargetState_whenSuspending_thenNoOp() async { + // Boundary: a redundant suspend is a no-op, not a wasted round-trip. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [OrgMember(userId: "u2", role: .member, active: false)], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + let callsBefore = await orgs.recorded.count + + let error = await vm.setSuspended(vm.members[0], suspended: true) + + XCTAssertNil(error) + let callsAfter = await orgs.recorded.count + XCTAssertEqual(callsAfter, callsBefore) + } + + // MARK: - Last-owner protection on the existing mutations + + func test_givenLastOwner_whenDemoting_thenRejectedBeforeAnyServiceCall() async { + // Invalid input: "The last remaining owner cannot be demoted." + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2")], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + let callsBefore = await orgs.recorded.count + + let error = await vm.changeRole(of: vm.members[0], to: .member) + + XCTAssertEqual(error as? OrgLifecycleError, .lastOwnerCannotBeDemoted) + XCTAssertEqual(vm.members[0].role, .owner, "The row keeps the owner role") + let callsAfter = await orgs.recorded.count + XCTAssertEqual(callsAfter, callsBefore) + } + + func test_givenLastOwner_whenRemoving_thenRejectedBeforeAnyServiceCall() async { + // Invalid input: "…cannot be demoted or removed." + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2")], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + let callsBefore = await orgs.recorded.count + + let error = await vm.removeMember(vm.members[0]) + + XCTAssertEqual(error as? OrgLifecycleError, .lastOwnerCannotBeDemoted) + XCTAssertEqual(vm.members.count, 2, "The row must not disappear on a rejected remove") + let callsAfter = await orgs.recorded.count + XCTAssertEqual(callsAfter, callsBefore) + } + + func test_givenTwoOwners_whenDemotingOne_thenAllowed() async { + // Boundary: the rule releases as soon as a second owner exists. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2", role: .owner)], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + await orgs.enqueueUpdateMember(success: OrgMember(userId: "u1", role: .member, active: true)) + + let error = await vm.changeRole(of: vm.members[0], to: .member) + + XCTAssertNil(error) + XCTAssertEqual(vm.members[0].role, .member) + } + + func test_givenSingleOwnerRoster_whenAskingWhatIsAllowed_thenGatesMatchTheRules() async { + // The UI reads these to enable/disable controls; they must agree with + // what the mutations actually enforce. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [member("u1", role: .owner), member("u2")], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + + XCTAssertFalse(vm.canSuspend(vm.members[0])) + XCTAssertFalse(vm.canRemove(vm.members[0])) + XCTAssertFalse(vm.canChangeRole(of: vm.members[0], to: .member)) + XCTAssertTrue(vm.canSuspend(vm.members[1])) + XCTAssertTrue(vm.canRemove(vm.members[1])) + XCTAssertTrue(vm.canChangeRole(of: vm.members[1], to: .admin)) + } + + // MARK: - Identity survives a mutation + + func test_givenNamedMember_whenChangingRole_thenIdentityIsNotBlanked() async { + // The membership envelope the server returns on a role change carries + // no username or avatar. Adopting it wholesale would blank the row, so + // the view model keeps the identity it already had. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueMembers(success: OrgMembersPage( + members: [ + member("u1", role: .owner), + OrgMember( + userId: "u2", + role: .member, + active: true, + username: "hubcity", + displayName: "HubCity", + avatarURL: URL(string: "https://cdn/h.jpg") + ) + ], + hasMore: false, + nextOffset: nil + )) + await vm.load(reset: true) + // The server's answer — identity-free, exactly as the real envelope is. + await orgs.enqueueUpdateMember(success: OrgMember(userId: "u2", role: .admin, active: true)) + + await vm.changeRole(of: vm.members[1], to: .admin) + + XCTAssertEqual(vm.members[1].role, .admin, "Server role is adopted") + XCTAssertEqual(vm.members[1].displayName, "HubCity", "Identity is preserved") + XCTAssertEqual(vm.members[1].username, "hubcity") + XCTAssertEqual(vm.members[1].displayLabel, "HubCity") + } } + diff --git a/AppTests/OrganizationsLifecycleViewModelTests.swift b/AppTests/OrganizationsLifecycleViewModelTests.swift new file mode 100644 index 0000000..5a81a31 --- /dev/null +++ b/AppTests/OrganizationsLifecycleViewModelTests.swift @@ -0,0 +1,291 @@ +// OrganizationsLifecycleViewModelTests +// +// BDD-named tests for the My Organizations lifecycle intents added in +// work-consolidation.md G25: join, leave, and delete, plus the client-side +// rules that decide whether those controls render at all. +// +// The two rules under test come from `/help/organizations`: +// - "You cannot leave the system 'The Public' organization." +// - Deleting an organization is owner-only. +// +// Each optimistic mutation is checked for its rollback path, and each +// precondition is checked for *not calling the service at all* — the whole +// point of enforcing them client-side is to answer immediately. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class OrganizationsLifecycleViewModelTests: XCTestCase { + + private func makeViewModel( + currentUserId: String? = "me" + ) -> (OrganizationsListViewModel, StubOrgService, StubUserService) { + let orgs = StubOrgService() + let users = StubUserService() + let vm = OrganizationsListViewModel( + orgService: orgs, + userService: users, + currentUserId: currentUserId + ) + return (vm, orgs, users) + } + + private func membership( + id: String = "o1", + name: String = "Bikey Life", + role: OrgRole = .member, + isSystem: Bool = false, + memberCount: Int? = 3 + ) -> UserOrganization { + UserOrganization( + organization: Organization( + id: id, + name: name, + isPublic: true, + isSystem: isSystem, + memberCount: memberCount + ), + role: role, + joinedAt: Date(timeIntervalSince1970: 1_000_000) + ) + } + + private var publicMembership: UserOrganization { + membership( + id: "00000000-0000-0000-0000-000000000001", + name: "The Public", + isSystem: true, + memberCount: 22 + ) + } + + private func load(_ vm: OrganizationsListViewModel, _ users: StubUserService, _ memberships: [UserOrganization]) async { + await users.enqueueOrganizations(success: memberships) + await vm.load() + } + + // MARK: - leave + + func test_givenOrdinaryOrg_whenLeaving_thenRowDisappearsAndServiceCalled() async { + // Happy path. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership(), membership(id: "o2", name: "Coding")]) + await orgs.enqueueLeaveSuccess() + + let error = await vm.leave(vm.memberships[0]) + + XCTAssertNil(error) + XCTAssertEqual(vm.memberships.map(\.organization.id), ["o2"]) + let recorded = await orgs.recorded + XCTAssertTrue(recorded.contains { $0.kind == .leave(orgId: "o1", userId: "me") }) + } + + func test_givenSystemOrg_whenLeaving_thenRejectedBeforeAnyServiceCall() async { + // Invalid input: nobody leaves "The Public". + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [publicMembership]) + + let error = await vm.leave(vm.memberships[0]) + + XCTAssertEqual( + error as? OrgLifecycleError, + .cannotLeaveSystemOrganization(name: "The Public") + ) + XCTAssertEqual(vm.memberships.count, 1, "The row stays") + let recorded = await orgs.recorded + XCTAssertFalse(recorded.contains { if case .leave = $0.kind { return true } else { return false } }) + } + + func test_givenNoSignedInUser_whenLeaving_thenRejectedBeforeAnyServiceCall() async { + // Invalid input: leaving needs to know which membership to remove. + let (vm, orgs, users) = makeViewModel(currentUserId: nil) + await load(vm, users, [membership()]) + + let error = await vm.leave(vm.memberships[0]) + + XCTAssertEqual(error as? OrgLifecycleError, .unknownCurrentUser) + let recorded = await orgs.recorded + XCTAssertFalse(recorded.contains { if case .leave = $0.kind { return true } else { return false } }) + } + + func test_givenLeaveFails_whenLeaving_thenRestoresTheRowAndSurfacesError() async { + // Upstream API failure: the optimistic removal is rolled back. This is + // also how the server-side last-owner rejection reaches the user, since + // this surface does not hold the roster. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership(), membership(id: "o2", name: "Coding")]) + await orgs.enqueueLeave(failure: URLError(.badServerResponse)) + + let error = await vm.leave(vm.memberships[0]) + + XCTAssertNotNil(error) + XCTAssertEqual(vm.memberships.map(\.organization.id), ["o1", "o2"], "Row restored") + XCTAssertNotNil(vm.actionError) + } + + func test_givenOnlyMembership_whenLeaving_thenListGoesEmpty() async { + // Boundary: leaving the last org empties the list rather than leaving + // a stale row behind. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership()]) + await orgs.enqueueLeaveSuccess() + + await vm.leave(vm.memberships[0]) + + XCTAssertTrue(vm.memberships.isEmpty) + } + + // MARK: - delete + + func test_givenOwnedOrg_whenDeleting_thenRowDisappearsAndServiceCalled() async { + // Happy path. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership(role: .owner), membership(id: "o2", name: "Coding")]) + await orgs.enqueueDeleteSuccess() + + let error = await vm.delete(vm.memberships[0]) + + XCTAssertNil(error) + XCTAssertEqual(vm.memberships.map(\.organization.id), ["o2"]) + let recorded = await orgs.recorded + XCTAssertTrue(recorded.contains { $0.kind == .delete(orgId: "o1", callerRole: "owner") }) + } + + func test_givenNonOwnedOrg_whenDeleting_thenRejectedAndRowRestored() async { + // Invalid input: delete is owner-only. The stub applies the same + // precondition the real service does. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership(role: .admin)]) + + let error = await vm.delete(vm.memberships[0]) + + XCTAssertEqual(error as? OrgLifecycleError, .onlyOwnerCanDelete) + XCTAssertEqual(vm.memberships.count, 1, "Row restored after the rejection") + XCTAssertNotNil(vm.actionError) + _ = orgs + } + + func test_givenDeleteFails_whenDeleting_thenRestoresTheRowAndSurfacesError() async { + // Upstream API failure. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership(role: .owner)]) + await orgs.enqueueDelete(failure: URLError(.badServerResponse)) + + let error = await vm.delete(vm.memberships[0]) + + XCTAssertNotNil(error) + XCTAssertEqual(vm.memberships.count, 1, "Row restored") + } + + func test_givenAnyRoster_whenAskingWhichControlsRender_thenMatchesTheRules() async { + // Boundary: ownership-gated controls are hidden, not disabled, and the + // system org never offers Leave. + let (vm, _, users) = makeViewModel() + await load(vm, users, [ + membership(role: .owner), + membership(id: "o2", name: "Coding", role: .member), + publicMembership + ]) + + XCTAssertTrue(vm.canDelete(vm.memberships[0])) + XCTAssertFalse(vm.canDelete(vm.memberships[1])) + XCTAssertTrue(vm.canLeave(vm.memberships[0])) + XCTAssertTrue(vm.canLeave(vm.memberships[1])) + XCTAssertFalse(vm.canLeave(vm.memberships[2]), "The Public can never be left") + } + + func test_givenNoSession_whenAskingWhetherLeaveRenders_thenHidesIt() async { + // Boundary: with no resolved session there is nothing to leave *as*. + let (vm, _, users) = makeViewModel(currentUserId: nil) + await load(vm, users, [membership()]) + + XCTAssertFalse(vm.canLeave(vm.memberships[0])) + } + + // MARK: - join + + func test_givenPublicOrg_whenJoining_thenAdoptsTheServersMembershipList() async { + // Happy path: the row comes from the server's re-read, not a guess. + let (vm, _, users) = makeViewModel() + await load(vm, users, []) + await users.enqueueJoinOrganization(success: [membership(id: "o9", name: "Metals")]) + + let error = await vm.join(organizationId: "o9") + + XCTAssertNil(error) + XCTAssertEqual(vm.memberships.map(\.organization.id), ["o9"]) + } + + func test_givenBlankOrgId_whenJoining_thenRejectedBeforeAnyServiceCall() async { + // Invalid input. + let (vm, _, users) = makeViewModel() + + let error = await vm.join(organizationId: " ") + + XCTAssertEqual(error as? OrganizationsListError, .emptyOrganizationId) + let recorded = await users.recorded + XCTAssertFalse(recorded.contains { if case .joinOrganization = $0.kind { return true } else { return false } }) + } + + func test_givenJoinFails_whenJoining_thenKeepsTheListAndSurfacesError() async { + // Upstream API failure: a failed join must not disturb the list. + let (vm, _, users) = makeViewModel() + await load(vm, users, [membership()]) + await users.enqueueJoinOrganization(failure: URLError(.badServerResponse)) + + let error = await vm.join(organizationId: "o9") + + XCTAssertNotNil(error) + XCTAssertEqual(vm.memberships.map(\.organization.id), ["o1"]) + XCTAssertNotNil(vm.actionError) + } + + func test_givenAlreadyJoinedOrgs_whenBrowsing_thenExcludesThem() async { + // Boundary: the browse list must never offer a Join that would fail. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, [membership(id: "o1", name: "Bikey Life")]) + await orgs.enqueueOrganizations(success: OrgsPage( + organizations: [ + Organization(id: "o1", name: "Bikey Life", isPublic: true), + Organization(id: "o2", name: "Metals", isPublic: true) + ], + hasMore: false, + nextOffset: nil + )) + + await vm.browse() + + XCTAssertEqual(vm.browsableOrganizations.map(\.id), ["o2"]) + } + + func test_givenBrowseFails_whenBrowsing_thenSurfacesErrorWithEmptyList() async { + // Upstream API failure on the browse read. + let (vm, orgs, _) = makeViewModel() + await orgs.enqueueOrganizations(failure: URLError(.badServerResponse)) + + await vm.browse() + + XCTAssertNotNil(vm.browseError) + XCTAssertTrue(vm.browsableOrganizations.isEmpty) + } + + func test_givenJoinSucceeds_whenBrowsing_thenTheJoinedRowLeavesTheBrowseList() async { + // Boundary: the browse sheet stays consistent after a join. + let (vm, orgs, users) = makeViewModel() + await load(vm, users, []) + await orgs.enqueueOrganizations(success: OrgsPage( + organizations: [Organization(id: "o2", name: "Metals", isPublic: true)], + hasMore: false, + nextOffset: nil + )) + await vm.browse() + await users.enqueueJoinOrganization(success: [membership(id: "o2", name: "Metals")]) + + await vm.join(organizationId: "o2") + + XCTAssertTrue(vm.browsableOrganizations.isEmpty) + XCTAssertEqual(vm.memberships.map(\.organization.id), ["o2"]) + } +} diff --git a/AppTests/Support/StubOrgService.swift b/AppTests/Support/StubOrgService.swift index 4e83c18..189e656 100644 --- a/AppTests/Support/StubOrgService.swift +++ b/AppTests/Support/StubOrgService.swift @@ -24,6 +24,14 @@ struct RecordedOrgCall: Sendable, Equatable { case updateMember(orgId: String, userId: String, role: String, active: Bool?) case removeMember(orgId: String, userId: String) case users(orgId: String) + // G25 lifecycle + org LinkedIn + case delete(orgId: String, callerRole: String?) + case leave(orgId: String, userId: String) + case setMemberSuspended(orgId: String, userId: String, suspended: Bool) + case linkedInStatus(orgId: String) + case syncLinkedInPages(orgId: String, callerRole: String?) + case assignLinkedInPage(orgId: String, userId: String, pageId: String?, callerRole: String?) + case disconnectLinkedIn(orgId: String, callerRole: String?) } let kind: Kind } @@ -41,6 +49,17 @@ actor StubOrgService: OrgServicing { private var updateMemberOutcomes: [Result] = [] private var removeMemberOutcomes: [Result] = [] private var usersOutcomes: [Result<[OrgUser], Error>] = [] + private var deleteOutcomes: [Result] = [] + private var leaveOutcomes: [Result] = [] + private var setSuspendedOutcomes: [Result] = [] + private var linkedInStatusOutcomes: [Result] = [] + private var syncPagesOutcomes: [Result] = [] + private var assignPageOutcomes: [Result] = [] + private var disconnectOutcomes: [Result] = [] + + /// What `linkedInAuthorizeURL` returns. Non-nil by default so the connect + /// affordance is exercisable; set to nil to cover the unbuildable case. + var authorizeURL: URL? = URL(string: "https://interlinedlist.com/api/auth/linkedin/org-authorize?organizationId=o1") private(set) var recorded: [RecordedOrgCall] = [] @@ -73,6 +92,29 @@ actor StubOrgService: OrgServicing { func enqueueUsers(success users: [OrgUser]) { usersOutcomes.append(.success(users)) } func enqueueUsers(failure error: Error) { usersOutcomes.append(.failure(error)) } + func enqueueDeleteSuccess() { deleteOutcomes.append(.success(())) } + func enqueueDelete(failure error: Error) { deleteOutcomes.append(.failure(error)) } + + func enqueueLeaveSuccess() { leaveOutcomes.append(.success(())) } + func enqueueLeave(failure error: Error) { leaveOutcomes.append(.failure(error)) } + + func enqueueSetSuspended(success member: OrgMember) { setSuspendedOutcomes.append(.success(member)) } + func enqueueSetSuspended(failure error: Error) { setSuspendedOutcomes.append(.failure(error)) } + + func enqueueLinkedInStatus(success status: OrgLinkedInStatus) { linkedInStatusOutcomes.append(.success(status)) } + func enqueueLinkedInStatus(failure error: Error) { linkedInStatusOutcomes.append(.failure(error)) } + + func enqueueSyncPages(success status: OrgLinkedInStatus) { syncPagesOutcomes.append(.success(status)) } + func enqueueSyncPages(failure error: Error) { syncPagesOutcomes.append(.failure(error)) } + + func enqueueAssignPageSuccess() { assignPageOutcomes.append(.success(())) } + func enqueueAssignPage(failure error: Error) { assignPageOutcomes.append(.failure(error)) } + + func enqueueDisconnectSuccess() { disconnectOutcomes.append(.success(())) } + func enqueueDisconnect(failure error: Error) { disconnectOutcomes.append(.failure(error)) } + + func setAuthorizeURL(_ url: URL?) { authorizeURL = url } + // MARK: OrgServicing func organizations( @@ -135,6 +177,84 @@ actor StubOrgService: OrgServicing { return try take(&usersOutcomes, label: "users") } + // MARK: OrgServicing — G25 lifecycle + + func delete(id: String, callerRole: OrgRole?) async throws { + recorded.append(.init(kind: .delete(orgId: id, callerRole: callerRole?.wireToken))) + // Mirror the real service's precondition so view-model tests exercise + // the same gate the production path applies. + if let violation = OrgOwnershipRules.validateDelete(callerRole: callerRole) { throw violation } + let _: Void = try take(&deleteOutcomes, label: "delete") + } + + func leave(organization: Organization, userId: String, members: [OrgMember]) async throws { + recorded.append(.init(kind: .leave(orgId: organization.id, userId: userId))) + if let violation = OrgOwnershipRules.validateLeave( + organization: organization, + userId: userId, + members: members + ) { throw violation } + let _: Void = try take(&leaveOutcomes, label: "leave") + } + + func setMemberSuspended( + in orgId: String, + member: OrgMember, + suspended: Bool, + members: [OrgMember] + ) async throws -> OrgMember { + recorded.append(.init(kind: .setMemberSuspended(orgId: orgId, userId: member.userId, suspended: suspended))) + if let violation = OrgOwnershipRules.validateSuspension( + member: member, + suspended: suspended, + members: members + ) { throw violation } + return try take(&setSuspendedOutcomes, label: "setMemberSuspended") + } + + // MARK: OrgServicing — G25 organization LinkedIn + + func linkedInStatus(of orgId: String) async throws -> OrgLinkedInStatus { + recorded.append(.init(kind: .linkedInStatus(orgId: orgId))) + return try take(&linkedInStatusOutcomes, label: "linkedInStatus") + } + + func syncLinkedInPages(of orgId: String, callerRole: OrgRole?) async throws -> OrgLinkedInStatus { + recorded.append(.init(kind: .syncLinkedInPages(orgId: orgId, callerRole: callerRole?.wireToken))) + try requireManager(callerRole) + return try take(&syncPagesOutcomes, label: "syncLinkedInPages") + } + + func assignLinkedInPage( + in orgId: String, + userId: String, + pageId: String?, + callerRole: OrgRole? + ) async throws { + recorded.append(.init(kind: .assignLinkedInPage( + orgId: orgId, userId: userId, pageId: pageId, callerRole: callerRole?.wireToken + ))) + try requireManager(callerRole) + let _: Void = try take(&assignPageOutcomes, label: "assignLinkedInPage") + } + + func disconnectLinkedIn(from orgId: String, callerRole: OrgRole?) async throws { + recorded.append(.init(kind: .disconnectLinkedIn(orgId: orgId, callerRole: callerRole?.wireToken))) + try requireManager(callerRole) + let _: Void = try take(&disconnectOutcomes, label: "disconnectLinkedIn") + } + + nonisolated func linkedInAuthorizeURL(organizationId: String) -> URL? { + URL(string: "https://interlinedlist.com/api/auth/linkedin/org-authorize?organizationId=\(organizationId)") + } + + private func requireManager(_ role: OrgRole?) throws { + switch role { + case .owner, .admin: return + case .member, .other, .none: throw OrgLifecycleError.linkedInRequiresOwnerOrAdmin + } + } + // MARK: - Internals private func take(_ queue: inout [Result], label: String) throws -> T { diff --git a/AppTests/Support/StubUserService.swift b/AppTests/Support/StubUserService.swift index f0f8e59..6a9ce9e 100644 --- a/AppTests/Support/StubUserService.swift +++ b/AppTests/Support/StubUserService.swift @@ -20,6 +20,7 @@ struct RecordedUserCall: Sendable, Equatable { enum Kind: Sendable, Equatable { case identities case organizations + case joinOrganization(id: String) case identityLinkURL(provider: String, instance: String?) case requestEmailChange(newEmail: String) case uploadAvatar(contentType: String) @@ -44,6 +45,7 @@ final class StubUserService: UserServicing, @unchecked Sendable { private var identitiesOutcomes: [Result<[LinkedIdentity], Error>] = [] private var organizationsOutcomes: [Result<[UserOrganization], Error>] = [] + private var joinOrganizationOutcomes: [Result<[UserOrganization], Error>] = [] private var requestEmailChangeOutcomes: [Result] = [] private var uploadAvatarOutcomes: [Result] = [] private var deleteAccountOutcomes: [Result] = [] @@ -96,6 +98,16 @@ final class StubUserService: UserServicing, @unchecked Sendable { organizationsOutcomes.append(.failure(error)) } + func enqueueJoinOrganization(success orgs: [UserOrganization]) { + lock.lock(); defer { lock.unlock() } + joinOrganizationOutcomes.append(.success(orgs)) + } + + func enqueueJoinOrganization(failure error: Error) { + lock.lock(); defer { lock.unlock() } + joinOrganizationOutcomes.append(.failure(error)) + } + func enqueueRequestEmailChange(success: Void = ()) { lock.lock(); defer { lock.unlock() } requestEmailChangeOutcomes.append(.success(())) @@ -213,6 +225,17 @@ final class StubUserService: UserServicing, @unchecked Sendable { readCachedOrganizations() } + /// Joins an org (work-consolidation.md G25). Mirrors the real service: + /// a blank id is rejected before anything is recorded, and success returns + /// the refreshed membership list rather than an optimistic row. + func joinOrganization(id: String) async throws -> [UserOrganization] { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw OrgLifecycleError.unknownCurrentUser } + return try perform(label: "joinOrganization", record: .joinOrganization(id: trimmed)) { + $0.joinOrganizationOutcomes + } set: { $0.joinOrganizationOutcomes = $1 } + } + /// Synchronous lock-guarded read — factored out so the `async` protocol /// requirement never touches `NSLock` from an async context (Swift 6 /// forbids `lock()`/`unlock()` there). diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift index 4950888..28f1311 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift @@ -43,11 +43,17 @@ extension UserOrganization { description: dto.description, isPublic: dto.isPublic ?? false, createdAt: dto.createdAt, - updatedAt: dto.updatedAt + updatedAt: dto.updatedAt, + slug: dto.slug, + // Carries the "The Public" flag through to the leave rule. + isSystem: dto.isSystem ?? false, + memberCount: dto.memberCount ) self.init( organization: organization, - role: OrgRole(wireToken: dto.role), + // `role` is required on this row; `userRole` is the server's + // duplicate of it and is used only if `role` were ever blank. + role: OrgRole(wireToken: dto.role.isEmpty ? (dto.userRole ?? dto.role) : dto.role), joinedAt: dto.joinedAt ) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift index 140c431..817f07c 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift @@ -113,7 +113,7 @@ public struct LinkedIdentity: Sendable, Equatable, Hashable, Identifiable { /// "Organizations" / org switcher, §6 M6). /// /// Distinct from `Organization`: this is the *membership view* (the org plus -/// the caller's own `role` and `joinedAt`), surfaced from the session-only +/// the caller's own `role` and `joinedAt`), surfaced from the /// `/api/user/organizations` endpoint. Domain projection of /// `InterlinedKit.UserOrganizationDTO`. public struct UserOrganization: Sendable, Equatable, Hashable, Identifiable { @@ -129,6 +129,18 @@ public struct UserOrganization: Sendable, Equatable, Hashable, Identifiable { public var id: String { organization.id } + /// Whether the caller may leave this org **on the client's own rules**, + /// before any network call. Two rules from `/help/organizations`: + /// + /// - nobody can leave the system org ("The Public"); and + /// - the last remaining owner cannot leave until another owner exists. + /// + /// The second rule needs the org's owner count, which this row does not + /// carry — so it is enforced by `OrgLifecycleError.lastOwnerCannotLeave` + /// at the service seam, where the roster is available. This property + /// answers only the part the row can decide by itself. + public var isLeavable: Bool { !organization.isSystem } + public init(organization: Organization, role: OrgRole, joinedAt: Date? = nil) { self.organization = organization self.role = role diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift index af177d6..9335f92 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift @@ -5,9 +5,23 @@ import InterlinedKit /// A LinkedIn destination the user can cross-post to (work-consolidation.md G11a). public struct LinkedInTarget: Sendable, Equatable, Hashable, Identifiable { + + /// The three destination kinds the API defines + /// (`/help/api/linkedin-integration`). + /// + /// `orgPage` was previously modelled as `org`, which matched no wire token, + /// so real org-page targets decoded as `.other`. The rename is the fix. public enum Kind: String, Sendable, Equatable, Hashable { + /// The user's own LinkedIn profile. case personal - case org + /// A company page assigned to the user through an **organization's** + /// shared credential. This is the kind that silently becomes the + /// user's default destination (work-consolidation.md G25). + case orgPage + /// A company page the user administers through their *own* LinkedIn + /// connection (requires the `rw_organization_admin` scope). + case personalPage + /// A token the client does not recognise. case other } @@ -16,13 +30,37 @@ public struct LinkedInTarget: Sendable, Equatable, Hashable, Identifiable { public let avatarURL: URL? public let isEnabled: Bool - public var id: String { "\(kind.rawValue):\(label)" } + /// The record id to send in a message's `linkedInTargets` — `pageId` for + /// an `orgPage`, `personalPageId` for a `personalPage`, `nil` for the + /// personal profile (which needs no id). + public let pageRecordId: String? + + /// LinkedIn's own page identifier, on either page kind. + public let linkedInPageId: String? + + /// Identity has to include the record id: a user can be assigned two + /// company pages that share a label, and collapsing them would drop one + /// from any `Identifiable` list. + public var id: String { "\(kind.rawValue):\(pageRecordId ?? label)" } - public init(kind: Kind, label: String, avatarURL: URL? = nil, isEnabled: Bool = false) { + /// Whether this target publishes to a company page rather than a personal + /// profile — the distinction the composer has to make visible. + public var isCompanyPage: Bool { kind == .orgPage || kind == .personalPage } + + public init( + kind: Kind, + label: String, + avatarURL: URL? = nil, + isEnabled: Bool = false, + pageRecordId: String? = nil, + linkedInPageId: String? = nil + ) { self.kind = kind self.label = label self.avatarURL = avatarURL self.isEnabled = isEnabled + self.pageRecordId = pageRecordId + self.linkedInPageId = linkedInPageId } } @@ -31,14 +69,20 @@ extension LinkedInTarget { let kind: Kind switch dto.kind.lowercased() { case "personal": kind = .personal - case "org", "organization": kind = .org + // "org" / "organization" are accepted alongside the real "orgPage" + // token so an older or renamed server spelling still resolves. + case "orgpage", "org", "organization": kind = .orgPage + case "personalpage": kind = .personalPage default: kind = .other } self.init( kind: kind, label: dto.label, - avatarURL: dto.avatarUrl.flatMap(URL.init(string:)), - isEnabled: dto.enabled ?? false + // Pages carry `logoUrl`; the personal profile carries `avatarUrl`. + avatarURL: (dto.avatarUrl ?? dto.logoUrl).flatMap(URL.init(string:)), + isEnabled: dto.enabled ?? false, + pageRecordId: dto.pageId ?? dto.personalPageId, + linkedInPageId: dto.linkedInPageId ) } } @@ -58,6 +102,30 @@ public struct LinkedInPostingTargets: Sendable, Equatable { } public static let empty = LinkedInPostingTargets(targets: [], orgScopeMissing: false) + + /// Where an ordinary LinkedIn cross-post actually lands when the user + /// enables the toggle **without** picking a destination + /// (work-consolidation.md G25). + /// + /// Per `/help/organizations`: *"For a member who has an assignment, the + /// assigned org page also becomes their default LinkedIn destination: if + /// they enable LinkedIn cross-posting without picking a specific + /// destination, the post goes to the assigned page rather than their + /// personal profile."* + /// + /// So the precedence is org page → personal profile → whatever else is + /// offered. Naming the personal profile here — which is what a + /// personal-first lookup does — would tell a member with an org assignment + /// that they are posting to themselves while the server publishes to a + /// company page. + /// + /// A page only counts as the default when it is `isEnabled`; a target the + /// user has switched off is not where the post goes. + public var defaultDestination: LinkedInTarget? { + targets.first { $0.kind == .orgPage && $0.isEnabled } + ?? targets.first { $0.kind == .personal } + ?? targets.first + } } extension LinkedInPostingTargets { diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgLifecycleError.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgLifecycleError.swift new file mode 100644 index 0000000..47f9e56 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgLifecycleError.swift @@ -0,0 +1,142 @@ +import Foundation + +// MARK: - OrgLifecycleError + +/// Client-side preconditions on the destructive organization verbs +/// (work-consolidation.md G25). +/// +/// These are the rules `/help/organizations` states, enforced **before** the +/// network call so the user gets an immediate, specific sentence instead of a +/// generic server rejection: +/// +/// - *"You cannot leave the system 'The Public' organization."* +/// - *"The last remaining owner cannot be demoted or removed"* — and cannot +/// leave until another owner is in place. +/// - Deleting an organization is owner-only. +/// +/// The server enforces all of this too; the client check is about the message, +/// not about trusting the client. Where a rule needs data the client may not +/// have (an owner count from a partially-loaded roster), the check deliberately +/// **passes** and lets the server be the backstop — a precondition that guesses +/// would block legitimate actions, which is the worse failure. +public enum OrgLifecycleError: LocalizedError, Equatable { + + /// Tried to leave "The Public" (or any `isSystem` org). + case cannotLeaveSystemOrganization(name: String) + + /// Tried to leave while the only remaining owner. + case lastOwnerCannotLeave + + /// Tried to demote or remove the only remaining owner. + case lastOwnerCannotBeDemoted + + /// Tried to suspend the only remaining owner. + case lastOwnerCannotBeSuspended + + /// Tried to delete an org the caller does not own. + case onlyOwnerCanDelete + + /// Tried to act without knowing who the signed-in user is. + case unknownCurrentUser + + /// Tried to manage the org's LinkedIn credential without owner/admin. + case linkedInRequiresOwnerOrAdmin + + public var errorDescription: String? { + switch self { + case .cannotLeaveSystemOrganization(let name): + return "\(name) is a system organization that everyone belongs to — you can't leave it." + case .lastOwnerCannotLeave: + return "You're the last owner. Make someone else an owner before you leave." + case .lastOwnerCannotBeDemoted: + return "This is the last owner. Promote another member to owner first." + case .lastOwnerCannotBeSuspended: + return "This is the last owner. Promote another member to owner before suspending them." + case .onlyOwnerCanDelete: + return "Only an owner can delete this organization." + case .unknownCurrentUser: + return "Sign in again — the app couldn't tell which account you're using." + case .linkedInRequiresOwnerOrAdmin: + return "Only owners and admins can manage the organization's LinkedIn connection." + } + } +} + +// MARK: - OrgOwnershipRules + +/// The pure, testable predicates behind `OrgLifecycleError`. +/// +/// Kept free of any service or networking dependency so the rules can be +/// unit-tested directly and reused by both the service seam and the UI's +/// enable/disable state — the UI must not re-derive them by hand. +public enum OrgOwnershipRules { + + /// Owners among a roster. A suspended owner still counts: they hold the + /// role, so demoting the only *other* owner would still strand the org. + public static func ownerCount(in members: [OrgMember]) -> Int { + members.filter { $0.role == .owner }.count + } + + /// Whether `userId` is the only owner in `members`. + /// + /// Answers `false` when the roster contains no owner at all — that means + /// the roster is partial (or the caller isn't in it), and a precondition + /// must not block on data it does not have. + public static func isLastOwner(userId: String, in members: [OrgMember]) -> Bool { + let owners = members.filter { $0.role == .owner } + guard owners.count == 1 else { return false } + return owners[0].userId == userId + } + + /// Validates a leave. `members` may be empty when the roster has not been + /// loaded; the last-owner half is then skipped and the server decides. + public static func validateLeave( + organization: Organization, + userId: String, + members: [OrgMember] + ) -> OrgLifecycleError? { + if organization.isSystem { + return .cannotLeaveSystemOrganization(name: organization.name) + } + if isLastOwner(userId: userId, in: members) { + return .lastOwnerCannotLeave + } + return nil + } + + /// Validates a role change on `member`. Demoting the last owner is the + /// only rejected transition; promoting anyone is always allowed. + public static func validateRoleChange( + member: OrgMember, + to newRole: OrgRole, + members: [OrgMember] + ) -> OrgLifecycleError? { + guard member.role == .owner, newRole != .owner else { return nil } + return isLastOwner(userId: member.userId, in: members) ? .lastOwnerCannotBeDemoted : nil + } + + /// Validates a removal. The last owner cannot be removed. + public static func validateRemoval( + member: OrgMember, + members: [OrgMember] + ) -> OrgLifecycleError? { + isLastOwner(userId: member.userId, in: members) ? .lastOwnerCannotBeDemoted : nil + } + + /// Validates a suspension. Suspending the last owner would leave the org + /// with no usable owner, so it is refused for the same reason as demotion. + /// Un-suspending is always allowed. + public static func validateSuspension( + member: OrgMember, + suspended: Bool, + members: [OrgMember] + ) -> OrgLifecycleError? { + guard suspended else { return nil } + return isLastOwner(userId: member.userId, in: members) ? .lastOwnerCannotBeSuspended : nil + } + + /// Validates a delete. Owner-only, per `/help/organizations`. + public static func validateDelete(callerRole: OrgRole?) -> OrgLifecycleError? { + callerRole == .owner ? nil : .onlyOwnerCanDelete + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgLinkedIn.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgLinkedIn.swift new file mode 100644 index 0000000..9b735fb --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgLinkedIn.swift @@ -0,0 +1,198 @@ +import Foundation +import InterlinedKit + +// MARK: - Organization LinkedIn domain models (work-consolidation.md G25) +// +// The organization-bound half of LinkedIn: one shared credential per org, the +// company pages LinkedIn returns for it, and the per-member page assignments +// that decide where a member's cross-post actually lands. +// +// Why this matters more than it looks: per `/help/organizations`, a member who +// has an assignment gets the assigned company page as their *default* LinkedIn +// destination. Enabling LinkedIn cross-posting without picking a target then +// publishes to the company page, not their personal profile. The composer has +// to be able to say so. + +// MARK: - OrgLinkedInPage + +/// A LinkedIn company page discovered for an organization. +/// +/// Domain projection of `InterlinedKit.OrgLinkedInPageDTO`. Per decision 0003 +/// the DTO never crosses into the UI. +public struct OrgLinkedInPage: Sendable, Equatable, Hashable, Identifiable { + + /// The InterlinedList record id — the value an assignment refers to. + public let id: String + + /// LinkedIn's own identifier for the page. + public let linkedInPageId: String? + + /// Display name. Falls back to the LinkedIn page id, then the record id, + /// so a page row is never blank. + public let name: String + + public let logoURL: URL? + + public let lastSyncedAt: Date? + + public init( + id: String, + linkedInPageId: String? = nil, + name: String, + logoURL: URL? = nil, + lastSyncedAt: Date? = nil + ) { + self.id = id + self.linkedInPageId = linkedInPageId + self.name = name + self.logoURL = logoURL + self.lastSyncedAt = lastSyncedAt + } +} + +extension OrgLinkedInPage { + public init(from dto: OrgLinkedInPageDTO) { + let resolvedName = dto.pageName + .flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } + ?? dto.linkedInPageId + ?? dto.id + self.init( + id: dto.id, + linkedInPageId: dto.linkedInPageId, + name: resolvedName, + logoURL: dto.pageLogoUrl + .flatMap { $0.isEmpty ? nil : $0 } + .flatMap(URL.init(string:)), + lastSyncedAt: dto.lastSyncedAt + ) + } +} + +// MARK: - OrgLinkedInAssignment + +/// One member's company-page assignment. +public struct OrgLinkedInAssignment: Sendable, Equatable, Hashable, Identifiable { + + public let userId: String + + /// The assigned page's record id. `nil` means the member has no + /// assignment and falls back to their personal LinkedIn identity. + public let pageId: String? + + /// Denormalized page name, when the server supplied it. + public let pageName: String? + + public var id: String { userId } + + public init(userId: String, pageId: String? = nil, pageName: String? = nil) { + self.userId = userId + self.pageId = pageId + self.pageName = pageName + } +} + +extension OrgLinkedInAssignment { + public init(from dto: OrgLinkedInAssignmentDTO) { + self.init(userId: dto.userId, pageId: dto.pageId, pageName: dto.pageName) + } +} + +// MARK: - OrgLinkedInStatus + +/// An organization's shared-LinkedIn state: whether a credential is connected, +/// what the caller may do about it, the discovered pages, and the assignments. +/// +/// `pages` and `assignments` arrive on this value because there is no separate +/// read route for either — `GET` on `linkedin/sync-pages` and on +/// `linkedin/assignments` both answer 405 (see `OrgLinkedInDTO.swift`). +public struct OrgLinkedInStatus: Sendable, Equatable { + + /// Whether the org has a shared credential connected. + public let isConnected: Bool + + /// When the stored credential expires, when known. + public let expiresAt: Date? + + /// The caller's role in the org as the status route reports it. `nil` when + /// the route omitted it; callers then fall back to the role they already + /// hold from the membership list. + public let callerRole: OrgRole? + + /// Company pages stored on the org. Empty when nothing is connected. + public let pages: [OrgLinkedInPage] + + /// Current per-member assignments, when the server returned them. + public let assignments: [OrgLinkedInAssignment] + + /// Whether the caller may connect / disconnect the credential and assign + /// pages. Owners and admins only, per `/help/organizations`. An unknown + /// role answers `false` — management controls stay hidden rather than + /// rendering an action the server will reject. + public var callerCanManage: Bool { + switch callerRole { + case .owner, .admin: return true + case .member, .other, .none: return false + } + } + + public init( + isConnected: Bool, + expiresAt: Date? = nil, + callerRole: OrgRole? = nil, + pages: [OrgLinkedInPage] = [], + assignments: [OrgLinkedInAssignment] = [] + ) { + self.isConnected = isConnected + self.expiresAt = expiresAt + self.callerRole = callerRole + self.pages = pages + self.assignments = assignments + } + + /// The disconnected boundary value. + public static let disconnected = OrgLinkedInStatus(isConnected: false) + + /// The page assigned to a given member, if any. + public func assignedPage(for userId: String) -> OrgLinkedInPage? { + guard let pageId = assignments.first(where: { $0.userId == userId })?.pageId else { + return nil + } + return pages.first { $0.id == pageId } + } +} + +extension OrgLinkedInStatus { + public init(from dto: OrgLinkedInStatusResponse) { + self.init( + isConnected: dto.isConnected, + // Expiry may be nested under `credential` or flattened onto the + // status; take whichever the server sent. + expiresAt: dto.credential?.expiresAt ?? dto.expiresAt, + callerRole: dto.role.map(OrgRole.init(wireToken:)), + pages: (dto.pages ?? []).map(OrgLinkedInPage.init(from:)), + assignments: (dto.assignments ?? []).map(OrgLinkedInAssignment.init(from:)) + ) + } +} + +// MARK: - OrgLinkedInAuthorization + +/// Where to send the browser to connect an org's shared LinkedIn credential. +/// +/// `GET /api/auth/linkedin/org-authorize?organizationId=…` is a browser +/// redirect flow (`x-auth-type: none` in the OpenAPI document), so the client +/// opens it rather than calling it — the same treatment the personal LinkedIn +/// link flow gets. +public enum OrgLinkedInAuthorization { + + /// Builds the org-authorize URL for `organizationId` against `baseURL`. + /// Returns `nil` only if the components cannot form a URL. + public static func url(baseURL: URL, organizationId: String) -> URL? { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + components.path = "/api/auth/linkedin/org-authorize" + components.queryItems = [URLQueryItem(name: "organizationId", value: organizationId)] + return components.url + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgMappers.swift index 6a658fb..7f07523 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OrgMappers.swift @@ -21,9 +21,22 @@ extension Organization { description: dto.description, isPublic: dto.isPublic ?? false, createdAt: dto.createdAt, - updatedAt: dto.updatedAt + updatedAt: dto.updatedAt, + slug: dto.slug, + // A missing `isSystem` means "ordinary org". Defaulting the other + // way would make an org un-leavable on a lean response. + isSystem: dto.isSystem ?? false, + memberCount: dto.memberCount ) } + + /// The caller's role in this org, when the row carries one. The live + /// collection routes emit `role` and `userRole` with the same value; + /// either is accepted so a route that sends only one still resolves. + public static func callerRole(from dto: OrganizationDTO) -> OrgRole? { + guard let token = dto.role ?? dto.userRole else { return nil } + return OrgRole(wireToken: token) + } } extension OrgsPage { @@ -43,14 +56,24 @@ extension OrgsPage { extension OrgMember { - /// Maps a member listing row (keyed by `userId`, no membership-record id). + /// Maps a member listing row (no membership-record id on this shape). + /// Carries the identity fields the live listing denormalizes onto the row, + /// so the roster renders names and avatars without a second lookup. public init(from dto: OrganizationMemberDTO) { self.init( userId: dto.userId, membershipId: nil, role: OrgRole(wireToken: dto.role), active: dto.active, - createdAt: dto.createdAt + createdAt: dto.createdAt, + username: dto.username, + displayName: dto.displayName, + // The live rows use "" for "no avatar", not null — treat a blank + // string as absent so the UI falls back to its placeholder. + avatarURL: dto.avatar + .flatMap { $0.isEmpty ? nil : $0 } + .flatMap(URL.init(string:)), + emailVerified: dto.emailVerified ) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Organization.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Organization.swift index 2ecb897..6c30d01 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Organization.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Organization.swift @@ -73,13 +73,33 @@ public struct Organization: Sendable, Equatable, Hashable, Identifiable { public let createdAt: Date? public let updatedAt: Date? + /// URL-safe short name (`"bikey-life"`), when the server sends one. + public let slug: String? + + /// Whether this is the **system** organization — "The Public", which every + /// account auto-joins and **nobody can leave** (`/help/organizations`). + /// + /// Defaults to `false` when the server omits the flag, which is the safe + /// direction: a missing flag must never make an ordinary org un-leavable. + /// The leave precondition treats this as authoritative, so it has to + /// survive both the wire and the on-disk cache. + public let isSystem: Bool + + /// Total members, when the route denormalizes the count onto the row. + /// `nil` when the server omits it — rendered as "—" rather than "0", since + /// an org always has at least its owner. + public let memberCount: Int? + public init( id: String, name: String, description: String? = nil, isPublic: Bool = false, createdAt: Date? = nil, - updatedAt: Date? = nil + updatedAt: Date? = nil, + slug: String? = nil, + isSystem: Bool = false, + memberCount: Int? = nil ) { self.id = id self.name = name @@ -87,6 +107,9 @@ public struct Organization: Sendable, Equatable, Hashable, Identifiable { self.isPublic = isPublic self.createdAt = createdAt self.updatedAt = updatedAt + self.slug = slug + self.isSystem = isSystem + self.memberCount = memberCount } } @@ -114,25 +137,69 @@ public struct OrgMember: Sendable, Equatable, Hashable, Identifiable { public let role: OrgRole /// Whether the membership is active. `nil` when the server omits the flag. + /// + /// `false` is a **suspended** member: still in the org, access revoked + /// (`/help/organizations` — "suspend their access"). Suspension is + /// expressed on the wire as `active` on the member-update body, not as a + /// separate route. public let active: Bool? /// When the membership was created, when the server includes the timestamp. + /// The live listing spells this `joinedAt`; the membership envelope spells + /// it `createdAt`. Both land here. public let createdAt: Date? + /// The member's handle, denormalized onto the live members listing. + public let username: String? + + /// The member's display name, denormalized onto the live members listing. + public let displayName: String? + + /// The member's avatar, denormalized onto the live members listing. + public let avatarURL: URL? + + /// Whether the member has verified their email, when the server says. + public let emailVerified: Bool? + public var id: String { userId } + /// Whether the member is currently suspended. A `nil` `active` flag means + /// the server did not say, which is treated as **not** suspended — the + /// roster must not show a member as suspended on missing data. + public var isSuspended: Bool { active == false } + + /// The best available label for the member: display name, then handle, + /// then the raw id, so a row is never blank. + public var displayLabel: String { + if let displayName, !displayName.trimmingCharacters(in: .whitespaces).isEmpty { + return displayName + } + if let username, !username.trimmingCharacters(in: .whitespaces).isEmpty { + return username + } + return userId + } + public init( userId: String, membershipId: String? = nil, role: OrgRole, active: Bool? = nil, - createdAt: Date? = nil + createdAt: Date? = nil, + username: String? = nil, + displayName: String? = nil, + avatarURL: URL? = nil, + emailVerified: Bool? = nil ) { self.userId = userId self.membershipId = membershipId self.role = role self.active = active self.createdAt = createdAt + self.username = username + self.displayName = displayName + self.avatarURL = avatarURL + self.emailVerified = emailVerified } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift index 51da730..59c8255 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift @@ -70,6 +70,82 @@ public protocol OrgServicing: Sendable { /// UI renders without a second per-user lookup. Bare-array shape today /// (no pagination on this endpoint). func users(of orgId: String) async throws -> [OrgUser] + + // MARK: - Lifecycle (work-consolidation.md G25) + + /// Deletes an organization. Owner-only and **not reversible** — the caller + /// is responsible for confirming with the user first. + /// + /// `callerRole` is checked client-side so a non-owner gets + /// `OrgLifecycleError.onlyOwnerCanDelete` instead of a bare 403. Pass the + /// role the membership list reported; `nil` means "unknown", which is + /// treated as not-an-owner. + func delete(id: String, callerRole: OrgRole?) async throws + + /// Leaves an organization. + /// + /// There is no `/leave` route — leaving is removing *yourself* from the + /// members collection, exactly as the web client does it. The system-org + /// and last-owner rules are enforced before the call; pass the roster in + /// `members` when it is loaded so the last-owner rule can apply, or `[]` + /// to let the server be the only backstop. + func leave( + organization: Organization, + userId: String, + members: [OrgMember] + ) async throws + + /// Suspends or restores a member's access without removing them from the + /// org. Sends the member's **existing** role alongside the new `active` + /// flag, because the update body requires a role and re-sending the current + /// one is what keeps a suspend from silently changing it. + /// + /// Refuses to suspend the last owner. + func setMemberSuspended( + in orgId: String, + member: OrgMember, + suspended: Bool, + members: [OrgMember] + ) async throws -> OrgMember + + // MARK: - Organization LinkedIn (work-consolidation.md G25) + + /// Reads the org's shared-LinkedIn state: connection, discovered company + /// pages, and current assignments. The only readable route of the four. + func linkedInStatus(of orgId: String) async throws -> OrgLinkedInStatus + + /// Re-discovers the org's LinkedIn company pages and returns the refreshed + /// status. + /// + /// The sync response body is unmodelled upstream, so the implementation + /// ignores it and re-reads `linkedInStatus`. That also means a *successful* + /// sync whose follow-up read fails still throws — callers that want to keep + /// showing the stale page list should catch and keep their previous value. + func syncLinkedInPages(of orgId: String, callerRole: OrgRole?) async throws -> OrgLinkedInStatus + + /// Assigns one member to one company page, or clears their assignment with + /// a `nil` `pageId`. Owner/admin only. + func assignLinkedInPage( + in orgId: String, + userId: String, + pageId: String?, + callerRole: OrgRole? + ) async throws + + /// Disconnects the org's shared LinkedIn credential. + /// + /// Destructive beyond the org itself: every assigned member silently falls + /// back to their personal LinkedIn identity. Owner/admin only. + func disconnectLinkedIn(from orgId: String, callerRole: OrgRole?) async throws + + /// The browser URL that starts the org's LinkedIn OAuth flow. + /// + /// `GET /api/auth/linkedin/org-authorize?organizationId=…` is a redirect + /// flow (`x-auth-type: none`), so the App opens it rather than calling it — + /// the same handoff `UserServicing.identityLinkURL` uses for personal + /// identity linking. Building it here keeps the App layer free of any URL + /// knowledge (decision 0003). + func linkedInAuthorizeURL(organizationId: String) -> URL? } // MARK: - OrgService @@ -78,18 +154,23 @@ public final class OrgService: OrgServicing { private let api: APIClientProtocol private let decoder: JSONDecoder + private let baseURL: URL /// - Parameters: /// - api: the networking seam (a stub in tests). /// - decoder: shared kit JSON configuration, used to split the paginated /// envelope. Defaults to the kit's `JSONCoders` decoder so dates parse /// identically to the client. + /// - baseURL: origin for the browser-redirect OAuth URL. Mirrors + /// `UserService`'s parameter of the same name. public init( api: APIClientProtocol, - decoder: JSONDecoder = JSONCoders.makeDecoder() + decoder: JSONDecoder = JSONCoders.makeDecoder(), + baseURL: URL = URL(string: "https://interlinedlist.com")! ) { self.api = api self.decoder = decoder + self.baseURL = baseURL } // MARK: Org CRUD @@ -206,4 +287,118 @@ public final class OrgService: OrgServicing { let dtos = try await api.send(Organizations.users(id: orgId)) return dtos.map(OrgUser.init(from:)) } + + // MARK: Lifecycle (work-consolidation.md G25) + + public func delete(id: String, callerRole: OrgRole?) async throws { + if let violation = OrgOwnershipRules.validateDelete(callerRole: callerRole) { + throw violation + } + // Response body is unmodelled upstream; only the status matters. + try await api.sendVoid(Organizations.delete(id: id)) + } + + public func leave( + organization: Organization, + userId: String, + members: [OrgMember] + ) async throws { + guard !userId.isEmpty else { throw OrgLifecycleError.unknownCurrentUser } + if let violation = OrgOwnershipRules.validateLeave( + organization: organization, + userId: userId, + members: members + ) { + throw violation + } + // Leaving is removing yourself — there is no dedicated /leave route. + try await api.sendVoid( + Organizations.removeMember(id: organization.id, userId: userId) + ) + } + + public func setMemberSuspended( + in orgId: String, + member: OrgMember, + suspended: Bool, + members: [OrgMember] + ) async throws -> OrgMember { + if let violation = OrgOwnershipRules.validateSuspension( + member: member, + suspended: suspended, + members: members + ) { + throw violation + } + // Re-send the member's current role: the update body requires one, and + // omitting it would let the server reinterpret the role on a suspend. + let body = UpdateOrganizationMemberRequest( + role: member.role.wireToken, + active: !suspended + ) + let response = try await api.send( + Organizations.updateMember(id: orgId, userId: member.userId, body) + ) + let updated = OrgMember(from: response.membership) + // The membership envelope drops the identity fields the roster row + // carries, so fold them back in rather than blanking the row. + return OrgMember( + userId: updated.userId, + membershipId: updated.membershipId, + role: updated.role, + active: updated.active, + createdAt: updated.createdAt ?? member.createdAt, + username: member.username, + displayName: member.displayName, + avatarURL: member.avatarURL, + emailVerified: member.emailVerified + ) + } + + // MARK: Organization LinkedIn (work-consolidation.md G25) + + public func linkedInStatus(of orgId: String) async throws -> OrgLinkedInStatus { + OrgLinkedInStatus(from: try await api.send(Organizations.linkedInStatus(id: orgId))) + } + + public func syncLinkedInPages( + of orgId: String, + callerRole: OrgRole? + ) async throws -> OrgLinkedInStatus { + try requireLinkedInManager(callerRole) + // The 201 body is unmodelled upstream — ignore it and re-read status, + // which is the only route that returns the discovered pages. + try await api.sendVoid(Organizations.syncLinkedInPages(id: orgId)) + return try await linkedInStatus(of: orgId) + } + + public func assignLinkedInPage( + in orgId: String, + userId: String, + pageId: String?, + callerRole: OrgRole? + ) async throws { + try requireLinkedInManager(callerRole) + guard !userId.isEmpty else { throw OrgLifecycleError.unknownCurrentUser } + let body = UpdateOrgLinkedInAssignmentRequest(userId: userId, pageId: pageId) + try await api.sendVoid(Organizations.assignLinkedInPage(id: orgId, body)) + } + + public func disconnectLinkedIn(from orgId: String, callerRole: OrgRole?) async throws { + try requireLinkedInManager(callerRole) + try await api.sendVoid(Organizations.disconnectLinkedIn(id: orgId)) + } + + public func linkedInAuthorizeURL(organizationId: String) -> URL? { + OrgLinkedInAuthorization.url(baseURL: baseURL, organizationId: organizationId) + } + + /// Owner/admin gate shared by the three org-LinkedIn writes. + private func requireLinkedInManager(_ role: OrgRole?) throws { + switch role { + case .owner, .admin: return + case .member, .other, .none: throw OrgLifecycleError.linkedInRequiresOwnerOrAdmin + } + } } + diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift index cdebbb1..797b552 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift @@ -38,6 +38,19 @@ public protocol UserServicing: Sendable { /// when no store is injected or the cache is cold. func cachedOrganizations() async -> [UserOrganization] + /// Joins a public organization and returns the refreshed membership list + /// (work-consolidation.md G25). + /// + /// Joining is free for every account — only *creating* an org is + /// subscriber-gated (`/help/organizations`), so this deliberately has no + /// entitlement check. + /// + /// The join response body is unmodelled upstream and was never observed + /// live, so the implementation ignores it and re-reads `organizations()` + /// instead of decoding a shape it has not seen. That re-read also writes + /// the new membership through to the cache. + func joinOrganization(id: String) async throws -> [UserOrganization] + /// Resolves the web authorize URL for linking a new OAuth identity /// (PLAN.md §4 — "OAuth … link-account-only in v1"; Wave 7 spike /// `docs/spikes/0002-oauth-identity-linking.md`). The v1 UX is a browser @@ -203,6 +216,17 @@ public final class UserService: UserServicing { await orgStore?.cachedMemberships() ?? [] } + public func joinOrganization(id: String) async throws -> [UserOrganization] { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OrgLifecycleError.unknownCurrentUser + } + // Ignore the 201 body (unmodelled upstream) and re-read the list, + // which also refreshes the cache. + try await api.sendVoid(User.joinOrganization(organizationId: trimmed)) + return try await organizations() + } + public func identityLinkURL(provider: IdentityProvider, instance: String?) throws -> URL { // Map the domain provider onto the kit's OAuth path segment. `.other` // has no authorize route — reject it before building anything. diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift index 3cd27b4..b0ea9ce 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift @@ -17,7 +17,7 @@ final class LinkedInServiceTests: XCTestCase { let result = try await service.postingTargets() - XCTAssertEqual(result.targets.map(\.kind), [.personal, .org]) + XCTAssertEqual(result.targets.map(\.kind), [.personal, .orgPage]) XCTAssertTrue(result.targets.first?.isEnabled ?? false) XCTAssertTrue(result.orgScopeMissing) let recorded = await api.recorded @@ -52,4 +52,85 @@ final class LinkedInServiceTests: XCTestCase { let target = LinkedInTarget(from: LinkedInTargetDTO(kind: "showcase", label: "X")) XCTAssertEqual(target.kind, .other) } + + // MARK: - Default destination (work-consolidation.md G25) + // + // /help/organizations: "For a member who has an assignment, the assigned + // org page also becomes their default LinkedIn destination: if they enable + // LinkedIn cross-posting without picking a specific destination, the post + // goes to the assigned page rather than their personal profile." + + func test_givenRealWireKinds_whenMapping_thenDecodesPageIdentity() async throws { + // Happy path: the three documented kinds and their page identifiers. + // `orgPage` used to fall through to `.other`, dropping pageId entirely. + let api = StubAPIClient() + await api.enqueue(json: #""" + {"targets":[ + {"kind":"personal","label":"Alice Example","avatarUrl":null,"enabled":true}, + {"kind":"orgPage","pageId":"page-1","linkedInPageId":"12345678", + "label":"Acme Corp","logoUrl":"https://cdn/l.png","enabled":true}, + {"kind":"personalPage","personalPageId":"page-2","linkedInPageId":"87654321", + "label":"Alice's Studio","logoUrl":null,"enabled":false} + ],"orgScopeMissing":false} + """#) + let service = LinkedInService(api: api) + + let result = try await service.postingTargets() + + XCTAssertEqual(result.targets.map(\.kind), [.personal, .orgPage, .personalPage]) + XCTAssertEqual(result.targets[1].pageRecordId, "page-1") + XCTAssertEqual(result.targets[1].linkedInPageId, "12345678") + XCTAssertEqual(result.targets[1].avatarURL?.absoluteString, "https://cdn/l.png") + XCTAssertEqual(result.targets[2].pageRecordId, "page-2") + XCTAssertTrue(result.targets[1].isCompanyPage) + XCTAssertFalse(result.targets[0].isCompanyPage) + } + + func test_givenEnabledOrgPage_whenResolvingDefault_thenPrefersCompanyPage() { + // The behaviour the composer's "Posting as …" line depends on: an + // assigned org page outranks the personal profile. + let targets = LinkedInPostingTargets(targets: [ + LinkedInTarget(kind: .personal, label: "Alice Example", isEnabled: true), + LinkedInTarget(kind: .orgPage, label: "Acme Corp", isEnabled: true, pageRecordId: "page-1") + ]) + + XCTAssertEqual(targets.defaultDestination?.kind, .orgPage) + XCTAssertEqual(targets.defaultDestination?.label, "Acme Corp") + } + + func test_givenDisabledOrgPage_whenResolvingDefault_thenFallsBackToPersonal() { + // Invalid-for-this-purpose input: a switched-off page is not where the + // post goes, so naming it would be the same lie in reverse. + let targets = LinkedInPostingTargets(targets: [ + LinkedInTarget(kind: .personal, label: "Alice Example", isEnabled: true), + LinkedInTarget(kind: .orgPage, label: "Acme Corp", isEnabled: false, pageRecordId: "page-1") + ]) + + XCTAssertEqual(targets.defaultDestination?.kind, .personal) + } + + func test_givenNoTargets_whenResolvingDefault_thenReturnsNil() { + // Boundary: an unconnected account has no destination to name. + XCTAssertNil(LinkedInPostingTargets.empty.defaultDestination) + } + + func test_givenOnlyUnknownKind_whenResolvingDefault_thenStillNamesSomething() { + // Boundary: an unrecognised kind is still a destination the server + // will post to, so the line shows it rather than going blank. + let targets = LinkedInPostingTargets(targets: [ + LinkedInTarget(kind: .other, label: "Mystery", isEnabled: true) + ]) + + XCTAssertEqual(targets.defaultDestination?.label, "Mystery") + } + + func test_givenTwoPagesSharingALabel_whenListed_thenIdentitiesStayDistinct() { + // Boundary: identity keys on the record id, so same-named pages do not + // collapse into one row in an Identifiable list. + let a = LinkedInTarget(kind: .orgPage, label: "Acme", pageRecordId: "page-1") + let b = LinkedInTarget(kind: .orgPage, label: "Acme", pageRecordId: "page-2") + + XCTAssertNotEqual(a.id, b.id) + } } + diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLifecycleServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLifecycleServiceTests.swift new file mode 100644 index 0000000..3c8545b --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLifecycleServiceTests.swift @@ -0,0 +1,375 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for the organization **lifecycle** verbs added in +/// work-consolidation.md G25: delete, leave, and suspend/restore a member — +/// plus the client-side preconditions (`OrgOwnershipRules`) that make the +/// last-owner and system-org rules produce a specific message instead of a +/// bare server rejection. +/// +/// The rules under test come from `/help/organizations`: +/// - "The last remaining owner cannot be demoted or removed." +/// - "You cannot leave the system 'The Public' organization." +/// - "Only an owner can delete an organization." +final class OrgLifecycleServiceTests: XCTestCase { + + // MARK: - Fixtures + + private func org( + id: String = "o1", + name: String = "Bikey Life", + isSystem: Bool = false + ) -> Organization { + Organization(id: id, name: name, isPublic: true, isSystem: isSystem) + } + + private func member( + _ id: String, + _ role: OrgRole, + active: Bool? = true, + displayName: String? = nil + ) -> OrgMember { + OrgMember(userId: id, role: role, active: active, displayName: displayName) + } + + private var publicOrg: Organization { + org(id: "00000000-0000-0000-0000-000000000001", name: "The Public", isSystem: true) + } + + // MARK: - delete + + func test_givenOwner_whenDeletingOrg_thenSendsDeleteToTheOrgPath() async throws { + // Happy path. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.delete(id: "o1", callerRole: .owner) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1") + } + + func test_givenAdmin_whenDeletingOrg_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: delete is owner-only. Assert no request was made — + // the point of the precondition is to not ask the server at all. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.delete(id: "o1", callerRole: .admin) + XCTFail("Expected onlyOwnerCanDelete") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .onlyOwnerCanDelete) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A rejected delete must not hit the network") + } + + func test_givenUnknownRole_whenDeletingOrg_thenRejects() async throws { + // Boundary: an unknown caller role is not an owner. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.delete(id: "o1", callerRole: nil) + XCTFail("Expected onlyOwnerCanDelete") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .onlyOwnerCanDelete) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenServerFailure_whenDeletingOrg_thenSurfacesAPIError() async throws { + // Upstream API failure: the typed APIError reaches the caller intact. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = OrgService(api: api) + + do { + try await service.delete(id: "o1", callerRole: .owner) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - leave + + func test_givenOrdinaryOrg_whenLeaving_thenDeletesOwnMembership() async throws { + // Happy path. There is no /leave route — leaving is removing yourself, + // which is exactly what the web client does. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.leave( + organization: org(), + userId: "u1", + members: [member("u1", .member), member("u2", .owner)] + ) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1/members/u1") + } + + func test_givenSystemOrg_whenLeaving_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: nobody leaves "The Public". + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.leave( + organization: publicOrg, + userId: "u1", + members: [member("u1", .member)] + ) + XCTFail("Expected cannotLeaveSystemOrganization") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .cannotLeaveSystemOrganization(name: "The Public")) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "Leaving the system org must not hit the network") + } + + func test_givenLastOwner_whenLeaving_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: the last owner cannot leave until another owner exists. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.leave( + organization: org(), + userId: "u1", + members: [member("u1", .owner), member("u2", .member)] + ) + XCTFail("Expected lastOwnerCannotLeave") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .lastOwnerCannotLeave) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenSecondOwnerExists_whenOwnerLeaves_thenAllowed() async throws { + // Boundary: exactly two owners — the rule releases at the second one. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.leave( + organization: org(), + userId: "u1", + members: [member("u1", .owner), member("u2", .owner)] + ) + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenUnloadedRoster_whenLeaving_thenDefersToTheServer() async throws { + // Boundary: an empty roster means "unknown", not "no owners". The + // precondition must not block on data the client does not have. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.leave(organization: org(), userId: "u1", members: []) + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1, "An unknown roster lets the server decide") + } + + func test_givenBlankUserId_whenLeaving_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: no signed-in user id to remove. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.leave(organization: org(), userId: "", members: []) + XCTFail("Expected unknownCurrentUser") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .unknownCurrentUser) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenServerFailure_whenLeaving_thenSurfacesAPIError() async throws { + // Upstream API failure. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = OrgService(api: api) + + do { + try await service.leave(organization: org(), userId: "u1", members: []) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - suspend / restore + + func test_givenActiveMember_whenSuspending_thenSendsActiveFalseWithExistingRole() async throws { + // Happy path. The update body requires a role; re-sending the member's + // current role is what keeps a suspend from changing it by omission. + let api = StubAPIClient() + await api.enqueue(json: #""" + {"message":"ok","membership":{"id":"m1","userId":"u2","organizationId":"o1", + "role":"admin","active":false}} + """#) + let service = OrgService(api: api) + let target = member("u2", .admin, displayName: "Bea") + + let updated = try await service.setMemberSuspended( + in: "o1", + member: target, + suspended: true, + members: [member("u1", .owner), target] + ) + + XCTAssertTrue(updated.isSuspended) + XCTAssertEqual(updated.role, .admin) + XCTAssertEqual(updated.displayName, "Bea", "Identity survives the membership envelope") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PUT") + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1/members/u2") + } + + func test_givenSuspendedMember_whenRestoring_thenSendsActiveTrue() async throws { + // Happy path in the other direction; restoring is never blocked. + let api = StubAPIClient() + await api.enqueue(json: #""" + {"message":"ok","membership":{"id":"m1","userId":"u1","organizationId":"o1", + "role":"owner","active":true}} + """#) + let service = OrgService(api: api) + let onlyOwner = member("u1", .owner, active: false) + + let updated = try await service.setMemberSuspended( + in: "o1", + member: onlyOwner, + suspended: false, + members: [onlyOwner] + ) + + XCTAssertFalse(updated.isSuspended) + } + + func test_givenLastOwner_whenSuspending_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: suspending the only owner strands the org. + let api = StubAPIClient() + let service = OrgService(api: api) + let onlyOwner = member("u1", .owner) + + do { + _ = try await service.setMemberSuspended( + in: "o1", + member: onlyOwner, + suspended: true, + members: [onlyOwner, member("u2", .member)] + ) + XCTFail("Expected lastOwnerCannotBeSuspended") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .lastOwnerCannotBeSuspended) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenServerFailure_whenSuspending_thenSurfacesAPIError() async throws { + // Upstream API failure. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = OrgService(api: api) + + do { + _ = try await service.setMemberSuspended( + in: "o1", + member: member("u2", .member), + suspended: true, + members: [member("u1", .owner), member("u2", .member)] + ) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - OrgOwnershipRules (the pure predicates) + + func test_givenOneOwnerAndOneMember_whenCheckingRules_thenOnlyTheOwnerIsProtected() { + // Boundary case the issue calls out explicitly: exactly one owner, + // one member. + let owner = member("u1", .owner) + let plain = member("u2", .member) + let roster = [owner, plain] + + XCTAssertEqual(OrgOwnershipRules.ownerCount(in: roster), 1) + XCTAssertTrue(OrgOwnershipRules.isLastOwner(userId: "u1", in: roster)) + XCTAssertFalse(OrgOwnershipRules.isLastOwner(userId: "u2", in: roster)) + + XCTAssertEqual( + OrgOwnershipRules.validateRoleChange(member: owner, to: .member, members: roster), + .lastOwnerCannotBeDemoted + ) + XCTAssertEqual( + OrgOwnershipRules.validateRemoval(member: owner, members: roster), + .lastOwnerCannotBeDemoted + ) + XCTAssertNil(OrgOwnershipRules.validateRemoval(member: plain, members: roster)) + } + + func test_givenLastOwner_whenPromotingThem_thenAllowed() { + // A no-op / promotion is never a demotion. + let owner = member("u1", .owner) + XCTAssertNil( + OrgOwnershipRules.validateRoleChange(member: owner, to: .owner, members: [owner]) + ) + } + + func test_givenRosterWithNoOwner_whenCheckingLastOwner_thenDoesNotBlock() { + // Boundary: a partial roster (no owner on this page) must not be read + // as "there are no owners". + let roster = [member("u2", .member), member("u3", .admin)] + XCTAssertFalse(OrgOwnershipRules.isLastOwner(userId: "u2", in: roster)) + XCTAssertNil(OrgOwnershipRules.validateRemoval(member: roster[0], members: roster)) + } + + func test_givenSuspendedLastOwner_whenCountingOwners_thenStillCounts() { + // A suspended owner still holds the role, so they still protect the + // org from being left ownerless. + let roster = [member("u1", .owner, active: false), member("u2", .member)] + XCTAssertEqual(OrgOwnershipRules.ownerCount(in: roster), 1) + XCTAssertTrue(OrgOwnershipRules.isLastOwner(userId: "u1", in: roster)) + } + + func test_givenEveryLifecycleError_whenDescribed_thenHasAUserFacingSentence() { + // Boundary: no error may reach the UI without a message, since these + // exist purely to be shown to the user. + let errors: [OrgLifecycleError] = [ + .cannotLeaveSystemOrganization(name: "The Public"), + .lastOwnerCannotLeave, + .lastOwnerCannotBeDemoted, + .lastOwnerCannotBeSuspended, + .onlyOwnerCanDelete, + .unknownCurrentUser, + .linkedInRequiresOwnerOrAdmin + ] + for error in errors { + XCTAssertFalse( + (error.errorDescription ?? "").isEmpty, + "\(error) has no user-facing description" + ) + } + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift new file mode 100644 index 0000000..9431180 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift @@ -0,0 +1,314 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for the **organization** LinkedIn surface +/// (work-consolidation.md G25): credential status, page sync, per-member page +/// assignment, and disconnect. +/// +/// Two shapes of the status body are exercised deliberately. Only the first +/// was observed live (`{"credential":null,"role":"member"}` — the test account +/// is a plain member of an org with no shared credential); the second is the +/// one `/help/api/organizations` documents (`{connected, expiresAt}` plus +/// pages). The client accepts both rather than betting on either. +final class OrgLinkedInServiceTests: XCTestCase { + + // MARK: - status + + func test_givenConnectedOrg_whenReadingStatus_thenMapsPagesAndAssignments() async throws { + // Happy path, documented shape. + let api = StubAPIClient() + await api.enqueue(json: #""" + {"connected":true,"expiresAt":"2026-12-01T00:00:00.000Z","role":"owner", + "pages":[{"id":"p1","linkedInPageId":"12345678","pageName":"Acme Corp", + "pageLogoUrl":"https://cdn/l.png"}], + "assignments":[{"userId":"u2","pageId":"p1","pageName":"Acme Corp"}]} + """#) + let service = OrgService(api: api) + + let status = try await service.linkedInStatus(of: "o1") + + XCTAssertTrue(status.isConnected) + XCTAssertEqual(status.callerRole, .owner) + XCTAssertTrue(status.callerCanManage) + XCTAssertEqual(status.pages.map(\.name), ["Acme Corp"]) + XCTAssertEqual(status.pages.first?.linkedInPageId, "12345678") + XCTAssertEqual(status.assignedPage(for: "u2")?.id, "p1") + XCTAssertNil(status.assignedPage(for: "u3")) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "GET") + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1/linkedin/status") + } + + func test_givenLiveDisconnectedShape_whenReadingStatus_thenReportsDisconnected() async throws { + // Boundary: the exact body observed live. A null credential with no + // `connected` boolean must read as "not connected", and a member must + // not be offered the management controls. + let api = StubAPIClient() + await api.enqueue(json: #"{"credential":null,"role":"member"}"#) + let service = OrgService(api: api) + + let status = try await service.linkedInStatus(of: "o1") + + XCTAssertFalse(status.isConnected) + XCTAssertEqual(status.callerRole, .member) + XCTAssertFalse(status.callerCanManage) + XCTAssertTrue(status.pages.isEmpty) + XCTAssertTrue(status.assignments.isEmpty) + } + + func test_givenNestedCredential_whenReadingStatus_thenInfersConnectedAndExpiry() async throws { + // Boundary between the observed and documented spellings. + let api = StubAPIClient() + await api.enqueue(json: #""" + {"credential":{"expiresAt":"2026-12-01T00:00:00.000Z"},"role":"admin","pages":[]} + """#) + let service = OrgService(api: api) + + let status = try await service.linkedInStatus(of: "o1") + + XCTAssertTrue(status.isConnected) + XCTAssertNotNil(status.expiresAt) + XCTAssertTrue(status.callerCanManage, "Admins manage the org credential too") + } + + func test_givenServerFailure_whenReadingStatus_thenSurfacesAPIError() async throws { + // Upstream API failure. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 502, serverMessage: "linkedin down")) + let service = OrgService(api: api) + + do { + _ = try await service.linkedInStatus(of: "o1") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 502, serverMessage: "linkedin down")) + } + } + + func test_givenUnknownRoleInStatus_whenGating_thenHidesManagement() async throws { + // Boundary: an unrecognised role is not a manager. Management controls + // stay hidden rather than rendering an action the server will reject. + let api = StubAPIClient() + await api.enqueue(json: #"{"credential":null,"role":"guest"}"#) + let service = OrgService(api: api) + + let status = try await service.linkedInStatus(of: "o1") + + XCTAssertEqual(status.callerRole, .other("guest")) + XCTAssertFalse(status.callerCanManage) + } + + // MARK: - sync-pages + + func test_givenOwner_whenSyncingPages_thenPostsThenRereadsStatus() async throws { + // Happy path. The 201 body is unmodelled upstream, so the service + // ignores it and re-reads status — the only route that returns pages. + let api = StubAPIClient() + await api.enqueue(json: "{}") + await api.enqueue(json: #""" + {"connected":true,"role":"owner", + "pages":[{"id":"p1","pageName":"Acme Corp"},{"id":"p2","pageName":"Acme Labs"}]} + """#) + let service = OrgService(api: api) + + let status = try await service.syncLinkedInPages(of: "o1", callerRole: .owner) + + XCTAssertEqual(status.pages.map(\.name), ["Acme Corp", "Acme Labs"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.map(\.method), ["POST", "GET"]) + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1/linkedin/sync-pages") + XCTAssertEqual(recorded.last?.path, "/api/organizations/o1/linkedin/status") + } + + func test_givenMember_whenSyncingPages_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: sync is owner/admin only. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + _ = try await service.syncLinkedInPages(of: "o1", callerRole: .member) + XCTFail("Expected linkedInRequiresOwnerOrAdmin") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .linkedInRequiresOwnerOrAdmin) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A rejected sync must not hit the network") + } + + func test_givenSyncFailure_whenSyncingPages_thenSurfacesAPIErrorAndDoesNotReread() async throws { + // Upstream API failure: the follow-up read must not run after a failed + // sync, so the caller can keep showing its stale page list. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 502, serverMessage: "linkedin down")) + let service = OrgService(api: api) + + do { + _ = try await service.syncLinkedInPages(of: "o1", callerRole: .owner) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 502, serverMessage: "linkedin down")) + } + + let recorded = await api.recorded + XCTAssertEqual(recorded.map(\.method), ["POST"], "No status re-read after a failed sync") + } + + func test_givenSyncReturningNoPages_whenSyncing_thenReturnsEmptyPageList() async throws { + // Boundary: a connected credential that administers zero pages. + let api = StubAPIClient() + await api.enqueue(json: "{}") + await api.enqueue(json: #"{"connected":true,"role":"owner","pages":[]}"#) + let service = OrgService(api: api) + + let status = try await service.syncLinkedInPages(of: "o1", callerRole: .owner) + + XCTAssertTrue(status.pages.isEmpty) + XCTAssertTrue(status.isConnected) + } + + // MARK: - assignments + + func test_givenAdmin_whenAssigningPage_thenPutsUserIdAndPageId() async throws { + // Happy path. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: "p1", callerRole: .admin) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PUT") + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1/linkedin/assignments") + } + + func test_givenNilPageId_whenAssigning_thenClearsTheAssignment() async throws { + // Boundary: clearing an assignment sends a null page reference. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: nil, callerRole: .owner) + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenMember_whenAssigningPage_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: assignment is owner/admin only. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: "p1", callerRole: .member) + XCTFail("Expected linkedInRequiresOwnerOrAdmin") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .linkedInRequiresOwnerOrAdmin) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenBlankUserId_whenAssigningPage_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: an assignment needs someone to assign to. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.assignLinkedInPage(in: "o1", userId: "", pageId: "p1", callerRole: .owner) + XCTFail("Expected unknownCurrentUser") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .unknownCurrentUser) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenServerFailure_whenAssigningPage_thenSurfacesAPIError() async throws { + // Upstream API failure. + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "unknown page")) + let service = OrgService(api: api) + + do { + try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: "nope", callerRole: .owner) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "unknown page")) + } + } + + // MARK: - disconnect + + func test_givenOwner_whenDisconnecting_thenDeletesTheCredential() async throws { + // Happy path. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = OrgService(api: api) + + try await service.disconnectLinkedIn(from: "o1", callerRole: .owner) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/organizations/o1/linkedin/credential") + } + + func test_givenMember_whenDisconnecting_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: disconnect is owner/admin only — and it silently + // redirects every assigned member's cross-posts, so the gate matters. + let api = StubAPIClient() + let service = OrgService(api: api) + + do { + try await service.disconnectLinkedIn(from: "o1", callerRole: .member) + XCTFail("Expected linkedInRequiresOwnerOrAdmin") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .linkedInRequiresOwnerOrAdmin) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenServerFailure_whenDisconnecting_thenSurfacesAPIError() async throws { + // Upstream API failure. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = OrgService(api: api) + + do { + try await service.disconnectLinkedIn(from: "o1", callerRole: .owner) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - page mapping + authorize URL + + func test_givenPageWithoutAName_whenMapping_thenFallsBackSoRowIsNeverBlank() { + // Boundary: name → LinkedIn page id → record id. + let named = OrgLinkedInPage(from: OrgLinkedInPageDTO(id: "p1", pageName: "Acme")) + XCTAssertEqual(named.name, "Acme") + + let byLinkedInId = OrgLinkedInPage(from: OrgLinkedInPageDTO(id: "p2", linkedInPageId: "999")) + XCTAssertEqual(byLinkedInId.name, "999") + + let bare = OrgLinkedInPage(from: OrgLinkedInPageDTO(id: "p3")) + XCTAssertEqual(bare.name, "p3") + } + + func test_givenOrgId_whenBuildingAuthorizeURL_thenTargetsOrgAuthorizeRoute() throws { + // The connect flow is a browser redirect (x-auth-type: none), so the + // client opens this URL rather than calling it. + let url = try XCTUnwrap( + OrgLinkedInAuthorization.url( + baseURL: URL(string: "https://interlinedlist.com")!, + organizationId: "o1" + ) + ) + XCTAssertEqual(url.path, "/api/auth/linkedin/org-authorize") + XCTAssertEqual(url.query, "organizationId=o1") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgMappersTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgMappersTests.swift index fdb24e2..8d37123 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgMappersTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgMappersTests.swift @@ -171,4 +171,104 @@ final class OrgMappersTests: XCTestCase { XCTAssertFalse(membership.organization.isPublic) XCTAssertEqual(membership.role, .member) } + + // MARK: - Membership context (work-consolidation.md G25) + + func test_givenLiveOrgRow_whenMapping_thenCarriesSystemFlagAndMemberCount() { + // Happy path: the fields the live collection routes add. + let org = Organization(from: OrganizationDTO( + id: "o1", + name: "The Public", + isPublic: true, + slug: "the-public", + isSystem: true, + memberCount: 22 + )) + + XCTAssertTrue(org.isSystem) + XCTAssertEqual(org.slug, "the-public") + XCTAssertEqual(org.memberCount, 22) + } + + func test_givenLeanOrgRow_whenMapping_thenDefaultsToOrdinaryLeavableOrg() { + // Boundary: a response without the flag must not make an ordinary org + // un-leavable. + let org = Organization(from: OrganizationDTO(id: "o1", name: "Acme")) + + XCTAssertFalse(org.isSystem) + XCTAssertNil(org.memberCount) + XCTAssertNil(org.slug) + } + + func test_givenRowWithOnlyUserRole_whenResolvingCallerRole_thenFallsBack() { + // The live routes send `role` and `userRole` with the same value; + // either alone still resolves. + let byRole = Organization.callerRole(from: OrganizationDTO(id: "o", name: "n", role: "owner")) + XCTAssertEqual(byRole, .owner) + + let byUserRole = Organization.callerRole(from: OrganizationDTO(id: "o", name: "n", userRole: "admin")) + XCTAssertEqual(byUserRole, .admin) + + // Boundary: a non-member row carries neither. + XCTAssertNil(Organization.callerRole(from: OrganizationDTO(id: "o", name: "n"))) + } + + func test_givenLiveMemberRow_whenMapping_thenCarriesIdentityAndSuspension() { + // Happy path for the corrected members shape. + let member = OrgMember(from: OrganizationMemberDTO( + userId: "u1", + role: "owner", + active: false, + username: "adron", + displayName: "Adron Hall", + avatar: "https://cdn/a.jpg", + emailVerified: true + )) + + XCTAssertEqual(member.displayLabel, "Adron Hall") + XCTAssertEqual(member.username, "adron") + XCTAssertEqual(member.avatarURL?.absoluteString, "https://cdn/a.jpg") + XCTAssertTrue(member.isSuspended) + } + + func test_givenBlankAvatarString_whenMapping_thenTreatsItAsAbsent() { + // Boundary: the live rows use "" for "no avatar", not null. + let member = OrgMember(from: OrganizationMemberDTO( + userId: "u2", role: "member", avatar: "" + )) + + XCTAssertNil(member.avatarURL) + } + + func test_givenMemberWithNoNames_whenLabelling_thenFallsBackToId() { + // Boundary: display name → handle → raw id, so a row is never blank. + let onlyHandle = OrgMember(from: OrganizationMemberDTO(userId: "u3", role: "member", username: "solo")) + XCTAssertEqual(onlyHandle.displayLabel, "solo") + + let bare = OrgMember(from: OrganizationMemberDTO(userId: "u4", role: "member")) + XCTAssertEqual(bare.displayLabel, "u4") + } + + func test_givenMissingActiveFlag_whenMapping_thenNotTreatedAsSuspended() { + // Invalid/absent input: missing data must not paint a member as + // suspended. + let member = OrgMember(from: OrganizationMemberDTO(userId: "u5", role: "member")) + XCTAssertFalse(member.isSuspended) + } + + func test_givenSystemOrgMembership_whenCheckingLeavable_thenRefuses() { + // The row-level half of the leave rule. + let system = UserOrganization( + organization: Organization(id: "p", name: "The Public", isSystem: true), + role: .member + ) + let ordinary = UserOrganization( + organization: Organization(id: "o", name: "Acme"), + role: .member + ) + + XCTAssertFalse(system.isLeavable) + XCTAssertTrue(ordinary.isLeavable) + } } + diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/UserServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/UserServiceTests.swift index 62e1a29..ed26270 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/UserServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/UserServiceTests.swift @@ -156,6 +156,73 @@ final class UserServiceTests: XCTestCase { XCTAssertTrue(memberships.isEmpty) } + // MARK: - join an organization (work-consolidation.md G25) + + func test_givenPublicOrg_whenJoining_thenPostsThenRereadsMemberships() async throws { + // Happy path. The 201 body is unmodelled upstream, so the service + // ignores it and re-reads the list — which also refreshes the cache. + let api = StubAPIClient() + await api.enqueue(json: "{}") + await api.enqueue(json: Fixtures.userOrganizationsEnvelope([ + Fixtures.userOrganizationObject(id: "o-1", name: "Acme", role: "member") + ])) + let service = UserService(api: api) + + let memberships = try await service.joinOrganization(id: "o-1") + + XCTAssertEqual(memberships.map(\.id), ["o-1"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.map(\.method), ["POST", "GET"]) + XCTAssertEqual(recorded.first?.path, "/api/user/organizations") + XCTAssertEqual(recorded.last?.path, "/api/user/organizations") + } + + func test_givenBlankOrgId_whenJoining_thenRejectsBeforeAnyServiceCall() async throws { + // Invalid input: nothing to join. + let api = StubAPIClient() + let service = UserService(api: api) + + do { + _ = try await service.joinOrganization(id: " ") + XCTFail("Expected an OrgLifecycleError") + } catch let error as OrgLifecycleError { + XCTAssertEqual(error, .unknownCurrentUser) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A rejected join must not hit the network") + } + + func test_givenJoinFails_whenJoining_thenSurfacesAPIErrorAndDoesNotReread() async throws { + // Upstream API failure: no follow-up read after a failed join. + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "private org")) + let service = UserService(api: api) + + do { + _ = try await service.joinOrganization(id: "o-1") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "private org")) + } + + let recorded = await api.recorded + XCTAssertEqual(recorded.map(\.method), ["POST"]) + } + + func test_givenJoinIntoNoMemberships_whenJoining_thenReturnsEmptyList() async throws { + // Boundary: the re-read comes back empty (the join did not stick). + // The caller sees the truth rather than an optimistic row. + let api = StubAPIClient() + await api.enqueue(json: "{}") + await api.enqueue(json: Fixtures.userOrganizationsEnvelope([])) + let service = UserService(api: api) + + let memberships = try await service.joinOrganization(id: "o-1") + + XCTAssertTrue(memberships.isEmpty) + } + // MARK: - organizations SWR cache func test_givenStore_whenLoadingOrganizations_thenWritesThroughToCache() async throws { diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift index 8a013fe..af5d3d5 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift @@ -8,31 +8,73 @@ import Foundation // GET /api/linkedin/targets // -> { targets: [{ kind, label, avatarUrl }] } // -// `kind` is "personal" or "org". Org targets require the LinkedIn org scope, -// which is not enabled for every tenant (`orgScopeMissing` / status -// `orgScopesEnabled:false`) — the org half is tracked separately as G11b. +// CORRECTED 2026-09-09 (work-consolidation.md G25) from the published +// reference at /help/api/linkedin-integration. `kind` is one of THREE tokens, +// not two: +// +// { "kind": "personal", "label": "Alice Example", "avatarUrl": "…" } +// { "kind": "orgPage", "pageId": "", "linkedInPageId": "12345678", +// "label": "Acme Corp", "logoUrl": "…" } +// { "kind": "personalPage", "personalPageId": "", "linkedInPageId": "87654321", +// "label": "Alice's Studio", "logoUrl": "…" } +// +// The model shipped with only `personal` / `org`, so a real `orgPage` row fell +// through to the unknown-token case and its page identity (`pageId`, +// `linkedInPageId`) and logo were dropped entirely. That matters beyond +// cosmetics: an org page assignment silently redirects a member's ordinary +// LinkedIn cross-post to the company page, so a client that cannot see the +// `orgPage` kind will tell the user they are posting to their own profile +// while the server publishes to a company page. +// +// `orgScopeMissing` is true when the user has a LinkedIn connection whose +// stored scope lacks `rw_organization_admin`. /// A LinkedIn posting target the user can cross-post to. public struct LinkedInTargetDTO: Decodable, Sendable, Equatable { public let kind: String public let label: String + /// Personal-profile avatar. Only the `personal` kind carries this. public let avatarUrl: String? /// Present on `posting-targets` (whether the target is currently enabled). public let enabled: Bool? - public init(kind: String, label: String, avatarUrl: String? = nil, enabled: Bool? = nil) { + /// `OrgLinkedInPage` record id — present on the `orgPage` kind. This is + /// the id that goes in a message's `linkedInTargets`. + public let pageId: String? + /// `LinkedInPersonalPage` record id — present on the `personalPage` kind. + public let personalPageId: String? + /// LinkedIn's own page identifier, on either page kind. + public let linkedInPageId: String? + /// Page logo, on either page kind (pages use `logoUrl`, not `avatarUrl`). + public let logoUrl: String? + + public init( + kind: String, + label: String, + avatarUrl: String? = nil, + enabled: Bool? = nil, + pageId: String? = nil, + personalPageId: String? = nil, + linkedInPageId: String? = nil, + logoUrl: String? = nil + ) { self.kind = kind self.label = label self.avatarUrl = avatarUrl self.enabled = enabled + self.pageId = pageId + self.personalPageId = personalPageId + self.linkedInPageId = linkedInPageId + self.logoUrl = logoUrl } } /// `GET /api/linkedin/posting-targets` response. public struct LinkedInPostingTargetsResponse: Decodable, Sendable, Equatable { public let targets: [LinkedInTargetDTO] - /// `true` when the LinkedIn org scope is not granted, so org page targets - /// are unavailable (G11b — deferred). + /// `true` when the LinkedIn connection's stored scope lacks + /// `rw_organization_admin`, so page targets are unavailable. The fix is to + /// reconnect via `GET /api/auth/linkedin/authorize?link=true`. public let orgScopeMissing: Bool? public init(targets: [LinkedInTargetDTO], orgScopeMissing: Bool? = nil) { diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrgLinkedInDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrgLinkedInDTO.swift new file mode 100644 index 0000000..61c37d7 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrgLinkedInDTO.swift @@ -0,0 +1,229 @@ +import Foundation + +// MARK: - Organization LinkedIn DTOs (work-consolidation.md G25) +// +// The organization-bound half of LinkedIn: an owner or admin connects one +// shared org credential, LinkedIn's company pages are stored on the org, and +// members are assigned individual pages to publish to. +// +// **What was verified, and how** (2026-09-09, read-only — the account is +// shared with concurrent sessions, so no write was ever issued): +// +// OPTIONS /api/organizations/{id}/linkedin/status -> Allow: GET, HEAD, OPTIONS +// OPTIONS /api/organizations/{id}/linkedin/sync-pages -> Allow: OPTIONS, POST +// OPTIONS /api/organizations/{id}/linkedin/assignments -> Allow: OPTIONS, PUT +// OPTIONS /api/organizations/{id}/linkedin/credential -> Allow: DELETE, OPTIONS +// +// `GET /api/openapi.json` agrees exactly, and marks all four +// `x-auth-type: sync-token` — so they are Bearer routes, not session-only. +// +// ⚠️ `/help/api/organizations` additionally documents `GET` on `sync-pages` +// and `assignments`. **Those GETs are not deployed**: both answer HTTP 405 +// live, and the OpenAPI document lists only the write verb for each. The help +// page is stale; OPTIONS + OpenAPI agree with each other and with the server. +// The practical consequence is that the discovered page list and the current +// assignments can only be read from `linkedin/status` — there is no separate +// read route — which is why `OrgLinkedInStatusResponse` below is the one type +// that has to carry them. +// +// The one live body observed (test account is a plain `member` of the org, +// and no org credential is connected on this tenant): +// +// GET /api/organizations/{id}/linkedin/status +// -> {"credential":null,"role":"member"} +// +// A *connected* status body could not be observed — no org reachable from the +// test account has a credential. `/help/api/organizations` describes it as +// `{ "connected": true, "expiresAt": "..." }` "plus the discovered pages". +// Rather than guess one spelling, every field below is optional and both +// spellings are accepted, so the type decodes the observed body, the +// documented body, or a mixture, and can never fail closed on a shape the +// client has not seen. `isConnected` folds the two spellings into one answer. + +// MARK: - OrgLinkedInPageDTO + +/// A LinkedIn company page discovered for an organization. +/// +/// Field names follow the personal-page shape documented on +/// `/help/api/linkedin-integration` (`id` / `linkedInPageId` / `pageName` / +/// `pageLogoUrl` / `lastSyncedAt`), with `name` and `logoUrl` accepted as +/// alternates because the org-scoped page shape is not separately documented. +public struct OrgLinkedInPageDTO: Codable, Sendable, Equatable, Identifiable { + + /// The InterlinedList `OrgLinkedInPage` record id — the value that goes in + /// an assignment's `pageId`. + public let id: String + /// LinkedIn's own identifier for the page. + public let linkedInPageId: String? + /// Display name of the company page. + public let pageName: String? + /// Page logo URL. + public let pageLogoUrl: String? + /// When the page was last re-discovered from LinkedIn. + public let lastSyncedAt: Date? + + public init( + id: String, + linkedInPageId: String? = nil, + pageName: String? = nil, + pageLogoUrl: String? = nil, + lastSyncedAt: Date? = nil + ) { + self.id = id + self.linkedInPageId = linkedInPageId + self.pageName = pageName + self.pageLogoUrl = pageLogoUrl + self.lastSyncedAt = lastSyncedAt + } + + private enum CodingKeys: String, CodingKey { + case id, pageId + case linkedInPageId + case pageName, name, label + case pageLogoUrl, logoUrl + case lastSyncedAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let id = try c.decodeIfPresent(String.self, forKey: .id) { + self.id = id + } else { + self.id = try c.decode(String.self, forKey: .pageId) + } + linkedInPageId = try c.decodeIfPresent(String.self, forKey: .linkedInPageId) + pageName = try c.decodeIfPresent(String.self, forKey: .pageName) + ?? c.decodeIfPresent(String.self, forKey: .name) + ?? c.decodeIfPresent(String.self, forKey: .label) + pageLogoUrl = try c.decodeIfPresent(String.self, forKey: .pageLogoUrl) + ?? c.decodeIfPresent(String.self, forKey: .logoUrl) + lastSyncedAt = try c.decodeIfPresent(Date.self, forKey: .lastSyncedAt) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encodeIfPresent(linkedInPageId, forKey: .linkedInPageId) + try c.encodeIfPresent(pageName, forKey: .pageName) + try c.encodeIfPresent(pageLogoUrl, forKey: .pageLogoUrl) + try c.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt) + } +} + +// MARK: - OrgLinkedInAssignmentDTO + +/// Which member is assigned to publish to which company page. +public struct OrgLinkedInAssignmentDTO: Codable, Sendable, Equatable { + public let userId: String + public let pageId: String? + /// Denormalized page name, when the server includes it. + public let pageName: String? + + public init(userId: String, pageId: String? = nil, pageName: String? = nil) { + self.userId = userId + self.pageId = pageId + self.pageName = pageName + } +} + +// MARK: - OrgLinkedInCredentialDTO + +/// The shared org credential, when one is connected. +public struct OrgLinkedInCredentialDTO: Codable, Sendable, Equatable { + /// When the stored LinkedIn token expires. + public let expiresAt: Date? + /// Who connected the credential, when the server says. + public let connectedByUserId: String? + public let connectedAt: Date? + + public init( + expiresAt: Date? = nil, + connectedByUserId: String? = nil, + connectedAt: Date? = nil + ) { + self.expiresAt = expiresAt + self.connectedByUserId = connectedByUserId + self.connectedAt = connectedAt + } +} + +// MARK: - OrgLinkedInStatusResponse + +/// `GET /api/organizations/[id]/linkedin/status`. +/// +/// Observed live as `{"credential":null,"role":"member"}`; documented as +/// `{ "connected": true, "expiresAt": "..." }` plus the discovered pages. +/// Every field is optional so either body — or a mixture — decodes. +public struct OrgLinkedInStatusResponse: Codable, Sendable, Equatable { + + /// The connected credential. `nil` (and present as JSON `null`) when the + /// org has no shared LinkedIn credential. This is the **observed** shape. + public let credential: OrgLinkedInCredentialDTO? + + /// The caller's role in the org, as the status route reports it. Lets the + /// client gate the connect / disconnect controls without a second read. + public let role: String? + + /// The documented boolean. Absent from the observed body. + public let connected: Bool? + + /// Credential expiry, when the server flattens it onto the status instead + /// of nesting it under `credential`. + public let expiresAt: Date? + + /// The company pages stored on the org. Absent when nothing is connected. + /// This is the only route that returns them — `GET` on `sync-pages` is 405. + public let pages: [OrgLinkedInPageDTO]? + + /// Current page assignments, when the status route includes them. + public let assignments: [OrgLinkedInAssignmentDTO]? + + public init( + credential: OrgLinkedInCredentialDTO? = nil, + role: String? = nil, + connected: Bool? = nil, + expiresAt: Date? = nil, + pages: [OrgLinkedInPageDTO]? = nil, + assignments: [OrgLinkedInAssignmentDTO]? = nil + ) { + self.credential = credential + self.role = role + self.connected = connected + self.expiresAt = expiresAt + self.pages = pages + self.assignments = assignments + } + + /// Whether the org has a usable shared credential. Prefers the explicit + /// `connected` boolean when the server sends it, and otherwise infers it + /// from the presence of `credential` — which is what the live body uses. + public var isConnected: Bool { + connected ?? (credential != nil) + } +} + +// MARK: - Request bodies + +/// `PUT /api/organizations/[id]/linkedin/assignments` body. +/// +/// VERIFIED 2026-09-09 from `GET /api/openapi.json`: the +/// `putOrganizationsByIdLinkedinAssignments` operation declares exactly two +/// body properties, `userId` and `pageId`, both strings. +/// +/// ⚠️ This contradicts `/help/api/organizations`, which calls the same route +/// "Replace the assignment map atomically". The OpenAPI document is generated +/// from the deployed handlers and has already been shown to beat the help page +/// on this resource (the help page also documents two GETs that answer 405), +/// so the client sends **one assignment per call**. Clearing an assignment is +/// modelled as a `nil` `pageId`; that is the least-surprising reading of a +/// nullable page reference, but it is the one part of this body that could not +/// be confirmed from either source. +public struct UpdateOrgLinkedInAssignmentRequest: Codable, Sendable, Equatable { + public let userId: String + public let pageId: String? + + public init(userId: String, pageId: String?) { + self.userId = userId + self.pageId = pageId + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift index 62f1ecb..8be79b6 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift @@ -2,7 +2,27 @@ import Foundation // MARK: - OrganizationDTO -/// An organization. Fields modelled `1:1` against the API reference. +/// An organization. +/// +/// The core fields (`id` … `updatedAt`) come from every organization route. +/// Everything below `slug` is **membership context** the live collection +/// routes add and the older `1:1`-with-the-reference model omitted; all of it +/// is optional so a route that answers the lean shape still decodes. +/// +/// VERIFIED live 2026-09-09 against `GET /api/organizations` and +/// `GET /api/user/organizations`, both of which answer rows like: +/// +/// ```json +/// { "id": "fb29220f-…", "name": "Bikey Life", "slug": "bikey-life", +/// "description": "…", "avatar": null, "isPublic": true, "isSystem": false, +/// "settings": null, "createdAt": "…", "updatedAt": "…", "deletedAt": null, +/// "role": "member", "joinedAt": "…", "userRole": "member", "memberCount": 3 } +/// ``` +/// +/// `isSystem` is the flag that marks "The Public" — the org everyone +/// auto-joins and **nobody may leave** — so it has to survive the wire. +/// `role` / `userRole` are absent on rows the caller does not belong to (the +/// browse list returns those too), which is why both are optional. public struct OrganizationDTO: Codable, Sendable, Equatable, Identifiable { public let id: String public let name: String @@ -11,13 +31,39 @@ public struct OrganizationDTO: Codable, Sendable, Equatable, Identifiable { public let createdAt: Date? public let updatedAt: Date? + /// URL-safe short name (`"bikey-life"`). + public let slug: String? + /// Org avatar URL. `null` for most orgs today. + public let avatar: String? + /// `true` for the system org ("The Public"). Absent on lean shapes. + public let isSystem: Bool? + /// Total members, when the route denormalizes the count onto the row. + public let memberCount: Int? + /// The caller's role in this org. Absent when the caller is not a member. + public let role: String? + /// Duplicate of `role` the server also emits. Kept so the client can fall + /// back if only one of the two is present on a given route. + public let userRole: String? + /// When the caller joined. Absent when the caller is not a member. + public let joinedAt: Date? + /// Soft-delete tombstone. Non-nil means the org is deleted server-side. + public let deletedAt: Date? + public init( id: String, name: String, description: String? = nil, isPublic: Bool? = nil, createdAt: Date? = nil, - updatedAt: Date? = nil + updatedAt: Date? = nil, + slug: String? = nil, + avatar: String? = nil, + isSystem: Bool? = nil, + memberCount: Int? = nil, + role: String? = nil, + userRole: String? = nil, + joinedAt: Date? = nil, + deletedAt: Date? = nil ) { self.id = id self.name = name @@ -25,30 +71,116 @@ public struct OrganizationDTO: Codable, Sendable, Equatable, Identifiable { self.isPublic = isPublic self.createdAt = createdAt self.updatedAt = updatedAt + self.slug = slug + self.avatar = avatar + self.isSystem = isSystem + self.memberCount = memberCount + self.role = role + self.userRole = userRole + self.joinedAt = joinedAt + self.deletedAt = deletedAt } } // MARK: - OrganizationMemberDTO /// A membership row from `GET /api/organizations/[id]/members`. -/// `role` is `"owner" | "admin" | "member"` (kept a free string at the kit -/// boundary; the Domain layer maps it to a typed role). +/// +/// VERIFIED live 2026-09-09 — and the shape this shipped with could **never +/// decode it**. The live row is keyed by `id`, not `userId`, and dates the +/// membership with `joinedAt`, not `createdAt`: +/// +/// ```json +/// { "id": "c65092fa-…", "username": "adron", "displayName": "Adron Hall", +/// "avatar": "https://…", "emailVerified": true, +/// "role": "owner", "active": true, "joinedAt": "2026-02-22T19:38:07.993Z" } +/// ``` +/// +/// The old model declared `userId: String` as non-optional, so every real +/// members response failed at the decoder with `keyNotFound(userId)` and the +/// roster never rendered — the same silent-decode-defect family as the +/// link-metadata G21 bug. +/// +/// The row also carries the member's **identity** (username / display name / +/// avatar), which is why the roster needs no second per-user lookup. +/// +/// Decoding is deliberately tolerant of both spellings: `id` **or** `userId` +/// for the user key, and `joinedAt` **or** `createdAt` for the timestamp. The +/// `/help/api/organizations` reference documents the `userId` spelling for the +/// membership envelope, so accepting both keeps one type usable for either +/// shape instead of failing closed on a server that changes its mind. public struct OrganizationMemberDTO: Codable, Sendable, Equatable { + + /// The member user's id. Wire key is `id` on the live listing; `userId` is + /// also accepted (the documented membership-envelope spelling). public let userId: String public let role: String public let active: Bool? + /// When the membership was created. Wire key is `joinedAt` on the live + /// listing; `createdAt` is also accepted. public let createdAt: Date? + // Identity, denormalized onto the row by the live listing. + public let username: String? + public let displayName: String? + public let avatar: String? + public let emailVerified: Bool? + public init( userId: String, role: String, active: Bool? = nil, - createdAt: Date? = nil + createdAt: Date? = nil, + username: String? = nil, + displayName: String? = nil, + avatar: String? = nil, + emailVerified: Bool? = nil ) { self.userId = userId self.role = role self.active = active self.createdAt = createdAt + self.username = username + self.displayName = displayName + self.avatar = avatar + self.emailVerified = emailVerified + } + + private enum CodingKeys: String, CodingKey { + case id, userId, role, active, joinedAt, createdAt + case username, displayName, avatar, emailVerified + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + // `id` is the live spelling; `userId` the documented one. Require one. + if let id = try c.decodeIfPresent(String.self, forKey: .id) { + userId = id + } else { + userId = try c.decode(String.self, forKey: .userId) + } + role = try c.decode(String.self, forKey: .role) + active = try c.decodeIfPresent(Bool.self, forKey: .active) + createdAt = try c.decodeIfPresent(Date.self, forKey: .joinedAt) + ?? c.decodeIfPresent(Date.self, forKey: .createdAt) + username = try c.decodeIfPresent(String.self, forKey: .username) + displayName = try c.decodeIfPresent(String.self, forKey: .displayName) + avatar = try c.decodeIfPresent(String.self, forKey: .avatar) + emailVerified = try c.decodeIfPresent(Bool.self, forKey: .emailVerified) + } + + /// Encodes with the live spellings (`id` / `joinedAt`) so a round-trip + /// through this type reproduces what the server actually sends. + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(userId, forKey: .id) + try c.encode(role, forKey: .role) + try c.encodeIfPresent(active, forKey: .active) + try c.encodeIfPresent(createdAt, forKey: .joinedAt) + try c.encodeIfPresent(username, forKey: .username) + try c.encodeIfPresent(displayName, forKey: .displayName) + try c.encodeIfPresent(avatar, forKey: .avatar) + try c.encodeIfPresent(emailVerified, forKey: .emailVerified) } } @@ -185,3 +317,23 @@ public struct UpdateOrganizationMemberRequest: Codable, Sendable, Equatable { self.active = active } } + +/// `POST /api/user/organizations` body — **join** an organization. +/// +/// VERIFIED 2026-09-09 from two independent read-only sources, because the +/// route could not be exercised without a real write: +/// 1. the shipped web client's own organizations bundle, which joins with +/// `fetch("/api/user/organizations", { method: "POST", body: +/// JSON.stringify({ organizationId: e }) })`; and +/// 2. `GET /api/openapi.json`, whose `postUserOrganizations` operation +/// declares exactly one body property, `organizationId` (string), and a +/// 201 response. +/// +/// There is no `/api/organizations/{id}/join` route — that path 404s. +public struct JoinOrganizationRequest: Codable, Sendable, Equatable { + public let organizationId: String + + public init(organizationId: String) { + self.organizationId = organizationId + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift index b8c2918..73d17cb 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift @@ -341,6 +341,11 @@ public struct UserOrganizationDTO: Decodable, Sendable, Equatable { public let deletedAt: Date? public let role: String public let joinedAt: Date? + /// Duplicate of `role` the live route also emits. Verified 2026-09-09. + public let userRole: String? + /// Total members in the org, denormalized onto the membership row. + /// Verified 2026-09-09; drives the My Organizations row subtitle. + public let memberCount: Int? public init( id: String, @@ -354,7 +359,9 @@ public struct UserOrganizationDTO: Decodable, Sendable, Equatable { updatedAt: Date? = nil, deletedAt: Date? = nil, role: String, - joinedAt: Date? = nil + joinedAt: Date? = nil, + userRole: String? = nil, + memberCount: Int? = nil ) { self.id = id self.name = name @@ -368,6 +375,8 @@ public struct UserOrganizationDTO: Decodable, Sendable, Equatable { self.deletedAt = deletedAt self.role = role self.joinedAt = joinedAt + self.userRole = userRole + self.memberCount = memberCount } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift index 3901c7e..e1ba853 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift @@ -10,8 +10,10 @@ import Foundation /// throwing. /// /// Auth: all `.bearer` (decision 0001 — Bearer works on the organizations -/// surface; only `/api/user/organizations` and `/api/exports/*` plus -/// `/api/user/identities` are session-only). +/// surface). The old note here also listed `/api/user/organizations` as +/// session-only; that was wrong and is corrected in `UserEndpoint.swift` — +/// a raw `Authorization: Bearer` request to it returns HTTP 200 (verified +/// 2026-09-09), and `GET /api/openapi.json` marks it `x-auth-type: sync-token`. public enum Organizations { // MARK: - Org CRUD @@ -67,6 +69,20 @@ public enum Organizations { Request(method: .put, path: "/api/organizations/\(id)", body: .json(body), auth: .bearer) } + /// `DELETE /api/organizations/[id]` — delete an organization (owner only). + /// + /// VERIFIED live 2026-09-09 (read-only): `OPTIONS` reports + /// `Allow: DELETE, GET, HEAD, OPTIONS, PUT`, and `GET /api/openapi.json` + /// declares `deleteOrganizationsById` with `x-auth-type: sync-token`. The + /// verb was confirmed **without** issuing a real delete — the test account + /// is shared, and deleting an org is not reversible. + /// + /// The response body is not modelled by the spec, so callers use + /// `sendVoid` and ignore it; only the status matters. + public static func delete(id: String) -> Request { + Request(method: .delete, path: "/api/organizations/\(id)", auth: .bearer) + } + // MARK: - Members /// `GET /api/organizations/[id]/members` @@ -120,4 +136,66 @@ public enum Organizations { public static func users(id: String) -> Request<[OrganizationUserDTO]> { Request(method: .get, path: "/api/organizations/\(id)/users", auth: .bearer) } + + // MARK: - Organization LinkedIn (work-consolidation.md G25) + // + // The shared-credential half of LinkedIn. All four verbs verified live + // 2026-09-09 by `OPTIONS`, and cross-checked against `GET + // /api/openapi.json`, which marks each `x-auth-type: sync-token`. + // + // Note the asymmetry: `status` is the only readable route. `GET` on + // `sync-pages` and on `assignments` — both documented on + // `/help/api/organizations` — answer **405** live and are absent from the + // OpenAPI document, so the discovered pages and current assignments have + // to come back through `status`. See `OrgLinkedInDTO.swift`. + + /// `GET /api/organizations/[id]/linkedin/status` — the org's shared + /// LinkedIn credential status, the caller's role, and (when connected) the + /// discovered company pages. + /// + /// Observed live: `{"credential":null,"role":"member"}`. + public static func linkedInStatus(id: String) -> Request { + Request(method: .get, path: "/api/organizations/\(id)/linkedin/status", auth: .bearer) + } + + /// `POST /api/organizations/[id]/linkedin/sync-pages` — re-discover the + /// org's LinkedIn company pages. + /// + /// Takes no request body (the OpenAPI operation declares none). The 201 + /// response body is unmodelled and was never observed, so this is typed + /// `EmptyResponse` and callers `sendVoid` it, then re-read `status` for the + /// refreshed page list. That keeps a sync from failing at the decoder on a + /// shape the client has not seen. + public static func syncLinkedInPages(id: String) -> Request { + Request(method: .post, path: "/api/organizations/\(id)/linkedin/sync-pages", auth: .bearer) + } + + /// `PUT /api/organizations/[id]/linkedin/assignments` — assign one member + /// to one company page (or clear their assignment with a nil `pageId`). + /// + /// One assignment per call, per the OpenAPI body schema (`userId` + + /// `pageId`). See `UpdateOrgLinkedInAssignmentRequest` for why this differs + /// from the help page's "assignment map". + public static func assignLinkedInPage( + id: String, + _ body: UpdateOrgLinkedInAssignmentRequest + ) -> Request { + Request( + method: .put, + path: "/api/organizations/\(id)/linkedin/assignments", + body: .json(body), + auth: .bearer + ) + } + + /// `DELETE /api/organizations/[id]/linkedin/credential` — disconnect the + /// org's shared LinkedIn credential. + /// + /// Destructive for every assigned member: the server clears assignments, + /// and each assigned member silently falls back to their personal LinkedIn + /// identity. The UI must say so before calling this. + public static func disconnectLinkedIn(id: String) -> Request { + Request(method: .delete, path: "/api/organizations/\(id)/linkedin/credential", auth: .bearer) + } } + diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift index 615ecf9..d7d65d8 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift @@ -2,9 +2,12 @@ import Foundation /// Request builders for the **User** (account) endpoint group. /// -/// Auth follows decision 0001: `.bearer` everywhere except the two confirmed +/// Auth follows decision 0001: `.bearer` everywhere except the confirmed /// session-only reads — `GET /api/user/identities` and -/// `GET /api/user/organizations` — which are `.session`. +/// `GET /api/user/engagement` — which are `.session`. +/// +/// `GET /api/user/organizations` used to be listed here as session-only. It is +/// not; see the builder below for the 2026-09-09 verification. public enum User { // MARK: - Read @@ -24,10 +27,49 @@ public enum User { Request(method: .get, path: "/api/user/identities", auth: .session) } - /// `GET /api/user/organizations` — organizations the user belongs to. - /// **Session-only** per decision 0001 (Bearer is rejected here). - public static func organizations() -> Request { - Request(method: .get, path: "/api/user/organizations", auth: .session) + /// `GET /api/user/organizations` — organizations the user belongs to, with + /// the caller's own role, joined-at, and the org's member count. + /// + /// **CORRECTED 2026-09-09 — this is a Bearer route, not a session route.** + /// Decision 0001 recorded it as session-only, and it shipped as + /// `auth: .session`. A raw `curl` carrying nothing but + /// `Authorization: Bearer ` — no cookie jar at all — returns + /// HTTP 200 with the full membership list, and `GET /api/openapi.json` + /// marks the operation `x-auth-type: sync-token`. The same spec correctly + /// reports `x-auth-type: session` for `/api/user/engagement`, which this + /// repo independently confirmed is session-only, so the field is + /// trustworthy on both sides. + /// + /// `role` narrows the result server-side (e.g. `"owner"`); `nil` returns + /// every membership. + public static func organizations(role: String? = nil) -> Request { + Request( + method: .get, + path: "/api/user/organizations", + query: [.string("role", role)], + auth: .bearer + ) + } + + /// `POST /api/user/organizations` — **join** an organization. + /// + /// The join path. There is no `/api/organizations/{id}/join` route (it + /// 404s); joining is a write against the caller's own membership + /// collection. Confirmed 2026-09-09 without issuing a write, from the + /// shipped web client (which posts `{ organizationId }` to this exact + /// path) and from `GET /api/openapi.json` (`postUserOrganizations`, + /// one body property `organizationId`, 201 response). + /// + /// The 201 body is unmodelled and was not observed, so this is typed + /// `EmptyResponse` — callers `sendVoid` it and re-read the membership + /// list rather than risking a decode failure on an unseen shape. + public static func joinOrganization(organizationId: String) -> Request { + Request( + method: .post, + path: "/api/user/organizations", + body: .json(JoinOrganizationRequest(organizationId: organizationId)), + auth: .bearer + ) } /// `GET /api/user/engagement` — lifetime dig/push totals on your own diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift index 38c891b..91f2772 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift @@ -61,17 +61,83 @@ final class OrganizationsEndpointTests: XCTestCase { XCTAssertEqual(page.items.first?.isPublic, true) } - func test_givenMembersEnvelope_whenMembersSent_thenDecodesMembershipRows() async throws { + func test_givenLiveMembersEnvelope_whenMembersSent_thenDecodesIdentityKeyedById() async throws { + // Happy path against the **real** members body, captured live + // 2026-09-09. The row is keyed by `id` (not `userId`), dated with + // `joinedAt` (not `createdAt`), and carries the member's identity. + // + // This is a regression test for a shipped decode defect: the DTO + // declared `userId: String` as required, so every real members + // response failed with `keyNotFound(userId)` and the roster never + // rendered. let (client, transport) = makeClient() await transport.enqueue(.json(#""" - {"members":[{"userId":"u1","role":"owner","active":true},{"userId":"u2","role":"member"}], + {"members":[ + {"id":"c65092fa","username":"adron","displayName":"Adron Hall", + "avatar":"https://cdn.example/a.jpg","emailVerified":true, + "role":"owner","active":true,"joinedAt":"2026-02-22T19:38:07.993Z"}, + {"id":"72a147f7","username":"hubcity","displayName":"HubCity", + "avatar":"","emailVerified":true, + "role":"member","active":true,"joinedAt":"2026-03-24T02:39:02.349Z"}], "pagination":{"total":2,"limit":50,"offset":0,"hasMore":false}} """#)) let page = try await fetchPaginated(OrganizationMemberDTO.self, request: Organizations.members(id: "o1"), using: client) + XCTAssertEqual(page.items.map(\.userId), ["c65092fa", "72a147f7"]) XCTAssertEqual(page.items.map(\.role), ["owner", "member"]) XCTAssertEqual(page.items.first?.active, true) + XCTAssertEqual(page.items.first?.displayName, "Adron Hall") + XCTAssertEqual(page.items.first?.username, "adron") + XCTAssertEqual(page.items.first?.emailVerified, true) + XCTAssertNotNil(page.items.first?.createdAt, "joinedAt maps onto createdAt") + } + + func test_givenDocumentedUserIdSpelling_whenMembersSent_thenStillDecodes() async throws { + // The published reference documents the `userId` / `createdAt` + // spelling for membership rows. Both spellings decode, so a server + // that changes its mind does not blank the roster. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"members":[{"userId":"u1","role":"owner","active":true, + "createdAt":"2026-02-22T19:38:07.993Z"}], + "pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}} + """#)) + + let page = try await fetchPaginated(OrganizationMemberDTO.self, request: Organizations.members(id: "o1"), using: client) + + XCTAssertEqual(page.items.first?.userId, "u1") + XCTAssertNotNil(page.items.first?.createdAt) + } + + func test_givenMemberRowWithNeitherIdKey_whenDecoded_thenThrows() async throws { + // Invalid input: a row with no user key at all is a genuine decode + // failure, not something to paper over with a placeholder id. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"members":[{"role":"owner","active":true}], + "pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}} + """#)) + + do { + _ = try await fetchPaginated(OrganizationMemberDTO.self, request: Organizations.members(id: "o1"), using: client) + XCTFail("Expected a decode failure for a row with no id or userId") + } catch { + // Expected. + } + } + + func test_givenEmptyMembersPage_whenMembersSent_thenDecodesEmpty() async throws { + // Boundary: an org page with zero member rows. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"members":[],"pagination":{"total":0,"limit":50,"offset":0,"hasMore":false}} + """#)) + + let page = try await fetchPaginated(OrganizationMemberDTO.self, request: Organizations.members(id: "o1"), using: client) + + XCTAssertTrue(page.items.isEmpty) + XCTAssertFalse(page.pagination.hasMore) } func test_givenMembershipEnvelope_whenAddMemberSent_thenDecodesNestedMembership() async throws { @@ -134,4 +200,120 @@ final class OrganizationsEndpointTests: XCTestCase { XCTAssertEqual(received[0].httpMethod, "DELETE") XCTAssertEqual(received[0].url?.path, "/api/organizations/o1/members/u2") } + + // MARK: - G25: delete + organization LinkedIn + + func test_givenOrgId_whenDeleteBuilt_thenUsesDeleteVerbAndBearer() { + // Happy path. Verb confirmed by OPTIONS + the OpenAPI document; never + // exercised as a live delete (not reversible, shared test account). + let request = Organizations.delete(id: "o1") + XCTAssertEqual(request.method, .delete) + XCTAssertEqual(request.path, "/api/organizations/o1") + XCTAssertEqual(request.auth, .bearer) + XCTAssertNil(request.body, "Delete carries no body") + } + + func test_givenOrgLinkedInRoutes_whenBuilt_thenUseTheDeployedVerbs() { + // The deployed verb set, from OPTIONS and cross-checked against + // /api/openapi.json. `status` is the only readable one — GET on + // sync-pages and assignments answers 405 live despite the help page + // documenting them. + XCTAssertEqual(Organizations.linkedInStatus(id: "o1").method, .get) + XCTAssertEqual(Organizations.linkedInStatus(id: "o1").path, "/api/organizations/o1/linkedin/status") + + XCTAssertEqual(Organizations.syncLinkedInPages(id: "o1").method, .post) + XCTAssertEqual(Organizations.syncLinkedInPages(id: "o1").path, "/api/organizations/o1/linkedin/sync-pages") + XCTAssertNil(Organizations.syncLinkedInPages(id: "o1").body, "sync-pages takes no body") + + XCTAssertEqual(Organizations.assignLinkedInPage(id: "o1", .init(userId: "u1", pageId: "p1")).method, .put) + XCTAssertEqual( + Organizations.assignLinkedInPage(id: "o1", .init(userId: "u1", pageId: "p1")).path, + "/api/organizations/o1/linkedin/assignments" + ) + + XCTAssertEqual(Organizations.disconnectLinkedIn(id: "o1").method, .delete) + XCTAssertEqual( + Organizations.disconnectLinkedIn(id: "o1").path, + "/api/organizations/o1/linkedin/credential" + ) + } + + func test_givenAssignment_whenBuilt_thenSendsUserIdAndPageId() async throws { + // The OpenAPI body schema is a single {userId, pageId} pair — not the + // "assignment map" the help page describes. + let (client, transport) = makeClient() + await transport.enqueue(.json("{}")) + + try await client.sendVoid( + Organizations.assignLinkedInPage(id: "o1", .init(userId: "u1", pageId: "p1")) + ) + + let received = await transport.received + let body = try XCTUnwrap(received[0].httpBody) + let decoded = try JSONDecoder().decode(UpdateOrgLinkedInAssignmentRequest.self, from: body) + XCTAssertEqual(decoded.userId, "u1") + XCTAssertEqual(decoded.pageId, "p1") + } + + func test_givenDisconnectedStatus_whenSent_thenDecodesLiveShape() async throws { + // The exact body observed live 2026-09-09 on an org with no shared + // credential, from an account that is a plain member. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"credential":null,"role":"member"}"#)) + + let status = try await client.send(Organizations.linkedInStatus(id: "o1")) + + XCTAssertFalse(status.isConnected) + XCTAssertEqual(status.role, "member") + XCTAssertNil(status.pages) + } + + func test_givenDocumentedConnectedStatus_whenSent_thenDecodesConnectedAndPages() async throws { + // The documented connected shape — never observed live, so the type + // accepts it alongside the observed one rather than betting on either. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"connected":true,"expiresAt":"2026-12-01T00:00:00.000Z","role":"owner", + "pages":[{"id":"p1","linkedInPageId":"12345678","pageName":"Acme Corp", + "pageLogoUrl":"https://cdn.example/l.png", + "lastSyncedAt":"2026-06-12T00:00:00.000Z"}], + "assignments":[{"userId":"u1","pageId":"p1","pageName":"Acme Corp"}]} + """#)) + + let status = try await client.send(Organizations.linkedInStatus(id: "o1")) + + XCTAssertTrue(status.isConnected) + XCTAssertEqual(status.role, "owner") + XCTAssertEqual(status.pages?.first?.pageName, "Acme Corp") + XCTAssertEqual(status.pages?.first?.linkedInPageId, "12345678") + XCTAssertEqual(status.assignments?.first?.userId, "u1") + XCTAssertNotNil(status.expiresAt) + } + + func test_givenNestedCredential_whenSent_thenInfersConnectedAndExpiry() async throws { + // Boundary between the two spellings: a nested credential with no + // `connected` boolean still reads as connected. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"credential":{"expiresAt":"2026-12-01T00:00:00.000Z"},"role":"admin","pages":[]} + """#)) + + let status = try await client.send(Organizations.linkedInStatus(id: "o1")) + + XCTAssertTrue(status.isConnected) + XCTAssertNotNil(status.credential?.expiresAt) + XCTAssertEqual(status.pages?.count, 0) + } + + func test_givenLinkedInFailure_whenStatusSent_thenThrowsForbidden() async throws { + // Upstream failure surfaces as a typed APIError, not a decode error. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"No access"}"#, status: 403)) + do { + _ = try await client.send(Organizations.linkedInStatus(id: "o1")) + XCTFail("Expected forbidden") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "No access")) + } + } } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift index 9e5d701..cc688e1 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift @@ -134,35 +134,71 @@ final class UserEndpointTests: XCTestCase { XCTAssertTrue(response.identities.isEmpty) } - // MARK: - organizations (session-only) + // MARK: - organizations (Bearer — corrected 2026-09-09, G25) - func test_givenOrganizations_whenBuilt_thenUsesSessionAuth() { + func test_givenOrganizations_whenBuilt_thenUsesBearerAuth() { + // CORRECTED: this route was modelled as session-only per decision + // 0001. A raw `Authorization: Bearer` request with no cookie jar + // returns HTTP 200 live, and the published OpenAPI document marks the + // operation `x-auth-type: sync-token`. let request = User.organizations() XCTAssertEqual(request.path, "/api/user/organizations") - XCTAssertEqual(request.auth, .session) + XCTAssertEqual(request.auth, .bearer) + } + + func test_givenRoleFilter_whenOrganizationsBuilt_thenSendsRoleQuery() { + // The OpenAPI document declares an optional `role` query parameter. + let request = User.organizations(role: "owner") + let sent = request.query.filter { $0.value != nil } + XCTAssertEqual(sent.map(\.name), ["role"]) + XCTAssertEqual(sent.first?.value, "owner") + } + + func test_givenNoRoleFilter_whenOrganizationsBuilt_thenOmitsRoleQuery() { + // Boundary: a nil filter must not put an empty `?role=` on the wire. + // A nil filter leaves the item valueless, and valueless items are + // dropped when the URL is built — no empty `?role=` on the wire. + let request = User.organizations() + XCTAssertTrue(request.query.filter { $0.value != nil }.isEmpty) } func test_givenOrganizationsEnvelope_whenSent_thenDecodesRoleAndMetadata() async throws { - // Happy path through the session transport. - let session = StubHTTPDataTransport() - await session.enqueue(.json(#""" + // Happy path over the **bearer** transport, with the membership + // context fields the live route actually returns. + let (client, transport, _) = makeClient() + await transport.enqueue(.json(#""" {"organizations":[{"id":"o1","name":"Acme","slug":"acme","description":"x", "avatar":null,"isPublic":true,"isSystem":false, "createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z", - "deletedAt":null,"role":"admin","joinedAt":"2026-02-01T00:00:00.000Z"}]} + "deletedAt":null,"role":"admin","joinedAt":"2026-02-01T00:00:00.000Z", + "userRole":"admin","memberCount":3}]} """#)) - let (client, _, _) = makeClient(sessionTransport: session) let response = try await client.send(User.organizations()) XCTAssertEqual(response.organizations.first?.role, "admin") XCTAssertEqual(response.organizations.first?.name, "Acme") + XCTAssertEqual(response.organizations.first?.userRole, "admin") + XCTAssertEqual(response.organizations.first?.memberCount, 3) + XCTAssertEqual(response.organizations.first?.isSystem, false) + } + + func test_givenSystemOrgRow_whenSent_thenDecodesIsSystemTrue() async throws { + // "The Public" is the row the leave rule must recognise. + let (client, transport, _) = makeClient() + await transport.enqueue(.json(#""" + {"organizations":[{"id":"00000000-0000-0000-0000-000000000001", + "name":"The Public","slug":"the-public","isPublic":true,"isSystem":true, + "role":"member","memberCount":22}]} + """#)) + + let response = try await client.send(User.organizations()) + XCTAssertEqual(response.organizations.first?.isSystem, true) } func test_givenOrganizationsFailure_whenSent_thenThrowsForbidden() async throws { - // Upstream API failure on the session transport. - let session = StubHTTPDataTransport() - await session.enqueue(.json(#"{"error":"No access"}"#, status: 403)) - let (client, _, _) = makeClient(sessionTransport: session) + // Upstream API failure on the bearer transport. + let (client, transport, _) = makeClient() + await transport.enqueue(.json(#"{"error":"No access"}"#, status: 403)) do { _ = try await client.send(User.organizations()) XCTFail("Expected forbidden") @@ -171,6 +207,20 @@ final class UserEndpointTests: XCTestCase { } } + // MARK: - joinOrganization (G25) + + func test_givenOrganizationId_whenJoinBuilt_thenPostsOrganizationIdBody() throws { + // Happy path: the join verb + body, confirmed from the shipped web + // client and the OpenAPI document (never exercised as a live write). + let request = User.joinOrganization(organizationId: "org_1") + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.path, "/api/user/organizations") + XCTAssertEqual(request.auth, .bearer) + let body = try encodedBody(request) + XCTAssertEqual(body["organizationId"] as? String, "org_1") + XCTAssertEqual(body.count, 1, "The join body carries only organizationId") + } + // MARK: - update func test_givenProfilePatch_whenUpdateBuilt_thenPatchesOnlySetFields() throws { diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgMembershipRecordMapping.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgMembershipRecordMapping.swift index ce9ee60..9dd000f 100644 --- a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgMembershipRecordMapping.swift +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgMembershipRecordMapping.swift @@ -23,6 +23,9 @@ extension OrgMembershipRecord { isPublic: membership.organization.isPublic, orgCreatedAt: membership.organization.createdAt, orgUpdatedAt: membership.organization.updatedAt, + slug: membership.organization.slug, + isSystem: membership.organization.isSystem, + memberCount: membership.organization.memberCount, roleRaw: membership.role.wireToken, joinedAt: membership.joinedAt, position: position @@ -38,7 +41,10 @@ extension OrgMembershipRecord { description: orgDescription, isPublic: isPublic, createdAt: orgCreatedAt, - updatedAt: orgUpdatedAt + updatedAt: orgUpdatedAt, + slug: slug, + isSystem: isSystem, + memberCount: memberCount ), role: OrgRole(wireToken: roleRaw), joinedAt: joinedAt diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgRecordMapping.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgRecordMapping.swift index 4fb75ec..4be4793 100644 --- a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgRecordMapping.swift +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/OrgRecordMapping.swift @@ -22,7 +22,10 @@ extension OrgRecord { orgDescription: organization.description, isPublic: organization.isPublic, createdAt: organization.createdAt, - updatedAt: organization.updatedAt + updatedAt: organization.updatedAt, + slug: organization.slug, + isSystem: organization.isSystem, + memberCount: organization.memberCount ) } @@ -36,6 +39,9 @@ extension OrgRecord { isPublic = organization.isPublic createdAt = organization.createdAt updatedAt = organization.updatedAt + slug = organization.slug + isSystem = organization.isSystem + memberCount = organization.memberCount } /// Hydrate the row into a domain `Organization` value. @@ -46,7 +52,10 @@ extension OrgRecord { description: orgDescription, isPublic: isPublic, createdAt: createdAt, - updatedAt: updatedAt + updatedAt: updatedAt, + slug: slug, + isSystem: isSystem, + memberCount: memberCount ) } } @@ -61,7 +70,11 @@ extension OrgMemberRecord { membershipId: member.membershipId, roleRaw: member.role.wireToken, active: member.active, - createdAt: member.createdAt + createdAt: member.createdAt, + username: member.username, + displayName: member.displayName, + avatarURLString: member.avatarURL?.absoluteString, + emailVerified: member.emailVerified ) } @@ -73,6 +86,10 @@ extension OrgMemberRecord { roleRaw = member.role.wireToken active = member.active createdAt = member.createdAt + username = member.username + displayName = member.displayName + avatarURLString = member.avatarURL?.absoluteString + emailVerified = member.emailVerified } /// Hydrate the row into a domain `OrgMember` value. @@ -82,7 +99,11 @@ extension OrgMemberRecord { membershipId: membershipId, role: OrgRole(wireToken: roleRaw), active: active, - createdAt: createdAt + createdAt: createdAt, + username: username, + displayName: displayName, + avatarURL: avatarURLString.flatMap(URL.init(string:)), + emailVerified: emailVerified ) } } diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgMembershipRecord.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgMembershipRecord.swift index d69c29d..de453cd 100644 --- a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgMembershipRecord.swift +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgMembershipRecord.swift @@ -38,6 +38,14 @@ final class OrgMembershipRecord { var isPublic: Bool var orgCreatedAt: Date? var orgUpdatedAt: Date? + var slug: String? + + /// The system-org flag. Cached so "you can't leave The Public" holds on a + /// cache-first paint, before the network read lands. + var isSystem: Bool = false + + /// Members in the org, denormalized for the My Organizations row subtitle. + var memberCount: Int? /// Wire string for the caller's role. Rehydrated via `OrgRole(wireToken:)`. var roleRaw: String @@ -55,6 +63,9 @@ final class OrgMembershipRecord { isPublic: Bool = false, orgCreatedAt: Date? = nil, orgUpdatedAt: Date? = nil, + slug: String? = nil, + isSystem: Bool = false, + memberCount: Int? = nil, roleRaw: String, joinedAt: Date? = nil, position: Int @@ -65,6 +76,9 @@ final class OrgMembershipRecord { self.isPublic = isPublic self.orgCreatedAt = orgCreatedAt self.orgUpdatedAt = orgUpdatedAt + self.slug = slug + self.isSystem = isSystem + self.memberCount = memberCount self.roleRaw = roleRaw self.joinedAt = joinedAt self.position = position diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgRecord.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgRecord.swift index 573ce9e..b1831b3 100644 --- a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgRecord.swift +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/OrgRecord.swift @@ -22,13 +22,25 @@ final class OrgRecord { var createdAt: Date? var updatedAt: Date? + var slug: String? + + /// The system-org flag ("The Public"). Cached because the client-side + /// no-leave rule has to hold when the list paints from cache, before any + /// network read has confirmed it. + var isSystem: Bool = false + + var memberCount: Int? + init( id: String, name: String, orgDescription: String? = nil, isPublic: Bool = false, createdAt: Date? = nil, - updatedAt: Date? = nil + updatedAt: Date? = nil, + slug: String? = nil, + isSystem: Bool = false, + memberCount: Int? = nil ) { self.id = id self.name = name @@ -36,6 +48,9 @@ final class OrgRecord { self.isPublic = isPublic self.createdAt = createdAt self.updatedAt = updatedAt + self.slug = slug + self.isSystem = isSystem + self.memberCount = memberCount } } @@ -76,18 +91,30 @@ final class OrgMemberRecord { /// Wire string for the role. Rehydrated via `OrgRole(wireToken:)`. var roleRaw: String - /// Whether the membership is active. `nil` when the server omits it. + /// Whether the membership is active. `nil` when the server omits it; + /// `false` is a suspended member. var active: Bool? var createdAt: Date? + // Identity, denormalized onto the row by the live members listing so the + // cached roster renders names and avatars offline. + var username: String? + var displayName: String? + var avatarURLString: String? + var emailVerified: Bool? + init( orgID: String, userId: String, membershipId: String? = nil, roleRaw: String, active: Bool? = nil, - createdAt: Date? = nil + createdAt: Date? = nil, + username: String? = nil, + displayName: String? = nil, + avatarURLString: String? = nil, + emailVerified: Bool? = nil ) { self.orgID = orgID self.userId = userId @@ -95,5 +122,9 @@ final class OrgMemberRecord { self.roleRaw = roleRaw self.active = active self.createdAt = createdAt + self.username = username + self.displayName = displayName + self.avatarURLString = avatarURLString + self.emailVerified = emailVerified } } diff --git a/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataOrgStoreTests.swift b/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataOrgStoreTests.swift index d23696d..e00ca0e 100644 --- a/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataOrgStoreTests.swift +++ b/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataOrgStoreTests.swift @@ -517,4 +517,107 @@ final class SwiftDataOrgStoreTests: XCTestCase { updatedAt: updatedAt ) } + + // MARK: - G25 membership context survives the cache + + func test_givenSystemOrgMembership_whenCached_thenSystemFlagSurvives() async throws { + // The no-leave rule for "The Public" has to hold on a cache-first + // paint, before any network read confirms it — so the flag must + // round-trip, not be re-derived. + let store = try SwiftDataOrgStore.inMemory() + let membership = UserOrganization( + organization: Organization( + id: "00000000-0000-0000-0000-000000000001", + name: "The Public", + isPublic: true, + slug: "the-public", + isSystem: true, + memberCount: 22 + ), + role: .member, + joinedAt: Date(timeIntervalSince1970: 1_000_000) + ) + + await store.cacheMemberships([membership]) + + let cached = await store.cachedMemberships() + XCTAssertEqual(cached, [membership]) + XCTAssertTrue(cached.first?.organization.isSystem ?? false) + XCTAssertFalse(cached.first?.isLeavable ?? true) + XCTAssertEqual(cached.first?.organization.memberCount, 22) + XCTAssertEqual(cached.first?.organization.slug, "the-public") + } + + func test_givenOrdinaryMembership_whenCached_thenStaysLeavable() async throws { + // Boundary in the other direction: an ordinary org must not come back + // from the cache flagged as a system org. + let store = try SwiftDataOrgStore.inMemory() + let membership = UserOrganization( + organization: Organization(id: "o-1", name: "Acme", isPublic: true), + role: .owner + ) + + await store.cacheMemberships([membership]) + + let cached = await store.cachedMemberships() + XCTAssertFalse(cached.first?.organization.isSystem ?? true) + XCTAssertTrue(cached.first?.isLeavable ?? false) + XCTAssertNil(cached.first?.organization.memberCount) + } + + func test_givenOrganizationWithContext_whenCached_thenRoundTripsSystemAndCount() async throws { + // Same fields on the org-management cache, not just the membership one. + let store = try SwiftDataOrgStore.inMemory() + let org = Organization( + id: "org-sys", + name: "The Public", + isPublic: true, + slug: "the-public", + isSystem: true, + memberCount: 22 + ) + + await store.cacheOrganization(org) + + let cached = await store.cachedOrganization(id: "org-sys") + XCTAssertEqual(cached, org) + } + + func test_givenSuspendedMemberWithIdentity_whenCached_thenRoundTripsBoth() async throws { + // The roster renders names, avatars and the suspended state offline. + let store = try SwiftDataOrgStore.inMemory() + let member = OrgMember( + userId: "u-1", + membershipId: "m-1", + role: .admin, + active: false, + createdAt: Date(timeIntervalSince1970: 1_000_000), + username: "adron", + displayName: "Adron Hall", + avatarURL: URL(string: "https://cdn/a.jpg"), + emailVerified: true + ) + + await store.cacheMembers([member], of: "org-1") + + let cached = await store.cachedMembers(of: "org-1") + XCTAssertEqual(cached, [member]) + XCTAssertTrue(cached.first?.isSuspended ?? false) + XCTAssertEqual(cached.first?.displayLabel, "Adron Hall") + } + + func test_givenMemberWithNoIdentity_whenCached_thenNilFieldsStayNil() async throws { + // Boundary: absent identity must not round-trip as empty strings. + let store = try SwiftDataOrgStore.inMemory() + let member = OrgMember(userId: "u-2", role: .member) + + await store.cacheMembers([member], of: "org-1") + + let cached = await store.cachedMembers(of: "org-1") + XCTAssertEqual(cached, [member]) + XCTAssertNil(cached.first?.username) + XCTAssertNil(cached.first?.avatarURL) + XCTAssertFalse(cached.first?.isSuspended ?? true) + } } + diff --git a/work-consolidation.md b/work-consolidation.md index 77db669..4a59a0f 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -160,8 +160,21 @@ Three routes the DM feature shipped without: `GET /api/dm/conversations` (one ro `GET /api/documents/tree` returns `{folders, rootDocuments}` in **one** call — today the sidebar assembles that from several. `GET /api/users/{username}/documents` is public documents by user (the profile page has no documents tab). `GET`/`POST /api/documents/invite/{token}` are the invite landing/claim pair matching the list ones. `POST /api/documents/folders/{id}/documents` creates a document directly in a folder. `POST`/`DELETE /api/documents/{id}/presence` is the live-cursor heartbeat — **defer**: it is a collaborative-editing feature with a polling cost, worth building only if multi-user editing is a goal. -**G25 · Organization admin + LinkedIn org pages — MEDIUM. Size M. (This is [G11b](#2d-upstream-blocked--deferred-confirm-demand-before-building), no longer upstream-blocked.)** -`PUT /api/organizations/{id}` and `DELETE /api/organizations/{id}` (the client can create and read but not rename or delete — and it sends `PATCH`, see [V4](#1c-live-verb-defects--fix-first)), plus the org LinkedIn set that was recorded as 404/not-deployed and now exists: `GET /api/organizations/{id}/linkedin/status`, `POST /api/organizations/{id}/linkedin/sync-pages`, `PUT /api/organizations/{id}/linkedin/assignments`, `DELETE /api/organizations/{id}/linkedin/credential`. Personal-scope `PUT /api/linkedin/posting-targets` and `POST /api/linkedin/sync-pages` also exist, which closes the "needs a verified per-target wire shape" note on [G11a](#g11a-linkedin-posting-target). +**G25 · Organization admin + LinkedIn org pages — ✅ SHIPPED 2026-09-09 (GitHub [#54](https://github.com/CompositeCode/interlinedlist-macos-native/issues/54), branch `feat/orgs-lifecycle-g25`). Size M. (Was [G11b](#2d-upstream-blocked--deferred-confirm-demand-before-building).)** +Delivered: `DELETE /api/organizations/{id}`, leave-an-org, suspend/restore a member, **My Organizations** with role / joined-at / member count, join, and the four org-LinkedIn routes (`linkedin/status`, `sync-pages`, `assignments`, `credential`). Last-owner protection and the system-org no-leave rule are enforced client-side in `OrgOwnershipRules` before any network call. + +What the 2026-09-09 read-only recon settled — all of it verified without a single write, since the test account is shared: + +- **`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` returns HTTP 200, and `GET /api/openapi.json` marks it `x-auth-type: sync-token`. Corrected in `UserEndpoint.swift`. (The same spec correctly reports `session` for `/api/user/engagement`, so the field is trustworthy — worth re-checking the rest of the session-only list against it.) +- **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 [G21](#g21-link-metadata). The row also carries the member's identity (`username`, `displayName`, `avatar`, `emailVerified`), so no second lookup is needed. +- **`POST /api/user/organizations` is the join path**, body `{ "organizationId": … }`. Confirmed twice without writing: the shipped web bundle posts exactly that, and the OpenAPI operation declares that one property. There is no `/api/organizations/{id}/join` — it 404s. +- **There is no leave route.** Leaving is `DELETE /api/organizations/{id}/members/{yourUserId}`, which is 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. +- **`/help/api/organizations` is stale on two rows.** It documents `GET` on `linkedin/sync-pages` and on `linkedin/assignments`; both answer **405** live and neither appears in the OpenAPI document. So `linkedin/status` is the only readable org-LinkedIn route, and it is where the discovered pages and current assignments have to come from. +- **`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. +- **`LinkedInTargetDTO.kind` had the wrong taxonomy.** The real tokens are `personal` / `orgPage` / `personalPage` (with `pageId` / `personalPageId` / `linkedInPageId` / `logoUrl`); the client modelled `personal` / `org`, so every org-page target decoded as unknown and lost its page identity. This is why the composer's "Posting as …" line was 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 the company page. Fixed via `LinkedInPostingTargets.defaultDestination`. + +Not verifiable read-only, and flagged rather than guessed: the **connected** shape of `linkedin/status` (no reachable org has a credential — the client accepts both the observed `{credential, role}` and the documented `{connected, expiresAt, pages}`), the 201 bodies of `join` and `sync-pages` (both ignored via `sendVoid` + a re-read rather than decoding an unseen shape), and whether a `nil` `pageId` is how an assignment is cleared. **G26 · Identity management: unlink + verify — LOW–MEDIUM. Size S.**