diff --git a/LehrerApp.Data.Tests/ChangeHookTests.cs b/LehrerApp.Data.Tests/ChangeHookTests.cs
new file mode 100644
index 0000000..b0f4f6e
--- /dev/null
+++ b/LehrerApp.Data.Tests/ChangeHookTests.cs
@@ -0,0 +1,60 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Data.Repositories;
+using Xunit;
+
+namespace LehrerApp.Data.Tests;
+
+// Vorlage-Test für den OnChange-Hook (Baustein 2), den die übrigen Repositories in den
+// Bausteinen 3/4 auf dieselbe Weise bekommen.
+public sealed class ChangeHookTests
+{
+ private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
+
+ [Fact]
+ public void StudentRepository_Save_LoestOnChangeMitKorrektenArgumentenAus()
+ {
+ 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 StudentRepository(db);
+ var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
+
+ repo.Save(student);
+
+ var call = Assert.Single(calls);
+ Assert.Equal("Student", call.EntityType);
+ Assert.Equal(student.Id.ToString(), call.EntityId);
+ Assert.Equal("Save", call.Operation);
+ Assert.Same(student, call.Payload);
+ }
+
+ [Fact]
+ public void StudentRepository_Delete_LoestOnChangeMitNullPayloadAus()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new StudentRepository(db);
+ var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
+ repo.Save(student);
+ var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
+ db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
+
+ repo.Delete(student.Id);
+
+ var call = Assert.Single(calls);
+ Assert.Equal("Student", call.EntityType);
+ Assert.Equal(student.Id.ToString(), call.EntityId);
+ Assert.Equal("Delete", call.Operation);
+ Assert.Null(call.Payload);
+ }
+
+ [Fact]
+ public void StudentRepository_Save_OhneGesetztenHook_WirftNicht()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new StudentRepository(db);
+
+ var exception = Record.Exception(() => repo.Save(new Student { FirstName = "Anna", LastName = "Beispiel" }));
+
+ Assert.Null(exception);
+ }
+}
diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs
index 940859d..bd7768b 100644
--- a/LehrerApp.Data/LiteDbContext.cs
+++ b/LehrerApp.Data/LiteDbContext.cs
@@ -3,6 +3,10 @@ using LehrerApp.Core.Models;
namespace LehrerApp.Data;
+/// Wird nach jedem Save/Delete einer Entität aufgerufen; payload ist die gespeicherte Entität
+/// bzw. null bei Delete. Sync-agnostisch – siehe .
+public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
+
///
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
///
@@ -67,6 +71,91 @@ public class LiteDbContext : IDisposable
public int SchemaVersion => ReadSchemaVersion();
+ /// Wird von den Repositories nach jedem Save/Delete aufgerufen (payload = die gespeicherte
+ /// Entität bzw. null bei Delete). Bewusst hier statt in LehrerApp.Sync definiert, damit Data
+ /// weiterhin ohne Verweis auf Sync auskommt — die eigentliche Sync-Anbindung setzt diesen
+ /// Hook von außen (siehe AppBootstrapper).
+ public ChangeHandler? OnChange { get; set; }
+
+ /// Führt dieselbe Kaskade wie GroupRepository.Delete aus. Hier auf dem Context statt
+ /// im Repository, damit ein später eingehendes Sync-Ereignis (Baustein 5) dieselbe Kaskade
+ /// nachvollziehen kann, ohne über die Repository-Save/Delete-Methoden (und damit erneut über
+ /// ) zu laufen.
+ internal void CascadeDeleteGroup(Guid id)
+ {
+ ExecuteInTransaction(() =>
+ {
+ foreach (var membership in Memberships.Find(e => e.GroupId == id).ToList())
+ Memberships.Delete(membership.Id);
+
+ foreach (var exam in Exams.Find(e => e.GroupId == id).ToList())
+ {
+ foreach (var result in ExamResults.Find(r => r.ExamId == exam.Id).ToList())
+ ExamResults.Delete(result.Id);
+ Exams.Delete(exam.Id);
+ }
+
+ foreach (var grade in Grades.Find(g => g.GroupId == id).ToList())
+ Grades.Delete(grade.Id);
+
+ foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList())
+ ReportGrades.Delete(reportGrade.Id);
+
+ foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList())
+ GradingSchemes.Delete(scheme.Id);
+
+ foreach (var unit in Units.Find(u => u.GroupId == id).ToList())
+ {
+ foreach (var lesson in Lessons.Find(l => l.UnitId == unit.Id).ToList())
+ Lessons.Delete(lesson.Id);
+ Units.Delete(unit.Id);
+ }
+
+ foreach (var lesson in Lessons.Find(l => l.GroupId == id).ToList())
+ Lessons.Delete(lesson.Id);
+
+ foreach (var session in ParticipationSessions.Find(s => s.GroupId == id).ToList())
+ {
+ foreach (var entry in ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
+ ParticipationEntries.Delete(entry.Id);
+ ParticipationSessions.Delete(session.Id);
+ }
+
+ foreach (var aspect in ParticipationAspects.Find(a => a.GroupId == id).ToList())
+ ParticipationAspects.Delete(aspect.Id);
+
+ foreach (var section in ParticipationSections.Find(s => s.GroupId == id).ToList())
+ ParticipationSections.Delete(section.Id);
+
+ foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
+ SeatingPlans.Delete(plan.Id);
+
+ // Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
+ // werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
+ foreach (var documentation in Documentation.Find(d => d.GroupId == id).ToList())
+ {
+ documentation.GroupId = null;
+ documentation.UpdatedAt = DateTime.UtcNow;
+ Documentation.Update(documentation);
+ }
+
+ foreach (var task in Tasks.Find(t => t.GroupId == id).ToList())
+ {
+ task.GroupId = null;
+ task.UpdatedAt = DateTime.UtcNow;
+ Tasks.Update(task);
+ }
+
+ foreach (var timeEntry in TimeEntries.Find(t => t.GroupId == id).ToList())
+ {
+ timeEntry.GroupId = null;
+ TimeEntries.Update(timeEntry);
+ }
+
+ Groups.Delete(id);
+ });
+ }
+
internal void ExecuteInTransaction(Action action)
{
_db.BeginTrans();
diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs
index 7fdb50c..7c9af89 100644
--- a/LehrerApp.Data/Repositories/AllRepositories.cs
+++ b/LehrerApp.Data/Repositories/AllRepositories.cs
@@ -35,7 +35,12 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
db.ReportGrades.Count(g => g.StudentId == studentId),
db.ParticipationEntries.Count(e => e.StudentId == studentId),
db.Documentation.Count(d => d.StudentId == studentId));
- public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); }
+ public void Save(Student s)
+ {
+ s.UpdatedAt = DateTime.UtcNow;
+ db.Students.Upsert(s);
+ db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s);
+ }
public void Delete(Guid id)
{
var references = GetReferenceSummary(id);
@@ -43,6 +48,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
throw new InvalidOperationException(
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
db.Students.Delete(id);
+ db.OnChange?.Invoke(nameof(Student), id.ToString(), "Delete", null);
}
}
@@ -77,77 +83,7 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
public void Delete(Guid id)
{
ArchivedGroupWriteGuard.EnsureActive(db, id);
- db.ExecuteInTransaction(() =>
- {
- foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
- db.Memberships.Delete(membership.Id);
-
- foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList())
- {
- foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList())
- db.ExamResults.Delete(result.Id);
- db.Exams.Delete(exam.Id);
- }
-
- foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList())
- db.Grades.Delete(grade.Id);
-
- foreach (var reportGrade in db.ReportGrades.Find(g => g.GroupId == id).ToList())
- db.ReportGrades.Delete(reportGrade.Id);
-
- foreach (var scheme in db.GradingSchemes.Find(s => s.GroupId == id).ToList())
- db.GradingSchemes.Delete(scheme.Id);
-
- foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList())
- {
- foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList())
- db.Lessons.Delete(lesson.Id);
- db.Units.Delete(unit.Id);
- }
-
- foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList())
- db.Lessons.Delete(lesson.Id);
-
- foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList())
- {
- foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
- db.ParticipationEntries.Delete(entry.Id);
- db.ParticipationSessions.Delete(session.Id);
- }
-
- foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList())
- db.ParticipationAspects.Delete(aspect.Id);
-
- foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList())
- db.ParticipationSections.Delete(section.Id);
-
- foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList())
- db.SeatingPlans.Delete(plan.Id);
-
- // Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
- // werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
- foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList())
- {
- documentation.GroupId = null;
- documentation.UpdatedAt = DateTime.UtcNow;
- db.Documentation.Update(documentation);
- }
-
- foreach (var task in db.Tasks.Find(t => t.GroupId == id).ToList())
- {
- task.GroupId = null;
- task.UpdatedAt = DateTime.UtcNow;
- db.Tasks.Update(task);
- }
-
- foreach (var timeEntry in db.TimeEntries.Find(t => t.GroupId == id).ToList())
- {
- timeEntry.GroupId = null;
- db.TimeEntries.Update(timeEntry);
- }
-
- db.Groups.Delete(id);
- });
+ db.CascadeDeleteGroup(id);
}
}