using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; namespace LehrerApp.Data.Repositories; internal static class ArchivedGroupWriteGuard { public const string Message = "Diese Lerngruppe ist archiviert. Bitte reaktiviere sie, bevor du Änderungen vornimmst."; public static void EnsureActive(LiteDbContext db, Guid groupId) { if (db.Groups.FindById(groupId) is { IsActive: false }) throw new InvalidOperationException(Message); } } public class StudentRepository(LiteDbContext db) : IStudentRepository { public Student? GetById(Guid id) => db.Students.FindById(id); public List GetAll(bool includeInactive = false) => (includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive)) .OrderBy(s => s.LastName).ToList(); public List GetByGroup(Guid groupId) { var ids = db.Memberships .Find(e => e.GroupId == groupId) .Select(e => e.StudentId).ToHashSet(); return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList(); } public StudentReferenceSummary GetReferenceSummary(Guid studentId) => new( db.Memberships.Count(m => m.StudentId == studentId), db.ExamResults.Count(r => r.StudentId == studentId), db.Grades.Count(g => g.StudentId == studentId), 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.ExternalIds ??= []; s.Contacts ??= []; 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); if (references.HasReferences) 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); } } public class GroupRepository(LiteDbContext db) : IGroupRepository { public LearningGroup? GetById(Guid id) => db.Groups.FindById(id); public List GetAll(bool includeInactive = false) => (includeInactive ? db.Groups.FindAll() : db.Groups.Find(g => g.IsActive)) .OrderBy(g => g.SchoolYear).ThenBy(g => g.Name).ToList(); public List GetBySchoolYear(string schoolYear, bool includeInactive = false) => (includeInactive ? db.Groups.Find(g => g.SchoolYear == schoolYear) : db.Groups.Find(g => g.SchoolYear == schoolYear && g.IsActive)) .OrderBy(g => g.Name).ToList(); public void Save(LearningGroup g) { var existing = db.Groups.FindById(g.Id); if (existing is { IsActive: false } && !g.IsActive) throw new InvalidOperationException(ArchivedGroupWriteGuard.Message); if (existing is { IsActive: false } && g.IsActive && (existing.Name != g.Name || existing.Type != g.Type || existing.SubjectId != g.SubjectId || existing.SchoolYear != g.SchoolYear || existing.GradeLevel != g.GradeLevel || existing.GradingSystem != g.GradingSystem || existing.HoursPerWeek != g.HoursPerWeek || existing.IsOwnClass != g.IsOwnClass || existing.IsDifferentiated != g.IsDifferentiated)) throw new InvalidOperationException( "Bitte reaktiviere die Lerngruppe zuerst und nimm die Änderungen anschließend vor."); if (g.SubjectId is Guid subjectId && db.Subjects.FindById(subjectId) is null) throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr."); g.UpdatedAt = DateTime.UtcNow; db.Groups.Upsert(g); db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g); } public void Delete(Guid id) { ArchivedGroupWriteGuard.EnsureActive(db, id); db.CascadeDeleteGroup(id); db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null); } } public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository { public SeatingPlan? GetById(Guid id) => db.SeatingPlans.FindById(id); public List GetByGroup(Guid groupId) => db.SeatingPlans.Find(p => p.GroupId == groupId) .OrderBy(p => p.Name).ThenBy(p => p.Room).ToList(); public void Save(SeatingPlan plan) { ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); // LiteDB can deserialize missing/legacy optional string fields as null even // though the current model initializes them with an empty string. plan.Name = plan.Name?.Trim() ?? ""; plan.Room = plan.Room?.Trim() ?? ""; plan.Assignments ??= []; plan.ColumnGapWidths ??= []; plan.HiddenSeats ??= []; if (plan.Name.Length == 0) throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein."); if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10) throw new ArgumentOutOfRangeException(nameof(plan), "Ein Sitzplan muss zwischen 1 und 10 Reihen und Spalten haben."); if (db.Groups.FindById(plan.GroupId) is null) throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht."); plan.ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1)) .Select(i => i < plan.ColumnGapWidths.Count && double.IsFinite(plan.ColumnGapWidths[i]) ? Math.Clamp(plan.ColumnGapWidths[i], 0, 300) : 0) .ToList(); var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId) .FirstOrDefault(p => p.Id != plan.Id && string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase) && string.Equals(p.Room, plan.Room, StringComparison.OrdinalIgnoreCase)); if (duplicateName is not null) throw new InvalidOperationException("Für diese Lerngruppe existiert bereits ein gleichnamiger Sitzplan in diesem Raum."); plan.Assignments = plan.Assignments .Where(a => a.Row >= 0 && a.Row < plan.Rows && a.Column >= 0 && a.Column < plan.Columns) .ToList(); if (plan.Assignments.GroupBy(a => (a.Row, a.Column)).Any(g => g.Count() > 1)) throw new InvalidOperationException("Ein Sitzplatz darf nur einmal belegt werden."); if (plan.Assignments.GroupBy(a => a.StudentId).Any(g => g.Count() > 1)) throw new InvalidOperationException("Ein Schüler darf in einem Sitzplan nur einmal vorkommen."); var memberIds = db.Memberships.Find(m => m.GroupId == plan.GroupId) .Select(m => m.StudentId).ToHashSet(); if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId))) throw new InvalidOperationException("Der Sitzplan enthält einen Schüler, der nicht zur Lerngruppe gehört."); // Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist // wertvoller als das Ausblenden, deshalb wird hier stillschweigend wieder eingeblendet // statt die Zuordnung zu verwerfen. Sollte über die UI (SeatingPlanTabViewModel verhindert // das Belegen ausgeblendeter Plätze) nie vorkommen - reine Absicherung bei fehlerhaften // oder von einem älteren Client synchronisierten Daten. var assignedPositions = plan.Assignments.Select(a => (a.Row, a.Column)).ToHashSet(); plan.HiddenSeats = plan.HiddenSeats .Where(h => h.Row >= 0 && h.Row < plan.Rows && h.Column >= 0 && h.Column < plan.Columns) .Where(h => !assignedPositions.Contains((h.Row, h.Column))) .DistinctBy(h => (h.Row, h.Column)) .ToList(); plan.UpdatedAt = DateTime.UtcNow; db.SeatingPlans.Upsert(plan); db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan); } public void Delete(Guid id) { if (db.SeatingPlans.FindById(id) is not { } plan) return; ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); db.MoveToTrash(nameof(SeatingPlan), id, plan, string.IsNullOrWhiteSpace(plan.Room) ? plan.Name : $"{plan.Name} (Raum {plan.Room})"); db.SeatingPlans.Delete(id); db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null); } public void Restore(Guid trashId) { if (db.RestoreFromTrash(trashId) is { } plan) Save(plan); } } public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository { public List GetByStudent(Guid id) => db.Memberships.Find(e => e.StudentId == id).ToList(); public List GetByGroup(Guid id) => db.Memberships.Find(e => e.GroupId == id).ToList(); public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) => db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId); public void Save(GroupMembership membership) { ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId); if (existing is not null && existing.Id != membership.Id) throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet."); db.Memberships.Upsert(membership); db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership); } public void Delete(Guid id) { if (db.Memberships.FindById(id) is { } membership) ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); db.Memberships.Delete(id); db.OnChange?.Invoke(nameof(GroupMembership), id.ToString(), "Delete", null); } } public class ExamRepository(LiteDbContext db) : IExamRepository { public Exam? GetById(Guid id) => db.Exams.FindById(id); public List GetAll() => db.Exams.FindAll().OrderBy(e => e.Date).ToList(); public List GetByGroup(Guid groupId) => db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList(); public void Save(Exam e) { ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId); e.UpdatedAt = DateTime.UtcNow; db.Exams.Upsert(e); db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e); } public void Delete(Guid id) { if (db.Exams.FindById(id) is { } exam) ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); db.CascadeDeleteExam(id); db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null); } } public class ExamResultRepository(LiteDbContext db) : IExamResultRepository { public List GetByExam(Guid id) => db.ExamResults.Find(r => r.ExamId == id).ToList(); public List GetByStudent(Guid id) => db.ExamResults.Find(r => r.StudentId == id).ToList(); public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) => db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId); public void Save(ExamResult r) { if (db.Exams.FindById(r.ExamId) is { } exam) ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); r.UpdatedAt = DateTime.UtcNow; db.ExamResults.Upsert(r); db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r); } public void SaveMany(List results) { foreach (var examId in results.Select(r => r.ExamId).Distinct()) if (db.Exams.FindById(examId) is { } exam) ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); var now = DateTime.UtcNow; foreach (var r in results) r.UpdatedAt = now; db.ExamResults.Upsert(results); foreach (var r in results) db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r); } } public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplateRepository { public List GetAll() => db.GradingKeyTemplates.FindAll().OrderBy(t => t.Name).ToList(); public List GetByGradingSystem(GradingSystem system) => db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList(); public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id); public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); db.OnChange?.Invoke(nameof(GradingKeyTemplate), t.Id.ToString(), "Save", t); } public void Delete(Guid id) { if (db.GradingKeyTemplates.FindById(id) is { } template) db.MoveToTrash(nameof(GradingKeyTemplate), id, template, template.Name); db.GradingKeyTemplates.Delete(id); db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null); } public void Restore(Guid trashId) { if (db.RestoreFromTrash(trashId) is { } template) Save(template); } } public class GradeRepository(LiteDbContext db) : IGradeRepository { public List GetByStudentAndGroup(Guid sid, Guid gid) => db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList(); public List GetByGroup(Guid id) => db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList(); public void Save(Grade g) { ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId); db.Grades.Upsert(g); db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g); } public void Delete(Guid id) { if (db.Grades.FindById(id) is not { } grade) return; ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); db.MoveToTrash(nameof(Grade), id, grade, $"Note {grade.Value} ({grade.Date:dd.MM.yyyy})"); db.Grades.Delete(id); db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null); } public void Restore(Guid trashId) { if (db.RestoreFromTrash(trashId) is { } grade) Save(grade); } } public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository { public GradingScheme? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId); public GradingScheme? GetDefaultForType(GroupType type) => db.GradingSchemes.FindOne(s => s.GroupId == null && s.GroupType == type); public void Save(GradingScheme s) { if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); s.UpdatedAt = DateTime.UtcNow; db.GradingSchemes.Upsert(s); db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); db.GradingSchemes.Delete(id); db.OnChange?.Invoke(nameof(GradingScheme), id.ToString(), "Delete", null); } } public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository { public List GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList(); public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) => db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period); public void Save(ReportGrade r) { ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId); r.UpdatedAt = DateTime.UtcNow; db.ReportGrades.Upsert(r); db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r); } public void Delete(Guid id) { if (db.ReportGrades.FindById(id) is { } grade) ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); db.ReportGrades.Delete(id); db.OnChange?.Invoke(nameof(ReportGrade), id.ToString(), "Delete", null); } } public class UnitRepository(LiteDbContext db) : IUnitRepository { public Unit? GetById(Guid id) => db.Units.FindById(id); public List GetByGroup(Guid id) => db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList(); public void Save(Unit u) { ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u); } public void Delete(Guid id) { if (db.Units.FindById(id) is { } unit) ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId); db.Units.Delete(id); db.OnChange?.Invoke(nameof(Unit), id.ToString(), "Delete", null); } } public class LessonRepository(LiteDbContext db) : ILessonRepository { public List GetByUnit(Guid id) => db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); public List GetByGroupAndDate(Guid gid, DateOnly date) => db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList(); public List GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) => db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to) .OrderBy(l => l.Date).ToList(); public void Save(Lesson l) { ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId); l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l); } public void Delete(Guid id) { if (db.Lessons.FindById(id) is { } lesson) { ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId); } db.Lessons.Delete(id); db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null); } } public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository { public List GetByStudent(Guid id) => db.Documentation.Find(d => d.StudentId == id && !d.IsDeleted).OrderByDescending(d => d.Date).ToList(); public List GetByStudentAndType(Guid sid, DocumentationType type) => db.Documentation.Find(d => d.StudentId == sid && d.Type == type && !d.IsDeleted) .OrderByDescending(d => d.Date).ToList(); public List GetAll() => db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList(); public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); db.OnChange?.Invoke(nameof(Documentation), d.Id.ToString(), "Save", d); } public void Delete(Guid id) { var doc = db.Documentation.FindById(id); if (doc is null) return; doc.IsDeleted = true; doc.DeletedAt = DateTime.UtcNow; db.Documentation.Update(doc); // Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein // anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen. db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Save", doc); } public void HardDelete(Guid id) { db.CascadeHardDeleteDocumentation(id); db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null); } } public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository { public List GetByStatus(WorkTaskStatus s) => db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList(); public List GetAll() => db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); public List GetByGroup(Guid groupId) => db.Tasks.Find(t => t.GroupId == groupId).OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); db.OnChange?.Invoke(nameof(WorkTask), t.Id.ToString(), "Save", t); } public void Delete(Guid id) { if (db.Tasks.FindById(id) is { } task) db.MoveToTrash(nameof(WorkTask), id, task, task.Title); db.Tasks.Delete(id); db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null); } public void Restore(Guid trashId) { if (db.RestoreFromTrash(trashId) is { } task) Save(task); } } public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository { public List GetByDate(DateOnly date) => db.TimeEntries.Find(e => e.Date == date).OrderBy(e => e.StartTime).ToList(); public List GetByDateRange(DateOnly from, DateOnly to) => db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList(); public List GetByTask(Guid id) => db.TimeEntries.Find(e => e.TaskId == id).ToList(); public void Save(TimeEntry e) { db.TimeEntries.Upsert(e); db.OnChange?.Invoke(nameof(TimeEntry), e.Id.ToString(), "Save", e); } public void Delete(Guid id) { if (db.TimeEntries.FindById(id) is { } entry) db.MoveToTrash(nameof(TimeEntry), id, entry, $"{entry.DurationMinutes} Min. {entry.Category} ({entry.Date:dd.MM.yyyy})"); db.TimeEntries.Delete(id); db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null); } public void Restore(Guid trashId) { if (db.RestoreFromTrash(trashId) is { } entry) Save(entry); } } public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository { public List GetByGroup(Guid groupId) => db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList(); public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id); public void Save(ParticipationSession s) { ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); s.UpdatedAt = DateTime.UtcNow; db.ParticipationSessions.Upsert(s); db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.ParticipationSessions.FindById(id) is { } session) ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); db.CascadeDeleteParticipationSession(id); db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null); } } public class ParticipationRepository(LiteDbContext db) : IParticipationRepository { public List GetBySession(Guid sessionId) => db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList(); public List GetByStudent(Guid studentId) => db.ParticipationEntries.Find(e => e.StudentId == studentId).ToList(); public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) => db.ParticipationEntries.FindOne(e => e.SessionId == sessionId && e.StudentId == studentId); public void Save(ParticipationEntry e) { if (db.ParticipationSessions.FindById(e.SessionId) is { } session) ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); e.UpdatedAt = DateTime.UtcNow; db.ParticipationEntries.Upsert(e); db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e); } public void SaveMany(List entries) { foreach (var sessionId in entries.Select(e => e.SessionId).Distinct()) if (db.ParticipationSessions.FindById(sessionId) is { } session) ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); var now = DateTime.UtcNow; foreach (var e in entries) e.UpdatedAt = now; db.ParticipationEntries.Upsert(entries); foreach (var e in entries) db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e); } public void DeleteBySession(Guid sessionId) { if (db.ParticipationSessions.FindById(sessionId) is { } session) ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList()) { db.ParticipationEntries.Delete(e.Id); db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Delete", null); } } } public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAspectRepository { public List GetDefaults() => db.ParticipationAspects.Find(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList(); public List GetByGroup(Guid groupId) => db.ParticipationAspects.Find(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList(); public List GetAllByGroup(Guid groupId) => db.ParticipationAspects.Find(a => a.GroupId == groupId).OrderBy(a => a.SortOrder).ToList(); public void Save(ParticipationAspect a) { if (a.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); a.Key = a.Key.Trim(); a.Label = a.Label.Trim(); if (a.Key.Length == 0) throw new ArgumentException("Der Schlüssel darf nicht leer sein."); if (a.Label.Length == 0) throw new ArgumentException("Die Bezeichnung darf nicht leer sein."); // Ein Schlüssel muss innerhalb dessen, was für eine Gruppe tatsächlich gilt, eindeutig // sein — das sind die globalen Standardaspekte UND die gruppenspezifischen zusammen // (siehe ParticipationTabViewModel.LoadAspects, das beide konkateniert). Sonst entstünde // in der Bewertungsübersicht eine mehrdeutige Spalte mit identischem Schlüssel. var relevant = db.ParticipationAspects.Find(x => x.GroupId == null || x.GroupId == a.GroupId); var duplicate = relevant.FirstOrDefault(x => x.Id != a.Id && string.Equals(x.Key, a.Key, StringComparison.OrdinalIgnoreCase)); if (duplicate is not null) throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits."); a.UpdatedAt = DateTime.UtcNow; db.ParticipationAspects.Upsert(a); db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a); } public void Delete(Guid id) { if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); db.ParticipationAspects.Delete(id); db.OnChange?.Invoke(nameof(ParticipationAspect), id.ToString(), "Delete", null); } } public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository { public List GetByGroup(Guid groupId) => db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList(); public void Save(ParticipationSection s) { ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); db.ParticipationSections.Upsert(s); db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.ParticipationSections.FindById(id) is { } section) ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId); db.ParticipationSections.Delete(id); db.OnChange?.Invoke(nameof(ParticipationSection), id.ToString(), "Delete", null); } } public class SubjectRepository(LiteDbContext db) : ISubjectRepository { public List GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList(); public Subject? GetById(Guid id) => db.Subjects.FindById(id); public Subject? GetByName(string name) => db.Subjects.FindAll().FirstOrDefault(s => string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase)); public void Save(Subject s) { s.Name = s.Name.Trim(); s.ShortName = s.ShortName.Trim(); if (s.Name.Length == 0) throw new ArgumentException("Der Fachname darf nicht leer sein."); var duplicate = GetByName(s.Name); if (duplicate is not null && duplicate.Id != s.Id) throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits."); s.UpdatedAt = DateTime.UtcNow; db.Subjects.Upsert(s); db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s); } public void Delete(Guid id) { if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id)) throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet."); db.Subjects.Delete(id); db.OnChange?.Invoke(nameof(Subject), id.ToString(), "Delete", null); } } public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepository { public List GetAll() => db.ShorthandCodes.FindAll().OrderBy(c => c.Code).ToList(); public void Save(ShorthandCode c) { c.Code = c.Code.Trim(); c.Label = c.Label.Trim(); if (c.Code.Length == 0) throw new ArgumentException("Das Kürzel darf nicht leer sein."); var duplicate = db.ShorthandCodes.FindAll() .FirstOrDefault(x => string.Equals(x.Code, c.Code, StringComparison.OrdinalIgnoreCase)); if (duplicate is not null && duplicate.Id != c.Id) throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits."); c.UpdatedAt = DateTime.UtcNow; db.ShorthandCodes.Upsert(c); db.OnChange?.Invoke(nameof(ShorthandCode), c.Id.ToString(), "Save", c); } public void Delete(Guid id) { db.ShorthandCodes.Delete(id); db.OnChange?.Invoke(nameof(ShorthandCode), id.ToString(), "Delete", null); } } public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository { public List GetAll() => db.AlternativeLessonPaths.FindAll().OrderBy(p => p.Name).ToList(); public AlternativeLessonPath? GetById(Guid id) => db.AlternativeLessonPaths.FindById(id); public void Save(AlternativeLessonPath p) { p.Name = p.Name.Trim(); p.Description = string.IsNullOrWhiteSpace(p.Description) ? null : p.Description.Trim(); if (p.Name.Length == 0) throw new ArgumentException("Der Name darf nicht leer sein."); var duplicate = db.AlternativeLessonPaths.FindAll() .FirstOrDefault(x => string.Equals(x.Name, p.Name, StringComparison.OrdinalIgnoreCase)); if (duplicate is not null && duplicate.Id != p.Id) throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits."); p.UpdatedAt = DateTime.UtcNow; db.AlternativeLessonPaths.Upsert(p); db.OnChange?.Invoke(nameof(AlternativeLessonPath), p.Id.ToString(), "Save", p); } public void Delete(Guid id) { db.AlternativeLessonPaths.Delete(id); db.OnChange?.Invoke(nameof(AlternativeLessonPath), id.ToString(), "Delete", null); } } public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository { public List GetAll() => db.TimetableSlots.FindAll().OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList(); public List GetByGroup(Guid groupId) => db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList(); public void Save(TimetableSlot slot) { ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); var occupied = db.TimetableSlots.FindAll() .FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber); if (occupied is not null && occupied.Id != slot.Id) throw new InvalidOperationException("Diese Stunde ist bereits belegt."); db.TimetableSlots.Upsert(slot); db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot); } public void Delete(Guid id) { if (db.TimetableSlots.FindById(id) is { } slot) ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); db.TimetableSlots.Delete(id); db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null); } } public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository { public List GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList(); public void Save(SchoolHoliday holiday) { db.SchoolHolidays.Upsert(holiday); db.OnChange?.Invoke(nameof(SchoolHoliday), holiday.Id.ToString(), "Save", holiday); } public void Delete(Guid id) { db.SchoolHolidays.Delete(id); db.OnChange?.Invoke(nameof(SchoolHoliday), id.ToString(), "Delete", null); } } public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository { public List GetAll() => db.SupervisionDuties.FindAll().OrderBy(d => d.Weekday).ThenBy(d => d.AfterPeriod).ToList(); public void Save(SupervisionDuty duty) { var occupied = db.SupervisionDuties.FindAll() .FirstOrDefault(d => d.Weekday == duty.Weekday && d.AfterPeriod == duty.AfterPeriod); if (occupied is not null && occupied.Id != duty.Id) throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen."); db.SupervisionDuties.Upsert(duty); db.OnChange?.Invoke(nameof(SupervisionDuty), duty.Id.ToString(), "Save", duty); } public void Delete(Guid id) { db.SupervisionDuties.Delete(id); db.OnChange?.Invoke(nameof(SupervisionDuty), id.ToString(), "Delete", null); } } public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository { public List GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); public List GetByDate(DateOnly date) => db.SubstitutionEntries.Find(e => e.Date == date).ToList(); public void Save(SubstitutionEntry entry) { db.SubstitutionEntries.Upsert(entry); db.OnChange?.Invoke(nameof(SubstitutionEntry), entry.Id.ToString(), "Save", entry); } public void Delete(Guid id) { db.SubstitutionEntries.Delete(id); db.OnChange?.Invoke(nameof(SubstitutionEntry), id.ToString(), "Delete", null); } } public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository { public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => db.CompetencyDomains .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .OrderBy(d => d.SortOrder) .ToList(); public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id); public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Save", d); } public void Delete(Guid id) { db.CompetencyDomains.Delete(id); db.OnChange?.Invoke(nameof(CompetencyDomain), id.ToString(), "Delete", null); } public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) { foreach (var d in db.CompetencyDomains .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .ToList()) { db.CompetencyDomains.Delete(d.Id); db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Delete", null); } } public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List domains) { db.ExecuteInTransaction(() => { foreach (var domain in db.CompetencyDomains .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList()) { db.CompetencyDomains.Delete(domain.Id); db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null); } var now = DateTime.UtcNow; foreach (var domain in domains) { domain.SubjectId = subjectId; domain.GradeLevel = gradeLevel; domain.UpdatedAt = now; db.CompetencyDomains.Upsert(domain); db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain); } }); } } /// Papierkorb (14.3) — bewusst nicht sync-fähig: TrashedItem wird nie über /// db.OnChange gemeldet, bleibt also rein lokal auf dem Gerät, auf dem gelöscht wurde. Ein /// "Fehlklick sofort rückgängig machen"-Werkzeug, kein geräteübergreifendes Archiv. public class TrashRepository(LiteDbContext db) : ITrashRepository { public List GetAll() => db.TrashedItems.FindAll().OrderByDescending(t => t.DeletedAt).ToList(); public void PurgeOlderThan(DateTime cutoffUtc) { foreach (var item in db.TrashedItems.Find(t => t.DeletedAt < cutoffUtc).ToList()) db.TrashedItems.Delete(item.Id); } }