Coverage Summary for Class: CreateWorkspaceUseCase (com.georgeci.moneysurfer.domain.usecase)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CreateWorkspaceUseCase |
100%
(4/4)
|
100%
(8/8)
|
100%
(71/71)
|
100%
(925/925)
|
| CreateWorkspaceUseCase$Companion |
|
| CreateWorkspaceUseCase$invoke$1 |
|
| CreateWorkspaceUseCase$Params |
100%
(1/1)
|
|
100%
(4/4)
|
100%
(17/17)
|
| CreateWorkspaceUseCase$registerRemotely$1 |
|
| CreateWorkspaceUseCase$writeLocal$1 |
|
| Total |
100%
(5/5)
|
100%
(8/8)
|
100%
(75/75)
|
100%
(942/942)
|
package com.georgeci.moneysurfer.domain.usecase
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.domain.auth.SessionMutator
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.constants.DEFAULT_CATEGORY_SEEDS
import com.georgeci.moneysurfer.domain.logging.redactUid
import com.georgeci.moneysurfer.domain.model.Category
import com.georgeci.moneysurfer.domain.model.Workspace
import com.georgeci.moneysurfer.domain.model.WorkspaceMember
import com.georgeci.moneysurfer.domain.model.WorkspaceRole
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.UserId
import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
import com.georgeci.moneysurfer.domain.repositories.CategoryRepository
import com.georgeci.moneysurfer.domain.repositories.UserRemoteRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceMemberRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceSyncer
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Single
import kotlin.time.Instant
/**
* End-to-end create-workspace flow:
* 1. Local: insert Workspace + WorkspaceMember(OWNER) + default Category rows.
* 2. Remote: push the workspace to Firestore and append the wid to `users/{uid}.workspaceIds`
* so other devices see it. Skipped for the local "demo" session (no Firebase uid), and for a
* push that did not land because sync is switched off.
* 3. Pin the new workspace as the active one so the next screen lands on Dashboard.
*/
@Single
@Suppress("LongParameterList")
class CreateWorkspaceUseCase(
private val workspaceRepository: WorkspaceRepository,
private val workspaceMemberRepository: WorkspaceMemberRepository,
private val categoryRepository: CategoryRepository,
private val userRemoteRepository: UserRemoteRepository,
private val workspaceSyncer: WorkspaceSyncer,
private val session: SessionPointers,
private val sessionMutator: SessionMutator,
private val getCurrentTime: GetCurrentTimeUseCase,
) {
private val log = Logger.withTag(TAG)
data class Params(
val name: String,
val description: String,
val baseCurrency: CurrencyCode,
)
suspend operator fun invoke(params: Params): Either<CreateWorkspaceError, WorkspaceId> = either {
val ownerId = session.currentUserId.first()
?: raise(CreateWorkspaceError.NoCurrentUser)
val newId = WorkspaceId.uuid()
val now = getCurrentTime()
log.i { "[start] uid=${ownerId.value.redactUid()} wid=${newId.value} name='${params.name}'" }
Either
.catch { writeLocal(newId, ownerId, now, params) }
.onLeft { log.e(it) { "[local] failed wid=${newId.value}" } }
.mapLeft { CreateWorkspaceError.LocalWriteFailed(it) }
.bind()
log.i { "[local] ok wid=${newId.value}" }
// Remote push pipeline. If `syncWorkspace` fails the use case fails LOUD
// (returns Left) — UI must surface the error so the user can retry.
// Skipping `addWorkspaceRef` / `setDefaultWorkspace` / pin keeps the global
// state consistent: `users/{uid}.workspaceIds` won't reference a workspace
// whose `members/{uid}` row never landed (would otherwise lock subsequent
// pulls with PERMISSION_DENIED via firestore.rules `isMember`).
//
// Demo session (no Firebase uid) → no remote contract; success is local-only.
//
// The two `UserRemoteRepository` calls below are conditional on the push having actually
// landed, and `pushAll()` reports that itself. Re-reading `SyncSettings` here instead would
// be a second read of a live setting: a server kill switch retracting between the two reads
// would let this branch run against a push that silently did nothing, filling
// `users/{uid}.workspaceIds` with ids whose `workspaces/{wid}` document was never created —
// exactly the dangling refs the block above exists to prevent (issue #342).
val firebaseUid = session.currentFirebaseUid.first()
if (firebaseUid == null) {
log.i { "[remote] skipped (no Firebase uid — local/demo session)" }
} else {
registerRemotely(firebaseUid = firebaseUid, newId = newId)
}
sessionMutator.setCurrentWorkspace(newId)
log.i { "[done] wid=${newId.value} pinned as current workspace" }
newId
}
/**
* Push the new workspace, then register it under `users/{uid}` — but only if the push actually
* landed. `pushAll()` returning `false` means sync is off, so the `workspaces/{wid}` document
* does not exist and a ref to it would be dangling.
*/
private suspend fun Raise<CreateWorkspaceError>.registerRemotely(firebaseUid: String, newId: WorkspaceId) {
val pushed = Either.catch { workspaceSyncer.pushAll() }
.onLeft { log.w(it) { "[remote] pushAll failed wid=${newId.value}" } }
.onRight { landed -> log.i { "[remote] pushAll landed=$landed wid=${newId.value}" } }
.mapLeft { CreateWorkspaceError.RemoteSyncFailed(it) }
.bind()
if (!pushed) {
log.i { "[remote] sync is off — wid=${newId.value} stays local-only, no ref registered" }
return
}
// Best-effort: a transient failure here doesn't invalidate the workspace, it just means
// cross-device discovery is delayed until the next sync cycle.
val at = "uid=${firebaseUid.redactUid()} wid=${newId.value}"
Either.catch { userRemoteRepository.addWorkspaceRef(firebaseUid, newId) }
.onLeft { log.w(it) { "[remote] addWorkspaceRef failed $at" } }
.onRight { log.i { "[remote] addWorkspaceRef ok $at" } }
Either.catch { userRemoteRepository.setDefaultWorkspace(firebaseUid, newId) }
.onLeft { log.w(it) { "[remote] setDefaultWorkspace failed $at" } }
.onRight { log.i { "[remote] setDefaultWorkspace ok $at" } }
}
private suspend fun writeLocal(
newId: WorkspaceId,
ownerId: UserId,
now: Instant,
params: Params,
) {
workspaceRepository.insert(
Workspace(
id = newId,
name = params.name.trim(),
description = params.description.trim(),
baseCurrency = params.baseCurrency,
ownerId = ownerId,
createdAt = now,
archived = false,
),
)
workspaceMemberRepository.insert(
WorkspaceMember(
userId = ownerId,
workspaceId = newId,
role = WorkspaceRole.OWNER,
addedByUserId = ownerId,
createdAt = now,
updatedAt = now,
),
)
DEFAULT_CATEGORY_SEEDS.forEach { seed ->
categoryRepository.insert(
Category(
id = CategoryId.uuid(),
workspaceId = newId,
name = seed.name,
type = seed.type,
parentId = null,
createdAt = now,
systemKind = seed.systemKind,
),
)
}
}
private companion object {
const val TAG = "CreateWorkspace"
}
}