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 ILiteCollection TrashedItems => _db.GetCollection("trash");
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);
}
// ── Papierkorb (14.3) ────────────────────────────────────────────────────
//
// Absichtlich hier statt in einer eigenen TrashRepository-Konstruktorabhängigkeit: jede
// Fach-Repository hat db (LiteDbContext) bereits im Konstruktor, ein zusätzlicher Papierkorb-
// Parameter wäre nur Boilerplate. Snapshot/Restore sind generisch über den Modelltyp, die
// eigentliche Wiederherstellung ruft aber bewusst die jeweilige Repository.Save-Methode auf
// (nicht direkt die LiteDB-Collection) — nur die kennt Validierung und OnChange-Ereignis für
// ihr Modell.
/// Legt einen Papierkorb-Eintrag an, BEVOR die aufrufende Repository die Entität aus
/// ihrer eigentlichen Collection löscht.
internal void MoveToTrash(string entityType, Guid entityId, T entity, string summary) =>
TrashedItems.Insert(new TrashedItem
{
EntityType = entityType,
EntityId = entityId,
Snapshot = System.Text.Json.JsonSerializer.Serialize(entity),
Summary = summary,
});
/// Liefert die gelöschte Entität aus dem Papierkorb-Eintrag zurück und entfernt den
/// Eintrag - unabhängig davon, ob die aufrufende Repository den Schnappschuss danach
/// erfolgreich speichert (ein fehlgeschlagenes Restore soll nicht endlos wiederholbar sein,
/// derselbe Datensatz landet notfalls einfach nicht wieder im Papierkorb).
internal T? RestoreFromTrash(Guid trashId)
{
var item = TrashedItems.FindById(trashId);
if (item is null) return default;
TrashedItems.Delete(trashId);
return System.Text.Json.JsonSerializer.Deserialize(item.Snapshot);
}
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();
}