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() + } }