AgendaSyncCoordinator.swift
514 lignes · 22146 octets
import Foundation /// Reservation conflict (HTTP 409) awaiting a user decision: keep it or `abandonLocalReservation`. struct AgendaConflict: Equatable { var localReservationId: Int64 var message: String? = nil } /// Calendar↔local-store bridge and DTO↔entity mapping for RDV / reservations / unavailabilities. /// Extracted from `SyncEngine` to keep the CRM+agenda orchestration readable; `SyncEngine` /// remains the sole owner of the `SyncOpEntity` queue (push) and of the watermark. final class AgendaSyncCoordinator { /// `CalendarBinding.kind` of the « Mes RDV » binding; also used as `SyncOpEntity.entityType`. static let kindRdv = "rdv" static let entityReservation = "reservation" private static let statutActive = "active" private let db: CrmDatabase init(db: CrmDatabase) { self.db = db } /// `salle:id,materiel:id,...` from non-`rdv` bindings that carry a `serverResourceId`. func ressourcesQuery(_ bindings: [CalendarBinding]) -> String? { Self.ressourcesQuery(bindings) } /// Usable without an instance (status banner). static func ressourcesQuery(_ bindings: [CalendarBinding]) -> String? { let parts = bindings .filter { $0.kind != kindRdv && $0.serverResourceId != nil } .map { "\($0.kind):\($0.serverResourceId!)" } return parts.isEmpty ? nil : parts.joined(separator: ",") } // ---- Calendar -> local store (events created/edited in the system calendar app) ---- func agendaToRoom(bindings: [CalendarBinding], bridge: CalendarBridge) async throws { for binding in bindings { guard let calendarId = binding.androidCalendarId else { continue } if binding.kind == Self.kindRdv { try await agendaEventsToRdv(calendarId: calendarId, bridge: bridge) } else { try await agendaEventsToReservation(binding: binding, calendarId: calendarId, bridge: bridge) } } } private func agendaEventsToRdv(calendarId: Int64, bridge: CalendarBridge) async throws { let events = try bridge.listEvents(calendarId: calendarId) let presentEventIds = Set(events.compactMap { $0.eventId }) for snap in events { guard let eventId = snap.eventId else { continue } let local = try await db.rdvDao.getByCalendarEventId(eventId) guard let local else { var entity = RdvEntity() entity.titre = snap.title entity.description = snap.descriptionBody entity.debutMs = snap.debutMs entity.finMs = snap.finMs entity.calendarEventId = eventId entity.updatedAt = Self.currentMillis() entity.dirtyLocal = true let localId = try await db.rdvDao.upsert(entity) try await enqueueOp( entityType: Self.kindRdv, op: "create", localId: localId, serverId: nil, payloadJson: rdvPayload(entity) ) continue } if !rdvDiffers(local, from: snap) { continue } var updated = local updated.titre = snap.title updated.description = snap.descriptionBody updated.debutMs = snap.debutMs updated.finMs = snap.finMs updated.updatedAt = Self.currentMillis() updated.dirtyLocal = true try await db.rdvDao.upsert(updated) try await enqueueOp( entityType: Self.kindRdv, op: local.serverId == nil ? "create" : "update", localId: local.localId, serverId: local.serverId, payloadJson: rdvPayload(updated) ) } // Event deleted in the system calendar app: propagate the deletion (SyncOp when known server-side). for rdv in try await db.rdvDao.listAll() { guard let eventId = rdv.calendarEventId else { continue } if presentEventIds.contains(eventId) { continue } if let serverId = rdv.serverId { try await enqueueOp( entityType: Self.kindRdv, op: "delete", localId: rdv.localId, serverId: serverId, payloadJson: "{}" ) } try await db.rdvDao.deleteByLocalId(rdv.localId) } } private func rdvDiffers(_ entity: RdvEntity, from snap: CalendarEventSnapshot) -> Bool { entity.titre != snap.title || entity.description != snap.descriptionBody || entity.debutMs != snap.debutMs || entity.finMs != snap.finMs } private func agendaEventsToReservation( binding: CalendarBinding, calendarId: Int64, bridge: CalendarBridge ) async throws { guard let cibleId = binding.serverResourceId else { return } let cibleType = binding.kind let events = try bridge.listEvents(calendarId: calendarId) let presentEventIds = Set(events.compactMap { $0.eventId }) for snap in events { guard let eventId = snap.eventId else { continue } if CalendarEventTitles.isIndispoTitle(snap.title) { continue } // read-only mirror, no push let local = try await db.reservationDao.getByCalendarEventId(eventId) guard let local else { var entity = ReservationEntity() entity.cibleType = cibleType entity.cibleId = cibleId entity.debutMs = snap.debutMs entity.finMs = snap.finMs entity.motif = Self.motifReservation(snap) entity.calendarEventId = eventId entity.updatedAt = Self.currentMillis() entity.dirtyLocal = true let localId = try await db.reservationDao.upsert(entity) try await enqueueOp( entityType: Self.entityReservation, op: "create", localId: localId, serverId: nil, payloadJson: reservationPayload(entity) ) continue } if local.conflictPending { continue } // awaiting user decision, do not re-push if !reservationDiffers(local, from: snap) { continue } var updated = local updated.debutMs = snap.debutMs updated.finMs = snap.finMs updated.motif = Self.motifReservation(snap) updated.updatedAt = Self.currentMillis() updated.dirtyLocal = true try await db.reservationDao.upsert(updated) try await enqueueOp( entityType: Self.entityReservation, op: local.serverId == nil ? "create" : "update", localId: local.localId, serverId: local.serverId, payloadJson: reservationPayload(updated) ) } // Event deleted in the system calendar app: propagate the deletion (SyncOp when known server-side). for reservation in try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) { guard let eventId = reservation.calendarEventId else { continue } if presentEventIds.contains(eventId) { continue } if reservation.conflictPending { continue } // user decision pending (see abandonLocalReservation) if let serverId = reservation.serverId { try await enqueueOp( entityType: Self.entityReservation, op: "delete", localId: reservation.localId, serverId: serverId, payloadJson: cibleRefPayload(cibleType: cibleType, cibleId: cibleId) ) } try await db.reservationDao.deleteByLocalId(reservation.localId) } } private func reservationDiffers(_ entity: ReservationEntity, from snap: CalendarEventSnapshot) -> Bool { entity.motif != Self.motifReservation(snap) || entity.debutMs != snap.debutMs || entity.finMs != snap.finMs } /// Motif of a pushed reservation: the event TITLE (symmetric with the downward /// direction, which displays the motif as title — see `roomToAgenda`), the /// description as fallback. The server refuses an empty motif. private static func motifReservation(_ snap: CalendarEventSnapshot) -> String { snap.title.isBlank ? snap.descriptionBody : snap.title } /// Replaces any pending op for this local entity before inserting the new one (avoids piling up). private func enqueueOp( entityType: String, op: String, localId: Int64, serverId: String?, payloadJson: String ) async throws { for pending in try await db.syncOpDao.listAll() where pending.entityType == entityType && pending.localId == localId { try await db.syncOpDao.deleteById(pending.id) } var entity = SyncOpEntity(entityType: entityType, op: op) entity.payloadJson = payloadJson entity.localId = localId entity.serverId = serverId entity.createdAt = Self.currentMillis() try await db.syncOpDao.insert(entity) } private func rdvPayload(_ entity: RdvEntity) -> String { let contactIds = (try? SyncJson.decode([String].self, from: entity.contactIdsJson)) ?? [] return SyncJson.encodeToString( RdvUpsertRequest( titre: entity.titre, description: entity.description, lieu: entity.lieu, debut: epochMsToIso(entity.debutMs), fin: epochMsToIso(entity.finMs), contactIds: contactIds, projetId: entity.projetId ) ) } private func reservationPayload(_ entity: ReservationEntity) -> String { SyncJson.encodeToString( ReservationUpsertRequest( debut: epochMsToIso(entity.debutMs), fin: epochMsToIso(entity.finMs), motif: entity.motif ) ) } /// Payload of a reservation `delete` SyncOp: carries `(cibleType, cibleId)` so that /// `SyncEngine` can resolve the server genre/id even after the local entity is gone /// (see `resolveReservationCible`). Reuses `CibleRessourceDto`, already used for `cible`. private func cibleRefPayload(cibleType: String, cibleId: String) -> String { SyncJson.encodeToString(CibleRessourceDto(type: cibleType, id: cibleId)) } // ---- Local store -> calendar (after applying the pull) ---- func roomToAgenda(bindings: [CalendarBinding], bridge: CalendarBridge) async throws { for binding in bindings { guard let calendarId = binding.androidCalendarId else { continue } if binding.kind == Self.kindRdv { try await rdvToAgenda(calendarId: calendarId, bridge: bridge) } else { try await resourceEntitiesToAgenda(binding: binding, calendarId: calendarId, bridge: bridge) } } } private func rdvToAgenda(calendarId: Int64, bridge: CalendarBridge) async throws { for rdv in try await db.rdvDao.listAll() { let eventId = try bridge.upsertEvent( calendarId: calendarId, snap: CalendarEventSnapshot( title: rdv.titre, debutMs: rdv.debutMs, finMs: rdv.finMs, eventId: rdv.calendarEventId, serverId: rdv.serverId, descriptionBody: rdv.description ) ) if eventId != rdv.calendarEventId { var updated = rdv updated.calendarEventId = eventId try await db.rdvDao.upsert(updated) } } try deleteOrphanEvents(calendarId: calendarId, bridge: bridge, kept: try await rdvKeptIds()) } private func resourceEntitiesToAgenda( binding: CalendarBinding, calendarId: Int64, bridge: CalendarBridge ) async throws { guard let cibleId = binding.serverResourceId else { return } let cibleType = binding.kind for reservation in try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) { if reservation.statut != Self.statutActive { continue } let eventId = try bridge.upsertEvent( calendarId: calendarId, snap: CalendarEventSnapshot( title: reservation.motif.isEmpty ? "Réservation" : reservation.motif, debutMs: reservation.debutMs, finMs: reservation.finMs, eventId: reservation.calendarEventId, serverId: reservation.serverId ) ) if eventId != reservation.calendarEventId { var updated = reservation updated.calendarEventId = eventId try await db.reservationDao.upsert(updated) } } for indispo in try await db.indisponibiliteDao.listByCible(cibleType: cibleType, cibleId: cibleId) { let baseTitle = nonBlank(indispo.commentaire) ?? indispo.nature ?? "Indisponible" let eventId = try bridge.upsertEvent( calendarId: calendarId, snap: CalendarEventSnapshot( title: CalendarEventTitles.indispoTitle(baseTitle), debutMs: indispo.debutMs, finMs: indispo.finMs, eventId: indispo.calendarEventId, serverId: indispo.serverId ) ) if eventId != indispo.calendarEventId { var updated = indispo updated.calendarEventId = eventId try await db.indisponibiliteDao.upsert(updated) } } try deleteOrphanEvents( calendarId: calendarId, bridge: bridge, kept: try await resourceKeptIds(cibleType: cibleType, cibleId: cibleId) ) } private func nonBlank(_ value: String?) -> String? { guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } return value } // ---- Orphan events (local store deleted the entity, the calendar event must follow) ---- private func rdvKeptIds() async throws -> (Set<String>, Set<Int64>) { let entities = try await db.rdvDao.listAll() return ( Set(entities.compactMap { $0.serverId }), Set(entities.compactMap { $0.calendarEventId }) ) } private func resourceKeptIds(cibleType: String, cibleId: String) async throws -> (Set<String>, Set<Int64>) { let reservations = try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) .filter { $0.statut == Self.statutActive } let indispos = try await db.indisponibiliteDao.listByCible(cibleType: cibleType, cibleId: cibleId) let serverIds = Set(reservations.compactMap { $0.serverId }).union(indispos.compactMap { $0.serverId }) let eventIds = Set(reservations.compactMap { $0.calendarEventId }).union(indispos.compactMap { $0.calendarEventId }) return (serverIds, eventIds) } /// `deleteEvent` for every `card2vcf:serverId=`-tagged event (see `CalendarServerIdCodec`) /// of `calendarId` whose `serverId` and `eventId` no longer match a local entity (`kept`). /// Untagged events (not yet pushed, or foreign to card2vcf) are never deleted. private func deleteOrphanEvents( calendarId: Int64, bridge: CalendarBridge, kept: (Set<String>, Set<Int64>) ) throws { for eventId in try orphanEvents(calendarId: calendarId, bridge: bridge, kept: kept) { try bridge.deleteEvent(eventId: eventId) } } private func orphanEvents( calendarId: Int64, bridge: CalendarBridge, kept: (Set<String>, Set<Int64>) ) throws -> [Int64] { let (keptServerIds, keptEventIds) = kept return try bridge.listEvents(calendarId: calendarId).compactMap { snap in guard let eventId = snap.eventId else { return nil } guard let serverId = snap.serverId else { return nil } // not card2vcf-tagged: never managed here return (keptServerIds.contains(serverId) || keptEventIds.contains(eventId)) ? nil : eventId } } /// Number of card2vcf-tagged events in the bound calendars with no matching local entity. func orphanEventCount(bindings: [CalendarBinding], bridge: CalendarBridge) async throws -> Int { var count = 0 for binding in bindings { guard let calendarId = binding.androidCalendarId else { continue } let kept: (Set<String>, Set<Int64>) if binding.kind == Self.kindRdv { kept = try await rdvKeptIds() } else { guard let cibleId = binding.serverResourceId else { continue } kept = try await resourceKeptIds(cibleType: binding.kind, cibleId: cibleId) } count += try orphanEvents(calendarId: calendarId, bridge: bridge, kept: kept).count } return count } // ---- Pull: DTO -> entities (LWW RDV, active reservations, read-only unavailabilities) ---- func applyRdvPull(_ dto: RendezVousDto) async throws { let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe) let local = try await db.rdvDao.getByServerId(dto.id) guard let local else { var entity = RdvEntity() entity.serverId = dto.id entity.titre = dto.titre entity.description = dto.description entity.lieu = dto.lieu entity.debutMs = parseIsoToEpochMs(dto.debut) entity.finMs = parseIsoToEpochMs(dto.fin) entity.contactIdsJson = SyncJson.encodeToString(dto.contactIds) entity.projetId = dto.projetId entity.updatedAt = remoteTs try await db.rdvDao.upsert(entity) return } var remoteEntity = local remoteEntity.titre = dto.titre remoteEntity.description = dto.description remoteEntity.lieu = dto.lieu remoteEntity.debutMs = parseIsoToEpochMs(dto.debut) remoteEntity.finMs = parseIsoToEpochMs(dto.fin) remoteEntity.contactIdsJson = SyncJson.encodeToString(dto.contactIds) remoteEntity.projetId = dto.projetId remoteEntity.updatedAt = remoteTs remoteEntity.dirtyLocal = false let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs) if merged != local { try await db.rdvDao.upsert(merged) } } func applyReservationPull(_ dto: ReservationDto) async throws { let local = try await db.reservationDao.getByServerId(dto.id) if dto.statut != Self.statutActive { if local != nil { try await db.reservationDao.deleteByServerId(dto.id) } return } let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe) guard let local else { var entity = ReservationEntity() entity.serverId = dto.id entity.cibleType = dto.cible.type entity.cibleId = dto.cible.id entity.debutMs = parseIsoToEpochMs(dto.debut) entity.finMs = parseIsoToEpochMs(dto.fin) entity.motif = dto.motif entity.statut = dto.statut entity.updatedAt = remoteTs try await db.reservationDao.upsert(entity) return } var remoteEntity = local remoteEntity.cibleType = dto.cible.type remoteEntity.cibleId = dto.cible.id remoteEntity.debutMs = parseIsoToEpochMs(dto.debut) remoteEntity.finMs = parseIsoToEpochMs(dto.fin) remoteEntity.motif = dto.motif remoteEntity.statut = dto.statut remoteEntity.updatedAt = remoteTs remoteEntity.dirtyLocal = false remoteEntity.conflictPending = false let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs) if merged != local { try await db.reservationDao.upsert(merged) } } func applyIndisponibilitePull(_ dto: IndisponibiliteDto) async throws { let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe) let local = try await db.indisponibiliteDao.getByServerId(dto.id) var entity = local ?? { var fresh = IndisponibiliteEntity() fresh.serverId = dto.id return fresh }() entity.cibleType = dto.cible.type entity.cibleId = dto.cible.id entity.debutMs = parseIsoToEpochMs(dto.debut) entity.finMs = parseIsoToEpochMs(dto.fin) entity.nature = dto.nature entity.commentaire = dto.commentaire entity.updatedAt = remoteTs try await db.indisponibiliteDao.upsert(entity) } @discardableResult func applyAgendaTombstone(_ dto: TombstoneDto) async throws -> Bool { switch dto.entityType { case "rdv": try await db.rdvDao.deleteByServerId(dto.id) case "reservation": try await db.reservationDao.deleteByServerId(dto.id) case "indisponibilite": try await db.indisponibiliteDao.deleteByServerId(dto.id) default: return false } return true } static func currentMillis() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } }
GitRust