feat: implement board detail repository and mutation aggregation

This commit is contained in:
2026-03-16 00:32:55 -04:00
parent e7ad14902d
commit c56b9d042a
2 changed files with 628 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
class BoardDetailRepository(
private val sessionStore: SessionStore,
private val apiKeyStore: ApiKeyStore,
private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
val normalizedBoardId = boardId.trim()
if (normalizedBoardId.isBlank()) {
return BoardsApiResult.Failure("Board id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.getBoardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
boardId = normalizedBoardId,
)
}
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
val normalizedListId = listId.trim()
if (normalizedListId.isBlank()) {
return BoardsApiResult.Failure("List id is required")
}
val normalizedTitle = newTitle.trim()
if (normalizedTitle.isBlank()) {
return BoardsApiResult.Failure("List title is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.renameList(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
listId = normalizedListId,
newTitle = normalizedTitle,
)
}
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
val normalizedTargetListId = targetListId.trim()
if (normalizedTargetListId.isBlank()) {
return CardBatchMutationResult.Failure("Target list id is required")
}
val normalizedCardIds = normalizeCardIds(cardIds)
if (normalizedCardIds.isEmpty()) {
return CardBatchMutationResult.Failure("At least one card id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message)
}
val failuresByCardId = linkedMapOf<String, String>()
normalizedCardIds.forEach { cardId ->
when (
val result = apiClient.moveCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = cardId,
targetListId = normalizedTargetListId,
)
) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message
}
}
return aggregateBatchMutationResult(
normalizedCardIds = normalizedCardIds,
failuresByCardId = failuresByCardId,
partialMessage = "Some cards could not be moved. Please try again.",
)
}
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
val normalizedCardIds = normalizeCardIds(cardIds)
if (normalizedCardIds.isEmpty()) {
return CardBatchMutationResult.Failure("At least one card id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message)
}
val failuresByCardId = linkedMapOf<String, String>()
normalizedCardIds.forEach { cardId ->
when (val result = apiClient.deleteCard(session.baseUrl, session.apiKey, cardId)) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message
}
}
return aggregateBatchMutationResult(
normalizedCardIds = normalizedCardIds,
failuresByCardId = failuresByCardId,
partialMessage = "Some cards could not be deleted. Please try again.",
)
}
private fun normalizeCardIds(cardIds: Collection<String>): List<String> {
return cardIds.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
private fun aggregateBatchMutationResult(
normalizedCardIds: List<String>,
failuresByCardId: Map<String, String>,
partialMessage: String,
): CardBatchMutationResult {
if (failuresByCardId.isEmpty()) {
return CardBatchMutationResult.Success
}
if (failuresByCardId.size == normalizedCardIds.size) {
val firstFailureMessage = normalizedCardIds
.asSequence()
.mapNotNull { failuresByCardId[it] }
.firstOrNull()
?.trim()
.orEmpty()
.ifBlank { "Unknown error" }
return CardBatchMutationResult.Failure(firstFailureMessage)
}
return CardBatchMutationResult.PartialSuccess(
failedCardIds = failuresByCardId.keys.toSet(),
message = partialMessage,
)
}
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult
}
return BoardsApiResult.Success(
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
)
}
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) {
return BoardsApiResult.Success(storedWorkspaceId)
}
return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) {
is BoardsApiResult.Success -> {
val first = workspacesResult.value.firstOrNull()?.id
?: return BoardsApiResult.Failure("No workspaces available for this account.")
sessionStore.saveWorkspaceId(first)
BoardsApiResult.Success(first)
}
is BoardsApiResult.Failure -> workspacesResult
}
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
@Suppress("unused")
val workspaceId: String,
)
}