using LiteDB; 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. /// public class LiteDbContext : IDisposable { /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter /// ergänzt, statt bei jedem Start erneut /// (idempotent, aber unnötig) über alle Daten zu laufen. private const int CurrentSchemaVersion = 4; private readonly LiteDatabase _db; public LiteDbContext(string databasePath, string? password = null) { _db = new LiteDatabase(new ConnectionString(databasePath) { Connection = ConnectionType.Shared, Password = password, }); RunVersionedMigrations(); EnsureIndexes(); } /// Für Tests: In-Memory-Datenbank ohne Datei auf der Festplatte. public LiteDbContext(Stream stream) { _db = new LiteDatabase(stream); RunVersionedMigrations(); EnsureIndexes(); } public ILiteCollection Students => _db.GetCollection("students"); public ILiteCollection Groups => _db.GetCollection("groups"); public ILiteCollection SeatingPlans => _db.GetCollection("seating_plans"); public ILiteCollection Memberships => _db.GetCollection("group_memberships"); public ILiteCollection Exams => _db.GetCollection("exams"); public ILiteCollection ExamResults => _db.GetCollection("exam_results"); public ILiteCollection Grades => _db.GetCollection("grades"); public ILiteCollection GradingSchemes => _db.GetCollection("grading_schemes"); public ILiteCollection ReportGrades => _db.GetCollection("report_grades"); public ILiteCollection GradingKeyTemplates => _db.GetCollection("grading_key_templates"); public ILiteCollection Units => _db.GetCollection("units"); public ILiteCollection Lessons => _db.GetCollection("lessons"); public ILiteCollection Documentation => _db.GetCollection("documentation"); public ILiteStorage Attachments => _db.GetStorage("attachments", "attachments_chunks"); public ILiteCollection Tasks => _db.GetCollection("tasks"); public ILiteCollection TimeEntries => _db.GetCollection("time_entries"); public ILiteCollection ParticipationSessions => _db.GetCollection("participation_sessions"); public ILiteCollection ParticipationEntries => _db.GetCollection("participation"); public ILiteCollection ParticipationAspects => _db.GetCollection("participation_aspects"); public ILiteCollection ParticipationSections => _db.GetCollection("participation_sections"); public ILiteCollection Subjects => _db.GetCollection("subjects"); public ILiteCollection CompetencyDomains => _db.GetCollection("competency_domains"); public ILiteCollection ShorthandCodes => _db.GetCollection("shorthand_codes"); public ILiteCollection AlternativeLessonPaths => _db.GetCollection("alternative_lesson_paths"); public ILiteCollection TimetableSlots => _db.GetCollection("timetable_slots"); public ILiteCollection SchoolHolidays => _db.GetCollection("school_holidays"); public ILiteCollection SupervisionDuties => _db.GetCollection("supervision_duties"); public ILiteCollection SubstitutionEntries => _db.GetCollection("substitution_entries"); public void Checkpoint() => _db.Checkpoint(); 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); }); } /// Führt dieselbe Kaskade wie ExamRepository.Delete aus (siehe ). internal void CascadeDeleteExam(Guid id) { foreach (var result in ExamResults.Find(r => r.ExamId == id).ToList()) ExamResults.Delete(result.Id); Exams.Delete(id); } /// Führt dieselbe Kaskade wie ParticipationSessionRepository.Delete aus (siehe /// ). internal void CascadeDeleteParticipationSession(Guid id) { ParticipationSessions.Delete(id); foreach (var e in ParticipationEntries.Find(e => e.SessionId == id).ToList()) ParticipationEntries.Delete(e.Id); } /// Führt dieselbe Kaskade wie DocumentationRepository.HardDelete aus (siehe /// ). internal void CascadeHardDeleteDocumentation(Guid id) { if (Documentation.FindById(id) is { } doc) foreach (var attachment in doc.Attachments) Attachments.Delete(attachment.StorageId); Documentation.Delete(id); } internal void ExecuteInTransaction(Action action) { _db.BeginTrans(); try { action(); _db.Commit(); } catch { _db.Rollback(); throw; } } private void RunVersionedMigrations() { var version = ReadSchemaVersion(); if (version < 1) { MigrateExistingData(); version = 1; } if (version < 2) { MigrateLessonPhases(); version = 2; } if (version < 3) { MigrateLessonShorthand(); version = 3; } if (version < 4) { MigrateLessonAlternativePaths(); version = 4; } WriteSchemaVersion(version); } private int ReadSchemaVersion() { var meta = _db.GetCollection("meta").FindById(1); return meta?["SchemaVersion"].AsInt32 ?? 0; } private void WriteSchemaVersion(int version) => _db.GetCollection("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = version, }); private void MigrateExistingData() { MigrateMemberships(); RemoveRedundantLegacyFields(); var groups = _db.GetCollection("groups"); var subjects = _db.GetCollection("subjects"); var missingArchiveState = groups.FindAll() .Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive))) .ToList(); foreach (var group in missingArchiveState) { group[nameof(LearningGroup.IsActive)] = true; groups.Update(group); } // Subject war anfangs nur als Text in der Gruppe gespeichert. Alte // Werte werden einmalig an die Fachstammdaten angebunden und danach // entfernt, damit es nur noch eine führende Fachbezeichnung gibt. foreach (var group in groups.FindAll().ToList()) { var changed = false; var legacyName = group.TryGetValue("Subject", out var value) && value.IsString ? value.AsString.Trim() : ""; var hasSubjectId = group.TryGetValue(nameof(LearningGroup.SubjectId), out var id) && id.IsGuid && subjects.FindById(id.AsGuid) is not null; if (!hasSubjectId && legacyName.Length > 0) { var subject = subjects.FindAll().FirstOrDefault(s => string.Equals(s.Name.Trim(), legacyName, StringComparison.OrdinalIgnoreCase)); if (subject is null) { subject = new Subject { Name = legacyName }; subjects.Insert(subject); } group[nameof(LearningGroup.SubjectId)] = subject.Id; changed = true; } else if (!hasSubjectId && group.ContainsKey(nameof(LearningGroup.SubjectId))) { group[nameof(LearningGroup.SubjectId)] = BsonValue.Null; changed = true; } changed |= group.Remove("Subject"); if (changed) groups.Update(group); } } private void MigrateMemberships() { const string oldName = "enrollments"; const string newName = "group_memberships"; var names = _db.GetCollectionNames().ToHashSet(StringComparer.OrdinalIgnoreCase); if (names.Contains(oldName) && !names.Contains(newName)) _db.RenameCollection(oldName, newName); var memberships = _db.GetCollection(newName); foreach (var membership in memberships.FindAll().ToList()) { var changed = false; if (!membership.ContainsKey(nameof(GroupMembership.AddedOn)) && membership.TryGetValue("EnrolledAt", out var enrolledAt)) { membership[nameof(GroupMembership.AddedOn)] = enrolledAt; changed = true; } changed |= membership.Remove("EnrolledAt"); changed |= membership.Remove("SchoolYear"); if (changed) memberships.Update(membership); } } /// Fasst die frühere flache Struktur einer Stunde (einzelnes Phase-Textfeld, Methoden-/ /// Materialien-Listen) verlustfrei in eine einzige -Zeile der /// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte /// -Klasse kennt sie nicht mehr, ein Zugriff über die typisierte /// Lessons-Collection würde sie beim Deserialisieren bereits verwerfen. private void MigrateLessonPhases() { var lessons = _db.GetCollection("lessons"); foreach (var lesson in lessons.FindAll().ToList()) { if (lesson.ContainsKey(nameof(Lesson.Phases))) continue; var phase = lesson.TryGetValue("Phase", out var p) && p.IsString ? p.AsString.Trim() : ""; var methods = lesson.TryGetValue("Methods", out var m) && m.IsArray ? string.Join("; ", m.AsArray.Select(x => x.AsString)) : ""; var materials = lesson.TryGetValue("Materials", out var mat) && mat.IsArray ? string.Join(", ", mat.AsArray.Select(x => x.AsString)) : ""; var phases = new BsonArray(); if (phase.Length > 0 || methods.Length > 0 || materials.Length > 0) { phases.Add(new BsonDocument { [nameof(LessonPhaseStep.Id)] = Guid.NewGuid(), [nameof(LessonPhaseStep.Name)] = phase, [nameof(LessonPhaseStep.DurationMinutes)] = 0, [nameof(LessonPhaseStep.Activity)] = methods, [nameof(LessonPhaseStep.Material)] = materials, [nameof(LessonPhaseStep.Shorthand)] = "", }); } lesson[nameof(Lesson.Phases)] = phases; lesson.Remove("Phase"); lesson.Remove("Methods"); lesson.Remove("Materials"); lessons.Update(lesson); } } /// Führt die anfangs erzwungene Von/Nach-Struktur des Kurzsymbols (ShorthandFrom/ /// ShorthandTo) in ein einzelnes Freitextfeld zusammen — der Praxis nach ist ein /// Kurzsymbol nicht immer ein Materialfluss-Pfeil, manchmal nur eine Sozialform ("Plenum"). /// Beide Felder gesetzt ergeben "Von->Nach", nur eines gesetzt bleibt als Einzelwert erhalten. private void MigrateLessonShorthand() { var lessons = _db.GetCollection("lessons"); foreach (var lesson in lessons.FindAll().ToList()) { if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue; var changed = false; foreach (var phaseValue in phasesValue.AsArray) { if (phaseValue is not BsonDocument phase) continue; if (phase.ContainsKey(nameof(LessonPhaseStep.Shorthand))) continue; var from = phase.TryGetValue("ShorthandFrom", out var f) && f.IsString ? f.AsString : ""; var to = phase.TryGetValue("ShorthandTo", out var t) && t.IsString ? t.AsString : ""; phase[nameof(LessonPhaseStep.Shorthand)] = (from.Length > 0, to.Length > 0) switch { (true, true) => $"{from}->{to}", (true, false) => from, (false, true) => to, _ => "", }; phase.Remove("ShorthandFrom"); phase.Remove("ShorthandTo"); changed = true; } if (changed) lessons.Update(lesson); } } /// Führt das anfangs freie Textfeld für den alternativen Ablauf einer Phase /// (AlternativePath, String) in einen Verweis auf einen Katalogeintrag /// () über — pro bisher verwendetem, distinktem /// Namen wird ein angelegt (bzw. ein gleichnamiger /// wiederverwendet) und referenziert. Die neue Collection ist von dieser Migration selbst nicht /// betroffen (keine Altstruktur), daher direkter Zugriff über die typisierte Collection. private void MigrateLessonAlternativePaths() { var lessons = _db.GetCollection("lessons"); var nameToId = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var existing in AlternativeLessonPaths.FindAll()) nameToId[existing.Name] = existing.Id; foreach (var lesson in lessons.FindAll().ToList()) { if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue; var changed = false; foreach (var phaseValue in phasesValue.AsArray) { if (phaseValue is not BsonDocument phase) continue; if (!phase.TryGetValue("AlternativePath", out var oldVal) || !oldVal.IsString) continue; var name = oldVal.AsString.Trim(); phase.Remove("AlternativePath"); changed = true; if (name.Length == 0) continue; if (!nameToId.TryGetValue(name, out var id)) { var entry = new AlternativeLessonPath { Name = name }; AlternativeLessonPaths.Insert(entry); id = entry.Id; nameToId[name] = id; } phase[nameof(LessonPhaseStep.AlternativePathId)] = id; } if (changed) lessons.Update(lesson); } } private void RemoveRedundantLegacyFields() { RemoveFields("exams", "Subject"); RemoveFields("units", "Subject", "SchoolYear"); RemoveFields("grades", "SchoolYear"); RemoveFields("participation", "GroupId", "Date"); } private void RemoveFields(string collectionName, params string[] fieldNames) { var collection = _db.GetCollection(collectionName); foreach (var document in collection.FindAll().ToList()) { var changed = false; foreach (var fieldName in fieldNames) changed |= document.Remove(fieldName); if (changed) collection.Update(document); } } private void EnsureIndexes() { Students.EnsureIndex(x => x.LastName); Students.EnsureIndex(x => x.IsActive); Groups.EnsureIndex(x => x.SchoolYear); Groups.EnsureIndex(x => x.IsActive); SeatingPlans.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex(x => x.StudentId); Memberships.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex("ux_student_group", BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId)"), unique: true); Exams.EnsureIndex(x => x.GroupId); Exams.EnsureIndex(x => x.Status); ExamResults.EnsureIndex(x => x.ExamId); ExamResults.EnsureIndex(x => x.StudentId); ExamResults.EnsureIndex("ux_exam_student", BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true); Grades.EnsureIndex(x => x.StudentId); Grades.EnsureIndex(x => x.GroupId); GradingSchemes.EnsureIndex(x => x.GroupId); GradingSchemes.EnsureIndex(x => x.GroupType); ReportGrades.EnsureIndex(x => x.GroupId); ReportGrades.EnsureIndex(x => x.StudentId); ReportGrades.EnsureIndex("ux_student_group_period", BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true); GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); Units.EnsureIndex(x => x.GroupId); Lessons.EnsureIndex(x => x.UnitId); Lessons.EnsureIndex(x => x.GroupId); Lessons.EnsureIndex(x => x.Date); Documentation.EnsureIndex(x => x.StudentId); Tasks.EnsureIndex(x => x.Status); TimeEntries.EnsureIndex(x => x.Date); ParticipationSessions.EnsureIndex(x => x.GroupId); ParticipationSessions.EnsureIndex(x => x.Date); ParticipationEntries.EnsureIndex(x => x.SessionId); ParticipationEntries.EnsureIndex(x => x.StudentId); ParticipationEntries.EnsureIndex("ux_session_student", BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true); ParticipationAspects.EnsureIndex(x => x.GroupId); ParticipationSections.EnsureIndex(x => x.GroupId); Subjects.EnsureIndex(x => x.Name); Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); CompetencyDomains.EnsureIndex(x => x.SubjectId); CompetencyDomains.EnsureIndex(x => x.GradeLevel); ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true); AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); TimetableSlots.EnsureIndex(x => x.GroupId); TimetableSlots.EnsureIndex("ux_timetable_weekday_period", BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true); SupervisionDuties.EnsureIndex("ux_supervision_weekday_period", BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true); SubstitutionEntries.EnsureIndex(x => x.Date); } public void Dispose() => _db.Dispose(); }