fix: reconcile leave requests in realtime

This commit is contained in:
admin
2026-07-10 22:32:13 +02:00
parent f9c3a2e305
commit 2baa544b32
8 changed files with 189 additions and 47 deletions
@@ -71,8 +71,8 @@ class DriverRepository(
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
@@ -193,6 +193,7 @@ data class CreateLeaveRequestBody(
data class CancelLeaveRequestBody(
val comment: String? = null,
val expectedVersion: Long? = null,
)
data class DriverLeaveRequestDto(
@@ -204,6 +205,10 @@ data class DriverLeaveRequestDto(
val typeLabel: String? = null,
val note: String? = null,
val status: String,
val version: Long = 1,
val updatedAt: String? = null,
val allowedDecisions: List<String> = emptyList(),
val canCancel: Boolean = false,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
@@ -98,6 +98,11 @@ class DriverSyncRepository(
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
saveSyncScope(scope)
}
}
suspend fun saveSyncScope(scope: SyncScopeDto) {
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
@@ -111,7 +116,6 @@ class DriverSyncRepository(
),
)
}
}
suspend fun clearCache() {
dao.clearBootstrap()
@@ -166,5 +170,6 @@ class DriverSyncRepository(
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
const val SCOPE_LEAVE_CALENDAR = "leave_calendar"
}
}
@@ -272,10 +272,11 @@ class DriverLiveSyncClient(
private fun subscribe(socket: WebSocket, socketId: String) {
val id = synchronized(lock) { driverId } ?: return
val channel = "private-driver-mobile.$id"
val channels = listOf("private-driver-mobile.$id", "private-driver-mobile.0")
scope.launch {
runCatching {
channels.forEach { channel ->
val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf(
@@ -286,6 +287,7 @@ class DriverLiveSyncClient(
),
)
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}
}.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}")
socket.close(1000, "subscription_failed")
@@ -52,6 +52,10 @@ class DriverSyncWorker(
syncRepository.saveSyncStates(response)
refreshed = true
}
DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
}
}
}
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate
import java.time.Instant
import pl.firmatpp.kierowca.data.AppPreferencesStore
@@ -121,12 +122,17 @@ internal fun DriverUiState.withLoadedLeaveRequests(
requests: List<DriverLeaveRequestDto>,
navigateToList: Boolean,
): DriverUiState {
val currentById = leaveRequests.associateBy { it.id }
val merged = requests.map { incoming ->
currentById[incoming.id]?.takeIf { current -> current.version > incoming.version } ?: incoming
}.toMutableList()
leaveRequests.filter { current -> requests.none { it.id == current.id } }.forEach(merged::add)
val refreshedSelectedRequest = selectedLeaveRequest?.let { selected ->
requests.firstOrNull { request -> request.id == selected.id } ?: selected
merged.firstOrNull { request -> request.id == selected.id }?.takeIf { it.version >= selected.version } ?: selected
}
return copy(
screen = if (navigateToList) DriverScreen.LeaveRequests else screen,
leaveRequests = requests,
leaveRequests = merged,
selectedLeaveRequest = refreshedSelectedRequest,
error = null,
)
@@ -151,6 +157,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var routeActionsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null
private val leaveMutationMutex = Mutex()
private var pendingLeaveHint: DriverSyncHint? = null
private var leaveCalendarRequestGeneration: Long = 0
val state: StateFlow<DriverUiState> = _state
init {
@@ -419,24 +428,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true, navigateToList = true)
viewModelScope.launch { loadLeaveRequests(showLoading = true, navigateToList = true) }
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false, navigateToList = false)
viewModelScope.launch { loadLeaveRequests(showLoading = false, navigateToList = false) }
}
private fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean) {
viewModelScope.launch {
private suspend fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean): Boolean {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
val result = runCatching { repository.leaveRequests() }
result.onSuccess { requests ->
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false, refreshing = false) }
}
return result.isSuccess
}
fun openLeaveRequest(id: String) {
@@ -550,6 +557,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { LocalDate.parse(snapshot.leaveCalendarLoadedUntil).plusDays(1) }.getOrDefault(LocalDate.now())
}
val to = from.plusMonths(6).minusDays(1)
val generation = ++leaveCalendarRequestGeneration
viewModelScope.launch {
_state.update {
@@ -562,6 +570,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
runCatching { repository.leaveCalendar(from.toString(), to.toString()) }
.onSuccess { entries ->
if (generation != leaveCalendarRequestGeneration) return@onSuccess
_state.update {
val merged = (it.leaveCalendarEntries + entries)
.distinctBy { entry -> entry.id }
@@ -575,6 +584,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
.onFailure { throwable ->
if (generation != leaveCalendarRequestGeneration) return@onFailure
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
@@ -583,13 +593,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
if (!leaveMutationMutex.tryLock()) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") }
leaveMutationMutex.unlock()
return
}
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
try {
runCatching {
repository.createLeaveRequest(
dateFrom = snapshot.leaveRequestDateFrom,
@@ -612,16 +625,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
} finally {
leaveMutationMutex.unlock()
consumePendingLeaveHint()
}
}
}
fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return
if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return
if (!leaveMutationMutex.tryLock()) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.cancelLeaveRequest(request.id) }
try {
runCatching { repository.cancelLeaveRequest(request.id, request.version) }
.onSuccess { updated ->
_state.update {
it.copy(
@@ -632,8 +651,26 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
.onFailure { throwable ->
val apiError = ApiErrorMapper.map(throwable)
if (apiError.kind == ApiErrorKind.Conflict && apiError.code == "leave_request_stale") {
runCatching { repository.leaveRequest(request.id) }
.onSuccess { current ->
_state.update {
it.withLoadedLeaveRequests(listOf(current), navigateToList = false)
.copy(error = "Wniosek został wcześniej zmieniony. Pokazujemy aktualny stan.")
}
}
.onFailure { refreshError -> _state.update { it.withApiError(refreshError) } }
} else {
_state.update { it.withApiError(throwable) }
}
}
_state.update { it.copy(loading = false) }
} finally {
leaveMutationMutex.unlock()
consumePendingLeaveHint()
}
}
}
@@ -1061,12 +1098,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value
val routeId = snapshot.selectedRoute?.id
val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return
val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) }
val staleScopes = remote.scopes.filter { syncRepository.shouldRefresh(it) }
if (needsRefresh) {
refreshCurrentScopeFromSyncState()
} else {
if (staleScopes.isEmpty()) {
syncRepository.saveSyncStates(remote)
return
}
staleScopes.forEach { scope ->
handleSyncHint(
DriverSyncHint(
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
version = scope.version,
checksum = scope.checksum,
),
)
}
}
@@ -1097,10 +1145,55 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
if (leaveMutationMutex.isLocked) {
val pending = pendingLeaveHint
if (pending == null || hint.version > pending.version) pendingLeaveHint = hint
return
}
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
if (loadLeaveRequests(showLoading = false, navigateToList = false)) {
syncRepository.saveSyncScope(hint.asScope())
}
} else {
syncRepository.saveSyncScope(hint.asScope())
}
}
DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> {
if (snapshot.screen == DriverScreen.LeaveCalendar) {
refreshLoadedLeaveCalendar(hint)
} else {
syncRepository.saveSyncScope(hint.asScope())
}
}
}
}
private suspend fun consumePendingLeaveHint() {
val hint = pendingLeaveHint ?: return
pendingLeaveHint = null
if (loadLeaveRequests(showLoading = false, navigateToList = false)) {
syncRepository.saveSyncScope(hint.asScope())
}
}
private suspend fun refreshLoadedLeaveCalendar(hint: DriverSyncHint) {
val generation = ++leaveCalendarRequestGeneration
val snapshot = _state.value
val from = LocalDate.now()
val loadedUntil = snapshot.leaveCalendarLoadedUntil
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?.takeUnless { it.isBefore(from) }
?: from.plusMonths(6).minusDays(1)
runCatching { repository.leaveCalendar(from.toString(), loadedUntil.toString()) }
.onSuccess { entries ->
if (generation != leaveCalendarRequestGeneration) return@onSuccess
_state.update { it.copy(leaveCalendarEntries = entries, leaveCalendarLoadedUntil = loadedUntil.toString(), leaveCalendarLoading = false) }
syncRepository.saveSyncScope(hint.asScope())
}
.onFailure { throwable ->
if (generation != leaveCalendarRequestGeneration) return@onFailure
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
@@ -107,6 +107,21 @@ class DriverLiveSyncClientTest {
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
}
@Test
fun subscribesToOwnUpdatesAndSharedLeaveCalendarUpdates() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = this)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
runCurrent()
val sent = factory.sockets.single().sent
assertTrue(sent.any { it.contains("private-driver-mobile.driver-1") })
assertTrue(sent.any { it.contains("private-driver-mobile.0") })
client.stop()
}
@Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory()
@@ -55,6 +55,23 @@ class DriverUiStateTest {
assertEquals(1, refreshed.leaveRequests.size)
}
@Test
fun staleLeaveRequestRefreshDoesNotOverwriteNewerMutationResult() {
val state = DriverUiState(
screen = DriverScreen.LeaveRequestDetail,
leaveRequests = listOf(leaveRequest(id = "leave-1", status = "cancel_requested", version = 3)),
selectedLeaveRequest = leaveRequest(id = "leave-1", status = "cancel_requested", version = 3),
)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "approved", version = 2)),
navigateToList = false,
)
assertEquals("cancel_requested", refreshed.leaveRequests.single().status)
assertEquals(3L, refreshed.selectedLeaveRequest?.version)
}
@Test
fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() {
val route = route(status = "ZAPLANOWANA")
@@ -97,13 +114,14 @@ class DriverUiStateTest {
assertEquals(listOf("above-first", "above-second", "span"), state.displayRoutes.map { it.id })
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
dateFrom = "2026-07-10",
dateTo = "2026-07-12",
type = "URLOP",
status = status,
version = version,
)
private fun route(