diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 92eb79a..dccf460 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -218,8 +218,8 @@ public interface IVorgangRepository /// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation). void Delete(Guid id); } -/// Rein lokaler "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) — -/// kein Sync, siehe DashboardWarningSnooze. +/// "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) — wird +/// synchronisiert, siehe DashboardWarningSnooze. public interface IDashboardWarningSnoozeRepository { DashboardWarningSnooze? Get(Guid studentId, string warningTag); diff --git a/LehrerApp.Core/Models/DashboardWarningSnooze.cs b/LehrerApp.Core/Models/DashboardWarningSnooze.cs index 995ca78..549fdfd 100644 --- a/LehrerApp.Core/Models/DashboardWarningSnooze.cs +++ b/LehrerApp.Core/Models/DashboardWarningSnooze.cs @@ -1,17 +1,30 @@ +using System.Security.Cryptography; +using System.Text; + namespace LehrerApp.Core.Models; /// /// "Zurückstellen" einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten/Hausaufgaben/ -/// Verspätungen, siehe DashboardViewModel) für eine Person — ein rein lokaler Erinnerungs- -/// Zeitstempel je (StudentId, WarningTag), bewusst nicht über den Sync repliziert (siehe -/// IDashboardWarningSnoozeRepository): eher ein Job-/Fetch-Zustand als echte Fallakten-Daten wie -/// Vorgang/Documentation. +/// Verspätungen, siehe DashboardViewModel) für eine Person — ein Erinnerungs-Zeitstempel je +/// (StudentId, WarningTag), der über den Sync repliziert wird, damit eine auf Gerät A +/// zurückgestellte Warnung auf Gerät B nicht weiter rot oben steht. /// public class DashboardWarningSnooze { - public Guid Id { get; set; } = Guid.NewGuid(); + /// Aus (StudentId, WarningTag) abgeleitet (siehe ; setzt + /// IDashboardWarningSnoozeRepository.Save) statt zufällig: stellen + /// zwei Geräte dieselbe Warnung unabhängig voneinander zurück, beschreiben sie so denselben + /// Datensatz (Last-Write-Wins im Sync), statt zwei Datensätze anzulegen, die beim Anwenden am + /// Unique-Index ux_student_tag scheitern würden. + public Guid Id { get; set; } public Guid StudentId { get; set; } /// Dieselben Strings wie VorgangTagDisplay.Suggestions ("Absentismus"/"Hausaufgaben"/"Verspätungen"). public string WarningTag { get; set; } = ""; public DateOnly SnoozedUntil { get; set; } + + public static Guid IdFor(Guid studentId, string warningTag) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{studentId:N}:{warningTag.ToLowerInvariant()}")); + return new Guid(hash.AsSpan(0, 16)); + } } diff --git a/LehrerApp.Data.Tests/ChangeHookTests.cs b/LehrerApp.Data.Tests/ChangeHookTests.cs index 60a41f4..fc6d2da 100644 --- a/LehrerApp.Data.Tests/ChangeHookTests.cs +++ b/LehrerApp.Data.Tests/ChangeHookTests.cs @@ -77,4 +77,26 @@ public sealed class ChangeHookTests Assert.Equal("Save", call.Operation); Assert.Same(state, call.Payload); } + + [Fact] + public void DashboardWarningSnoozeRepository_Save_LoestOnChangeAus() + { + using var db = NewInMemoryContext(); + var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload)); + var repo = new DashboardWarningSnoozeRepository(db); + var studentId = Guid.NewGuid(); + var snooze = new DashboardWarningSnooze + { + StudentId = studentId, WarningTag = "Absentismus", SnoozedUntil = new DateOnly(2026, 10, 12), + }; + + repo.Save(snooze); + + var call = Assert.Single(calls); + Assert.Equal(nameof(DashboardWarningSnooze), call.EntityType); + Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Absentismus").ToString(), call.EntityId); + Assert.Equal("Save", call.Operation); + Assert.Same(snooze, call.Payload); + } } diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs index 9dd2b12..ac9e26e 100644 --- a/LehrerApp.Data.Tests/LiteDbContextTests.cs +++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs @@ -121,7 +121,7 @@ public sealed class LiteDbContextTests using var temp = new TempDatabase(); using var context = new LiteDbContext(temp.Path); - Assert.Equal(6, context.SchemaVersion); + Assert.Equal(7, context.SchemaVersion); } [Fact] @@ -132,7 +132,30 @@ public sealed class LiteDbContextTests using var second = new LiteDbContext(temp.Path); - Assert.Equal(6, second.SchemaVersion); + Assert.Equal(7, second.SchemaVersion); + } + + [Fact] + public void MigrateDashboardWarningSnoozeIds_SchluesseltLokaleEintraegeAufAbgeleiteteIdUm() + { + using var temp = new TempDatabase(); + var studentId = Guid.NewGuid(); + + using (var legacy = new LiteDatabase(temp.Path)) + { + legacy.GetCollection("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = 6 }); + legacy.GetCollection("dashboard_warning_snoozes").Insert(new DashboardWarningSnooze + { + Id = Guid.NewGuid(), StudentId = studentId, WarningTag = "Verspätungen", + SnoozedUntil = new DateOnly(2026, 10, 12), + }); + } + + using var context = new LiteDbContext(temp.Path); + + var snooze = Assert.Single(context.DashboardWarningSnoozes.FindAll()); + Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Verspätungen"), snooze.Id); + Assert.Equal(new DateOnly(2026, 10, 12), snooze.SnoozedUntil); } [Fact] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index c6782f4..9d12173 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -274,9 +274,31 @@ public class LiteDbContext : IDisposable MigrateCompetencyReferences(); version = 6; } + if (version < 7) + { + MigrateDashboardWarningSnoozeIds(); + version = 7; + } WriteSchemaVersion(version); } + /// DashboardWarningSnooze wird seit Schema 7 synchronisiert und braucht dafür eine aus + /// (StudentId, WarningTag) abgeleitete Id (siehe DashboardWarningSnooze.IdFor). Vorher lokal + /// angelegte Einträge mit zufälliger Id werden umgeschlüsselt, sonst kollidierte ein + /// eingehender Sync-Datensatz für dasselbe Paar am Unique-Index ux_student_tag. + private void MigrateDashboardWarningSnoozeIds() + { + var snoozes = DashboardWarningSnoozes; + foreach (var snooze in snoozes.FindAll().ToList()) + { + var id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag); + if (snooze.Id == id) continue; + snoozes.Delete(snooze.Id); + snooze.Id = id; + snoozes.Upsert(snooze); + } + } + private int ReadSchemaVersion() { var meta = _db.GetCollection("meta").FindById(1); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index fc644f4..9e56096 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -909,7 +909,12 @@ public class DashboardWarningSnoozeRepository(LiteDbContext db) : IDashboardWarn public DashboardWarningSnooze? Get(Guid studentId, string warningTag) => db.DashboardWarningSnoozes.FindOne(s => s.StudentId == studentId && s.WarningTag == warningTag); public List GetAll() => db.DashboardWarningSnoozes.FindAll().ToList(); - public void Save(DashboardWarningSnooze snooze) => db.DashboardWarningSnoozes.Upsert(snooze); + public void Save(DashboardWarningSnooze snooze) + { + snooze.Id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag); + db.DashboardWarningSnoozes.Upsert(snooze); + db.OnChange?.Invoke(nameof(DashboardWarningSnooze), snooze.Id.ToString(), "Save", snooze); + } } public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 555c365..4e2b846 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -347,7 +347,12 @@ public class FakeDashboardWarningSnoozeRepository : IDashboardWarningSnoozeRepos public DashboardWarningSnooze? Get(Guid studentId, string warningTag) => _all.FirstOrDefault(s => s.StudentId == studentId && s.WarningTag == warningTag); public List GetAll() => _all.ToList(); - public void Save(DashboardWarningSnooze snooze) { _all.RemoveAll(s => s.Id == snooze.Id); _all.Add(snooze); } + public void Save(DashboardWarningSnooze snooze) + { + snooze.Id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag); + _all.RemoveAll(s => s.Id == snooze.Id); + _all.Add(snooze); + } } public class FakeAttachmentStorage : IAttachmentStorage diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs index c807fe4..149cb6d 100644 --- a/LehrerApp.Sync.Tests/EventApplierTests.cs +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -52,6 +52,31 @@ public sealed class EventApplierTests Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind); } + [Fact] + public async Task ApplyAsync_DashboardWarningSnoozeSave_UeberschreibtLokalenEintragFuerDasselbePaar() + { + using var db = NewInMemoryContext(); + var applier = new EventApplier(db, Key); + var studentId = Guid.NewGuid(); + // Beide Geräte stellen dieselbe Warnung unabhängig zurück — dank IdFor dieselbe Id, also + // kein Konflikt am Unique-Index ux_student_tag, sondern ein schlichtes Überschreiben. + db.DashboardWarningSnoozes.Insert(new DashboardWarningSnooze + { + Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"), + StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 5), + }); + var remote = new DashboardWarningSnooze + { + Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"), + StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 26), + }; + + await applier.ApplyAsync(MakeEvent(nameof(DashboardWarningSnooze), remote.Id.ToString(), "Save", remote)); + + var saved = Assert.Single(db.DashboardWarningSnoozes.FindAll()); + Assert.Equal(new DateOnly(2026, 10, 26), saved.SnoozedUntil); + } + [Fact] public async Task ApplyAsync_CompetencyDomainSave_UebernimmtVerschachtelteKompetenzen() { diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index 28cdab9..5f2859e 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n Simple(context => context.SubstitutionEntries); Simple(context => context.Vorgaenge); Simple(context => context.UntisHubJobStates); + Simple(context => context.DashboardWarningSnoozes); handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) => { diff --git a/TODO.md b/TODO.md index a892f02..7811e58 100644 --- a/TODO.md +++ b/TODO.md @@ -2005,6 +2005,19 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk `DashboardViewModel.Load()`/`RebuildAttention()`. `DashboardView.axaml` bekam dafür einen neuen `.defused`-Stil (nach `.attendance` deklariert, damit er bei gleicher Klassenanzahl gewinnt) für Severity-Balken und Fehlzeiten-Prozent-Text. + **Nachtrag, Zurückstellen synchronisiert (2026-09-28):** Nutzer-Feedback: eine auf Gerät A + zurückgestellte Warnung stand auf Gerät B weiter rot oben — die ursprüngliche Einordnung als + "rein lokaler Erinnerungs-Zeitstempel" passte nicht, weil das Zurückstellen eine bewusste + Entscheidung über die Person ist, nicht Geräte-Buchhaltung (gleiche Begründung wie beim + Sync von `UntisHubJobState`). `DashboardWarningSnoozeRepository.Save` ruft jetzt `db.OnChange`, + `EventApplier` hat einen `Simple`-Eintrag. Anders als bei + `UntisHubJobState` liegt auf (StudentId, WarningTag) ein Unique-Index — zwei Geräte mit + zufälligen Ids hätten beim Anwenden einen `LiteException` ausgelöst und das Ereignis + übersprungen. Die Id wird deshalb jetzt deterministisch aus dem Paar abgeleitet + (`DashboardWarningSnooze.IdFor`, gesetzt im Repository-`Save`), sodass beide Geräte denselben + Datensatz beschreiben (Last-Write-Wins). Schema-Migration 7 schlüsselt bereits lokal + gespeicherte Einträge auf die abgeleitete Id um. Bestehende lokale Zurückstellungen werden + erst beim nächsten erneuten Zurückstellen übertragen (kein automatischer Nach-Push). - **Nachtrag, Detail-Popup und Schnellbausteine (September 2026):** Nutzer-Feedback: (a) im Dashboard-Eintrag mehr erfahren, (b) beim Anlegen von Vorgang/Dokumentation Unterstützung statt alles von Hand. Gemeinsame Grundlage ist der neue `StudentWarningEvidenceService` (Desktop/