Coverage Summary for Class: AcceptInviteUseCase (com.georgeci.moneysurfer.domain.usecase)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| AcceptInviteUseCase |
100%
(2/2)
|
82.1%
(23/28)
|
68%
(66/97)
|
78.8%
(1151/1461)
|
| AcceptInviteUseCase$Companion |
|
| AcceptInviteUseCase$invoke$1 |
|
| Total |
100%
(2/2)
|
82.1%
(23/28)
|
68%
(66/97)
|
78.8%
(1151/1461)
|
package com.georgeci.moneysurfer.domain.usecase
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.logging.redactEmail
import com.georgeci.moneysurfer.domain.logging.redactUid
import com.georgeci.moneysurfer.domain.model.InviteStatus
import com.georgeci.moneysurfer.domain.model.WorkspaceMember
import com.georgeci.moneysurfer.domain.model.WorkspaceMemberStatus
import com.georgeci.moneysurfer.domain.primitives.WorkspaceInviteId
import com.georgeci.moneysurfer.domain.repositories.UserRemoteRepository
import com.georgeci.moneysurfer.domain.repositories.UserRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceInviteRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceMemberRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceSyncer
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Single
/**
* Accepts a pending invitation:
* 1. Validate caller, invite (PENDING + not expired + targets caller via email or targetUserId).
* 2. Insert local [WorkspaceMember] row, snapshotting profile.
* 3. Mark invite ACCEPTED locally.
* 4. (Firebase session only) push `users/{uid}.workspaceIds += wid` and trigger a
* targeted [WorkspaceSyncer.syncWorkspace] so the recipient's local DB hydrates with
* only the newly joined workspace immediately — without re-syncing all workspaces or
* waiting for the next bootstrap cycle.
*
* Steps 4 are best-effort and log-only on failure: the invite is already accepted, the
* local member row is in place, the next sync cycle will pick up the rest.
*/
@Single
class AcceptInviteUseCase(
private val inviteRepository: WorkspaceInviteRepository,
private val memberRepository: WorkspaceMemberRepository,
private val userRepository: UserRepository,
private val userRemoteRepository: UserRemoteRepository,
private val workspaceSyncer: WorkspaceSyncer,
private val session: SessionPointers,
private val getCurrentTime: GetCurrentTimeUseCase,
) {
private val log = Logger.withTag(TAG)
suspend operator fun invoke(id: WorkspaceInviteId): Either<InviteError, Unit> = either {
log.i { "[start] id=${id.value}" }
val callerId = session.currentUserId.first()
?: raise(InviteError.NoCurrentUser).also { log.w { "[reject] no current user" } }
val caller = userRepository.getById(callerId)
?: raise(InviteError.NoCurrentUser).also {
log.w { "[reject] users.${callerId.value.redactUid()} not found locally" }
}
val invite = inviteRepository.getById(id)
?: raise(InviteError.InviteNotFound).also { log.w { "[reject] invite ${id.value} not found" } }
log.d {
"[loaded] id=${id.value} wid=${invite.workspaceId.value} email=${invite.email.redactEmail()} " +
"target=${(invite.targetUserId?.value).redactUid()} status=${invite.status} " +
"expiresAt=${invite.expiresAt}"
}
ensure(invite.status == InviteStatus.PENDING) {
log.w { "[reject] not PENDING — actual=${invite.status}" }
InviteError.InviteNotPending
}
val now = getCurrentTime()
ensure(now <= invite.expiresAt) {
log.w { "[reject] expired now=$now > expiresAt=${invite.expiresAt}" }
InviteError.InviteExpired
}
val emailMatches = caller.email?.lowercase() == invite.email.lowercase()
val uidMatches = invite.targetUserId == callerId
ensure(emailMatches || uidMatches) {
log.w {
"[reject] caller doesn't match invite — emailMatch=$emailMatches " +
"uidMatch=$uidMatches callerEmail=${caller.email.redactEmail()} " +
"inviteEmail=${invite.email.redactEmail()}"
}
InviteError.InviteNotFound
}
log.d { "[validated] caller=${callerId.value.redactUid()} matchedBy=${if (uidMatches) "uid" else "email"}" }
val member = WorkspaceMember(
userId = callerId,
workspaceId = invite.workspaceId,
role = invite.role,
status = WorkspaceMemberStatus.ACTIVE,
displayName = caller.displayName.orEmpty(),
email = caller.email,
addedByUserId = invite.invitedByUserId,
createdAt = now,
updatedAt = now,
)
Either.catch { memberRepository.insert(member) }
.onLeft { log.e(it) { "[local] member insert failed wid=${invite.workspaceId.value}" } }
.onRight {
log.i {
"[local] member inserted wid=${invite.workspaceId.value} " +
"role=${invite.role}"
}
}
.mapLeft { InviteError.LocalWriteFailed(it) }
.bind()
Either.catch { inviteRepository.markAccepted(id) }
.onLeft { log.e(it) { "[local] invite markAccepted failed id=${id.value}" } }
.onRight { log.i { "[local] invite markAccepted id=${id.value}" } }
.mapLeft { InviteError.LocalWriteFailed(it) }
.bind()
// Best-effort remote follow-ups: register the workspace under the user's doc and
// trigger a local sync so workspace data lands immediately. Failures here don't fail
// the accept — the member row + ACCEPTED invite are already pushed via the outbox,
// and the next bootstrap will pick up whatever this skipped.
val firebaseUid = session.currentFirebaseUid.first()
if (firebaseUid.isNullOrBlank()) {
log.i { "[remote:skip] no firebase session — local-only accept" }
} else {
Either.catch { userRemoteRepository.addWorkspaceRef(firebaseUid, invite.workspaceId) }
.onLeft {
log.w(it) {
"[remote] addWorkspaceRef failed uid=${firebaseUid.redactUid()} " +
"wid=${invite.workspaceId.value} — non-fatal"
}
}
.onRight {
log.i {
"[remote] addWorkspaceRef ok uid=${firebaseUid.redactUid()} wid=${invite.workspaceId.value}"
}
}
Either.catch {
userRemoteRepository.removeInvitedWorkspaceRef(firebaseUid, invite.workspaceId)
}.onLeft {
log.w(it) {
"[remote] removeInvitedWorkspaceRef failed uid=${firebaseUid.redactUid()} " +
"wid=${invite.workspaceId.value} — non-fatal"
}
}.onRight {
log.i {
"[remote] removeInvitedWorkspaceRef ok uid=${firebaseUid.redactUid()} " +
"wid=${invite.workspaceId.value}"
}
}
Either.catch { workspaceSyncer.syncWorkspace(invite.workspaceId) }
.onLeft {
log.w(it) {
"[remote] syncWorkspace failed wid=${invite.workspaceId.value} — " +
"non-fatal, next bootstrap will retry"
}
}
.onRight {
log.i { "[remote] syncWorkspace ok wid=${invite.workspaceId.value}" }
}
}
log.i {
"[done] uid=${callerId.value.redactUid()} wid=${invite.workspaceId.value} role=${invite.role}"
}
}
private companion object {
const val TAG = "AcceptInvite"
}
}