From cffa5c67f73f704309b9217693a595474652561c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:31:47 -0700 Subject: [PATCH] feat(organizations): manage the shared LinkedIn credential and page assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An organization's LinkedIn section on the detail screen, shown only to a role that may manage it: owner and admin. That is the help centre's rule ("when an organization's owners or admins connect a shared LinkedIn credential") and the server's — verified live, `PUT .../linkedin/assignments` and `POST .../linkedin/sync-pages` answer a plain member `403 {"error":"Admin or owner required"}`. The capability is a new `OrgPermissions.canManageLinkedIn`, so #83's role matrix stays the one place roles are decided. The section reads `GET .../linkedin/status`, assigns a member to a company page (or clears it) through `PUT .../linkedin/assignments`, refreshes the page list with `POST .../linkedin/sync-pages`, and disconnects the credential with `DELETE .../linkedin/credential` behind a confirmation that states the cost: the organization can no longer post to its pages and every assignment is cleared. An organization with no credential is a first-class state, not an error. Live, `GET .../linkedin/status` answers `{"credential":null,"role":"owner"}` for one, and the 404s `No LinkedIn credential found` / `No active LinkedIn credential for this organization` mean the same thing — all three render as "not connected" with nothing to sync or disconnect. The assignment body is one live-verified `{userId, pageId}` pair per call, not the "map" the help centre describes: `{}` answers `400 "userId required"`, a `userId` with no `pageId` answers `200 {"assigned":false}` and so clears that member's page, an unknown page answers `404 "Page not found in this organization"`, and a stranger `400 "User is not a member of this organization"`. `GET` on `.../assignments` and `.../sync-pages` answer 405, so the documented read endpoints do not exist and the status payload is the only source of pages and assignments. The connected payload itself was not observable (no reachable account holds an org credential), so its wire model is deliberately tolerant: `connected` or the presence of `credential`, pages and assignments at either the top level or inside `credential`, and a page read under either field naming. Tests: status parse for connected and not-connected payloads, the assignment round-trip asserting the exact request body (and the unassign form that omits the page), the credential delete and its "none present" answer, sync posting then re-reading the status, the role gate in `OrgPermissions`, and Compose coverage that the section is hidden for a member, shown for an owner or admin, and that disconnecting only fires after the confirmation. Closes #42 --- .../ui/detail/OrganizationDetailScreenTest.kt | 146 ++++++++++ .../data/DefaultOrganizationsRepository.kt | 38 +++ .../organizations/data/OrgLinkedInMapper.kt | 58 ++++ .../data/OrganizationsRepository.kt | 24 ++ .../data/remote/OrganizationsApi.kt | 38 +++ .../data/remote/dto/OrgLinkedInDtos.kt | 98 +++++++ .../organizations/domain/OrgLinkedIn.kt | 57 ++++ .../organizations/domain/OrgPermissions.kt | 14 + .../ui/OrganizationsErrorMessages.kt | 46 +++ .../ui/detail/OrganizationDetailScreen.kt | 267 +++++++++++++++++ .../ui/detail/OrganizationDetailViewModel.kt | 120 ++++++++ .../FakeOrganizationsRepository.kt | 37 +++ .../DefaultOrganizationsRepositoryTest.kt | 196 +++++++++++++ .../domain/OrgPermissionsTest.kt | 22 ++ .../ui/OrganizationsErrorMessagesTest.kt | 39 +++ .../detail/OrganizationDetailViewModelTest.kt | 269 ++++++++++++++++++ 16 files changed, 1469 insertions(+) create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrgLinkedInMapper.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrgLinkedInDtos.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgLinkedIn.kt diff --git a/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt b/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt index 01bbae1..8e23f95 100644 --- a/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt +++ b/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt @@ -8,9 +8,12 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInPage +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgRole import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.ui.LINKEDIN_DISCONNECT_CONSEQUENCE import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -38,6 +41,9 @@ class OrganizationDetailScreenTest { onDelete: () -> Unit = {}, onJoin: () -> Unit = {}, onLeave: () -> Unit = {}, + onAssignLinkedInPage: (OrgMember, String?) -> Unit = { _, _ -> }, + onSyncLinkedInPages: () -> Unit = {}, + onRemoveLinkedInCredential: () -> Unit = {}, ) { composeRule.setContent { InterlinedListTheme { @@ -52,6 +58,9 @@ class OrganizationDetailScreenTest { onDelete = onDelete, onJoin = onJoin, onLeave = onLeave, + onAssignLinkedInPage = onAssignLinkedInPage, + onSyncLinkedInPages = onSyncLinkedInPages, + onRemoveLinkedInCredential = onRemoveLinkedInCredential, ) } } @@ -62,6 +71,7 @@ class OrganizationDetailScreenTest { members: List = emptyList(), isPublic: Boolean = false, isSystem: Boolean = false, + linkedIn: OrgLinkedInStatus? = null, ) = OrganizationDetailUiState( organization = Organization( id = "o1", @@ -76,6 +86,7 @@ class OrganizationDetailScreenTest { ), members = members, isLoading = false, + linkedIn = linkedIn, ) private val ada = OrgMember("u1", "ada", "Ada", null, OrgRole.OWNER, active = true) @@ -312,4 +323,139 @@ class OrganizationDetailScreenTest { assert(saved?.third == true) { "expected the toggled visibility to reach the save, got $saved" } } + + // ---- LinkedIn company pages -------------------------------------------- + + private val acmePage = OrgLinkedInPage(id = "p1", linkedInPageId = "12345678", name = "Acme Corp") + private val labsPage = OrgLinkedInPage(id = "p2", linkedInPageId = "87654321", name = "Acme Labs") + + private fun connected(assignments: Map = emptyMap()) = OrgLinkedInStatus( + connected = true, + expiresAt = "2026-12-01T00:00:00.000Z", + pages = listOf(acmePage, labsPage), + assignments = assignments, + ) + + @Test + fun owner_seesTheLinkedInSection() { + setScreen(state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected())) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_CONNECTED).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInPage("p1")).assertIsDisplayed() + } + + @Test + fun admin_seesTheLinkedInSection() { + setScreen(state = state(OrgRole.ADMIN, listOf(ada, grace), linkedIn = connected())) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN).assertIsDisplayed() + } + + @Test + fun member_isOfferedNoLinkedInSection() { + // The server refuses a member outright ("Admin or owner required"), so the + // section they could not use is not shown at all. + setScreen(state = state(OrgRole.MEMBER, listOf(ada, grace), linkedIn = connected())) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN).assertDoesNotExist() + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_SYNC).assertDoesNotExist() + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).assertDoesNotExist() + } + + @Test + fun notConnected_readsAsAState_notAnError() { + setScreen( + state = state( + OrgRole.OWNER, + listOf(ada, grace), + linkedIn = OrgLinkedInStatus.NOT_CONNECTED, + ), + ) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_NOT_CONNECTED).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_ERROR).assertDoesNotExist() + // Nothing to sync or disconnect without a credential. + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_SYNC).assertDoesNotExist() + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).assertDoesNotExist() + } + + @Test + fun disconnect_statesTheConsequenceAndOnlyActsOnConfirmation() { + var disconnected = false + setScreen( + state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()), + onRemoveLinkedInCredential = { disconnected = true }, + ) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).performClick() + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT_DIALOG).assertIsDisplayed() + // The dialog names the cost before anything happens. + composeRule.onNodeWithText(LINKEDIN_DISCONNECT_CONSEQUENCE).assertIsDisplayed() + assert(!disconnected) { "the credential must not be removed before confirmation" } + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT_CONFIRM).performClick() + assert(disconnected) + } + + @Test + fun disconnect_cancelLeavesTheCredentialAlone() { + var disconnected = false + setScreen( + state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()), + onRemoveLinkedInCredential = { disconnected = true }, + ) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).performClick() + composeRule.onNodeWithText("Cancel").performClick() + + assert(!disconnected) + } + + @Test + fun sync_refreshesThePageList() { + var synced = false + setScreen( + state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()), + onSyncLinkedInPages = { synced = true }, + ) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_SYNC).performClick() + assert(synced) + } + + @Test + fun assigningAPage_reportsTheMemberAndPage() { + var assigned: Pair? = null + setScreen( + state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()), + onAssignLinkedInPage = { member, pageId -> assigned = member.userId to pageId }, + ) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInAssignment("u2")).performClick() + composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInPageOption("u2", "p2")).performClick() + + assert(assigned == "u2" to "p2") { "expected grace to be assigned Acme Labs, got $assigned" } + } + + @Test + fun clearingAnAssignment_sendsNoPage() { + var assigned: Pair? = null + setScreen( + state = state( + OrgRole.OWNER, + listOf(ada, grace), + linkedIn = connected(mapOf("u2" to "p1")), + ), + onAssignLinkedInPage = { member, pageId -> assigned = member.userId to pageId }, + ) + + // The current assignment is what the control reads. + composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInAssignment("u2")) + .assertTextContains("Acme Corp") + composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInAssignment("u2")).performClick() + composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInClearOption("u2")).performClick() + + assert(assigned == "u2" to null) { "expected the assignment to be cleared, got $assigned" } + } } diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt index 0f56608..6337378 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt @@ -10,10 +10,12 @@ import com.interlinedlist.android.feature.organizations.data.remote.Organization import com.interlinedlist.android.feature.organizations.data.remote.dto.AddMemberRequest import com.interlinedlist.android.feature.organizations.data.remote.dto.CreateOrganizationRequest import com.interlinedlist.android.feature.organizations.data.remote.dto.JoinOrganizationRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.LinkedInAssignmentRequest import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationsResponse import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateMemberRequest import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateOrganizationRequest import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgRole import com.interlinedlist.android.feature.organizations.domain.Organization @@ -219,6 +221,42 @@ class DefaultOrganizationsRepository @Inject constructor( withContext(dispatchers.io) { safeApiCall(json) { api.removeMember(orgId, userId) }.map { } } + + override suspend fun getLinkedInStatus(orgId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getLinkedInStatus(orgId) }.map(OrgLinkedInMapper::fromDto) + } + + override suspend fun assignLinkedInPage( + orgId: String, + userId: String, + pageId: String?, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.putLinkedInAssignment(orgId, LinkedInAssignmentRequest(userId = userId, pageId = pageId)) + }.map { response -> + // The server answers {"assigned": …}; fall back to what we asked for. + response.assigned ?: (pageId != null) + } + } + + override suspend fun removeLinkedInCredential(orgId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.deleteLinkedInCredential(orgId) }.map { } + } + + /** + * Syncs and then re-reads the status, because the sync response shape could + * not be observed (no reachable organization has a credential) while the + * status shape is confirmed. One extra GET buys a page list we can trust. + */ + override suspend fun syncLinkedInPages(orgId: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.syncLinkedInPages(orgId) }) { + is ApiResult.Success -> getLinkedInStatus(orgId) + is ApiResult.Failure -> result + } + } } /** Builds a [Paged] from the response's pagination block, tolerating its absence. */ diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrgLinkedInMapper.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrgLinkedInMapper.kt new file mode 100644 index 0000000..b9ed3c4 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrgLinkedInMapper.kt @@ -0,0 +1,58 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgLinkedInPageDto +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgLinkedInStatusResponse +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInPage +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus + +/** + * DTO → domain mapping for the organization LinkedIn status. + * + * "Not connected" is the ordinary answer (`{"credential":null}` — verified live), + * so it maps to [OrgLinkedInStatus.NOT_CONNECTED] rather than being treated as a + * missing payload. The connected payload could not be observed, so pages and + * assignments are read from either the top level or from inside `credential`, and + * an assignment is accepted either as its own `{userId,pageId}` row or as an + * `assignedUserId` on the page. + */ +object OrgLinkedInMapper { + + fun fromDto(dto: OrgLinkedInStatusResponse): OrgLinkedInStatus { + val credential = dto.credential + val connected = dto.connected ?: (credential != null) + val pageDtos = credential?.pages ?: dto.pages.orEmpty() + val pages = pageDtos.mapNotNull(::pageFromDto) + val assignmentDtos = credential?.assignments ?: dto.assignments.orEmpty() + + val assignments = buildMap { + pageDtos.forEach { page -> + val pageId = page.resolvedId ?: return@forEach + page.assignedUserId?.let { put(it, pageId) } + } + assignmentDtos.forEach { assignment -> + val userId = assignment.userId ?: return@forEach + val pageId = assignment.pageId ?: return@forEach + put(userId, pageId) + } + } + + return OrgLinkedInStatus( + connected = connected, + expiresAt = credential?.expiresAt ?: dto.expiresAt, + pages = pages, + // Assignments only mean anything against a page we know about. + assignments = assignments.filterValues { pageId -> pages.any { it.id == pageId } }, + ) + } + + private fun pageFromDto(dto: OrgLinkedInPageDto): OrgLinkedInPage? { + val id = dto.resolvedId ?: return null + return OrgLinkedInPage( + id = id, + linkedInPageId = dto.linkedInPageId, + name = dto.resolvedName, + logoUrl = dto.resolvedLogo, + lastSyncedAt = dto.lastSyncedAt, + ) + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt index 41fe286..fdba7f4 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.organizations.data import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgRole import com.interlinedlist.android.feature.organizations.domain.Organization @@ -78,6 +79,29 @@ interface OrganizationsRepository { /** Removes a user's membership from the organization. */ suspend fun removeMember(orgId: String, userId: String): ApiResult + /** + * The organization's shared LinkedIn credential, its company pages and the + * per-member page assignments. An organization with no credential is a + * success carrying [OrgLinkedInStatus.NOT_CONNECTED], not a failure. + */ + suspend fun getLinkedInStatus(orgId: String): ApiResult + + /** + * Assigns [userId] to the company page [pageId], or clears their assignment + * when [pageId] is null. Returns whether the member ends up assigned, as the + * server reports it. + */ + suspend fun assignLinkedInPage(orgId: String, userId: String, pageId: String?): ApiResult + + /** + * Disconnects the shared credential. The server clears the page assignments + * with it, so the organization can no longer post to its company pages. + */ + suspend fun removeLinkedInCredential(orgId: String): ApiResult + + /** Re-discovers the company pages and returns the refreshed status. */ + suspend fun syncLinkedInPages(orgId: String): ApiResult + companion object { const val DEFAULT_PAGE_SIZE = 20 } diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt index 6be810f..c7c9e99 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt @@ -3,7 +3,10 @@ package com.interlinedlist.android.feature.organizations.data.remote import com.interlinedlist.android.feature.organizations.data.remote.dto.AddMemberRequest import com.interlinedlist.android.feature.organizations.data.remote.dto.CreateOrganizationRequest import com.interlinedlist.android.feature.organizations.data.remote.dto.JoinOrganizationRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.LinkedInAssignmentRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.LinkedInAssignmentResponse import com.interlinedlist.android.feature.organizations.data.remote.dto.MembersResponse +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgLinkedInStatusResponse import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgUsersResponse import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationEnvelope import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationsResponse @@ -96,4 +99,39 @@ interface OrganizationsApi { @Query("limit") limit: Int, @Query("offset") offset: Int, ): OrgUsersResponse + + /** + * The organization's shared LinkedIn credential and the pages discovered for + * it. Members-only: an outsider gets + * `403 {"error":"Not a member of this organization"}` (verified live), and an + * organization with no credential answers `200 {"credential":null,"role":…}`. + */ + @GET("api/organizations/{id}/linkedin/status") + suspend fun getLinkedInStatus(@Path("id") id: String): OrgLinkedInStatusResponse + + /** + * Assigns one member to one company page — or clears their assignment when the + * body carries no `pageId`. Owner/admin only (`403 "Admin or owner required"`). + */ + @PUT("api/organizations/{id}/linkedin/assignments") + suspend fun putLinkedInAssignment( + @Path("id") id: String, + @Body body: LinkedInAssignmentRequest, + ): LinkedInAssignmentResponse + + /** + * Disconnects the shared credential; the server clears the assignments with + * it. Answers `404 {"error":"No LinkedIn credential found"}` when there is + * none (verified live). + */ + @DELETE("api/organizations/{id}/linkedin/credential") + suspend fun deleteLinkedInCredential(@Path("id") id: String) + + /** + * Re-discovers the organization's company pages. Answers + * `404 {"error":"No active LinkedIn credential for this organization"}` when + * the organization has not connected LinkedIn (verified live). + */ + @POST("api/organizations/{id}/linkedin/sync-pages") + suspend fun syncLinkedInPages(@Path("id") id: String) } diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrgLinkedInDtos.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrgLinkedInDtos.kt new file mode 100644 index 0000000..d1f3607 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrgLinkedInDtos.kt @@ -0,0 +1,98 @@ +package com.interlinedlist.android.feature.organizations.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for an organization's shared LinkedIn credential. + * + * **Verified live** (subscriber token, 2026-09-16): + * - `GET /api/organizations/{id}/linkedin/status` → `{"credential":null,"role":"owner"}` + * for an organization with no credential. The help centre's documented + * `{"connected":true,"expiresAt":"…"}` shape was never returned, so both forms + * are accepted: `connected` if the server ever sends it, otherwise the presence + * of `credential`. + * - `GET …/linkedin/assignments` and `GET …/linkedin/sync-pages` answer **405** — + * the help centre's table lists GETs that do not exist, so the page list and the + * assignments can only arrive on the status payload. + * + * **Not observable**: no reachable account has an organization credential, so the + * *connected* payload — the credential object, the discovered pages and the + * assignment map — is modelled defensively. Every field is optional and each is + * read under the names the rest of the API already uses for a LinkedIn page + * (`/help/api/linkedin-integration` returns `id`/`pageId`, `linkedInPageId`, + * `pageName`/`label`, `pageLogoUrl`/`logoUrl`, `lastSyncedAt`). + */ +@Serializable +data class OrgLinkedInStatusResponse( + val credential: OrgLinkedInCredentialDto? = null, + @Serializable(with = FlexibleBooleanSerializer::class) + val connected: Boolean? = null, + val expiresAt: String? = null, + /** The caller's role in the organization; the server includes it on status. */ + val role: String? = null, + val pages: List? = null, + val assignments: List? = null, +) + +/** The stored credential. Its absence (`null`) is how "not connected" is reported. */ +@Serializable +data class OrgLinkedInCredentialDto( + val expiresAt: String? = null, + val connectedAt: String? = null, + val pages: List? = null, + val assignments: List? = null, +) + +/** One discovered company page (`OrgLinkedInPage`), under either field naming. */ +@Serializable +data class OrgLinkedInPageDto( + val id: String? = null, + val pageId: String? = null, + val linkedInPageId: String? = null, + val pageName: String? = null, + val name: String? = null, + val label: String? = null, + val pageLogoUrl: String? = null, + val logoUrl: String? = null, + val lastSyncedAt: String? = null, + /** Some payloads may carry the assignment on the page rather than separately. */ + val assignedUserId: String? = null, +) { + /** The InterlinedList record id — what `…/assignments` expects as `pageId`. */ + val resolvedId: String? get() = id ?: pageId + val resolvedName: String get() = pageName ?: name ?: label ?: "" + val resolvedLogo: String? get() = pageLogoUrl ?: logoUrl +} + +/** A member → page assignment as reported by the status payload. */ +@Serializable +data class OrgLinkedInAssignmentDto( + val userId: String? = null, + val pageId: String? = null, +) + +/** + * Body for `PUT /api/organizations/{id}/linkedin/assignments`. + * + * Verified live: the endpoint takes **one** assignment, not a map — + * `{}` answers `400 {"error":"userId required","code":"bad_request"}`, a `userId` + * with no `pageId` answers `200 {"assigned":false}` (i.e. it clears that member's + * assignment), an unknown page answers + * `404 {"error":"Page not found in this organization"}` and a non-member answers + * `400 {"error":"User is not a member of this organization"}`. + * + * The shared `Json` sets `explicitNulls = false`, so a null [pageId] is omitted — + * which is exactly the "unassign" form the server accepts. + */ +@Serializable +data class LinkedInAssignmentRequest( + val userId: String, + val pageId: String? = null, +) + +/** Response of the assignment PUT: `{"assigned": true|false}` (verified live). */ +@Serializable +data class LinkedInAssignmentResponse( + @Serializable(with = FlexibleBooleanSerializer::class) + val assigned: Boolean? = null, +) diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgLinkedIn.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgLinkedIn.kt new file mode 100644 index 0000000..68c8fe3 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgLinkedIn.kt @@ -0,0 +1,57 @@ +package com.interlinedlist.android.feature.organizations.domain + +/** + * The organization's shared LinkedIn credential, the company pages discovered for + * it, and which member may post to which page. + * + * Captured live from `GET /api/organizations/{id}/linkedin/status`, which answers + * `{"credential":null,"role":"member"}` for an organization that has never + * connected LinkedIn — **the normal case**, not an error. [NOT_CONNECTED] models + * exactly that, so the UI can say "no LinkedIn credential" rather than "something + * went wrong". + * + * The *connected* payload was not observable (no account reachable from here has + * an org credential), so the wire model behind this is deliberately tolerant; see + * `OrgLinkedInDtos`. + */ +data class OrgLinkedInStatus( + /** True once the organization holds a LinkedIn credential. */ + val connected: Boolean, + /** When the stored credential expires, if the API reports it. */ + val expiresAt: String? = null, + /** Company pages discovered for the credential; empty until a sync finds any. */ + val pages: List = emptyList(), + /** + * Which page each member posts to, keyed by user id. The API assigns at most + * one page per member: `PUT …/linkedin/assignments` takes a single + * `{ userId, pageId }` pair and clears the member's assignment when `pageId` + * is absent (verified live). + */ + val assignments: Map = emptyMap(), +) { + + /** The page assigned to [userId], or null when they have none (or it is unknown). */ + fun pageFor(userId: String): OrgLinkedInPage? = + assignments[userId]?.let { pageId -> pages.firstOrNull { it.id == pageId } } + + companion object { + /** An organization with no LinkedIn credential — the ordinary starting state. */ + val NOT_CONNECTED = OrgLinkedInStatus(connected = false) + } +} + +/** + * One LinkedIn company page bound to the organization (an `OrgLinkedInPage` + * record). [id] is the InterlinedList record id — the value `…/linkedin/assignments` + * expects as `pageId` — while [linkedInPageId] is LinkedIn's own identifier. + */ +data class OrgLinkedInPage( + val id: String, + val linkedInPageId: String? = null, + val name: String, + val logoUrl: String? = null, + val lastSyncedAt: String? = null, +) { + /** Best label for a row: the page name, falling back to a placeholder. */ + val displayName: String get() = name.ifBlank { "LinkedIn page" } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissions.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissions.kt index 31ecb66..d62265b 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissions.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissions.kt @@ -51,6 +51,20 @@ data class OrgPermissions( /** "Owner: … can delete the org" — and a system organization is never deletable. */ val canDeleteOrganization: Boolean get() = viewerRole == OrgRole.OWNER && !isSystem + /** + * The organization's shared LinkedIn credential: its status, page sync, the + * per-member page assignments and disconnecting it. + * + * The help centre says "When an organization's **owners or admins** connect a + * shared LinkedIn credential…", and the server enforces exactly that — unlike + * the member endpoints, these answer a plain member + * `403 {"error":"Admin or owner required","code":"forbidden"}` (verified live + * against both `PUT …/linkedin/assignments` and `POST …/linkedin/sync-pages`). + * `GET …/linkedin/status` is merely members-only, but nothing outside + * management reads it, so the whole section follows the stricter rule. + */ + val canManageLinkedIn: Boolean get() = administers + /** Any member may leave, except from a system organization. */ val canLeave: Boolean get() = isMember && !isSystem diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt index 6a93971..1e62d99 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt @@ -79,3 +79,49 @@ const val LAST_OWNER_DEMOTE_EXPLANATION: String = const val LAST_OWNER_REMOVE_EXPLANATION: String = "This is the organization's only owner. Make someone else an owner first, " + "then you can remove them." + +// ---- LinkedIn company pages ------------------------------------------------- + +/** + * True when the API says the organization simply has no LinkedIn credential. + * Both spellings are live captures: + * + * - `DELETE …/linkedin/credential` → 404 `{"error":"No LinkedIn credential found"}` + * - `POST …/linkedin/sync-pages` → 404 + * `{"error":"No active LinkedIn credential for this organization"}` + * + * That is a state, not a fault: it is reported as the not-connected section + * rather than as an error. + */ +val AppError.isMissingLinkedInCredential: Boolean + get() = this is AppError.NotFound && + message?.contains("LinkedIn credential", ignoreCase = true) == true + +/** + * Explains why a LinkedIn action was refused, using the server's own vocabulary. + * The owner/admin rejection is a real 403 here — unlike the member endpoints, the + * server does enforce it (`{"error":"Admin or owner required"}`, verified live). + */ +fun AppError.toLinkedInMessage(): String = when { + isMissingLinkedInCredential -> LINKEDIN_NOT_CONNECTED_EXPLANATION + this is AppError.Forbidden -> + "Only an owner or admin can manage this organization's LinkedIn connection." + this is AppError.NotFound && message?.contains("Page not found", ignoreCase = true) == true -> + "That page is no longer in this organization. Sync pages and try again." + else -> toUserMessage() +} + +/** + * The not-connected state in one sentence. An organization without a LinkedIn + * credential is the ordinary case, so this reads as information rather than a + * failure. Linking itself is a browser OAuth redirect + * (`GET /api/auth/linkedin/org-authorize`), which this screen does not perform. + */ +const val LINKEDIN_NOT_CONNECTED_EXPLANATION: String = + "This organization has no LinkedIn connection, so it can't post to company pages. " + + "An owner or admin can connect one on interlinedlist.com." + +/** What disconnecting actually costs, stated before the user confirms it. */ +const val LINKEDIN_DISCONNECT_CONSEQUENCE: String = + "This organization will no longer be able to post to its LinkedIn company pages, " + + "and every member's page assignment is cleared. Reconnecting on the web restores it." diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt index 2b043df..c2a1bd4 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -20,7 +21,9 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.Button @@ -33,6 +36,8 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch @@ -55,10 +60,14 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInPage +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgRole import com.interlinedlist.android.feature.organizations.domain.Organization import com.interlinedlist.android.feature.organizations.ui.LAST_OWNER_EXPLANATION +import com.interlinedlist.android.feature.organizations.ui.LINKEDIN_DISCONNECT_CONSEQUENCE +import com.interlinedlist.android.feature.organizations.ui.LINKEDIN_NOT_CONNECTED_EXPLANATION /** Stable test tags for the organization detail screen. */ object OrganizationDetailTestTags { @@ -85,6 +94,26 @@ object OrganizationDetailTestTags { const val VIEWER_ROLE = "orgDetailViewerRole" const val SYSTEM = "orgDetailSystem" const val EDIT_VISIBILITY = "orgDetailEditVisibility" + const val LINKEDIN = "orgDetailLinkedIn" + const val LINKEDIN_CONNECTED = "orgDetailLinkedInConnected" + const val LINKEDIN_NOT_CONNECTED = "orgDetailLinkedInNotConnected" + const val LINKEDIN_ERROR = "orgDetailLinkedInError" + const val LINKEDIN_SYNC = "orgDetailLinkedInSync" + const val LINKEDIN_NO_PAGES = "orgDetailLinkedInNoPages" + const val LINKEDIN_DISCONNECT = "orgDetailLinkedInDisconnect" + const val LINKEDIN_DISCONNECT_DIALOG = "orgDetailLinkedInDisconnectDialog" + const val LINKEDIN_DISCONNECT_CONFIRM = "orgDetailLinkedInDisconnectConfirm" + fun linkedInPage(pageId: String) = "orgLinkedInPage_$pageId" + + /** The assignment control on a member's LinkedIn row. */ + fun linkedInAssignment(userId: String) = "orgLinkedInAssignment_$userId" + + /** A page option inside a member's assignment menu. */ + fun linkedInPageOption(userId: String, pageId: String) = "orgLinkedInOption_${userId}_$pageId" + + /** The "Not assigned" option inside a member's assignment menu. */ + fun linkedInClearOption(userId: String) = "orgLinkedInOptionNone_$userId" + fun member(userId: String) = "orgMember_$userId" fun remove(userId: String) = "orgMemberRemove_$userId" fun candidate(userId: String) = "orgCandidate_$userId" @@ -121,6 +150,9 @@ fun OrganizationDetailRoute( onDelete = { viewModel.deleteOrganization(onDeleted) }, onJoin = viewModel::join, onLeave = { viewModel.leave(onLeft) }, + onAssignLinkedInPage = viewModel::assignLinkedInPage, + onSyncLinkedInPages = viewModel::syncLinkedInPages, + onRemoveLinkedInCredential = viewModel::removeLinkedInCredential, modifier = modifier, ) } @@ -139,6 +171,9 @@ fun OrganizationDetailScreen( onDelete: () -> Unit, onJoin: () -> Unit, onLeave: () -> Unit, + onAssignLinkedInPage: (OrgMember, String?) -> Unit, + onSyncLinkedInPages: () -> Unit, + onRemoveLinkedInCredential: () -> Unit, modifier: Modifier = Modifier, ) { var menuOpen by remember { mutableStateOf(false) } @@ -254,6 +289,20 @@ fun OrganizationDetailScreen( onAddCandidate = onAddCandidate, onChangeRole = onChangeRole, onRemoveMember = onRemoveMember, + // Only an owner or admin manages the shared credential, so + // the section exists only for them. + header = if (state.showLinkedIn) { + { + LinkedInSection( + state = state, + onAssignPage = onAssignLinkedInPage, + onSync = onSyncLinkedInPages, + onDisconnect = onRemoveLinkedInCredential, + ) + } + } else { + null + }, ) } else { JoinPrompt(canJoin = state.canJoin, isJoining = state.isJoining, onJoin = onJoin) @@ -441,6 +490,8 @@ private fun MemberList( onAddCandidate: (MemberCandidate) -> Unit, onChangeRole: (OrgMember, OrgRole) -> Unit, onRemoveMember: (OrgMember) -> Unit, + /** Optional content above the roster; the screen uses it for LinkedIn. */ + header: (@Composable () -> Unit)? = null, ) { LazyColumn( modifier = Modifier @@ -449,6 +500,7 @@ private fun MemberList( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + header?.let { item { it() } } if (state.candidates.isNotEmpty()) { item { Text("Suggestions", style = MaterialTheme.typography.labelLarge) } items(state.candidates, key = { "candidate-${it.userId}" }) { candidate -> @@ -554,6 +606,218 @@ private fun MemberRow( } } +/** + * The organization's shared LinkedIn credential: whether one is connected, the + * company pages discovered for it, which member posts to which page, and the two + * management actions (sync, disconnect). + * + * Shown only to a role that may manage it — the server answers anyone else + * `403 {"error":"Admin or owner required"}` — and an organization with **no** + * credential is rendered as its own ordinary state, not as a failure: that is + * what almost every organization looks like. + */ +@Composable +private fun LinkedInSection( + state: OrganizationDetailUiState, + onAssignPage: (OrgMember, String?) -> Unit, + onSync: () -> Unit, + onDisconnect: () -> Unit, +) { + var showDisconnectConfirm by remember { mutableStateOf(false) } + val status = state.linkedIn + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(OrganizationDetailTestTags.LINKEDIN), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("LinkedIn company pages", style = MaterialTheme.typography.titleMedium) + + when { + status == null && state.isLinkedInLoading -> + Text( + text = "Checking the LinkedIn connection…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + status?.connected == true -> ConnectedLinkedIn( + state = state, + status = status, + onAssignPage = onAssignPage, + onSync = onSync, + onDisconnect = { showDisconnectConfirm = true }, + ) + + // No credential — the ordinary case, stated plainly. + else -> Text( + text = LINKEDIN_NOT_CONNECTED_EXPLANATION, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_NOT_CONNECTED), + ) + } + + state.linkedInError?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_ERROR), + ) + } + } + } + + if (showDisconnectConfirm) { + AlertDialog( + onDismissRequest = { showDisconnectConfirm = false }, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT_DIALOG), + title = { Text("Disconnect LinkedIn?") }, + // The consequence is spelled out before anything is destroyed. + text = { Text(LINKEDIN_DISCONNECT_CONSEQUENCE) }, + confirmButton = { + TextButton( + onClick = { showDisconnectConfirm = false; onDisconnect() }, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT_CONFIRM), + ) { Text("Disconnect") } + }, + dismissButton = { + TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") } + }, + ) + } +} + +/** The connected half of [LinkedInSection]: pages, assignments and the actions. */ +@Composable +private fun ConnectedLinkedIn( + state: OrganizationDetailUiState, + status: OrgLinkedInStatus, + onAssignPage: (OrgMember, String?) -> Unit, + onSync: () -> Unit, + onDisconnect: () -> Unit, +) { + Text( + text = status.expiresAt?.let { "Connected · access expires ${it.asDate()}" } ?: "Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_CONNECTED), + ) + + if (status.pages.isEmpty()) { + Text( + text = "No company pages yet. Sync to fetch the pages this credential administers.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_NO_PAGES), + ) + } else { + status.pages.forEach { page -> + Column(Modifier.testTag(OrganizationDetailTestTags.linkedInPage(page.id))) { + Text(page.displayName, style = MaterialTheme.typography.bodyLarge) + page.linkedInPageId?.let { + Text( + text = "LinkedIn page $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (state.members.isNotEmpty()) { + HorizontalDivider() + Text("Who posts to which page", style = MaterialTheme.typography.labelLarge) + state.members.forEach { member -> + LinkedInAssignmentRow( + member = member, + pages = status.pages, + assignedPage = status.pageFor(member.userId), + onAssign = { pageId -> onAssignPage(member, pageId) }, + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onSync, + enabled = !state.isLinkedInSyncing, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_SYNC), + ) { + Icon(Icons.Default.Sync, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(if (state.isLinkedInSyncing) "Syncing…" else "Sync pages") + } + TextButton( + onClick = onDisconnect, + modifier = Modifier.testTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT), + ) { + Icon(Icons.Default.LinkOff, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Disconnect") + } + } +} + +/** + * One member's page assignment. The API keeps at most one page per member + * (`PUT …/linkedin/assignments` takes a single `{userId, pageId}` and clears the + * assignment when `pageId` is absent), so this is a single-choice menu with an + * explicit "Not assigned" entry rather than a multi-select. + */ +@Composable +private fun LinkedInAssignmentRow( + member: OrgMember, + pages: List, + assignedPage: OrgLinkedInPage?, + onAssign: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = member.label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Box { + TextButton( + onClick = { expanded = true }, + modifier = Modifier.testTag(OrganizationDetailTestTags.linkedInAssignment(member.userId)), + ) { Text(assignedPage?.displayName ?: "Not assigned") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Not assigned") }, + onClick = { expanded = false; onAssign(null) }, + modifier = Modifier.testTag( + OrganizationDetailTestTags.linkedInClearOption(member.userId), + ), + ) + pages.forEach { page -> + DropdownMenuItem( + text = { Text(page.displayName) }, + onClick = { expanded = false; onAssign(page.id) }, + modifier = Modifier.testTag( + OrganizationDetailTestTags.linkedInPageOption(member.userId, page.id), + ), + ) + } + } + } + } +} + +/** ISO-8601 instants are shown as their date part; the time adds nothing here. */ +private fun String.asDate(): String = substringBefore('T') + @Composable private fun CandidateRow(candidate: MemberCandidate, onAdd: () -> Unit) { Card( @@ -710,6 +974,9 @@ private fun OrganizationDetailScreenPreview() { onDelete = {}, onJoin = {}, onLeave = {}, + onAssignLinkedInPage = { _, _ -> }, + onSyncLinkedInPages = {}, + onRemoveLinkedInCredential = {}, ) } } diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt index d050661..1b0fcc0 100644 --- a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt @@ -4,8 +4,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.organizations.data.OrganizationsRepository import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgPermissions import com.interlinedlist.android.feature.organizations.domain.OrgRole @@ -13,7 +15,9 @@ import com.interlinedlist.android.feature.organizations.domain.Organization import com.interlinedlist.android.feature.organizations.ui.LAST_OWNER_DEMOTE_EXPLANATION import com.interlinedlist.android.feature.organizations.ui.LAST_OWNER_EXPLANATION import com.interlinedlist.android.feature.organizations.ui.LAST_OWNER_REMOVE_EXPLANATION +import com.interlinedlist.android.feature.organizations.ui.isMissingLinkedInCredential import com.interlinedlist.android.feature.organizations.ui.isSubscriptionGate +import com.interlinedlist.android.feature.organizations.ui.toLinkedInMessage import com.interlinedlist.android.feature.organizations.ui.toJoinMessage import com.interlinedlist.android.feature.organizations.ui.toLeaveMessage import com.interlinedlist.android.feature.organizations.ui.toRemoveMemberMessage @@ -46,6 +50,12 @@ data class OrganizationDetailUiState( val searchQuery: String = "", val candidates: List = emptyList(), val isSearching: Boolean = false, + // Shared LinkedIn credential; null until it has been read (or when the + // viewer's role may not manage it, in which case it is never requested). + val linkedIn: OrgLinkedInStatus? = null, + val isLinkedInLoading: Boolean = false, + val isLinkedInSyncing: Boolean = false, + val linkedInError: String? = null, ) { val title: String get() = organization?.displayName.orEmpty() val isEmpty: Boolean get() = members.isEmpty() && !isLoading && errorMessage == null && isMember @@ -62,6 +72,13 @@ data class OrganizationDetailUiState( /** A public organization the user has not joined can be joined from here. */ val canJoin: Boolean get() = permissions.canJoin + /** + * Whether the LinkedIn section is offered at all. Only an owner or admin may + * manage the shared credential, and the server enforces it, so no one else is + * shown controls that would only be refused. + */ + val showLinkedIn: Boolean get() = permissions.canManageLinkedIn && organization != null + /** How many owners the loaded member list holds; the server protects the last one. */ private val ownerCount: Int get() = members.count { it.role == OrgRole.OWNER } @@ -144,6 +161,9 @@ class OrganizationDetailViewModel @Inject constructor( it.copy(errorMessage = it.errorMessage ?: members.error.toUserMessage()) } } + // Only an owner or admin manages the shared LinkedIn credential, so + // nobody else's screen even asks for it. + if (OrgPermissions.of(organization).canManageLinkedIn) loadLinkedIn() } } @@ -320,5 +340,105 @@ class OrganizationDetailViewModel @Inject constructor( } } + // ---- LinkedIn company pages -------------------------------------------- + + /** + * Reads the shared credential, its pages and the per-member assignments. An + * organization with no credential is a normal success + * (`{"credential":null}`), so it lands in [OrgLinkedInStatus.NOT_CONNECTED] + * and the section says so instead of showing an error. + */ + fun loadLinkedIn() { + if (!_uiState.value.permissions.canManageLinkedIn) return + _uiState.update { it.copy(isLinkedInLoading = true, linkedInError = null) } + viewModelScope.launch { + when (val result = repository.getLinkedInStatus(orgId)) { + is ApiResult.Success -> _uiState.update { + it.copy(linkedIn = result.data, isLinkedInLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLinkedInLoading = false).withLinkedInFailure(result.error) + } + } + } + } + + /** + * Assigns [member] to the company page [pageId], or clears their assignment + * when it is null. The server takes one pair per call and answers with + * whether the member ends up assigned, which is what the UI then shows. + */ + fun assignLinkedInPage(member: OrgMember, pageId: String?) { + val state = _uiState.value + if (!state.permissions.canManageLinkedIn) return + if (state.linkedIn?.connected != true) return + _uiState.update { it.copy(linkedInError = null) } + viewModelScope.launch { + when (val result = repository.assignLinkedInPage(orgId, member.userId, pageId)) { + is ApiResult.Success -> _uiState.update { current -> + val status = current.linkedIn ?: return@update current + val assignments = status.assignments.toMutableMap() + if (result.data && pageId != null) { + assignments[member.userId] = pageId + } else { + assignments.remove(member.userId) + } + current.copy(linkedIn = status.copy(assignments = assignments)) + } + is ApiResult.Failure -> _uiState.update { it.withLinkedInFailure(result.error) } + } + } + } + + /** Re-discovers the organization's company pages and refreshes the list. */ + fun syncLinkedInPages() { + val state = _uiState.value + if (!state.permissions.canManageLinkedIn || state.isLinkedInSyncing) return + _uiState.update { it.copy(isLinkedInSyncing = true, linkedInError = null) } + viewModelScope.launch { + when (val result = repository.syncLinkedInPages(orgId)) { + is ApiResult.Success -> _uiState.update { + it.copy(isLinkedInSyncing = false, linkedIn = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLinkedInSyncing = false).withLinkedInFailure(result.error) + } + } + } + } + + /** + * Disconnects the shared credential. Destructive — the organization can no + * longer post to its company pages and every assignment is cleared — so the + * UI only calls this from a confirmed dialog. + */ + fun removeLinkedInCredential() { + if (!_uiState.value.permissions.canManageLinkedIn) return + _uiState.update { it.copy(linkedInError = null) } + viewModelScope.launch { + when (val result = repository.removeLinkedInCredential(orgId)) { + is ApiResult.Success -> _uiState.update { + it.copy(linkedIn = OrgLinkedInStatus.NOT_CONNECTED) + } + // "No credential" is the outcome the user asked for, not a failure. + is ApiResult.Failure -> _uiState.update { it.withLinkedInFailure(result.error) } + } + } + } + + /** + * Applies a LinkedIn failure. An organization whose credential is missing (or + * has just been disconnected elsewhere) is not an error state: the section + * becomes the not-connected one. Everything else is explained to the user. + */ + private fun OrganizationDetailUiState.withLinkedInFailure(error: AppError) = + if (error.isMissingLinkedInCredential) { + copy(linkedIn = OrgLinkedInStatus.NOT_CONNECTED, linkedInError = null) + } else { + copy(linkedInError = error.toLinkedInMessage()) + } + + fun clearLinkedInError() = _uiState.update { it.copy(linkedInError = null) } + fun clearError() = _uiState.update { it.copy(errorMessage = null) } } diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt index 0be976f..4f8200c 100644 --- a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt @@ -4,6 +4,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.organizations.data.OrganizationsRepository import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgRole import com.interlinedlist.android.feature.organizations.domain.Organization @@ -34,6 +35,10 @@ class FakeOrganizationsRepository : OrganizationsRepository { var removeMemberResult: ApiResult = ApiResult.Success(Unit) var updateRoleCount = 0 var joinResult: ApiResult = ApiResult.Success(Unit) + var linkedInStatusResult: ApiResult = ApiResult.Success(OrgLinkedInStatus.NOT_CONNECTED) + var assignPageResult: ApiResult? = null + var removeCredentialResult: ApiResult = ApiResult.Success(Unit) + var syncPagesResult: ApiResult? = null var leaveResult: ApiResult = ApiResult.Success(Unit) var refreshCount = 0 @@ -44,6 +49,11 @@ class FakeOrganizationsRepository : OrganizationsRepository { var leftOrgIds = mutableListOf() var lastMemberSearch: String? = null var lastUpdate: Triple? = null + var linkedInStatusCount = 0 + var syncPagesCount = 0 + var removeCredentialCount = 0 + /** Every assignment the UI asked for, as (userId, pageId) — pageId null clears it. */ + val assignments = mutableListOf>() override fun observeOrganizations(): Flow> = cache @@ -141,6 +151,33 @@ class FakeOrganizationsRepository : OrganizationsRepository { return removeMemberResult } + override suspend fun getLinkedInStatus(orgId: String): ApiResult { + linkedInStatusCount++ + return linkedInStatusResult + } + + override suspend fun assignLinkedInPage( + orgId: String, + userId: String, + pageId: String?, + ): ApiResult { + assignments += userId to pageId + return assignPageResult ?: ApiResult.Success(pageId != null) + } + + override suspend fun removeLinkedInCredential(orgId: String): ApiResult { + removeCredentialCount++ + if (removeCredentialResult is ApiResult.Success) { + linkedInStatusResult = ApiResult.Success(OrgLinkedInStatus.NOT_CONNECTED) + } + return removeCredentialResult + } + + override suspend fun syncLinkedInPages(orgId: String): ApiResult { + syncPagesCount++ + return syncPagesResult ?: linkedInStatusResult + } + companion object { fun subscriptionFailure(): ApiResult.Failure = ApiResult.Failure(AppError.SubscriptionRequired("Organizations require an active subscription")) diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt index 9d90a85..5f54c86 100644 --- a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt @@ -397,6 +397,202 @@ class DefaultOrganizationsRepositoryTest { assertThat(org.isMember).isFalse() } + // ---- LinkedIn company pages --------------------------------------------- + + @Test + fun `getLinkedInStatus reads the not-connected payload as a normal state`() = runTest(dispatcher) { + // Captured live: an organization that never connected LinkedIn answers 200. + server.enqueue(MockResponse().setBody("""{ "credential": null, "role": "owner" }""")) + + val result = repository.getLinkedInStatus("o1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val status = (result as ApiResult.Success).data + assertThat(status.connected).isFalse() + assertThat(status.pages).isEmpty() + assertThat(status.assignments).isEmpty() + assertThat(server.takeRequest().path).isEqualTo("/api/organizations/o1/linkedin/status") + } + + @Test + fun `getLinkedInStatus reads a connected credential with its pages and assignments`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "credential": { + "expiresAt": "2026-12-01T00:00:00.000Z", + "pages": [ + { "id": "p1", "linkedInPageId": "12345678", "pageName": "Acme Corp", + "pageLogoUrl": "https://img/acme.png", "lastSyncedAt": "2026-06-12T00:00:00.000Z" }, + { "id": "p2", "linkedInPageId": "87654321", "pageName": "Acme Labs" } + ], + "assignments": [ { "userId": "u1", "pageId": "p2" } ] + }, + "role": "admin" + } + """.trimIndent(), + ), + ) + + val status = (repository.getLinkedInStatus("o1") as ApiResult.Success).data + + assertThat(status.connected).isTrue() + assertThat(status.expiresAt).isEqualTo("2026-12-01T00:00:00.000Z") + assertThat(status.pages.map { it.id }).containsExactly("p1", "p2").inOrder() + assertThat(status.pages[0].name).isEqualTo("Acme Corp") + assertThat(status.pages[0].linkedInPageId).isEqualTo("12345678") + assertThat(status.pages[0].logoUrl).isEqualTo("https://img/acme.png") + // Assignments are per member: u1 posts as Acme Labs. + assertThat(status.assignments).containsExactly("u1", "p2") + assertThat(status.pageFor("u1")?.name).isEqualTo("Acme Labs") + } + + @Test + fun `getLinkedInStatus tolerates the documented top-level shape`() = runTest(dispatcher) { + // `/help/api/organizations` documents `{ "connected": true, "expiresAt": … }` + // "plus the discovered pages"; the live server answers the `credential` + // shape instead, so both are accepted. + server.enqueue( + MockResponse().setBody( + """ + { + "connected": true, + "expiresAt": "2026-12-01T00:00:00.000Z", + "pages": [ { "pageId": "p9", "label": "Acme Corp", "logoUrl": null, "assignedUserId": "u7" } ] + } + """.trimIndent(), + ), + ) + + val status = (repository.getLinkedInStatus("o1") as ApiResult.Success).data + + assertThat(status.connected).isTrue() + assertThat(status.pages.single().id).isEqualTo("p9") + assertThat(status.pages.single().name).isEqualTo("Acme Corp") + // An assignment carried on the page itself is read the same way. + assertThat(status.assignments).containsExactly("u7", "p9") + } + + @Test + fun `assignLinkedInPage puts the member and page the server expects`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "assigned": true }""")) + + val result = repository.assignLinkedInPage("o1", userId = "u1", pageId = "p2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isTrue() + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/organizations/o1/linkedin/assignments") + // The live contract is one { userId, pageId } pair per call, not a map. + assertThat(request.body.readUtf8()).isEqualTo("""{"userId":"u1","pageId":"p2"}""") + } + + @Test + fun `assignLinkedInPage clears an assignment by omitting the page`() = runTest(dispatcher) { + // Verified live: { "userId": … } with no pageId answers { "assigned": false }. + server.enqueue(MockResponse().setBody("""{ "assigned": false }""")) + + val result = repository.assignLinkedInPage("o1", userId = "u1", pageId = null) + + assertThat((result as ApiResult.Success).data).isFalse() + assertThat(server.takeRequest().body.readUtf8()).isEqualTo("""{"userId":"u1"}""") + } + + @Test + fun `assignLinkedInPage surfaces a page the organization no longer has`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Page not found in this organization", "code": "not_found" }"""), + ) + + val result = repository.assignLinkedInPage("o1", userId = "u1", pageId = "gone") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `assignLinkedInPage surfaces the owner-or-admin rejection`() = runTest(dispatcher) { + // Live: a plain member really is refused here, unlike the member endpoints. + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "Admin or owner required", "code": "forbidden" }"""), + ) + + val result = repository.assignLinkedInPage("o1", userId = "u1", pageId = "p2") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Forbidden::class.java) + } + + @Test + fun `removeLinkedInCredential deletes the credential`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "message": "LinkedIn credential removed" }""")) + + val result = repository.removeLinkedInCredential("o1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/organizations/o1/linkedin/credential") + } + + @Test + fun `removeLinkedInCredential reports an organization that has none`() = runTest(dispatcher) { + // Captured live against an org with no credential. + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "No LinkedIn credential found", "code": "not_found" }"""), + ) + + val result = repository.removeLinkedInCredential("o1") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("No LinkedIn credential found") + } + + @Test + fun `syncLinkedInPages posts the sync and returns the refreshed page list`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "pages": [] }""")) + server.enqueue( + MockResponse().setBody( + """ + { "credential": { "pages": [ { "id": "p3", "pageName": "Acme Studio" } ] }, "role": "owner" } + """.trimIndent(), + ), + ) + + val result = repository.syncLinkedInPages("o1") + + val status = (result as ApiResult.Success).data + assertThat(status.connected).isTrue() + assertThat(status.pages.map { it.name }).containsExactly("Acme Studio") + val sync = server.takeRequest() + assertThat(sync.method).isEqualTo("POST") + assertThat(sync.path).isEqualTo("/api/organizations/o1/linkedin/sync-pages") + // The refreshed list comes from the status endpoint, whose shape is known. + assertThat(server.takeRequest().path).isEqualTo("/api/organizations/o1/linkedin/status") + } + + @Test + fun `syncLinkedInPages reports an organization with no credential`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404).setBody( + """{ "error": "No active LinkedIn credential for this organization", "code": "not_found" }""", + ), + ) + + val result = repository.syncLinkedInPages("o1") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + // No pointless status re-read after a failed sync. + assertThat(server.requestCount).isEqualTo(1) + } + @Test fun `removeMember deletes the membership`() = runTest(dispatcher) { server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissionsTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissionsTest.kt index 720eb47..4211987 100644 --- a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissionsTest.kt +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgPermissionsTest.kt @@ -146,6 +146,27 @@ class OrgPermissionsTest { assertThat(permissionsFor(null).canDeleteOrganization).isFalse() } + // ---- LinkedIn company pages --------------------------------------------- + // "When an organization's owners or admins connect a shared LinkedIn + // credential…" — and the server agrees: a member PUTting an assignment or + // POSTing a page sync gets 403 {"error":"Admin or owner required"} (live). + + @Test + fun `only an owner or admin may manage the LinkedIn connection`() { + assertThat(permissionsFor(OrgRole.OWNER).canManageLinkedIn).isTrue() + assertThat(permissionsFor(OrgRole.ADMIN).canManageLinkedIn).isTrue() + assertThat(permissionsFor(OrgRole.MEMBER).canManageLinkedIn).isFalse() + assertThat(permissionsFor(null).canManageLinkedIn).isFalse() + } + + @Test + fun `a system organization's LinkedIn connection follows the same role rule`() { + // Unlike leaving or deleting, nothing about the credential is special-cased + // for a built-in organization. + assertThat(permissionsFor(OrgRole.OWNER, isSystem = true).canManageLinkedIn).isTrue() + assertThat(permissionsFor(OrgRole.MEMBER, isSystem = true).canManageLinkedIn).isFalse() + } + // ---- Join / leave ------------------------------------------------------- @Test @@ -182,5 +203,6 @@ class OrgPermissionsTest { assertThat(none.canDeleteOrganization).isFalse() assertThat(none.canLeave).isFalse() assertThat(none.canJoin).isFalse() + assertThat(none.canManageLinkedIn).isFalse() } } diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessagesTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessagesTest.kt index 2d155d1..cc53770 100644 --- a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessagesTest.kt +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessagesTest.kt @@ -76,4 +76,43 @@ class OrganizationsErrorMessagesTest { assertThat(forbidden.toRoleChangeMessage()).isEqualTo(forbidden.toUserMessage()) assertThat(forbidden.toRemoveMemberMessage()).isEqualTo(forbidden.toUserMessage()) } + + // ---- LinkedIn ---------------------------------------------------------- + + @Test + fun `an organization with no LinkedIn credential is a state, not an error`() { + // Both spellings captured live, from DELETE .../credential and POST .../sync-pages. + val onDelete = AppError.NotFound("No LinkedIn credential found") + val onSync = AppError.NotFound("No active LinkedIn credential for this organization") + + assertThat(onDelete.isMissingLinkedInCredential).isTrue() + assertThat(onSync.isMissingLinkedInCredential).isTrue() + assertThat(onSync.toLinkedInMessage()).isEqualTo(LINKEDIN_NOT_CONNECTED_EXPLANATION) + } + + @Test + fun `the owner-or-admin rejection is explained in role terms`() { + // Live 403: {"error":"Admin or owner required","code":"forbidden"} + val error = AppError.Forbidden("Admin or owner required") + + assertThat(error.isMissingLinkedInCredential).isFalse() + assertThat(error.toLinkedInMessage()) + .isEqualTo("Only an owner or admin can manage this organization's LinkedIn connection.") + } + + @Test + fun `a stale page assignment points at syncing`() { + // Live 404: {"error":"Page not found in this organization","code":"not_found"} + val error = AppError.NotFound("Page not found in this organization") + + assertThat(error.toLinkedInMessage()) + .isEqualTo("That page is no longer in this organization. Sync pages and try again.") + } + + @Test + fun `other LinkedIn failures fall back to the shared mapping`() { + val error = AppError.Network(null) + + assertThat(error.toLinkedInMessage()).isEqualTo(error.toUserMessage()) + } } diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt index b07ec26..ddf331a 100644 --- a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt @@ -6,6 +6,8 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.organizations.FakeOrganizationsRepository import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInPage +import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus import com.interlinedlist.android.feature.organizations.domain.OrgMember import com.interlinedlist.android.feature.organizations.domain.OrgRole import com.interlinedlist.android.feature.organizations.domain.Organization @@ -573,4 +575,271 @@ class OrganizationDetailViewModelTest { assertThat(repo.lastUpdate).isNull() assertThat(vm.uiState.value.organization?.isPublic).isTrue() } + + // ---- LinkedIn company pages -------------------------------------------- + + private val acmePage = OrgLinkedInPage(id = "p1", linkedInPageId = "12345678", name = "Acme Corp") + private val labsPage = OrgLinkedInPage(id = "p2", linkedInPageId = "87654321", name = "Acme Labs") + + private fun connected(assignments: Map = emptyMap()) = OrgLinkedInStatus( + connected = true, + expiresAt = "2026-12-01T00:00:00.000Z", + pages = listOf(acmePage, labsPage), + assignments = assignments, + ) + + @Test + fun `an owner sees the LinkedIn section and its credential status`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER), member("u2")).apply { + linkedInStatusResult = ApiResult.Success(connected(mapOf("u2" to "p2"))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.showLinkedIn).isTrue() + assertThat(state.linkedIn?.connected).isTrue() + assertThat(state.linkedIn?.pages?.map { it.name }).containsExactly("Acme Corp", "Acme Labs") + assertThat(state.linkedIn?.pageFor("u2")?.name).isEqualTo("Acme Labs") + assertThat(repo.linkedInStatusCount).isEqualTo(1) + } + + @Test + fun `an admin also manages LinkedIn`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.ADMIN, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success(connected()) + } + val vm = vmFor(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.showLinkedIn).isTrue() + assertThat(repo.linkedInStatusCount).isEqualTo(1) + } + + @Test + fun `a member is offered no LinkedIn section and the status is never requested`() = + runTest(dispatcher) { + // Live: PUT/POST under .../linkedin answer a member + // 403 {"error":"Admin or owner required"}, so there is nothing to show. + val repo = repoWith(OrgRole.MEMBER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success(connected()) + } + val vm = vmFor(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.showLinkedIn).isFalse() + assertThat(vm.uiState.value.linkedIn).isNull() + assertThat(repo.linkedInStatusCount).isEqualTo(0) + } + + @Test + fun `a non-member is offered no LinkedIn section`() = runTest(dispatcher) { + val repo = repoWith(role = null) + val vm = vmFor(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.showLinkedIn).isFalse() + assertThat(repo.linkedInStatusCount).isEqualTo(0) + } + + @Test + fun `an organization with no credential reads as not connected, not as an error`() = + runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success(OrgLinkedInStatus.NOT_CONNECTED) + } + val vm = vmFor(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.showLinkedIn).isTrue() + assertThat(state.linkedIn?.connected).isFalse() + assertThat(state.linkedIn?.pages).isEmpty() + assertThat(state.linkedInError).isNull() + assertThat(state.errorMessage).isNull() + } + + @Test + fun `a missing-credential rejection is shown as the not-connected state`() = runTest(dispatcher) { + // Captured live: 404 {"error":"No active LinkedIn credential for this organization"}. + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = + ApiResult.Failure(AppError.NotFound("No active LinkedIn credential for this organization")) + } + val vm = vmFor(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.linkedIn).isEqualTo(OrgLinkedInStatus.NOT_CONNECTED) + assertThat(vm.uiState.value.linkedInError).isNull() + } + + @Test + fun `assigning a page records the member's page`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER), member("u2")).apply { + linkedInStatusResult = ApiResult.Success(connected()) + } + val vm = vmFor(repo) + advanceUntilIdle() + + val target = vm.uiState.value.members.first { it.userId == "u2" } + vm.assignLinkedInPage(target, pageId = "p1") + advanceUntilIdle() + + assertThat(repo.assignments).containsExactly("u2" to "p1") + assertThat(vm.uiState.value.linkedIn?.pageFor("u2")?.name).isEqualTo("Acme Corp") + } + + @Test + fun `clearing an assignment sends no page and drops it from the map`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER), member("u2")).apply { + linkedInStatusResult = ApiResult.Success(connected(mapOf("u2" to "p1"))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + val target = vm.uiState.value.members.first { it.userId == "u2" } + vm.assignLinkedInPage(target, pageId = null) + advanceUntilIdle() + + assertThat(repo.assignments).containsExactly("u2" to null) + assertThat(vm.uiState.value.linkedIn?.pageFor("u2")).isNull() + } + + @Test + fun `a member's assignment attempt is never sent`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.MEMBER, member("u1", OrgRole.OWNER), member("u2")).apply { + linkedInStatusResult = ApiResult.Success(connected()) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.assignLinkedInPage(member("u2"), pageId = "p1") + advanceUntilIdle() + + assertThat(repo.assignments).isEmpty() + } + + @Test + fun `an assignment rejection is explained and leaves the map alone`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.ADMIN, member("u1", OrgRole.OWNER), member("u2")).apply { + linkedInStatusResult = ApiResult.Success(connected(mapOf("u2" to "p1"))) + assignPageResult = ApiResult.Failure(AppError.NotFound("Page not found in this organization")) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.assignLinkedInPage(vm.uiState.value.members.first { it.userId == "u2" }, pageId = "p2") + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.linkedInError).isEqualTo( + "That page is no longer in this organization. Sync pages and try again.", + ) + assertThat(state.linkedIn?.pageFor("u2")?.id).isEqualTo("p1") + } + + @Test + fun `syncing refreshes the page list`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success( + OrgLinkedInStatus(connected = true, pages = listOf(acmePage)), + ) + syncPagesResult = ApiResult.Success( + OrgLinkedInStatus(connected = true, pages = listOf(acmePage, labsPage)), + ) + } + val vm = vmFor(repo) + advanceUntilIdle() + assertThat(vm.uiState.value.linkedIn?.pages?.map { it.id }).containsExactly("p1") + + vm.syncLinkedInPages() + advanceUntilIdle() + + assertThat(repo.syncPagesCount).isEqualTo(1) + assertThat(vm.uiState.value.linkedIn?.pages?.map { it.id }).containsExactly("p1", "p2").inOrder() + assertThat(vm.uiState.value.isLinkedInSyncing).isFalse() + } + + @Test + fun `a member's sync attempt is never sent`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.MEMBER, member("u1", OrgRole.OWNER)) + val vm = vmFor(repo) + advanceUntilIdle() + + vm.syncLinkedInPages() + advanceUntilIdle() + + assertThat(repo.syncPagesCount).isEqualTo(0) + } + + @Test + fun `removing the credential leaves the organization not connected`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success(connected(mapOf("u1" to "p1"))) + } + val vm = vmFor(repo) + advanceUntilIdle() + assertThat(vm.uiState.value.linkedIn?.connected).isTrue() + + vm.removeLinkedInCredential() + advanceUntilIdle() + + assertThat(repo.removeCredentialCount).isEqualTo(1) + val status = vm.uiState.value.linkedIn + assertThat(status).isEqualTo(OrgLinkedInStatus.NOT_CONNECTED) + // The assignments went with it, exactly as the server clears them. + assertThat(status?.assignments).isEmpty() + } + + @Test + fun `a member's removal attempt is never sent`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.MEMBER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success(connected()) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.removeLinkedInCredential() + advanceUntilIdle() + + assertThat(repo.removeCredentialCount).isEqualTo(0) + } + + @Test + fun `removing a credential that has already gone is not reported as a failure`() = + runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Success(connected()) + removeCredentialResult = ApiResult.Failure(AppError.NotFound("No LinkedIn credential found")) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.removeLinkedInCredential() + advanceUntilIdle() + + assertThat(vm.uiState.value.linkedIn).isEqualTo(OrgLinkedInStatus.NOT_CONNECTED) + assertThat(vm.uiState.value.linkedInError).isNull() + } + + @Test + fun `a LinkedIn failure is surfaced without breaking the rest of the screen`() = runTest(dispatcher) { + val repo = repoWith(OrgRole.OWNER, member("u1", OrgRole.OWNER)).apply { + linkedInStatusResult = ApiResult.Failure(AppError.Forbidden("Admin or owner required")) + } + val vm = vmFor(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.linkedInError).isEqualTo( + "Only an owner or admin can manage this organization's LinkedIn connection.", + ) + // The org and its members are untouched by a LinkedIn problem. + assertThat(state.organization?.name).isEqualTo("Acme") + assertThat(state.members).hasSize(1) + assertThat(state.errorMessage).isNull() + + vm.clearLinkedInError() + assertThat(vm.uiState.value.linkedInError).isNull() + } }