SyncEngine.kt
956 lignes · 42778 octets
package fr.ebii.card2vcf.sync import fr.ebii.card2vcf.data.CrmContactEntity import fr.ebii.card2vcf.data.CrmDatabase import fr.ebii.card2vcf.data.EntrepriseEntity import fr.ebii.card2vcf.data.InteractionEntity import fr.ebii.card2vcf.data.NoteProjetEntity import fr.ebii.card2vcf.data.ProjetEntity import fr.ebii.card2vcf.data.TacheEntity import fr.ebii.card2vcf.data.WorkflowEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString data class SyncStatusResult( val pendingRemoteChanges: Int, val response: SyncStatusResponse? = null, val error: String? = null, ) /** * Op refusée par le serveur et laissée en file. [message] est le message serveur verbatim * (« Réservation refusée : période en conflit avec Entretien »), `null` si l'op n'a même pas * atteint le réseau (référence locale non résolue) ; [code] vaut -1 pour une panne réseau. */ data class PushFailure( val entityType: String, val op: String, val code: Int? = null, val message: String? = null, ) data class SyncResult( val success: Boolean, val serverTime: String? = null, val error: String? = null, val conflicts: List<AgendaConflict> = emptyList(), val pushed: Int = 0, val received: Int = 0, val pushFailures: List<PushFailure> = emptyList(), ) /** Résultat d'un push immédiat (sans pull) via [SyncEngine.pousserEnAttente]. */ data class RapportPush(val envoyes: Int, val echecs: Int) /** * Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones. * Le pont Agenda↔Room (RDV + réservations/indispos liés à des calendriers Android) est délégué à * [AgendaSyncCoordinator] ; ce moteur orchestre l'ordre des étapes et le push (y compris le cas * particulier 409 sur les réservations). */ class SyncEngine( private val api: AilianceApi, private val db: CrmDatabase, private val imageStore: fr.ebii.card2vcf.data.ContactImageStore? = null, /** * Identité « serveur|utilisateur » à laquelle appartient le watermark. Si elle diffère de * celle enregistrée (changement de serveur ou de compte), le pull repart de l'époque : * sans cela, un watermark hérité d'un autre serveur masque toute donnée plus ancienne * (ex. données de démo antidatées → « 0 reçu » définitif). `null` = pas de contrôle. */ private val serverIdentity: String? = null, ) { private val agenda = AgendaSyncCoordinator(db) suspend fun checkStatus(ressourcesQuery: String? = null): SyncStatusResult = withContext(Dispatchers.IO) { when (val result = api.syncStatus(watermark(), ressourcesQuery)) { is AilianceApiClient.ApiResult.Ok -> SyncStatusResult(pendingRemoteChanges = result.value.total, response = result.value) is AilianceApiClient.ApiResult.Err -> SyncStatusResult(pendingRemoteChanges = 0, error = result.message) } } /** * Pousse uniquement les ops en file, sans pull ni pont Agenda. * Utilisé pour un envoi immédiat après création d'une note vocale en mode serveur. */ suspend fun pousserEnAttente(): RapportPush = withContext(Dispatchers.IO) { val rapport = pushOps() RapportPush(envoyes = rapport.succeeded, echecs = rapport.failures.size) } /** * [bindings]/[bridge] vides ou `null` : chemin CRM v1 inchangé (pas de pont Agenda, `ressources=` omis). */ suspend fun syncNow( bindings: List<CalendarBinding> = emptyList(), bridge: CalendarBridgeApi? = null, ): SyncResult = withContext(Dispatchers.IO) { val agendaEnabled = bridge != null && bindings.isNotEmpty() if (agendaEnabled) agenda.agendaToRoom(bindings, bridge!!) val push = pushOps() val ressourcesQuery = if (agendaEnabled) agenda.ressourcesQuery(bindings) else null when (val result = api.syncPull(watermark(), ressourcesQuery)) { is AilianceApiClient.ApiResult.Ok -> { val received = applyPull(result.value) if (agendaEnabled) agenda.roomToAgenda(bindings, bridge!!) setWatermark(result.value.serverTime) SyncResult( success = true, serverTime = result.value.serverTime, conflicts = push.conflicts, pushed = push.succeeded, received = received, pushFailures = push.failures, ) } is AilianceApiClient.ApiResult.Err -> SyncResult( success = false, error = result.message, conflicts = push.conflicts, pushed = push.succeeded, pushFailures = push.failures, ) } } /** * Nombre d'éléments locaux non encore poussés (bandeau « calendrier local en avance »). * Avec [bindings]/[bridge] : ajoute les events Agenda card2vcf-tagués sans entité Room * (orphelins pas encore nettoyés côté Agenda, voir [AgendaSyncCoordinator.orphanEventCount]). */ suspend fun localAheadCount( bindings: List<CalendarBinding> = emptyList(), bridge: CalendarBridgeApi? = null, ): Int = withContext(Dispatchers.IO) { val dirtyRdv = db.rdvDao().listDirty().size val dirtyReservations = db.reservationDao().listDirty().size val pendingAgendaOps = db.syncOpDao().listAll().count { it.entityType == AgendaSyncCoordinator.KIND_RDV || it.entityType == AgendaSyncCoordinator.ENTITY_RESERVATION } val orphanEvents = if (bridge != null && bindings.isNotEmpty()) agenda.orphanEventCount(bindings, bridge) else 0 dirtyRdv + dirtyReservations + pendingAgendaOps + orphanEvents } /** L'utilisateur renonce à sa réservation en conflit : purge entité + SyncOp + event Agenda. */ suspend fun abandonLocalReservation(localId: Long, bridge: CalendarBridgeApi? = null) = withContext(Dispatchers.IO) { val reservation = db.reservationDao().getByLocalId(localId) ?: return@withContext reservation.calendarEventId?.let { bridge?.deleteEvent(it) } db.syncOpDao().listAll() .filter { it.entityType == AgendaSyncCoordinator.ENTITY_RESERVATION && it.localId == localId } .forEach { db.syncOpDao().deleteById(it.id) } db.reservationDao().deleteByLocalId(localId) } // ---- watermark ---- private suspend fun watermark(): String { val stored = db.syncMetaDao().get(WATERMARK_KEY)?.value ?: return DEFAULT_WATERMARK if (serverIdentity != null) { val origin = db.syncMetaDao().get(WATERMARK_ORIGIN_KEY)?.value if (origin != serverIdentity) return DEFAULT_WATERMARK } return stored } private suspend fun setWatermark(serverTime: String) { db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_KEY, value = serverTime)) if (serverIdentity != null) { db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_ORIGIN_KEY, value = serverIdentity)) } } // ---- push ---- /** * Résultat d'une op poussée. [conflict] non nul uniquement pour un 409 réservation ([ok] reste * `false`) : c'est un cas arbitré par la dialog dédiée, pas un échec générique. [code]/[message] * portent la réponse serveur pour tous les autres refus, afin de les remonter à l'utilisateur. */ private data class PushOutcome( val ok: Boolean, val conflict: AgendaConflict? = null, val code: Int? = null, val message: String? = null, ) /** Récapitulatif d'un cycle de push : ops tentées / dépilées, conflits 409 et échecs génériques. */ private data class PushReport( val attempted: Int, val succeeded: Int, val conflicts: List<AgendaConflict>, val failures: List<PushFailure>, ) private suspend fun pushOps(): PushReport { val conflicts = mutableListOf<AgendaConflict>() val failures = mutableListOf<PushFailure>() var attempted = 0 var succeeded = 0 for (op in db.syncOpDao().listAll()) { attempted++ val outcome = when (op.entityType) { "contact" -> pushContactOp(op) ENTITY_CONTACT_MEDIA -> pushContactMediaOp(op) "entreprise" -> pushEntrepriseOp(op) "projet" -> pushProjetOp(op) "tache" -> pushTacheOp(op) "interaction" -> pushInteractionOp(op) ENTITY_INTERACTION_MEDIA -> pushInteractionMediaOp(op) "note_projet" -> pushNoteProjetOp(op) ENTITY_NOTE_MEDIA -> pushNoteMediaOp(op) AgendaSyncCoordinator.KIND_RDV -> pushRdvOp(op) AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op) else -> PushOutcome(true) } if (outcome.ok) { succeeded++ db.syncOpDao().deleteById(op.id) } else if (outcome.conflict == null) { failures += PushFailure(op.entityType, op.op, outcome.code, outcome.message) db.syncOpDao().markFailure( op.id, outcome.message ?: outcome.code?.let { "HTTP $it" } ?: "réseau", ) } outcome.conflict?.let(conflicts::add) } return PushReport(attempted, succeeded, conflicts, failures) } private suspend fun pushContactOp(op: SyncOpEntity): PushOutcome { return when (op.op) { "create" -> { val result = api.createContact(op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.crmContactDao().getById(op.localId)?.let { db.crmContactDao().update(it.copy(serverId = newServerId)) } if (!pushContactImages(op.localId, newServerId)) { enqueueContactMediaRetry(op.localId, newServerId) } } } result.toOutcome() } "update" -> { val serverId = op.serverId ?: return PushOutcome(false) val result = api.updateContact(serverId, op.payloadJson) if (!result.isOk()) return result.toOutcome() val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id if (localId != null && !pushContactImages(localId, serverId)) { enqueueContactMediaRetry(localId, serverId) } PushOutcome(true) } "delete" -> op.serverId?.let { api.deleteContact(it).toOutcome() } ?: PushOutcome(false) else -> PushOutcome(true) } } private suspend fun pushContactMediaOp(op: SyncOpEntity): PushOutcome { val serverId = op.serverId ?: return PushOutcome(true) val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return PushOutcome(true) return PushOutcome(pushContactImages(localId, serverId)) } /** * Upload carte / photo locales vers le serveur. `true` si rien à envoyer ou tout OK. * `false` si au moins un fichier présent n'a pas pu être téléversé (retry via [ENTITY_CONTACT_MEDIA]). */ private suspend fun pushContactImages(localId: Long, serverId: String): Boolean { val contact = db.crmContactDao().getById(localId) ?: return true var ok = true val cardPath = contact.cardImagePath?.takeIf { it.isNotBlank() } if (cardPath != null) { val file = java.io.File(cardPath) if (file.isFile) { val result = api.uploadContactCarte(serverId, file.readBytes(), "card.jpg") if (result !is AilianceApiClient.ApiResult.Ok) ok = false } } val profilePath = contact.profileImagePath?.takeIf { it.isNotBlank() } if (profilePath != null) { val file = java.io.File(profilePath) if (file.isFile) { val result = api.uploadContactPhoto(serverId, file.readBytes(), "profile.jpg") if (result !is AilianceApiClient.ApiResult.Ok) ok = false } } return ok } private suspend fun enqueueContactMediaRetry(localId: Long, serverId: String) { val already = db.syncOpDao().listAll().any { it.entityType == ENTITY_CONTACT_MEDIA && it.localId == localId && it.serverId == serverId } if (already) return db.syncOpDao().insert( SyncOpEntity( entityType = ENTITY_CONTACT_MEDIA, op = "upload", payloadJson = "{}", localId = localId, serverId = serverId, createdAt = System.currentTimeMillis(), ), ) } private suspend fun pushEntrepriseOp(op: SyncOpEntity): PushOutcome = when (op.op) { "create" -> { val result = api.createEntreprise(op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.entrepriseDao().getByLocalId(op.localId)?.let { db.entrepriseDao().upsert(it.copy(serverId = newServerId)) } } } result.toOutcome() } "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).toOutcome() } ?: PushOutcome(false) "delete" -> op.serverId?.let { api.deleteEntreprise(it).toOutcome() } ?: PushOutcome(false) else -> PushOutcome(true) } private suspend fun pushProjetOp(op: SyncOpEntity): PushOutcome = when (op.op) { "create" -> { val result = api.createProjet(op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.projetDao().getByLocalId(op.localId)?.let { db.projetDao().upsert(it.copy(serverId = newServerId)) } } } result.toOutcome() } "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).toOutcome() } ?: PushOutcome(false) "delete" -> op.serverId?.let { api.deleteProjet(it).toOutcome() } ?: PushOutcome(false) else -> PushOutcome(true) } /** Résout le `projetServerId` d'une tâche via [op.localId] (create) ou [op.serverId] (update/delete/move). */ private suspend fun resolveTacheProjetId(op: SyncOpEntity): String? { op.localId?.let { db.tacheDao().getByLocalId(it)?.let { t -> return t.projetServerId } } op.serverId?.let { db.tacheDao().getByServerId(it)?.let { t -> return t.projetServerId } } return null } private suspend fun pushTacheOp(op: SyncOpEntity): PushOutcome { val projetId = resolveTacheProjetId(op) ?: return PushOutcome(false) return when (op.op) { "create" -> { val result = api.createTache(projetId, op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.tacheDao().getByLocalId(op.localId)?.let { db.tacheDao().upsert(it.copy(serverId = newServerId)) } } } result.toOutcome() } "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false) "delete" -> op.serverId?.let { api.deleteTache(projetId, it).toOutcome() } ?: PushOutcome(false) "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false) // `payloadJson` porte l'identifiant de la sous-tâche à basculer. "soustache" -> op.serverId?.let { api.toggleSousTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false) else -> PushOutcome(true) } } /** * `op.serverId` porte le `serverId` du contact cible (create) ou l'iid de l'interaction (delete). * Après create réussi : sauvegarde le serverId et tente l'upload audio. */ private suspend fun pushInteractionOp(op: SyncOpEntity): PushOutcome { return when (op.op) { "create" -> { val contactServerId = op.serverId ?: return PushOutcome(false) val result = api.createInteraction(contactServerId, op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.interactionDao().getByLocalId(op.localId)?.let { db.interactionDao().update(it.copy(serverId = newServerId)) } if (!pushInteractionAudio(op.localId, newServerId)) { enqueueInteractionMediaRetry(op.localId, newServerId) } } } result.toOutcome() } "delete" -> { val iid = op.serverId ?: return PushOutcome(false) api.deleteInteraction(iid).toOutcome() } else -> PushOutcome(true) } } private suspend fun pushInteractionMediaOp(op: SyncOpEntity): PushOutcome { val interactionServerId = op.serverId ?: return PushOutcome(true) val localId = op.localId ?: db.interactionDao().getByServerId(interactionServerId)?.localId ?: return PushOutcome(true) return PushOutcome(pushInteractionAudio(localId, interactionServerId)) } /** Upload audio d'une interaction. `true` si rien à envoyer ou upload OK. */ private suspend fun pushInteractionAudio(localId: Long, interactionServerId: String): Boolean { val interaction = db.interactionDao().getByLocalId(localId) ?: return true val audioPath = interaction.audioPath?.takeIf { it.isNotBlank() } ?: return true val file = java.io.File(audioPath) if (!file.isFile) return true val result = api.uploadInteractionAudio( interaction.contactServerId, interactionServerId, file.readBytes(), "audio.wav", ) return result is AilianceApiClient.ApiResult.Ok } private suspend fun enqueueInteractionMediaRetry(localId: Long, interactionServerId: String) { val already = db.syncOpDao().findByEntityTypeAndLocalId(ENTITY_INTERACTION_MEDIA, localId) != null if (already) return db.syncOpDao().insert( SyncOpEntity( entityType = ENTITY_INTERACTION_MEDIA, op = "upload", payloadJson = "{}", localId = localId, serverId = interactionServerId, createdAt = System.currentTimeMillis(), ), ) } private suspend fun resolveNoteProjetServerId(op: SyncOpEntity): String? { op.localId?.let { db.noteProjetDao().getByLocalId(it)?.let { n -> return n.projetServerId } } op.serverId?.let { db.noteProjetDao().getByServerId(it)?.let { n -> return n.projetServerId } } return null } private suspend fun pushNoteProjetOp(op: SyncOpEntity): PushOutcome { return when (op.op) { "create" -> { val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false) val result = api.createNoteProjet(projetId, op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.noteProjetDao().getByLocalId(op.localId)?.let { db.noteProjetDao().upsert(it.copy(serverId = newServerId)) } if (!pushNoteAudio(op.localId, newServerId, projetId)) { enqueueNoteMediaRetry(op.localId, newServerId) } } } result.toOutcome() } "update" -> { val noteServerId = op.serverId ?: return PushOutcome(false) val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false) api.updateNoteProjet(projetId, noteServerId, op.payloadJson).toOutcome() } "delete" -> { val noteServerId = op.serverId ?: return PushOutcome(false) val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false) api.deleteNoteProjet(projetId, noteServerId).toOutcome() } else -> PushOutcome(true) } } private suspend fun pushNoteMediaOp(op: SyncOpEntity): PushOutcome { val noteServerId = op.serverId ?: return PushOutcome(true) val localId = op.localId ?: db.noteProjetDao().getByServerId(noteServerId)?.localId ?: return PushOutcome(true) val projetId = db.noteProjetDao().getByLocalId(localId)?.projetServerId ?: return PushOutcome(true) return PushOutcome(pushNoteAudio(localId, noteServerId, projetId)) } /** Upload audio d'une note projet. `true` si rien à envoyer ou upload OK. */ private suspend fun pushNoteAudio(localId: Long, noteServerId: String, projetId: String): Boolean { val note = db.noteProjetDao().getByLocalId(localId) ?: return true val audioPath = note.audioPath?.takeIf { it.isNotBlank() } ?: return true val file = java.io.File(audioPath) if (!file.isFile) return true val result = api.uploadNoteAudio(projetId, noteServerId, file.readBytes(), "audio.wav") return result is AilianceApiClient.ApiResult.Ok } private suspend fun enqueueNoteMediaRetry(localId: Long, noteServerId: String) { val already = db.syncOpDao().listAll().any { it.entityType == ENTITY_NOTE_MEDIA && it.localId == localId } if (already) return db.syncOpDao().insert( SyncOpEntity( entityType = ENTITY_NOTE_MEDIA, op = "upload", payloadJson = "{}", localId = localId, serverId = noteServerId, createdAt = System.currentTimeMillis(), ), ) } private suspend fun pushRdvOp(op: SyncOpEntity): PushOutcome = when (op.op) { "create" -> { val result = api.createRdv(op.payloadJson) if (result is AilianceApiClient.ApiResult.Ok) { val newServerId = parseCreatedId(result.value) if (newServerId != null && op.localId != null) { db.rdvDao().getByLocalId(op.localId)?.let { db.rdvDao().upsert(it.copy(serverId = newServerId, dirtyLocal = false)) } } } result.toOutcome() } "update" -> op.serverId?.let { serverId -> val result = api.updateRdv(serverId, op.payloadJson) if (result.isOk() && op.localId != null) { db.rdvDao().getByLocalId(op.localId)?.let { db.rdvDao().upsert(it.copy(dirtyLocal = false)) } } result.toOutcome() } ?: PushOutcome(false) "delete" -> op.serverId?.let { api.deleteRdv(it).toOutcome() } ?: PushOutcome(false) else -> PushOutcome(true) } /** * Résout `(cibleType, cibleId)` via [op.localId] (create) ou [op.serverId] (update/delete), avec * repli sur `op.payloadJson` (un `delete` détecté après suppression Agenda supprime déjà l'entité * locale ; voir `AgendaSyncCoordinator.cibleRefPayload`). */ private suspend fun resolveReservationCible(op: SyncOpEntity): Pair<String, String>? { op.localId?.let { db.reservationDao().getByLocalId(it)?.let { r -> return r.cibleType to r.cibleId } } op.serverId?.let { db.reservationDao().getByServerId(it)?.let { r -> return r.cibleType to r.cibleId } } return parseCibleRef(op.payloadJson) } /** * Sur 409 (chevauchement serveur) : ne pas dépiler l'op (voir [pushOps]), marquer `conflictPending` * sur l'entité locale et remonter un [AgendaConflict] pour la dialog « Annuler ma réservation ? ». */ private suspend fun pushReservationOp(op: SyncOpEntity): PushOutcome { val (cibleType, cibleId) = resolveReservationCible(op) ?: return PushOutcome(false) return when (op.op) { "create" -> handleReservationPushResult(api.createReservation(cibleType, cibleId, op.payloadJson), op) "update" -> op.serverId?.let { serverId -> handleReservationPushResult(api.updateReservation(cibleType, cibleId, serverId, op.payloadJson), op) } ?: PushOutcome(false) "delete" -> op.serverId?.let { api.deleteReservation(cibleType, cibleId, it).toOutcome() } ?: PushOutcome(false) else -> PushOutcome(true) } } private suspend fun handleReservationPushResult( result: AilianceApiClient.ApiResult<String>, op: SyncOpEntity, ): PushOutcome = when (result) { is AilianceApiClient.ApiResult.Ok -> { if (op.localId != null) { db.reservationDao().getByLocalId(op.localId)?.let { local -> val serverId = if (op.op == "create") parseCreatedId(result.value) ?: local.serverId else local.serverId db.reservationDao().upsert(local.copy(serverId = serverId, dirtyLocal = false)) } } PushOutcome(true) } is AilianceApiClient.ApiResult.Err -> { if (result.code == 409 && op.localId != null) { db.reservationDao().getByLocalId(op.localId)?.let { local -> db.reservationDao().upsert(local.copy(conflictPending = true)) } PushOutcome(false, AgendaConflict(localReservationId = op.localId, message = result.message)) } else { result.toOutcome() } } } // ---- pull ---- /** * Applique le pull et renvoie le nombre d'éléments reçus tel qu'un utilisateur le compte : * workflows (référentiel) et tombstones (suppressions) sont exclus. */ private suspend fun applyPull(pull: SyncPullResponse): Int { pull.contacts.forEach { applyContact(it) } pull.entreprises.forEach { applyEntreprise(it) } pull.projets.forEach { applyProjet(it) } pull.taches.forEach { applyTache(it) } pull.interactions.forEach { applyInteraction(it) } pull.notes.forEach { applyNoteProjet(it) } pull.workflows.forEach { applyWorkflow(it) } pull.rdv.forEach { agenda.applyRdvPull(it) } pull.reservations.forEach { agenda.applyReservationPull(it) } pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) } pull.tombstones.forEach { applyTombstone(it) } return pull.contacts.size + pull.entreprises.size + pull.projets.size + pull.taches.size + pull.interactions.size + pull.notes.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size } private suspend fun applyContact(dto: ContactDto) { val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe) val local = db.crmContactDao().getByServerId(dto.id) if (local == null) { val newId = db.crmContactDao().insert( CrmContactEntity( serverId = dto.id, fullName = "${dto.prenom} ${dto.nom}".trim(), firstName = dto.prenom, lastName = dto.nom, jobTitle = dto.fonction, statut = dto.statut, etape = dto.etape, tags = dto.tags, entrepriseServerId = dto.entrepriseId, createdAt = parseIsoToEpochMs(dto.creeLe), updatedAt = remoteTs, ), ) pullContactImages(newId, dto, remoteTs, localUpdatedAt = 0L) return } val remoteEntity = local.copy( fullName = "${dto.prenom} ${dto.nom}".trim(), firstName = dto.prenom, lastName = dto.nom, jobTitle = dto.fonction, statut = dto.statut, etape = dto.etape, tags = dto.tags, entrepriseServerId = dto.entrepriseId, updatedAt = remoteTs, ) val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs) if (merged !== local) db.crmContactDao().update(merged) pullContactImages(local.id, dto, remoteTs, local.updatedAt) } /** * Télécharge carte/photo si présentes côté serveur et (fichier local absent * ou serveur plus récent), sauf si un push contact/média est encore en file. */ private suspend fun pullContactImages( localId: Long, dto: ContactDto, remoteTs: Long, localUpdatedAt: Long, ) { val store = imageStore ?: return if (hasPendingContactPush(localId, dto.id)) return var contact = db.crmContactDao().getById(localId) ?: return val serverNewer = remoteTs > localUpdatedAt if (!dto.carteVisite.isNullOrBlank()) { val path = contact.cardImagePath val missing = path.isNullOrBlank() || !java.io.File(path).isFile if (missing || serverNewer) { when (val result = api.downloadContactCarte(dto.id)) { is AilianceApiClient.ApiResult.Ok -> { val saved = store.saveBytes(localId, "card.jpg", result.value) contact = contact.copy(cardImagePath = saved) db.crmContactDao().update(contact) } is AilianceApiClient.ApiResult.Err -> Unit } } } if (!dto.photo.isNullOrBlank()) { val path = contact.profileImagePath val missing = path.isNullOrBlank() || !java.io.File(path).isFile if (missing || serverNewer) { when (val result = api.downloadContactPhoto(dto.id)) { is AilianceApiClient.ApiResult.Ok -> { val saved = store.saveBytes(localId, "profile.jpg", result.value) contact = contact.copy(profileImagePath = saved) db.crmContactDao().update(contact) } is AilianceApiClient.ApiResult.Err -> Unit } } } } private suspend fun hasPendingContactPush(localId: Long, serverId: String): Boolean = db.syncOpDao().listAll().any { op -> when (op.entityType) { "contact", ENTITY_CONTACT_MEDIA -> (op.localId == localId || op.serverId == serverId) && op.op != "delete" else -> false } } private suspend fun applyEntreprise(dto: EntrepriseDto) { val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe) val local = db.entrepriseDao().getByServerId(dto.id) if (local == null) { db.entrepriseDao().upsert( EntrepriseEntity( serverId = dto.id, nom = dto.nom, secteur = dto.secteur, creePar = dto.creePar, createdAt = parseIsoToEpochMs(dto.creeLe), updatedAt = remoteTs, ), ) return } val remoteEntity = local.copy(nom = dto.nom, secteur = dto.secteur, creePar = dto.creePar, updatedAt = remoteTs) val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs) if (merged !== local) db.entrepriseDao().upsert(merged) } private suspend fun applyProjet(dto: ProjetDto) { val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe) val local = db.projetDao().getByServerId(dto.id) val membresJson = syncJson.encodeToString(dto.membres) if (local == null) { db.projetDao().upsert( ProjetEntity( serverId = dto.id, nom = dto.nom, description = dto.description, workflowServerId = dto.workflowId, membresJson = membresJson, creePar = dto.creePar, planification = dto.planification, echeance = dto.echeance, joursOuvres = dto.joursOuvres, createdAt = parseIsoToEpochMs(dto.creeLe), updatedAt = remoteTs, ), ) return } val remoteEntity = local.copy( nom = dto.nom, description = dto.description, workflowServerId = dto.workflowId, membresJson = membresJson, creePar = dto.creePar, planification = dto.planification, echeance = dto.echeance, joursOuvres = dto.joursOuvres, updatedAt = remoteTs, ) val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs) if (merged !== local) db.projetDao().upsert(merged) } private suspend fun applyTache(dto: TacheSyncDto) { val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe) val dependDeJson = syncJson.encodeToString(dto.dependDe) val sousTachesJson = syncJson.encodeToString(dto.sousTaches) val local = db.tacheDao().getByServerId(dto.id) val projetLocalId = db.projetDao().getByServerId(dto.projetId)?.localId if (local == null) { db.tacheDao().upsert( TacheEntity( serverId = dto.id, projetLocalId = projetLocalId, projetServerId = dto.projetId, titre = dto.titre, description = dto.description, colonneId = dto.colonneId, ordre = dto.ordre, assigneA = dto.assigneA, debut = dto.debut, dureeJours = dto.dureeJours, etiquette = dto.etiquette, parentId = dto.parentId, dependDeJson = dependDeJson, sousTachesJson = sousTachesJson, auteur = dto.auteur, createdAt = parseIsoToEpochMs(dto.creeLe), updatedAt = remoteTs, ), ) return } val remoteEntity = local.copy( projetLocalId = projetLocalId ?: local.projetLocalId, projetServerId = dto.projetId, titre = dto.titre, description = dto.description, colonneId = dto.colonneId, ordre = dto.ordre, assigneA = dto.assigneA, debut = dto.debut, dureeJours = dto.dureeJours, etiquette = dto.etiquette, parentId = dto.parentId, dependDeJson = dependDeJson, sousTachesJson = sousTachesJson, auteur = dto.auteur, updatedAt = remoteTs, ) val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs) if (merged !== local) db.tacheDao().upsert(merged) } private suspend fun applyInteraction(dto: InteractionDto) { val existing = db.interactionDao().listByContactServerId(dto.contactId).mapNotNull { it.serverId }.toSet() if (LwwMerger.shouldInsertInteraction(existing, dto.id)) { db.interactionDao().upsert( InteractionEntity( serverId = dto.id, contactServerId = dto.contactId, type = dto.typeInteraction, sujet = dto.sujet, description = dto.description, creePar = dto.creePar, transcriptionStatut = dto.transcription, transcriptionErreur = dto.transcriptionErreur, createdAt = parseIsoToEpochMs(dto.creeLe), updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) }, ), ) return } // Déjà connue : mise à jour LWW si le distant est plus récent val local = db.interactionDao().getByServerId(dto.id) ?: return val remoteTs = dto.misAJourLe?.let { parseIsoToEpochMs(it) } ?: return val localTs = local.updatedAt ?: local.createdAt if (LwwMerger.shouldUpdateInteraction(localTs, remoteTs)) { db.interactionDao().upsert( local.copy( type = dto.typeInteraction, sujet = dto.sujet, description = dto.description, transcriptionStatut = dto.transcription, transcriptionErreur = dto.transcriptionErreur, updatedAt = remoteTs, // audioPath préservé : local.copy() ne l'écrase pas ), ) } } /** * Insert si [dto.id] inconnu ; met à jour si le timestamp distant ([majLe] ou [creeLe]) * est plus récent que [NoteProjetEntity.updatedAt] local ; préserve [NoteProjetEntity.audioPath]. */ private suspend fun applyNoteProjet(dto: NoteProjetDto) { val remoteTs = parseIsoToEpochMs(dto.majLe ?: dto.creeLe) val local = db.noteProjetDao().getByServerId(dto.id) if (local == null) { db.noteProjetDao().upsert( NoteProjetEntity( serverId = dto.id, projetServerId = dto.projetId, titre = dto.titre, texte = dto.contenu, auteur = dto.auteur, createdAt = parseIsoToEpochMs(dto.creeLe), updatedAt = remoteTs, transcriptionStatut = dto.transcription, transcriptionErreur = dto.transcriptionErreur, ), ) return } val localTs = local.updatedAt ?: local.createdAt if (remoteTs > localTs) { db.noteProjetDao().upsert( local.copy( projetServerId = dto.projetId, titre = dto.titre, texte = dto.contenu, auteur = dto.auteur, updatedAt = remoteTs, transcriptionStatut = dto.transcription, transcriptionErreur = dto.transcriptionErreur, // audioPath préservé : local.copy() ne l'écrase pas ), ) } } private suspend fun applyWorkflow(dto: WorkflowDto) { db.workflowDao().upsert( WorkflowEntity( serverId = dto.id, nom = dto.nom, description = dto.description, colonnesJson = syncJson.encodeToString(dto.colonnes), creePar = dto.creePar, createdAt = parseIsoToEpochMs(dto.creeLe), ), ) } private suspend fun applyTombstone(dto: TombstoneDto) { when (dto.entityType) { "contact" -> db.crmContactDao().deleteByServerId(dto.id) "entreprise" -> db.entrepriseDao().deleteByServerId(dto.id) "projet" -> { db.tacheDao().listByProjetServerId(dto.id).mapNotNull { it.serverId } .forEach { db.tacheDao().deleteByServerId(it) } db.projetDao().deleteByServerId(dto.id) } "tache" -> db.tacheDao().deleteByServerId(dto.id) "interaction" -> db.interactionDao().deleteByServerId(dto.id) "note" -> db.noteProjetDao().deleteByServerId(dto.id) else -> agenda.applyAgendaTombstone(dto) } } private companion object { const val WATERMARK_KEY = "watermark" const val WATERMARK_ORIGIN_KEY = "watermark_origin" const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z" const val ENTITY_CONTACT_MEDIA = "contact_media" const val ENTITY_INTERACTION_MEDIA = "interaction_media" const val ENTITY_NOTE_MEDIA = "note_media" fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok /** Conserve `code`/`message` serveur sur refus, pour que [pushOps] puisse les remonter à l'UI. */ fun <T> AilianceApiClient.ApiResult<T>.toOutcome(): PushOutcome = when (this) { is AilianceApiClient.ApiResult.Ok -> PushOutcome(true) is AilianceApiClient.ApiResult.Err -> PushOutcome(false, code = code, message = message) } @Serializable data class CreatedIdDto(val id: String? = null) fun parseCreatedId(body: String): String? = try { syncJson.decodeFromString(CreatedIdDto.serializer(), body).id } catch (_: Exception) { null } fun parseCibleRef(payloadJson: String): Pair<String, String>? = try { val ref = syncJson.decodeFromString(CibleRessourceDto.serializer(), payloadJson) ref.type to ref.id } catch (_: Exception) { null } } }
GitRust