11 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 9286bfa3b5 Baustein 11: Kapitel 10 in TODO.md final abgleichen
- Einleitungstext von "Grundgerüst existiert..." auf den finalen Stand
  aktualisiert: Outbound-Publishing, Inbound-Apply, Anhang-Sync,
  Client-UI und Server-Härtung sind umgesetzt und nur aktiv, wenn
  Server-URL + Login konfiguriert sind.
- Abschließenden Verifikations-Absatz zu 10.3 ergänzt: Build/Test-Stand
  (591 Tests) sowie Live-Rauchtest gegen einen laufenden
  LehrerApp.Api-Prozess dokumentiert, inkl. der zwei in dieser Umgebung
  nicht verifizierbaren Szenarien (Zwei-Geräte-Sync, docker compose up).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:42:05 +02:00
adminandClaude Sonnet 5 6774123270 Baustein 10: Deployment-Haertung (Kapitel 10)
- Rate Limiting ueber ASP.NET Cores eingebautes
  Microsoft.AspNetCore.RateLimiting (keine neue Paketabhaengigkeit):
  /api/auth/login auf 5 Versuche/Minute begrenzt (Brute-Force-Schutz),
  alle Endpunkte zusaetzlich global auf 120 Anfragen/Minute je IP
- Kestrel MaxRequestBodySize auf 15 MB gedeckelt (Anhaenge sind
  clientseitig ohnehin auf 10 MB begrenzt)
- Neu docker/backup.sh: Tar-Archiv von ./data (Ereignis-Logs,
  Snapshots, Anhaenge, Nutzer), raeumt Archive aelter als 30 Tage auf,
  laeuft direkt auf dem Host
- docker/README.md um Backup- und Rate-Limit-Dokumentation ergaenzt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:38:42 +02:00
adminandClaude Sonnet 5 fc2d7aea3e Baustein 9: Konflikt-Review-UI (Kapitel 10)
Minimale Liste im Tab "Synchronisation" - Entitaet, Zeitpunkt, welche
Seite ConflictResolver gewaehlt hat, mit "Gesehen"-Aktion. Kein
Feld-Diff fuer v1: die Payloads sind clientseitig verschluesselt, ein
Diff wuerde ohnehin nur rohes JSON zeigen.

Neu EventQueue.MarkReviewed(id) - bisher gab es AddConflict/
GetUnreviewed/ConflictCount, aber keinen Weg, einen Konflikt als
gesehen zu markieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:37:20 +02:00
adminandClaude Sonnet 5 de98b4ed54 Baustein 8: Settings-Tab "Synchronisation" (Kapitel 10)
Neuer Tab in den Einstellungen: Server-URL, Login, Verbindungstest,
Logout.

- Neu SyncSettingsService (Muster AiSettingsService): Token
  AES-256-verschluesselt ueber SyncCrypto mit eigenem, rein lokalem
  Schluessel - ersetzt die bisherigen Klartext-Helfer
  AppBootstrapper.LoadServerUrl/SaveServerUrl und die unverschluesselte
  auth.token-Datei
- Neu SyncAuthService fuer den Login-HTTP-Aufruf gegen /api/auth/login
  und den Verbindungstest (unterscheidet erreichbar & angemeldet /
  erreichbar aber nicht angemeldet / nicht erreichbar)
- Speichern/Anmelden startet die App neu (AppBootstrapper.
  RestartApplication, gleiches Muster wie bei DB-Passwort/AppLock-
  Aenderungen) - SyncEngine/SnapshotService werden nur einmalig beim
  Start registriert, es gibt keinen Live-Re-Registrierungspfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:34:49 +02:00
adminandClaude Sonnet 5 95345f9c46 Baustein 7: TODO.md-Korrektur (Kapitel 10)
- 10.1.4/10.1.5 abgehakt (SyncStatusViewModel/SyncStatusBar waren
  bereits vorhanden, aber in der Checkliste noch nicht als erledigt
  markiert)
- 10.1.6 abgehakt mit Umsetzungsnotiz (Baustein 2-4)
- Neuer Punkt 10.1.7 fuer den zuvor komplett fehlenden, in der
  Checkliste nicht erfassten Inbound-Apply-Baustein (Baustein 5)
- Neuer Punkt 10.1.8 fuer Anhang-Sync (Baustein 6)
- 10.2.1 abgehakt mit Umsetzungsnotiz (Baustein 1)
- Neuer Punkt 10.3.4 fuer die bewusst akzeptierte v1-Einschraenkung
  beim Regel-Bypass im Event-Applier

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:31:09 +02:00
adminandClaude Sonnet 5 f0f8fa25e5 Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)
Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.

- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
  LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
  ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
  gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
  SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
  Documentation-Ereignisses nach - ueber die rohe Collection statt
  IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
  hier die Original-StorageId erhalten bleiben muss

Round-Trip-Tests belegen byteidentische Uebertragung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:30:04 +02:00
adminandClaude Sonnet 5 ce4dfb0197 Baustein 5: EventApplier (Inbound Apply) + Loop-Prevention (Kapitel 10)
Die zentrale, bisher komplett fehlende Luecke: selbst mit Baustein
1-4 haette SyncEngine.PullAsync empfangene Ereignisse nur zur
Konflikterkennung genutzt, nie in die lokale LiteDB geschrieben -
ankommende Aenderungen von anderen Geraeten waeren nirgends sichtbar
geworden.

Neu EventApplier: entschluesselt, dispatcht ueber eine explizite
EntityType-Tabelle, schreibt IMMER direkt auf die rohe LiteDB-
Collection, nie ueber eine Repository-Save/Delete-Methode - sonst
wuerde der OnChange-Hook (Baustein 2) die gerade angewendete Aenderung
als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). Ein
gemeinsames Suppress-Flag wurde geprueft und verworfen: SyncEngine
laeuft per Timer nebenlaeufig zum UI-Thread, ein Flag koennte einen
echten Nutzer-Save waehrenddessen verschlucken. Der direkte Collection-
Zugriff ist zustandslos und dadurch korrekt. Kaskaden-Faelle nutzen
dieselben internen LiteDbContext-Hilfsmethoden wie die Repositories
(Baustein 4).

Neu SyncEventPublisher, der den OnChange-Hook in ein verschluesseltes
EventQueue.Enqueue uebersetzt (an LiteDbContext.OnChange gehaengt).

Mit dediziertem Loop-Prevention-Test abgesichert: belegt mit echtem
LiteDbContext + OnChange-Zaehler, dass Apply keinen neuen Hook-Aufruf
ausloest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:27:12 +02:00
adminandClaude Sonnet 5 1240a2cd8a Baustein 4: Repositories verdrahten Teil B (Kaskaden & Batch, Kapitel 10)
Die heiklen Faelle, die nicht ueber die oeffentliche Save/Delete-Methode
laufen, sondern mehrere Collections direkt anfassen: GroupRepository
(Save + Delete-Kaskade), ExamRepository.Delete, ExamResultRepository
(Save/SaveMany), DocumentationRepository (Save/Delete/HardDelete),
ParticipationSessionRepository.Delete, ParticipationRepository
(SaveMany/DeleteBySession), CompetencyDomainRepository (inkl. Replace-
/DeleteBySubjectAndGrade).

Regel: pro oeffentlichem Repository-Aufruf genau EIN Sync-Ereignis
(z.B. GroupRepository.Delete -> ein Group/Delete-Ereignis, nicht 14),
Batch-Methoden feuern ein Ereignis pro betroffener Entitaet. Dafuer
ExamRepository.Delete/ParticipationSessionRepository.Delete/
DocumentationRepository.HardDelete auf die in Baustein 2 vorbereiteten
LiteDbContext-Kaskadenhelfer umgestellt (CascadeDeleteExam,
CascadeDeleteParticipationSession, CascadeHardDeleteDocumentation) -
dieselbe Kaskade existiert dadurch nur an einer Stelle im Code, nicht
doppelt (wichtig fuer Baustein 5, wo ein eingehendes Sync-Ereignis sie
erneut braucht).

Tests pruefen explizit die Ereignis-Anzahl bei Kaskaden/Batches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:24:22 +02:00
adminandClaude Sonnet 5 ed34e5d036 Baustein 3: Repositories verdrahten Teil A (Kapitel 10)
OnChange-Hook auf den 19 einfachen Repositories (keine Kaskaden/Batches)
ergaenzt: SeatingPlan, GroupMembership, GradingKeyTemplate, Grade,
GradingScheme, ReportGrade, Unit, Lesson, WorkTask, TimeEntry,
ParticipationAspect, ParticipationSection, Subject, ShorthandCode,
AlternativeLessonPath, TimetableSlot, SchoolHoliday, SupervisionDuty,
SubstitutionEntry. Mechanisch, ein Aufruf nach dem bestehenden
Upsert/Delete.

Tabellengetriebener Test statt 19 fast identischer Testdateien.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:22:46 +02:00
adminandClaude Sonnet 5 831cc1c16c Baustein 2: Outbound-Hook-Infrastruktur (Kapitel 10)
LiteDbContext.OnChange-Hook (Sync-agnostisch, kein Verweis auf
LehrerApp.Sync aus LehrerApp.Data), damit Repositories lokale
Schreibvorgaenge signalisieren koennen, ohne dass Data von Sync
abhaengt. StudentRepository als Vorlage verdrahtet.

GroupRepository.Delete-Kaskade (14 betroffene Collections) nach
LiteDbContext.CascadeDeleteGroup extrahiert - reine Verschiebung,
kein Verhaltensunterschied, macht sie aber von einem spaeter
eingehenden Sync-Ereignis (Baustein 5) wiederverwendbar, ohne ueber
Repository-Save/Delete (und damit erneut ueber OnChange) zu laufen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:20:30 +02:00
adminandClaude Sonnet 5 6f9de325d5 Baustein 1: Server-Auth-Fix (Kapitel 10)
/api/auth/login und /api/auth/register akzeptierten zuvor jeden
beliebigen Nutzernamen/Passwort und stellten ein gueltiges 30-Tage-JWT
aus - konkrete, ausnutzbare Luecke bei echtem Deployment.

- PasswordHasher (PBKDF2, Salt pro Nutzer) + UserStore (LiteDB) statt
  des ungeprueften Stubs
- /api/auth/register ersatzlos entfernt (kein offener
  Registrierungs-Endpunkt fuer ein Einzel-/Familien-Deployment)
- Neue Nutzer per CLI (dotnet LehrerApp.Api.dll create-user <name>),
  dokumentiert in docker/README.md
- Neues Testprojekt LehrerApp.Api.Tests (bisher als einziges Projekt
  ohne Tests)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:20:19 +02:00
38 changed files with 2458 additions and 152 deletions
@@ -0,0 +1,70 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class AttachmentStoreTests
{
[Fact]
public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
byte[] original = [1, 2, 3, 4, 5, 255, 0, 42];
await store.StoreAsync("user-1", "abc123", new MemoryStream(original));
using var read = store.OpenRead("user-1", "abc123");
Assert.NotNull(read);
using var ms = new MemoryStream();
await read!.CopyToAsync(ms);
Assert.Equal(original, ms.ToArray());
}
[Fact]
public void OpenRead_UnbekannteStorageId_GibtNullZurueck()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
Assert.Null(store.OpenRead("user-1", "unbekannt"));
}
[Fact]
public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
Assert.Null(store.OpenRead("user-2", "shared-id"));
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
}
[Fact]
public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
// Darf keinesfalls außerhalb von <root>/attachments/<user> landen.
await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3]));
Assert.False(File.Exists(Path.Combine(temp.Path, "evil")));
var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories);
Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil");
}
private sealed class TempDataPath : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}");
public TempDataPath() => Directory.CreateDirectory(Path);
public void Dispose()
{
if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true);
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Api\LehrerApp.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class PasswordHasherTests
{
[Fact]
public void Verify_RichtigesPasswort_GibtTrueZurueck()
{
var hash = PasswordHasher.Hash("korrektes-passwort-123");
Assert.True(PasswordHasher.Verify("korrektes-passwort-123", hash));
}
[Fact]
public void Verify_FalschesPasswort_GibtFalseZurueck()
{
var hash = PasswordHasher.Hash("korrektes-passwort-123");
Assert.False(PasswordHasher.Verify("falsches-passwort-456", hash));
}
[Fact]
public void Hash_ZweiAufrufeMitGleichemPasswort_ErzeugenUnterschiedlicheHashes()
{
// Zufälliger Salt pro Nutzer -> selbst identische Passwörter dürfen nicht denselben
// gespeicherten Wert ergeben.
var hash1 = PasswordHasher.Hash("dasselbe-passwort");
var hash2 = PasswordHasher.Hash("dasselbe-passwort");
Assert.NotEqual(hash1, hash2);
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash1));
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash2));
}
[Fact]
public void Verify_UngueltigesGespeichertesFormat_GibtFalseZurueckStattZuWerfen()
{
Assert.False(PasswordHasher.Verify("irgendwas", "kein-gueltiges-format"));
}
}
+64
View File
@@ -0,0 +1,64 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class UserStoreTests
{
[Fact]
public void CreateUser_NeuerNutzername_GibtTrueZurueckUndKannSichAnmelden()
{
using var temp = new TempUserStore();
var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.True(created);
Assert.True(temp.Store.VerifyPassword("sebastian", "einSicheresPasswort"));
}
[Fact]
public void CreateUser_BereitsVorhandenerNutzername_GibtFalseZurueck()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
var created = temp.Store.CreateUser("sebastian", "einAnderesPasswort");
Assert.False(created);
}
[Fact]
public void VerifyPassword_FalschesPasswort_GibtFalseZurueck()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.False(temp.Store.VerifyPassword("sebastian", "falschesPasswort"));
}
[Fact]
public void VerifyPassword_UnbekannterNutzer_GibtFalseZurueck()
{
using var temp = new TempUserStore();
Assert.False(temp.Store.VerifyPassword("unbekannt", "irgendwas"));
}
private sealed class TempUserStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
public UserStore Store { get; }
public TempUserStore()
{
Directory.CreateDirectory(_directory);
Store = new UserStore(_directory);
}
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace LehrerApp.Api;
/// <summary>
/// Rohdaten-Ablage für verschlüsselte Datei-Anhänge, getrennt vom Ereignis-/Snapshot-Speicher.
/// Server sieht nur verschlüsselte Bytes, kein LiteDB nötig - einfache Dateien pro Nutzer/Id.
/// </summary>
public class AttachmentStore(string dataPath)
{
private readonly string _root = Path.Combine(dataPath, "attachments");
public async Task StoreAsync(string userId, string storageId, Stream content)
{
var dir = Path.Combine(_root, Safe(userId));
Directory.CreateDirectory(dir);
await using var file = File.Create(Path.Combine(dir, Safe(storageId)));
await content.CopyToAsync(file);
}
public Stream? OpenRead(string userId, string storageId)
{
var path = Path.Combine(_root, Safe(userId), Safe(storageId));
return File.Exists(path) ? File.OpenRead(path) : null;
}
// storageId kommt als Routen-Parameter vom Client - nie ungeprüft in einen Dateipfad
// übernehmen (Path-Traversal).
private static string Safe(string value) =>
string.Concat(value.Where(c => char.IsLetterOrDigit(c) || c == '-'));
}
+56
View File
@@ -0,0 +1,56 @@
using System.Text;
namespace LehrerApp.Api;
internal static class Cli
{
public static Task<int> RunCreateUserAsync(string dataPath, string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("Verwendung: create-user <benutzername> [--password <passwort>]");
return Task.FromResult(1);
}
var username = args[1];
string? password = null;
for (var i = 2; i < args.Length - 1; i++)
if (args[i] == "--password") password = args[i + 1];
password ??= ReadPassword("Passwort (mind. 12 Zeichen): ");
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
{
Console.Error.WriteLine("Passwort muss mindestens 12 Zeichen lang sein.");
return Task.FromResult(1);
}
Directory.CreateDirectory(dataPath);
using var store = new UserStore(dataPath);
if (!store.CreateUser(username, password))
{
Console.Error.WriteLine($"Nutzer '{username}' existiert bereits.");
return Task.FromResult(1);
}
Console.WriteLine($"Nutzer '{username}' angelegt.");
return Task.FromResult(0);
}
// docker exec ohne -it liefert kein TTY -> ReadKey wäre nicht möglich, dann normal lesen.
private static string ReadPassword(string prompt)
{
Console.Write(prompt);
if (Console.IsInputRedirected) return Console.ReadLine() ?? "";
var sb = new StringBuilder();
ConsoleKeyInfo key;
while ((key = Console.ReadKey(intercept: true)).Key != ConsoleKey.Enter)
{
if (key.Key == ConsoleKey.Backspace && sb.Length > 0) { sb.Length--; Console.Write("\b \b"); }
else if (!char.IsControl(key.KeyChar)) { sb.Append(key.KeyChar); Console.Write('*'); }
}
Console.WriteLine();
return sb.ToString();
}
}
+29 -12
View File
@@ -4,6 +4,7 @@ using System.Text;
using LehrerApp.Core.Models;
using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
namespace LehrerApp.Api;
@@ -14,21 +15,14 @@ public static class Endpoints
public static void MapAuthEndpoints(this WebApplication app, string secret)
{
app.MapPost("/api/auth/login", (LoginRequest req) =>
app.MapPost("/api/auth/login", (LoginRequest req, UserStore store) =>
{
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized();
// TODO: Passwort gegen DB prüfen
if (!store.VerifyPassword(req.Username, req.Password))
return Results.Unauthorized();
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
});
app.MapPost("/api/auth/register", (RegisterRequest req) =>
{
if (req.Password.Length < 12)
return Results.BadRequest("Passwort mind. 12 Zeichen.");
// TODO: User anlegen, Passwort hashen (BCrypt)
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
});
}).RequireRateLimiting("login");
}
// ── Sync ──────────────────────────────────────────────────────────────────
@@ -55,6 +49,30 @@ public static class Endpoints
});
}
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
public static void MapAttachmentEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/sync/attachments").RequireAuthorization();
g.MapPost("/{storageId}", async (string storageId, HttpRequest req,
ClaimsPrincipal user, AttachmentStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes)
return Results.BadRequest("Datei zu groß oder Content-Length fehlt.");
await store.StoreAsync(uid, storageId, req.Body);
return Results.Ok();
});
g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var stream = store.OpenRead(uid, storageId);
return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream");
});
}
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
public static void MapSnapshotEndpoints(this WebApplication app)
@@ -130,4 +148,3 @@ public static class Endpoints
}
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string DisplayName);
+27
View File
@@ -0,0 +1,27 @@
using System.Security.Cryptography;
namespace LehrerApp.Api;
public static class PasswordHasher
{
private const int Iterations = 100_000;
private const int SaltSize = 16;
private const int HashSize = 32;
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string stored)
{
var parts = stored.Split(':');
if (parts.Length != 2) return false;
var salt = Convert.FromBase64String(parts[0]);
var expected = Convert.FromBase64String(parts[1]);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}
+39 -1
View File
@@ -1,6 +1,8 @@
using System.Text;
using System.Threading.RateLimiting;
using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
@@ -9,8 +11,16 @@ builder.WebHost.UseKestrel(o =>
{
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
o.ListenAnyIP(port);
// Anhänge sind clientseitig auf 10 MB begrenzt (IAttachmentStorage.MaxSizeBytes) - etwas
// Puffer für Verschlüsselungs-Overhead/JSON-Ereignisse, aber trotzdem eine harte Obergrenze.
o.Limits.MaxRequestBodySize = 15 * 1024 * 1024;
});
var data = builder.Configuration["Api:DataPath"] ?? "./data";
if (args.Length > 0 && args[0] == "create-user")
return await Cli.RunCreateUserAsync(data, args);
var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
@@ -24,7 +34,32 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
});
builder.Services.AddAuthorization();
var data = builder.Configuration["Api:DataPath"] ?? "./data";
// Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein
// grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", o =>
{
o.PermitLimit = 5;
o.Window = TimeSpan.FromMinutes(1);
o.QueueLimit = 0;
});
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
builder.Services.AddSingleton<UserStore>(_ => new UserStore(data));
builder.Services.AddSingleton<AttachmentStore>(_ => new AttachmentStore(data));
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
@@ -32,11 +67,14 @@ builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>()));
var app = builder.Build();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints(secret);
app.MapSyncEndpoints();
app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
app.Run();
return 0;
+40
View File
@@ -0,0 +1,40 @@
using LiteDB;
namespace LehrerApp.Api;
public class UserStore(string dataPath) : IDisposable
{
private readonly LiteDatabase _db = new(Path.Combine(dataPath, "users.db"));
private ILiteCollection<UserEntry> Col
{
get
{
var col = _db.GetCollection<UserEntry>("users");
col.EnsureIndex(x => x.Username, unique: true);
return col;
}
}
public bool CreateUser(string username, string password)
{
if (Col.Exists(x => x.Username == username)) return false;
Col.Insert(new UserEntry { Username = username, PasswordHash = PasswordHasher.Hash(password) });
return true;
}
public bool VerifyPassword(string username, string password)
{
var user = Col.FindOne(x => x.Username == username);
return user is not null && PasswordHasher.Verify(password, user.PasswordHash);
}
public void Dispose() => _db.Dispose();
}
internal class UserEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
@@ -0,0 +1,196 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
// Baustein 4: pro öffentlichem Repository-Aufruf genau EIN Sync-Ereignis, auch wenn die
// eigentliche Kaskade mehrere Collections betrifft. Batch-Methoden feuern ein Ereignis pro
// betroffener Entität.
public sealed class ChangeHookCascadeTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void GroupRepository_Delete_LoestGenauEinEreignisAusTrotzKaskade()
{
using var db = NewInMemoryContext();
var groupRepo = new GroupRepository(db);
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
groupRepo.Save(group);
db.Grades.Insert(new Grade { GroupId = group.Id, StudentId = Guid.NewGuid() });
db.Exams.Insert(new Exam { GroupId = group.Id });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
groupRepo.Delete(group.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(LearningGroup), "Delete"), call);
}
[Fact]
public void ExamRepository_Delete_LoestGenauEinEreignisAusTrotzErgebnisKaskade()
{
using var db = NewInMemoryContext();
var repo = new ExamRepository(db);
var exam = new Exam { GroupId = Guid.NewGuid() };
repo.Save(exam);
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.Delete(exam.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Exam), "Delete"), call);
}
[Fact]
public void ParticipationSessionRepository_Delete_LoestGenauEinEreignisAusTrotzEintragKaskade()
{
using var db = NewInMemoryContext();
var repo = new ParticipationSessionRepository(db);
var session = new ParticipationSession { GroupId = Guid.NewGuid() };
repo.Save(session);
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.Delete(session.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(ParticipationSession), "Delete"), call);
}
[Fact]
public void ExamResultRepository_SaveMany_LoestEinEreignisProEintragAus()
{
using var db = NewInMemoryContext();
var repo = new ExamResultRepository(db);
var results = new List<ExamResult>
{
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
};
var calls = new List<(string EntityType, string EntityId, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op));
repo.SaveMany(results);
Assert.Equal(3, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(ExamResult), "Save"), (c.EntityType, c.Operation)));
Assert.Equal(results.Select(r => r.Id.ToString()).OrderBy(x => x), calls.Select(c => c.EntityId).OrderBy(x => x));
}
[Fact]
public void ParticipationRepository_SaveMany_LoestEinEreignisProEintragAus()
{
using var db = NewInMemoryContext();
var repo = new ParticipationRepository(db);
var sessionId = Guid.NewGuid();
var entries = new List<ParticipationEntry>
{
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
};
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.SaveMany(entries);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Save"), c));
}
[Fact]
public void ParticipationRepository_DeleteBySession_LoestEinEreignisProEintragAus()
{
using var db = NewInMemoryContext();
var repo = new ParticipationRepository(db);
var sessionId = Guid.NewGuid();
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.DeleteBySession(sessionId);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Delete"), c));
}
[Fact]
public void CompetencyDomainRepository_ReplaceForSubjectAndGrade_LoestDeleteFuerAlteUndSaveFuerNeueAus()
{
using var db = NewInMemoryContext();
var repo = new CompetencyDomainRepository(db);
var subjectId = Guid.NewGuid();
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "Alt" });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.ReplaceForSubjectAndGrade(subjectId, 8,
[
new CompetencyDomain { Name = "Neu 1" },
new CompetencyDomain { Name = "Neu 2" },
]);
Assert.Equal(3, calls.Count);
Assert.Equal(1, calls.Count(c => c.Operation == "Delete"));
Assert.Equal(2, calls.Count(c => c.Operation == "Save"));
Assert.All(calls, c => Assert.Equal(nameof(CompetencyDomain), c.EntityType));
}
[Fact]
public void CompetencyDomainRepository_DeleteBySubjectAndGrade_LoestEinEreignisProDomaenAus()
{
using var db = NewInMemoryContext();
var repo = new CompetencyDomainRepository(db);
var subjectId = Guid.NewGuid();
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "A" });
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "B" });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.DeleteBySubjectAndGrade(subjectId, 8);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(CompetencyDomain), "Delete"), c));
}
[Fact]
public void DocumentationRepository_Delete_LoestSaveMitIsDeletedAus()
{
using var db = NewInMemoryContext();
var repo = new DocumentationRepository(db);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
repo.Save(doc);
var calls = new List<(string EntityType, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
repo.Delete(doc.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Save"), (call.EntityType, call.Operation));
Assert.True(((Documentation)call.Payload!).IsDeleted);
}
[Fact]
public void DocumentationRepository_HardDelete_LoestDeleteAus()
{
using var db = NewInMemoryContext();
var repo = new DocumentationRepository(db);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
repo.Save(doc);
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.HardDelete(doc.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Delete"), call);
}
}
@@ -0,0 +1,204 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
// Baustein 3: prüft für jedes der ~19 "einfachen" Repositories (keine Kaskaden/Batches), dass
// Save/Delete den OnChange-Hook mit dem korrekten EntityType auslöst. Tabellengetrieben statt
// eine Datei pro Repository, um die immer gleiche Prüfung nicht 19x zu wiederholen.
public sealed class ChangeHookMatrixTests
{
public static IEnumerable<object[]> Cases()
{
yield return Case("SeatingPlan", nameof(SeatingPlan), db =>
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var repo = new SeatingPlanRepository(db);
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 };
repo.Save(plan);
return (plan.Id, () => repo.Delete(plan.Id));
});
yield return Case("GroupMembership", nameof(GroupMembership), db =>
{
var repo = new GroupMembershipRepository(db);
var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid() };
repo.Save(membership);
return (membership.Id, () => repo.Delete(membership.Id));
});
yield return Case("GradingKeyTemplate", nameof(GradingKeyTemplate), db =>
{
var repo = new GradingKeyTemplateRepository(db);
var template = new GradingKeyTemplate { Name = "Standard", GradingSystem = GradingSystem.Points0To15 };
repo.Save(template);
return (template.Id, () => repo.Delete(template.Id));
});
yield return Case("Grade", nameof(Grade), db =>
{
var repo = new GradeRepository(db);
var grade = new Grade { GroupId = Guid.NewGuid(), StudentId = Guid.NewGuid(), Value = "2" };
repo.Save(grade);
return (grade.Id, () => repo.Delete(grade.Id));
});
yield return Case("GradingScheme", nameof(GradingScheme), db =>
{
var repo = new GradingSchemeRepository(db);
var scheme = new GradingScheme { GroupId = Guid.NewGuid(), ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 };
repo.Save(scheme);
return (scheme.Id, () => repo.Delete(scheme.Id));
});
yield return Case("ReportGrade", nameof(ReportGrade), db =>
{
var repo = new ReportGradeRepository(db);
var grade = new ReportGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Period = "1. Halbjahr" };
repo.Save(grade);
return (grade.Id, () => repo.Delete(grade.Id));
});
yield return Case("Unit", nameof(Unit), db =>
{
var repo = new UnitRepository(db);
var unit = new Unit { GroupId = Guid.NewGuid(), Title = "Einheit" };
repo.Save(unit);
return (unit.Id, () => repo.Delete(unit.Id));
});
yield return Case("Lesson", nameof(Lesson), db =>
{
var repo = new LessonRepository(db);
var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 3, 12) };
repo.Save(lesson);
return (lesson.Id, () => repo.Delete(lesson.Id));
});
yield return Case("WorkTask", nameof(WorkTask), db =>
{
var repo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Klausur korrigieren", Category = TaskCategory.Correction };
repo.Save(task);
return (task.Id, () => repo.Delete(task.Id));
});
yield return Case("TimeEntry", nameof(TimeEntry), db =>
{
var repo = new TimeEntryRepository(db);
var entry = new TimeEntry { Date = new DateOnly(2026, 3, 12), DurationMinutes = 30, Category = "Vorbereitung" };
repo.Save(entry);
return (entry.Id, () => repo.Delete(entry.Id));
});
yield return Case("ParticipationAspect", nameof(ParticipationAspect), db =>
{
var repo = new ParticipationAspectRepository(db);
var aspect = new ParticipationAspect { GroupId = Guid.NewGuid(), Key = "quality", Label = "Qualität" };
repo.Save(aspect);
return (aspect.Id, () => repo.Delete(aspect.Id));
});
yield return Case("ParticipationSection", nameof(ParticipationSection), db =>
{
var repo = new ParticipationSectionRepository(db);
var section = new ParticipationSection
{
GroupId = Guid.NewGuid(), Label = "1. Abschnitt",
StartDate = new DateOnly(2026, 3, 1), EndDate = new DateOnly(2026, 4, 1),
};
repo.Save(section);
return (section.Id, () => repo.Delete(section.Id));
});
yield return Case("Subject", nameof(Subject), db =>
{
var repo = new SubjectRepository(db);
var subject = new Subject { Name = "Chemie" };
repo.Save(subject);
return (subject.Id, () => repo.Delete(subject.Id));
});
yield return Case("ShorthandCode", nameof(ShorthandCode), db =>
{
var repo = new ShorthandCodeRepository(db);
var code = new ShorthandCode { Code = "Tb", Label = "Tafelbild" };
repo.Save(code);
return (code.Id, () => repo.Delete(code.Id));
});
yield return Case("AlternativeLessonPath", nameof(AlternativeLessonPath), db =>
{
var repo = new AlternativeLessonPathRepository(db);
var path = new AlternativeLessonPath { Name = "Kurzversion" };
repo.Save(path);
return (path.Id, () => repo.Delete(path.Id));
});
yield return Case("TimetableSlot", nameof(TimetableSlot), db =>
{
var repo = new TimetableSlotRepository(db);
var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 };
repo.Save(slot);
return (slot.Id, () => repo.Delete(slot.Id));
});
yield return Case("SchoolHoliday", nameof(SchoolHoliday), db =>
{
var repo = new SchoolHolidayRepository(db);
var holiday = new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) };
repo.Save(holiday);
return (holiday.Id, () => repo.Delete(holiday.Id));
});
yield return Case("SupervisionDuty", nameof(SupervisionDuty), db =>
{
var repo = new SupervisionDutyRepository(db);
var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" };
repo.Save(duty);
return (duty.Id, () => repo.Delete(duty.Id));
});
yield return Case("SubstitutionEntry", nameof(SubstitutionEntry), db =>
{
var repo = new SubstitutionEntryRepository(db);
var entry = new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" };
repo.Save(entry);
return (entry.Id, () => repo.Delete(entry.Id));
});
}
[Theory]
[MemberData(nameof(Cases))]
public void Save_LoestOnChangeMitKorrektemEntityTypeAus(
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
{
using var db = new LiteDbContext(new MemoryStream());
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
setup(db);
Assert.Contains((entityType, "Save"), calls);
}
[Theory]
[MemberData(nameof(Cases))]
public void Delete_LoestOnChangeMitKorrektemEntityTypeAus(
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
{
using var db = new LiteDbContext(new MemoryStream());
var (id, delete) = setup(db);
var calls = new List<(string EntityType, string EntityId, string Operation)>();
db.OnChange = (type, entId, op, payload) => calls.Add((type, entId, op));
delete();
Assert.Contains((entityType, id.ToString(), "Delete"), calls);
}
private static object[] Case(string name, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup) =>
[name, entityType, setup];
}
+60
View File
@@ -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);
}
}
+5
View File
@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
// EventApplier (Baustein 5) muss dieselben internen Kaskaden-Hilfsmethoden auf LiteDbContext
// wiederverwenden können wie die Repositories selbst (siehe LiteDbContext.CascadeDelete*).
[assembly: InternalsVisibleTo("LehrerApp.Sync")]
+115
View File
@@ -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 <see cref="LiteDbContext.OnChange"/>.
public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
/// <summary>
/// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer.
/// </summary>
@@ -67,6 +71,117 @@ 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 <c>GroupRepository.Delete</c> 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
/// <see cref="OnChange"/>) 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 <c>ExamRepository.Delete</c> aus (siehe <see cref="CascadeDeleteGroup"/>).
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 <c>ParticipationSessionRepository.Delete</c> aus (siehe
/// <see cref="CascadeDeleteGroup"/>).
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 <c>DocumentationRepository.HardDelete</c> aus (siehe
/// <see cref="CascadeDeleteGroup"/>).
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();
+146 -98
View File
@@ -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);
}
}
@@ -73,81 +79,13 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
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.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);
db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
}
}
@@ -196,6 +134,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
plan.UpdatedAt = DateTime.UtcNow;
db.SeatingPlans.Upsert(plan);
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
}
public void Delete(Guid id)
@@ -203,6 +142,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (db.SeatingPlans.FindById(id) is { } plan)
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.SeatingPlans.Delete(id);
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
}
}
@@ -221,12 +161,14 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos
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);
}
}
@@ -241,14 +183,14 @@ public class ExamRepository(LiteDbContext db) : IExamRepository
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);
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList())
db.ExamResults.Delete(result.Id);
db.Exams.Delete(id);
db.CascadeDeleteExam(id);
db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
}
}
@@ -266,6 +208,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
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<ExamResult> results)
{
@@ -275,6 +218,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
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);
}
}
@@ -285,8 +230,17 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
public List<GradingKeyTemplate> 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); }
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(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)
{
db.GradingKeyTemplates.Delete(id);
db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null);
}
}
public class GradeRepository(LiteDbContext db) : IGradeRepository
@@ -299,12 +253,14 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
{
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 { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.Grades.Delete(id);
db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null);
}
}
@@ -318,12 +274,14 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor
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);
}
}
@@ -337,12 +295,14 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
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);
}
}
@@ -356,12 +316,14 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
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);
}
}
@@ -379,12 +341,14 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
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);
db.Lessons.Delete(id);
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
}
}
@@ -397,7 +361,12 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
.OrderByDescending(d => d.Date).ToList();
public List<Documentation> 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); }
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);
@@ -405,13 +374,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
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)
{
var doc = db.Documentation.FindById(id);
if (doc is not null)
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
db.Documentation.Delete(id);
db.CascadeHardDeleteDocumentation(id);
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
}
}
@@ -421,8 +391,17 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
public List<WorkTask> GetAll() =>
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); }
public void Delete(Guid id) => db.Tasks.Delete(id);
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)
{
db.Tasks.Delete(id);
db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null);
}
}
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
@@ -433,8 +412,16 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
public List<TimeEntry> GetByTask(Guid id) =>
db.TimeEntries.Find(e => e.TaskId == id).ToList();
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e);
public void Delete(Guid id) => db.TimeEntries.Delete(id);
public void Save(TimeEntry e)
{
db.TimeEntries.Upsert(e);
db.OnChange?.Invoke(nameof(TimeEntry), e.Id.ToString(), "Save", e);
}
public void Delete(Guid id)
{
db.TimeEntries.Delete(id);
db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null);
}
}
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
@@ -447,14 +434,14 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
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.ParticipationSessions.Delete(id);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
db.ParticipationEntries.Delete(e.Id);
db.CascadeDeleteParticipationSession(id);
db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
}
}
@@ -472,6 +459,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
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<ParticipationEntry> entries)
{
@@ -481,13 +469,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
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);
}
}
}
@@ -517,12 +510,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp
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);
}
}
@@ -534,12 +529,14 @@ public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSe
{
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);
}
}
@@ -559,12 +556,14 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository
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);
}
}
@@ -582,8 +581,13 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor
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 void Delete(Guid id) => db.ShorthandCodes.Delete(id);
}
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
@@ -601,8 +605,13 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes
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 void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id);
}
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
@@ -619,20 +628,30 @@ public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepositor
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<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday);
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
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
@@ -646,8 +665,13 @@ public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepos
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 void Delete(Guid id) => db.SupervisionDuties.Delete(id);
}
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
@@ -655,8 +679,16 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry);
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id);
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
@@ -667,14 +699,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.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); }
public void Delete(Guid id) => db.CompetencyDomains.Delete(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<CompetencyDomain> domains)
{
@@ -682,7 +726,10 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
{
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)
{
@@ -690,6 +737,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
domain.GradeLevel = gradeLevel;
domain.UpdatedAt = now;
db.CompetencyDomains.Upsert(domain);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain);
}
});
}
+21
View File
@@ -1,6 +1,7 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
namespace LehrerApp.Desktop.Tests;
@@ -26,6 +27,26 @@ public static class TestSupport
public static AiPlanningService BuildAiPlanningService() => new(
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SyncSettingsService(tempPath);
}
/// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe
/// BuildAiPlanningService).
public static SyncAuthService BuildSyncAuthService() => new(new HttpClient());
/// Eigenes Temp-Verzeichnis je Aufruf (echte, dateibasierte LiteDB wie bei EventQueue üblich).
public static EventQueue BuildEventQueue()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-eventqueue-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new EventQueue(Path.Combine(tempPath, "queue.db"));
}
}
public class FakeStudents(List<Student> all) : IStudentRepository
@@ -2,6 +2,9 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
using System.Linq;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -10,7 +13,8 @@ public sealed class SettingsViewModelTests
{
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = null,
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null)
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
EventQueue? eventQueue = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -26,7 +30,48 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue());
}
[Fact]
public void SyncConflicts_ZeigtUnreviewedKonflikteBeimLaden()
{
var queue = TestSupport.BuildEventQueue();
var conflict = new ConflictEntry
{
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
Resolution = "RemoteWon",
};
queue.AddConflict(conflict);
var vm = BuildViewModel(eventQueue: queue);
var item = Assert.Single(vm.SyncConflicts);
Assert.Equal(conflict.Id, item.Id);
Assert.Contains("anderen Gerät", item.ResolutionDisplay);
}
[Fact]
public void MarkConflictReviewed_EntferntKonfliktAusListeUndAusDerQueue()
{
var queue = TestSupport.BuildEventQueue();
var conflict = new ConflictEntry
{
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" },
Resolution = "LocalWon",
};
queue.AddConflict(conflict);
var vm = BuildViewModel(eventQueue: queue);
var item = vm.SyncConflicts.Single();
vm.MarkConflictReviewedCommand.Execute(item);
Assert.Empty(vm.SyncConflicts);
Assert.Empty(queue.GetUnreviewed());
}
[Fact]
@@ -102,7 +147,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
vm.SelectedStateName = "Bayern";
@@ -124,7 +170,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
vm.PeriodTimes[0].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45";
@@ -150,7 +197,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
vm.PeriodTimes[0].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00";
@@ -0,0 +1,76 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SyncSettingsServiceTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void SetServerUrl_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new SyncSettingsService(path).SetServerUrl("https://sync.example.com");
var reloaded = new SyncSettingsService(path);
Assert.Equal("https://sync.example.com", reloaded.ServerUrl);
}
[Fact]
public void SetCredentialsAndToken_TokenIstVerschluesseltAbrufbar()
{
var service = new SyncSettingsService(BuildTempPath());
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
Assert.True(service.IsLoggedIn);
Assert.Equal("sebastian", service.Username);
Assert.Equal("geheimes-token-123", service.GetToken());
}
[Fact]
public void Token_UeberlebtNeueInstanzMitDemselbenPfad()
{
var path = BuildTempPath();
new SyncSettingsService(path).SetCredentialsAndToken("sebastian", "token-abc");
var reloaded = new SyncSettingsService(path);
Assert.True(reloaded.IsLoggedIn);
Assert.Equal("token-abc", reloaded.GetToken());
}
[Fact]
public void TokenDateiEnthaeltNichtDenKlartext()
{
var path = BuildTempPath();
var service = new SyncSettingsService(path);
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
var raw = File.ReadAllText(Path.Combine(path, "sync-settings.json"));
Assert.DoesNotContain("geheimes-token-123", raw);
}
[Fact]
public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername()
{
var service = new SyncSettingsService(BuildTempPath());
service.SetServerUrl("https://sync.example.com");
service.SetCredentialsAndToken("sebastian", "token-abc");
service.Logout();
Assert.False(service.IsLoggedIn);
Assert.Null(service.GetToken());
Assert.Equal("https://sync.example.com", service.ServerUrl);
Assert.Equal("sebastian", service.Username);
}
}
+31 -17
View File
@@ -164,6 +164,10 @@ public static class AppBootstrapper
services.AddSingleton<AiPlanningService>();
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
services.AddSingleton<byte[]>(_ =>
@@ -173,15 +177,26 @@ public static class AppBootstrapper
return key;
});
var serverUrl = LoadServerUrl(appData);
var serverUrl = syncSettings.ServerUrl;
var deviceId = LoadOrCreateDeviceId(appData);
if (!string.IsNullOrEmpty(serverUrl))
{
services.AddSingleton(sp => new EventApplier(
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
BuildHttp(serverUrl, syncSettings)));
services.AddSingleton(sp => new SyncEventPublisher(
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
services.AddSingleton(sp => new AttachmentSyncer(
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<byte[]>()));
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(),
BuildHttp(serverUrl, appData),
sp.GetRequiredService<EventApplier>(),
sp.GetRequiredService<AttachmentSyncer>(),
BuildHttp(serverUrl, syncSettings),
new SyncConfig
{
ServerUrl = serverUrl,
@@ -191,7 +206,7 @@ public static class AppBootstrapper
}));
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
BuildHttp(serverUrl, appData),
BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<LiteDbContext>(),
sp.GetRequiredService<byte[]>(),
DeviceType.Desktop, DbPath, keyPath));
@@ -223,30 +238,29 @@ public static class AppBootstrapper
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>();
return services.BuildServiceProvider();
var provider = services.BuildServiceProvider();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
if (!string.IsNullOrEmpty(serverUrl))
provider.GetRequiredService<LiteDbContext>().OnChange =
provider.GetRequiredService<SyncEventPublisher>().Publish;
return provider;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static HttpClient BuildHttp(string url, string appData)
private static HttpClient BuildHttp(string url, SyncSettingsService syncSettings)
{
var http = new HttpClient { BaseAddress = new Uri(url) };
var tokenPath = Path.Combine(appData, "auth.token");
if (File.Exists(tokenPath))
var token = syncSettings.GetToken();
if (token is not null)
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", File.ReadAllText(tokenPath).Trim());
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
return http;
}
public static string LoadServerUrl(string? path = null) =>
File.Exists(Path.Combine(path ?? AppDataPath, "server.txt"))
? File.ReadAllText(Path.Combine(path ?? AppDataPath, "server.txt")).Trim()
: "";
public static void SaveServerUrl(string url) =>
File.WriteAllText(Path.Combine(AppDataPath, "server.txt"), url);
private static string LoadOrCreateDeviceId(string appData)
{
var p = Path.Combine(appData, "device.id");
@@ -0,0 +1,59 @@
using System.Net;
using System.Net.Http.Json;
namespace LehrerApp.Desktop.Services;
public class SyncAuthException(string userMessage) : Exception(userMessage);
public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
/// <summary>
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
/// bestehenden <see cref="AiPlanningService"/> (anderes Backend, anderer Wire-Vertrag). Nimmt die
/// Server-URL je Aufruf entgegen statt fest im Konstruktor, weil sie über die Einstellungen zur
/// Laufzeit geändert werden kann (anders als die feste KI-Backend-URL).
/// </summary>
public class SyncAuthService(HttpClient http)
{
public async Task<string> LoginAsync(string serverUrl, string username, string password)
{
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync(CombineUrl(serverUrl, "/api/auth/login"), new { username, password });
}
catch (HttpRequestException)
{
throw new SyncAuthException(
"Der Sync-Server ist nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new SyncAuthException("Benutzername oder Passwort ist falsch.");
if (!resp.IsSuccessStatusCode)
throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
var result = await resp.Content.ReadFromJsonAsync<LoginResult>();
return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.");
}
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
{
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
if (token is not null)
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
try
{
var resp = await http.SendAsync(req);
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
}
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
}
private static string CombineUrl(string serverUrl, string path) =>
new Uri(new Uri(serverUrl), path).ToString();
private record LoginResult(string Token, string UserId);
}
@@ -0,0 +1,82 @@
using LehrerApp.Sync.Crypto;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal class SyncSettingsConfig
{
public string ServerUrl { get; set; } = "";
public string Username { get; set; } = "";
public string? EncryptedToken { get; set; }
}
/// <summary>
/// Ersetzt die früheren Klartext-Helfer (<c>AppBootstrapper.LoadServerUrl</c>/<c>SaveServerUrl</c>,
/// unverschlüsselte <c>auth.token</c>-Datei). Token liegt wie beim KI-Backend-Token (siehe
/// <see cref="AiSettingsService"/>) AES-256-verschlüsselt über <see cref="SyncCrypto"/>, mit
/// eigenem, rein lokalem Schlüssel — nicht zu verwechseln mit dem in <c>AppBootstrapper</c>
/// separat verwalteten Sync-Schlüssel (<c>sync.key</c>), der die Ereignis-Payloads zwischen den
/// gepaarten Geräten verschlüsselt.
/// </summary>
public class SyncSettingsService
{
private readonly string _configPath;
private readonly string _keyPath;
private readonly byte[] _tokenKey;
private SyncSettingsConfig _config;
public string ServerUrl => _config.ServerUrl;
public string Username => _config.Username;
public bool IsLoggedIn => _config.EncryptedToken is not null;
public SyncSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "sync-settings.json");
_keyPath = Path.Combine(appDataPath, "sync-token.key");
_tokenKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey();
_config = Load();
}
public void SetServerUrl(string url)
{
_config.ServerUrl = url.Trim();
Save();
}
public void SetCredentialsAndToken(string username, string token)
{
_config.Username = username;
_config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey);
Save();
}
public string? GetToken() =>
_config.EncryptedToken is null ? null : SyncCrypto.DecryptObject<string>(_config.EncryptedToken, _tokenKey);
public void Logout()
{
_config.EncryptedToken = null;
Save();
}
private byte[] GenerateAndSaveKey()
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, _keyPath);
return key;
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
private SyncSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<SyncSettingsConfig>(File.ReadAllText(_configPath))
?? new SyncSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new SyncSettingsConfig();
}
}
@@ -6,6 +6,7 @@ using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Sync;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json;
@@ -164,6 +165,17 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = "";
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = "";
[ObservableProperty] private string _syncUsername = "";
[ObservableProperty] private string _syncPassword = "";
[ObservableProperty] private string _syncLoginError = "";
[ObservableProperty] private bool _syncIsLoggedIn;
[ObservableProperty] private string _syncConnectionStatus = "";
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
// ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -172,6 +184,9 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -182,7 +197,8 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning)
AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue)
{
_subjects = subjects;
_domainRepo = domainRepo;
@@ -204,6 +220,9 @@ public partial class SettingsViewModel : ObservableObject
_letterTemplates = letterTemplates;
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
_catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects();
LoadShorthandCodes();
@@ -221,6 +240,8 @@ public partial class SettingsViewModel : ObservableObject
LoadSupervisionDuties();
LoadLetterTemplates();
LoadAiSettings();
LoadSyncSettings();
LoadSyncConflicts();
}
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
@@ -362,6 +383,86 @@ public partial class SettingsViewModel : ObservableObject
AiBalanceDisplay = "";
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
//
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
// gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig
// beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad.
private void LoadSyncSettings()
{
SyncServerUrl = _syncSettings.ServerUrl;
SyncUsername = _syncSettings.Username;
SyncIsLoggedIn = _syncSettings.IsLoggedIn;
}
[RelayCommand]
private async Task SyncTestConnection()
{
SyncConnectionStatus = "Teste Verbindung…";
if (string.IsNullOrWhiteSpace(SyncServerUrl))
{
SyncConnectionStatus = "Bitte Server-Adresse eingeben.";
return;
}
var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken());
SyncConnectionStatus = result switch
{
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
};
}
[RelayCommand]
private async Task SyncLogin()
{
SyncLoginError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; }
if (!valid) return;
try
{
var token = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
_syncSettings.SetServerUrl(SyncServerUrl);
_syncSettings.SetCredentialsAndToken(SyncUsername, token);
SyncPassword = "";
AppBootstrapper.RestartApplication();
}
catch (SyncAuthException ex) { SyncLoginError = ex.Message; }
}
[RelayCommand]
private void SyncLogout()
{
_syncSettings.Logout();
AppBootstrapper.RestartApplication();
}
// ── Synchronisation: Konflikte ────────────────────────────────────────────
//
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
// Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur
// rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion.
private void LoadSyncConflicts()
{
SyncConflicts.Clear();
foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt))
SyncConflicts.Add(new SyncConflictListItem(c));
}
[RelayCommand]
private void MarkConflictReviewed(SyncConflictListItem? item)
{
if (item is null) return;
_eventQueue.MarkReviewed(item.Id);
SyncConflicts.Remove(item);
}
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes()
@@ -1221,6 +1322,19 @@ public class ExpiredDocumentItem(Documentation d, string studentName)
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
}
public class SyncConflictListItem(ConflictEntry c)
{
public Guid Id { get; } = c.Id;
public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)";
public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm");
public string ResolutionDisplay { get; } = c.Resolution switch
{
"LocalWon" => "Lokale Änderung übernommen (dieses Gerät)",
"RemoteWon" => "Änderung vom anderen Gerät übernommen",
_ => c.Resolution,
};
}
public class SchoolHolidayItem(SchoolHoliday h)
{
public Guid Id { get; } = h.Id;
@@ -759,6 +759,83 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Synchronisation (Kapitel 10) -->
<ContentPage Header="Synchronisation">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Geräte-Synchronisation" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Gleicht Daten zwischen mehreren eigenen Geräten über einen selbst betriebenen Server ab. Erfordert einen bereits eingerichteten Server und einen dort angelegten Nutzer."/>
<StackPanel Spacing="4">
<TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding !SyncIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding SyncLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding SyncLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Anmelden" Command="{Binding SyncLoginCommand}"/>
<Button Content="Verbindung testen" Command="{Binding SyncTestConnectionCommand}"/>
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding SyncIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding SyncUsername}"/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Verbindung testen" Command="{Binding SyncTestConnectionCommand}"/>
<Button Content="Abmelden" Command="{Binding SyncLogoutCommand}"/>
</StackPanel>
</StackPanel>
<TextBlock Text="{Binding SyncConnectionStatus}" FontSize="12"
IsVisible="{Binding SyncConnectionStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
IsVisible="{Binding SyncConflicts.Count}"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
IsVisible="{Binding SyncConflicts.Count}"
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht — hier steht, welche Seite jeweils übernommen wurde."/>
<ItemsControl ItemsSource="{Binding SyncConflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SyncConflictListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding EntityDisplay}" FontSize="13"/>
<TextBlock FontSize="12" Opacity="0.6">
<Run Text="{Binding DetectedAtDisplay}"/><Run Text=" · "/><Run Text="{Binding ResolutionDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Gesehen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).MarkConflictReviewedCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
+6
View File
@@ -0,0 +1,6 @@
using Xunit;
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
// Testklassen hinweg (EventApplierTests/AttachmentSyncerTests konstruieren beide LiteDbContext).
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,76 @@
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class AttachmentSyncerTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
private static readonly byte[] Key = SyncCrypto.GenerateKey();
[Fact]
public async Task UploadPendingAsync_LaedtVerschluesselteBytesHochUndMarkiertAlsErledigt()
{
using var temp = new TempEventQueue();
using var db = NewInMemoryContext();
var storageId = Guid.NewGuid().ToString("N");
db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3, 4]));
temp.Queue.QueueAttachmentUpload(storageId);
// Bytes MÜSSEN synchron innerhalb des Handler-Callbacks gelesen werden: AttachmentSyncer
// disposed sein ByteArrayContent direkt nach dem PostAsync-Aufruf (eigenes "using"),
// ein Zugriff auf request.Content danach würde ObjectDisposedException werfen.
byte[]? uploadedEncrypted = null;
var handler = new FakeHttpMessageHandler(req =>
{
uploadedEncrypted = req.Content!.ReadAsByteArrayAsync().GetAwaiter().GetResult();
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
});
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
var syncer = new AttachmentSyncer(db, http, Key);
await syncer.UploadPendingAsync(temp.Queue);
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath);
Assert.NotNull(uploadedEncrypted);
Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key));
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
}
[Fact]
public async Task UploadPendingAsync_LokalNichtMehrVorhandenerAnhang_WirdOhneUploadAlsErledigtMarkiert()
{
using var temp = new TempEventQueue();
using var db = NewInMemoryContext();
temp.Queue.QueueAttachmentUpload("laengst-geloescht");
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK));
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
var syncer = new AttachmentSyncer(db, http, Key);
await syncer.UploadPendingAsync(temp.Queue);
Assert.Empty(handler.Requests);
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
}
private sealed class TempEventQueue : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-sync-tests-attachments-{Guid.NewGuid():N}");
public EventQueue Queue { get; }
public TempEventQueue()
{
Directory.CreateDirectory(_directory);
Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
}
public void Dispose()
{
Queue.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
+193
View File
@@ -0,0 +1,193 @@
using LehrerApp.Core.Models;
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class EventApplierTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
private static readonly byte[] Key = SyncCrypto.GenerateKey();
[Fact]
public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
var saved = db.Students.FindById(student.Id);
Assert.NotNull(saved);
Assert.Equal("Anna", saved!.FirstName);
}
[Fact]
public async Task ApplyAsync_Delete_EntferntDenDatensatz()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
db.Students.Insert(student);
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
Assert.Null(db.Students.FindById(student.Id));
}
[Fact]
public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var exception = await Record.ExceptionAsync(() =>
applier.ApplyAsync(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" })));
Assert.Null(exception);
}
[Fact]
public async Task ApplyAsync_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var groupId = Guid.NewGuid();
db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" });
var gradeId = Guid.NewGuid();
db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = Guid.NewGuid() });
await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
Assert.Null(db.Groups.FindById(groupId));
Assert.Null(db.Grades.FindById(gradeId));
}
[Fact]
public async Task ApplyAsync_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var studentId = Guid.NewGuid();
var groupId = Guid.NewGuid();
db.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId });
// Zweite Mitgliedschaft für dasselbe Schüler/Gruppe-Paar verletzt den ux_student_group-Index.
var duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId };
var exception = await Record.ExceptionAsync(() =>
applier.ApplyAsync(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate)));
Assert.Null(exception);
Assert.Single(db.Memberships.FindAll());
}
// ── Loop-Prevention: der wichtigste Test in dieser Datei ────────────────────
// Ein angewendetes Ereignis darf NIE selbst wieder ein ausgehendes Ereignis auslösen,
// sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar).
[Fact]
public async Task ApplyAsync_Save_LoestNIEMALSDenOnChangeHookAus()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++;
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
Assert.Equal(0, onChangeCallCount);
}
[Fact]
public async Task ApplyAsync_Delete_LoestNIEMALSDenOnChangeHookAus()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
db.Students.Insert(student);
var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++;
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
Assert.Equal(0, onChangeCallCount);
}
[Fact]
public async Task ApplyAsync_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var groupId = Guid.NewGuid();
db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" });
db.Grades.Insert(new Grade { GroupId = groupId, StudentId = Guid.NewGuid() });
db.Exams.Insert(new Exam { GroupId = groupId });
var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++;
await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
Assert.Equal(0, onChangeCallCount);
}
// ── Fehlende Anhänge nachladen ───────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_DocumentationMitFehlendemAnhang_LaedtIhnUeberHttpNach()
{
using var db = NewInMemoryContext();
var storageId = Guid.NewGuid().ToString("N");
var handler = new FakeHttpMessageHandler(req =>
{
Assert.Equal($"/api/sync/attachments/{storageId}", req.RequestUri!.AbsolutePath);
var encrypted = SyncCrypto.Encrypt([9, 8, 7], Key);
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{ Content = new ByteArrayContent(encrypted) };
});
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
var applier = new EventApplier(db, Key, http);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 });
await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc));
Assert.Single(handler.Requests);
Assert.True(db.Attachments.Exists(storageId));
using var read = db.Attachments.OpenRead(storageId);
using var ms = new MemoryStream();
await read.CopyToAsync(ms);
Assert.Equal([9, 8, 7], ms.ToArray());
}
[Fact]
public async Task ApplyAsync_DocumentationMitBereitsVorhandenemAnhang_LaedtNichtErneut()
{
using var db = NewInMemoryContext();
var storageId = Guid.NewGuid().ToString("N");
db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3]));
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK));
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
var applier = new EventApplier(db, Key, http);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 });
await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc));
Assert.Empty(handler.Requests);
}
private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
{
DeviceId = "companion-1",
DeviceType = DeviceType.Companion,
EntityType = entityType,
EntityId = entityId,
Operation = operation,
Payload = payload is null ? "" : SyncCrypto.EncryptObject(payload, Key),
Timestamp = DateTime.UtcNow,
};
}
+64
View File
@@ -0,0 +1,64 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class EventQueueTests
{
[Fact]
public void MarkReviewed_EntferntKonfliktAusDerUnreviewedListe()
{
using var temp = new TempEventQueue();
var conflict = new ConflictEntry
{
LocalEvent = MakeEvent(),
RemoteEvent = MakeEvent(),
Resolution = "LocalWon",
};
temp.Queue.AddConflict(conflict);
temp.Queue.MarkReviewed(conflict.Id);
Assert.Empty(temp.Queue.GetUnreviewed());
Assert.Equal(0, temp.Queue.ConflictCount());
}
[Fact]
public void MarkReviewed_UnbekannteId_TutNichtsUndWirftNicht()
{
using var temp = new TempEventQueue();
var exception = Record.Exception(() => temp.Queue.MarkReviewed(Guid.NewGuid()));
Assert.Null(exception);
}
private static SyncEvent MakeEvent() => new()
{
DeviceId = "desktop-1",
DeviceType = DeviceType.Desktop,
EntityType = "Student",
EntityId = Guid.NewGuid().ToString(),
Operation = "Save",
Payload = "{}",
};
private sealed class TempEventQueue : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-sync-tests-eventqueue-{Guid.NewGuid():N}");
public EventQueue Queue { get; }
public TempEventQueue()
{
Directory.CreateDirectory(_directory);
Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
}
public void Dispose()
{
Queue.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
@@ -0,0 +1,14 @@
namespace LehrerApp.Sync.Tests;
/// <summary>Zeichnet Requests auf und beantwortet sie über eine Callback-Funktion, ohne echtes Netzwerk.</summary>
public sealed class FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(respond(request));
}
}
+36
View File
@@ -0,0 +1,36 @@
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Sync;
/// <summary>
/// Lädt ausstehende Datei-Anhänge (siehe <see cref="EventQueue.GetPendingAttachmentUploads"/>)
/// als eigenen, verschlüsselten Binärtransfer hoch — getrennt vom JSON-Ereigniskanal, damit
/// Fotos/Scans ihn nicht aufblähen. Gegenstück zum Download in <see cref="EventApplier"/>.
/// </summary>
public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey)
{
public async Task UploadPendingAsync(EventQueue queue)
{
foreach (var storageId in queue.GetPendingAttachmentUploads())
{
if (!db.Attachments.Exists(storageId))
{
// Lokal inzwischen wieder gelöscht (z.B. HardDelete vor dem eigentlichen Upload) -
// nichts hochzuladen, Warteliste trotzdem bereinigen.
queue.MarkAttachmentUploaded(storageId);
continue;
}
using var raw = db.Attachments.OpenRead(storageId);
using var buffer = new MemoryStream();
await raw.CopyToAsync(buffer);
var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey);
using var content = new ByteArrayContent(encrypted);
var resp = await http.PostAsync($"/api/sync/attachments/{storageId}", content);
resp.EnsureSuccessStatusCode();
queue.MarkAttachmentUploaded(storageId);
}
}
}
+135
View File
@@ -0,0 +1,135 @@
using System.Text;
using JsonSerializer = System.Text.Json.JsonSerializer;
using LehrerApp.Core.Models;
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using LiteDB;
namespace LehrerApp.Sync;
/// <summary>
/// Wendet ein von <see cref="SyncEngine"/> empfangenes (und nicht durch einen Konflikt
/// verlorenes) Ereignis auf die lokale Datenbank an.
///
/// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository-
/// Save/Delete-Methode — sonst würde <see cref="LiteDbContext.OnChange"/> erneut feuern und die
/// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong).
/// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig
/// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save
/// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt.
///
/// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
/// </summary>
public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null)
{
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
public async Task ApplyAsync(SyncEvent evt)
{
if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return;
try
{
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
handler(db, evt.Operation, evt.EntityId, json);
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
await DownloadMissingAttachmentsAsync(json);
}
catch (LiteException)
{
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
// überspringen, statt den gesamten Sync-Lauf abzubrechen.
}
}
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
// Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln
// nachladen. Gegenstück zum Upload in AttachmentSyncer.
private async Task DownloadMissingAttachmentsAsync(string json)
{
var doc = JsonSerializer.Deserialize<Documentation>(json);
if (doc is null) return;
foreach (var attachment in doc.Attachments)
{
if (db.Attachments.Exists(attachment.StorageId)) continue;
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
if (!resp.IsSuccessStatusCode) continue;
var encrypted = await resp.Content.ReadAsByteArrayAsync();
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
using var stream = new MemoryStream(decrypted);
// Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine
// neue Id vergibt - hier muss die Original-StorageId erhalten bleiben.
db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream);
}
}
private string Decrypt(string payloadBase64) =>
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json);
private static Dictionary<string, EntityHandler> BuildHandlers()
{
var handlers = new Dictionary<string, EntityHandler>();
void Simple<T>(Func<LiteDbContext, ILiteCollection<T>> collection) where T : class =>
handlers[typeof(T).Name] = (context, operation, entityId, json) =>
{
if (operation == "Delete") collection(context).Delete(new Guid(entityId));
else collection(context).Upsert(JsonSerializer.Deserialize<T>(json)!);
};
Simple<Student>(context => context.Students);
Simple<SeatingPlan>(context => context.SeatingPlans);
Simple<GroupMembership>(context => context.Memberships);
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
Simple<Grade>(context => context.Grades);
Simple<GradingScheme>(context => context.GradingSchemes);
Simple<ReportGrade>(context => context.ReportGrades);
Simple<Unit>(context => context.Units);
Simple<Lesson>(context => context.Lessons);
Simple<WorkTask>(context => context.Tasks);
Simple<TimeEntry>(context => context.TimeEntries);
Simple<ExamResult>(context => context.ExamResults);
Simple<ParticipationEntry>(context => context.ParticipationEntries);
Simple<ParticipationAspect>(context => context.ParticipationAspects);
Simple<ParticipationSection>(context => context.ParticipationSections);
Simple<Subject>(context => context.Subjects);
Simple<ShorthandCode>(context => context.ShorthandCodes);
Simple<AlternativeLessonPath>(context => context.AlternativeLessonPaths);
Simple<TimetableSlot>(context => context.TimetableSlots);
Simple<SchoolHoliday>(context => context.SchoolHolidays);
Simple<SupervisionDuty>(context => context.SupervisionDuties);
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains);
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
handlers[nameof(LearningGroup)] = (context, operation, entityId, json) =>
{
if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId));
else context.Groups.Upsert(JsonSerializer.Deserialize<LearningGroup>(json)!);
};
handlers[nameof(Exam)] = (context, operation, entityId, json) =>
{
if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId));
else context.Exams.Upsert(JsonSerializer.Deserialize<Exam>(json)!);
};
handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) =>
{
if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId));
else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize<ParticipationSession>(json)!);
};
// "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als
// "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig.
handlers[nameof(Documentation)] = (context, operation, entityId, json) =>
{
if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId));
else context.Documentation.Upsert(JsonSerializer.Deserialize<Documentation>(json)!);
};
return handlers;
}
}
+28
View File
@@ -13,6 +13,7 @@ public class EventQueue : IDisposable
private readonly ILiteCollection<SyncEvent> _queue;
private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts;
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
private long _currentSeq;
public EventQueue(string path)
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
_queue = _db.GetCollection<SyncEvent>("queue");
_meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
_queue.EnsureIndex(x => x.SequenceNr);
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
}
@@ -56,6 +59,25 @@ public class EventQueue : IDisposable
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
public void MarkReviewed(Guid id)
{
var conflict = _conflicts.FindById(id);
if (conflict is null) return;
conflict.Reviewed = true;
_conflicts.Update(conflict);
}
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
public void QueueAttachmentUpload(string storageId)
{
if (!_attachmentUploads.Exists(a => a.StorageId == storageId))
_attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId });
}
public List<string> GetPendingAttachmentUploads() =>
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
public void MarkAttachmentUploaded(string storageId) =>
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
public void Dispose() => _db.Dispose();
}
@@ -75,3 +97,9 @@ internal class SyncMeta
public long Value { get; set; }
public DateTime? Timestamp { get; set; }
}
internal class PendingAttachmentUpload
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string StorageId { get; set; } = "";
}
+15 -7
View File
@@ -11,6 +11,8 @@ public class SyncEngine : IDisposable
{
private readonly EventQueue _queue;
private readonly ConflictResolver _resolver;
private readonly EventApplier _applier;
private readonly AttachmentSyncer _attachments;
private readonly HttpClient _http;
private readonly SyncConfig _config;
private readonly Timer _timer;
@@ -18,13 +20,15 @@ public class SyncEngine : IDisposable
public SyncStatus Status { get; private set; } = new();
public event Action<SyncStatus>? StatusChanged;
public SyncEngine(EventQueue queue, ConflictResolver resolver,
HttpClient http, SyncConfig config)
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
AttachmentSyncer attachments, HttpClient http, SyncConfig config)
{
_queue = queue;
_resolver = resolver;
_http = http;
_config = config;
_queue = queue;
_resolver = resolver;
_applier = applier;
_attachments = attachments;
_http = http;
_config = config;
_timer = new Timer(
async _ => await SyncNowAsync(true), null,
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
@@ -40,6 +44,7 @@ public class SyncEngine : IDisposable
try
{
var (pushed, _) = await PushAsync();
await _attachments.UploadPendingAsync(_queue);
var (pulled, conflicts) = await PullAsync();
_queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle);
@@ -75,7 +80,10 @@ public class SyncEngine : IDisposable
foreach (var evt in resp.Events)
{
var c = _resolver.TryResolve(evt, _config.DeviceId);
if (c is not null) { _queue.AddConflict(c); conflicts++; }
if (c is null) { await _applier.ApplyAsync(evt); continue; }
_queue.AddConflict(c);
conflicts++;
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
}
_queue.SetLastServerSeq(resp.ServerSequenceNr);
return (resp.Events.Count, conflicts);
+26
View File
@@ -0,0 +1,26 @@
using LehrerApp.Core.Models;
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Wandelt <see cref="LiteDbContext.OnChange"/>-Aufrufe in ausgehende Sync-Ereignisse um. Wird in
/// AppBootstrapper an <see cref="LiteDbContext.OnChange"/> gehängt, wenn Sync konfiguriert ist —
/// dort entsteht aus den ~27 Repository-Aufrufen genau ein verschlüsseltes Ereignis pro Aufruf.
/// </summary>
public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKey)
{
public void Publish(string entityType, string entityId, string operation, object? payload)
{
var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey);
queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted);
// Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen),
// sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
if (payload is Documentation doc)
foreach (var attachment in doc.Attachments)
queue.QueueAttachmentUpload(attachment.StorageId);
}
}
+14
View File
@@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop.Tests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -124,6 +126,18 @@ Global
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x64.Build.0 = Release|Any CPU
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.ActiveCfg = Release|Any CPU
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.Build.0 = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x64.ActiveCfg = Debug|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x64.Build.0 = Debug|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x86.ActiveCfg = Debug|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x86.Build.0 = Debug|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|Any CPU.Build.0 = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.ActiveCfg = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+118 -11
View File
@@ -1299,30 +1299,137 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
## 10. Sync & Server
Grundgerüst existiert in [LehrerApp.Sync](LehrerApp.Sync/) und [LehrerApp.Api](LehrerApp.Api/),
ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
Vollständig funktionsfähig: Outbound-Publishing (10.1.6), Inbound-Apply (10.1.7),
Anhang-Sync (10.1.8), Client-UI (10.1.110.1.5) und Server-Härtung (10.2) sind umgesetzt.
Nur aktiv, wenn in den Einstellungen eine Server-URL konfiguriert und ein Login erfolgt ist.
Offen bleiben bewusst die Schlüsselverwaltungs-Punkte unter 10.3 (Gerätewechsel/-verlust) und
die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügbar).
### 10.1 Client
- [ ] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
- [ ] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
- [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
- [ ] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
- [ ] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
- [ ] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
- [x] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
**Umsetzung:** Neuer Tab "Synchronisation" in den Einstellungen. Neu
`LehrerApp.Desktop/Services/SyncSettingsService.cs` (Muster `AiSettingsService`: Token
AES-256-verschlüsselt über `SyncCrypto`, eigener rein lokaler Schlüssel — ersetzt die
bisherigen Klartext-Helfer `AppBootstrapper.LoadServerUrl`/`SaveServerUrl` und die
unverschlüsselte `auth.token`-Datei). Neu `SyncAuthService` für den Login-HTTP-Aufruf gegen
`/api/auth/login`. Speichern/Anmelden startet die App neu (`AppBootstrapper.
RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
`SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
Live-Re-Registrierungspfad.
- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
& angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über
einen GET auf `/api/sync/status` mit optionalem Bearer-Token.
- [x] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
**Umsetzung:** Minimale Liste im Tab "Synchronisation" (kein Feld-Diff für v1 — die
Payloads sind clientseitig verschlüsselt, ein Diff würde ohnehin nur rohes JSON zeigen).
Zeigt Entität, Zeitpunkt und welche Seite gewonnen hat, mit "Gesehen"-Aktion. Neu
`EventQueue.MarkReviewed(id)` (bisher nur `AddConflict`/`GetUnreviewed`/`ConflictCount`,
kein Weg, einen Konflikt als gesehen zu markieren).
- [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel.SyncNowCommand`,
`SyncStatusBar` in `MainWindow.axaml`), nur hier noch nicht abgehakt.
- [x] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel`/`SyncStatusBar`), nur hier noch
nicht abgehakt.
- [x] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse.
**Umsetzung:** `LiteDbContext.OnChange`-Hook (Sync-agnostisch, kein Verweis auf
`LehrerApp.Sync` aus `LehrerApp.Data`) — alle 27 Repositories rufen ihn nach Save/Delete auf,
Kaskaden (`GroupRepository.Delete` u.a.) und Batch-Methoden (`SaveMany`/`DeleteBySession`)
feuern genau ein Ereignis pro betroffener Entität statt pro Collection-Zugriff. In
`AppBootstrapper` an `SyncEventPublisher.Publish` gehängt, das den Hook in ein
AES-verschlüsseltes `EventQueue.Enqueue` übersetzt.
- [x] **10.1.7** Eingehende Sync-Ereignisse tatsächlich auf die lokale Datenbank anwenden.
Bisher komplett fehlender, in dieser Checkliste nicht erfasster Baustein: selbst mit 10.1.6
hätte `SyncEngine.PullAsync` empfangene Ereignisse nur zur Konflikterkennung genutzt, nie in
die lokale LiteDB geschrieben — ankommende Änderungen von anderen Geräten wären nirgends
sichtbar geworden.
**Umsetzung:** Neu `LehrerApp.Sync/EventApplier.cs` — entschlüsselt, dispatcht über eine
explizite `EntityType`-Tabelle, schreibt **immer direkt auf die rohe LiteDB-Collection**,
nie über eine Repository-Save/Delete-Methode (sonst würde der 10.1.6-Hook die gerade
angewendete Änderung als neues ausgehendes Ereignis re-enqueuen — Sync-Ping-Pong). Ein
gemeinsames Suppress-Flag wurde geprüft und verworfen (Timer-Thread vs. UI-Thread — ein Flag
könnte einen echten Nutzer-Save währenddessen verschlucken); der direkte Collection-Zugriff
ist zustandslos und dadurch korrekt. Kaskaden-Fälle nutzen dieselben internen
`LiteDbContext`-Hilfsmethoden wie die Repositories. Mit dediziertem Loop-Prevention-Test
abgesichert (`EventApplierTests`).
**Bekannte v1-Einschränkung:** weiche Geschäftsregeln (`ArchivedGroupWriteGuard`,
Namens-Eindeutigkeit bei Aspekten u.ä.) werden auf diesem Pfad nicht geprüft — nur harte
LiteDB-Unique-Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
Für Einzel-/Wenig-Geräte-Nutzung akzeptiert, siehe 10.3.4.
- [x] **10.1.8** Datei-Anhänge (Dokumentation) über den laufenden Sync mitschicken.
**Umsetzung:** Eigener, unverschlüsselt im JSON-Ereigniskanal nicht mitgeführter Binärkanal
(würde ihn für Fotos/Scans stark aufblähen) — neue Endpunkte
`POST/GET /api/sync/attachments/{storageId}` in `LehrerApp.Api`, neue
`EventQueue`-Warteliste für ausstehende Uploads, `AttachmentSyncer` (Upload, in
`SyncEngine.SyncNowAsync` nach dem Event-Push) und `EventApplier` (Download fehlender
Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
das immer eine neue Id vergäbe).
### 10.2 Server
- [ ] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
([Endpoints.cs](LehrerApp.Api/Endpoints/Endpoints.cs)).
- [ ] **10.2.2** Rate Limiting und Request-Größenbegrenzung.
- [ ] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien.
**Umsetzung:** `/api/auth/login` und `/api/auth/register` akzeptierten zuvor jeden
beliebigen Nutzernamen/Passwort und stellten ein gültiges 30-Tage-JWT aus (unadressierte
`// TODO`-Kommentare im Code) — konkrete, ausnutzbare Lücke bei echtem Deployment. Neu
`PasswordHasher` (PBKDF2, Salt pro Nutzer — kein neues NuGet-Paket, gleiche BCL-Technik wie
`SyncCrypto`) und `UserStore` (LiteDB-Collection `users`). `/api/auth/register` ersatzlos
entfernt (kein offener Registrierungs-Endpunkt für ein Einzel-/Familien-Deployment); neue
Nutzer werden per CLI angelegt (`dotnet LehrerApp.Api.dll create-user <name>`, dokumentiert
in `docker/README.md`), damit keine zusätzliche unauthentifizierte Angriffsfläche entsteht.
- [x] **10.2.2** Rate Limiting und Request-Größenbegrenzung.
**Umsetzung:** ASP.NET Cores eingebautes `Microsoft.AspNetCore.RateLimiting` (keine neue
Paketabhängigkeit). `/api/auth/login` speziell auf 5 Versuche/Minute begrenzt
(Brute-Force-Schutz), alle Endpunkte zusätzlich global auf 120 Anfragen/Minute je IP. Kestrel
`MaxRequestBodySize` auf 15 MB gedeckelt (Anhänge sind clientseitig ohnehin auf 10 MB
begrenzt, siehe `IAttachmentStorage.MaxSizeBytes`).
- [x] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien.
**Umsetzung:** `docker/backup.sh` — Tar-Archiv von `./data` (Ereignis-Logs, Snapshots,
Anhänge, Nutzer), räumt Archive älter als 30 Tage auf, läuft direkt auf dem Host (kein
Container-Zugriff nötig), dokumentiert samt Cron-Beispiel in `docker/README.md`.
- [ ] **10.2.4** Docker-Setup in [docker/](docker/) verifizieren und dokumentieren.
**Teilweise:** `docker/README.md` um `create-user`-Flow, Backup und Rate-Limits ergänzt.
Ein tatsächlicher `docker compose up`-Durchlauf konnte in dieser Umgebung nicht verifiziert
werden (kein Docker verfügbar) — steht vor dem ersten echten Deployment noch aus.
### 10.3 Verschlüsselung
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [ ] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht.
- [ ] **10.3.4** Bekannte v1-Einschränkung aus 10.1.7: weiche Geschäftsregeln greifen beim Anwenden
eingehender Sync-Ereignisse nicht, nur harte LiteDB-Unique-Constraints. Bei mehreren eigenen
Geräten in Randfällen möglich, dass sich Datenstände leicht unterscheiden. Für v1 bewusst
akzeptiert (Einzel-/Wenig-Geräte-Nutzung) — falls das je zum echten Problem wird, müsste der
Event-Applier dieselben Validierungen wie die Repository-Save-Methoden durchlaufen, ohne
dabei erneut ein Sync-Ereignis auszulösen.
**Verifikation:** `dotnet build LehrerApp.sln && dotnet test LehrerApp.sln` grün (591 Tests,
inkl. `EventApplierTests`, `ChangeHookCascadeTests`, `AttachmentSyncerTests`,
`AttachmentStoreTests`). Zusätzlich Live-Rauchtest gegen einen tatsächlich laufenden
`LehrerApp.Api`-Prozess (nicht gemockt): `create-user`-CLI, Login (richtig/falsch), Zugriffsschutz
auf `/api/sync/status`, entferntes `/api/auth/register` (404), Push/Pull-Roundtrip zwischen zwei
Geräte-Ids, Anhang-Upload/Download byteidentisch, Rate-Limit auf `/api/auth/login` löst nach 5
Versuchen tatsächlich 429 aus. **Nicht verifizierbar in dieser Umgebung:** zwei echte
Desktop-Instanzen gegeneinander synchronisieren (kein Mehrfach-AppData-Mechanismus vorhanden, GUI
nicht headless steuerbar) und ein echter `docker compose up`-Durchlauf (kein Docker verfügbar) —
beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuholen.
---
+53
View File
@@ -0,0 +1,53 @@
# LehrerApp.Api Deployment per Docker
## Starten
```bash
cd docker
JWT_SECRET=<zufälliger-langer-string> docker compose up -d
```
`./data` (relativ zu `docker/`) wird als Volume gemountet und enthält alle Server-Daten
(Ereignis-Logs, Snapshots, Nutzer) bei Neustarts/Updates bleibt es erhalten.
## Nutzer anlegen
Es gibt keine offene Registrierung (`/api/auth/register`). Neue Nutzer werden per CLI im
laufenden Container angelegt:
```bash
docker compose exec api dotnet LehrerApp.Api.dll create-user <benutzername>
```
Fragt interaktiv nach einem Passwort (mind. 12 Zeichen). Alternativ nicht-interaktiv, z. B. aus
einem Skript:
```bash
docker compose exec api dotnet LehrerApp.Api.dll create-user <benutzername> --password "<passwort>"
```
Der Befehl beendet sich danach sofort wieder, ohne den API-Dienst zu starten für den
eigentlichen Serverbetrieb läuft `docker compose up` unverändert weiter.
## Backup
`backup.sh` sichert `./data` (Ereignis-Logs, Snapshots, Anhänge, Nutzer) als komprimiertes
Tar-Archiv unter `docker/backups/` und räumt Archive älter als 30 Tage automatisch auf. Läuft
direkt auf dem Host (kein Container-Zugriff nötig, `./data` liegt dort per Bind-Mount ohnehin):
```bash
./docker/backup.sh
```
Für regelmäßige Sicherung z. B. per Cron, einmal täglich nachts:
```bash
0 3 * * * /pfad/zu/docker/backup.sh >> /pfad/zu/docker/backup.log 2>&1
```
## Rate Limiting
`/api/auth/login` ist auf 5 Versuche pro Minute begrenzt (Brute-Force-Schutz). Alle übrigen
Endpunkte sind zusätzlich global auf 120 Anfragen pro Minute je IP begrenzt. Anhänge sind auf
15 MB Anfragegröße gedeckelt (Kestrel `MaxRequestBodySize`), einzelne Dateien clientseitig
zusätzlich auf 10 MB (`IAttachmentStorage.MaxSizeBytes`).
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Sichert das komplette ./data-Verzeichnis (Ereignis-Logs, Snapshots, Anhänge, Nutzer) als
# komprimiertes Tar-Archiv. Läuft außerhalb des Containers direkt auf dem Host, da ./data per
# Bind-Mount ohnehin dort liegt (siehe docker-compose.yml) — kein Zugriff auf den Container nötig.
#
# Aufruf z.B. per Cron:
# 0 3 * * * /pfad/zu/docker/backup.sh >> /pfad/zu/docker/backup.log 2>&1
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DATA_DIR="$SCRIPT_DIR/data"
BACKUP_DIR="$SCRIPT_DIR/backups"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
KEEP_DAYS=30
if [ ! -d "$DATA_DIR" ]; then
echo "Kein Datenverzeichnis unter $DATA_DIR gefunden - nichts zu sichern." >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_DIR/lehrerapp-data-$TIMESTAMP.tar.gz" -C "$SCRIPT_DIR" data
# Alte Backups jenseits von KEEP_DAYS aufräumen, damit das Verzeichnis nicht unbegrenzt wächst.
find "$BACKUP_DIR" -name 'lehrerapp-data-*.tar.gz' -mtime "+$KEEP_DAYS" -delete
echo "Backup erstellt: $BACKUP_DIR/lehrerapp-data-$TIMESTAMP.tar.gz"