using System.Text; using JsonSerializer = System.Text.Json.JsonSerializer; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Models; using LiteDB; namespace LehrerApp.Sync; /// /// Wendet ein von empfangenes (und nicht durch einen Konflikt /// verlorenes) Ereignis auf die lokale Datenbank an. /// /// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository- /// Save/Delete-Methode — sonst würde erneut feuern und die /// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). /// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig /// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save /// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt. /// /// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem /// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique- /// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses. /// public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null, AppLogger? logger = null, EventQueue? versions = null) { private static readonly Dictionary Handlers = BuildHandlers(); public async Task ApplyAsync(SyncEvent evt) { if (!Handlers.TryGetValue(evt.EntityType, out var handler)) { logger?.Warn($"Sync: kein Handler für Entitätstyp '{evt.EntityType}' (EntityId={evt.EntityId}, " + $"Operation={evt.Operation}) - Ereignis übersprungen."); return; } try { var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload); handler(db, evt.Operation, evt.EntityId, json); if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null) await DownloadMissingAttachmentsAsync(json); // evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen // ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten // eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4). versions?.SetKnownServerSeq(evt.EntityType, evt.EntityId, evt.SequenceNr); logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}"); } catch (LiteException ex) { // Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis // überspringen, statt den gesamten Sync-Lauf abzubrechen. logger?.Error($"Sync: Ereignis übersprungen (LiteDB-Constraint) - {evt.EntityType} " + $"{evt.Operation} EntityId={evt.EntityId}", ex); } catch (Exception ex) { // Jede andere Ausnahme (z.B. Entschlüsselung/Deserialisierung fehlgeschlagen) darf // NICHT aus ApplyAsync herausfallen: SyncEngine.PullAsync ruft dies in einer Schleife // über einen ganzen Ereignis-Batch auf, ohne eigenes try/catch - eine unbehandelte // Ausnahme hier würde den kompletten Pull-Lauf abbrechen, BEVOR SetLastServerSeq // aufgerufen wird. Der Pull würde beim nächsten Versuch denselben Batch erneut laden // und an genau demselben Ereignis wieder scheitern - ein dauerhaft blockierter Sync, // bei dem selbst bereits erfolgreich angewendete Ereignisse im selben Batch nie als // erledigt markiert werden. logger?.Error($"Sync: Ereignis konnte nicht angewendet werden - {evt.EntityType} " + $"{evt.Operation} EntityId={evt.EntityId}", ex); } } // Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem // Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln // nachladen. Gegenstück zum Upload in AttachmentSyncer. private async Task DownloadMissingAttachmentsAsync(string json) { var doc = JsonSerializer.Deserialize(json); if (doc is null) return; foreach (var attachment in doc.Attachments) { if (db.Attachments.Exists(attachment.StorageId)) continue; var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}"); if (!resp.IsSuccessStatusCode) continue; var encrypted = await resp.Content.ReadAsByteArrayAsync(); var decrypted = SyncCrypto.Decrypt(encrypted, syncKey); using var stream = new MemoryStream(decrypted); // Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine // neue Id vergibt - hier muss die Original-StorageId erhalten bleiben. db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream); } } private string Decrypt(string payloadBase64) => Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey)); private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json); private static Dictionary BuildHandlers() { var handlers = new Dictionary(); void Simple(Func> collection) where T : class => handlers[typeof(T).Name] = (context, operation, entityId, json) => { if (operation == "Delete") collection(context).Delete(new Guid(entityId)); else collection(context).Upsert(JsonSerializer.Deserialize(json)!); }; Simple(context => context.Students); Simple(context => context.SeatingPlans); Simple(context => context.Memberships); Simple(context => context.GradingKeyTemplates); Simple(context => context.Grades); Simple(context => context.GradingSchemes); Simple(context => context.ReportGrades); Simple(context => context.Units); Simple(context => context.Lessons); Simple(context => context.Tasks); Simple(context => context.TimeEntries); Simple(context => context.ExamResults); Simple(context => context.ParticipationEntries); Simple(context => context.ParticipationAspects); Simple(context => context.ParticipationSections); Simple(context => context.Subjects); Simple(context => context.ShorthandCodes); Simple(context => context.AlternativeLessonPaths); Simple(context => context.TimetableSlots); Simple(context => context.SchoolHolidays); Simple(context => context.SupervisionDuties); Simple(context => context.SubstitutionEntries); Simple(context => context.CompetencyDomains); // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Repositories, damit die Kaskade nur an einer Stelle im Code existiert. handlers[nameof(LearningGroup)] = (context, operation, entityId, json) => { if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId)); else context.Groups.Upsert(JsonSerializer.Deserialize(json)!); }; handlers[nameof(Exam)] = (context, operation, entityId, json) => { if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId)); else context.Exams.Upsert(JsonSerializer.Deserialize(json)!); }; handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) => { if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId)); else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize(json)!); }; // "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als // "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig. handlers[nameof(Documentation)] = (context, operation, entityId, json) => { if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId)); else context.Documentation.Upsert(JsonSerializer.Deserialize(json)!); }; return handlers; } }