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.Core.Models;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace LehrerApp.Api; namespace LehrerApp.Api;
@@ -14,21 +15,14 @@ public static class Endpoints
public static void MapAuthEndpoints(this WebApplication app, string secret) 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)) if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized(); 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 }); return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
}); }).RequireRateLimiting("login");
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 });
});
} }
// ── Sync ────────────────────────────────────────────────────────────────── // ── 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) ───────────────────────────────────────────── // ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
public static void MapSnapshotEndpoints(this WebApplication app) public static void MapSnapshotEndpoints(this WebApplication app)
@@ -130,4 +148,3 @@ public static class Endpoints
} }
public record LoginRequest(string Username, string Password); 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.Text;
using System.Threading.RateLimiting;
using LehrerApp.Api; using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -9,8 +11,16 @@ builder.WebHost.UseKestrel(o =>
{ {
var port = builder.Configuration.GetValue<int>("Api:Port", 5000); var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
o.ListenAnyIP(port); 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"] var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
@@ -24,7 +34,32 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
}); });
builder.Services.AddAuthorization(); 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<EventStore>(_ => new EventStore(data));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data)); builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
@@ -32,11 +67,14 @@ builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>())); new PlainEventStore(sp.GetRequiredService<EventStore>()));
var app = builder.Build(); var app = builder.Build();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapAuthEndpoints(secret); app.MapAuthEndpoints(secret);
app.MapSyncEndpoints(); app.MapSyncEndpoints();
app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints(); app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
app.Run(); 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; 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> /// <summary>
/// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer. /// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer.
/// </summary> /// </summary>
@@ -67,6 +71,117 @@ public class LiteDbContext : IDisposable
public int SchemaVersion => ReadSchemaVersion(); 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) internal void ExecuteInTransaction(Action action)
{ {
_db.BeginTrans(); _db.BeginTrans();
+146 -98
View File
@@ -35,7 +35,12 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
db.ReportGrades.Count(g => g.StudentId == studentId), db.ReportGrades.Count(g => g.StudentId == studentId),
db.ParticipationEntries.Count(e => e.StudentId == studentId), db.ParticipationEntries.Count(e => e.StudentId == studentId),
db.Documentation.Count(d => d.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) public void Delete(Guid id)
{ {
var references = GetReferenceSummary(id); var references = GetReferenceSummary(id);
@@ -43,6 +48,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
throw new InvalidOperationException( throw new InvalidOperationException(
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden."); "Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
db.Students.Delete(id); 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."); throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
g.UpdatedAt = DateTime.UtcNow; g.UpdatedAt = DateTime.UtcNow;
db.Groups.Upsert(g); db.Groups.Upsert(g);
db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, id); ArchivedGroupWriteGuard.EnsureActive(db, id);
db.ExecuteInTransaction(() => db.CascadeDeleteGroup(id);
{ db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
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);
});
} }
} }
@@ -196,6 +134,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
plan.UpdatedAt = DateTime.UtcNow; plan.UpdatedAt = DateTime.UtcNow;
db.SeatingPlans.Upsert(plan); db.SeatingPlans.Upsert(plan);
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
} }
public void Delete(Guid id) public void Delete(Guid id)
@@ -203,6 +142,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (db.SeatingPlans.FindById(id) is { } plan) if (db.SeatingPlans.FindById(id) is { } plan)
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.SeatingPlans.Delete(id); 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) if (existing is not null && existing.Id != membership.Id)
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet."); throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
db.Memberships.Upsert(membership); db.Memberships.Upsert(membership);
db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Memberships.FindById(id) is { } membership) if (db.Memberships.FindById(id) is { } membership)
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
db.Memberships.Delete(id); 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); ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
e.UpdatedAt = DateTime.UtcNow; e.UpdatedAt = DateTime.UtcNow;
db.Exams.Upsert(e); db.Exams.Upsert(e);
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Exams.FindById(id) is { } exam) if (db.Exams.FindById(id) is { } exam)
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList()) db.CascadeDeleteExam(id);
db.ExamResults.Delete(result.Id); db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
db.Exams.Delete(id);
} }
} }
@@ -266,6 +208,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
r.UpdatedAt = DateTime.UtcNow; r.UpdatedAt = DateTime.UtcNow;
db.ExamResults.Upsert(r); db.ExamResults.Upsert(r);
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
} }
public void SaveMany(List<ExamResult> results) public void SaveMany(List<ExamResult> results)
{ {
@@ -275,6 +218,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var r in results) r.UpdatedAt = now; foreach (var r in results) r.UpdatedAt = now;
db.ExamResults.Upsert(results); 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) => public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList(); db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id); public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); } public void Save(GradingKeyTemplate t)
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id); {
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 public class GradeRepository(LiteDbContext db) : IGradeRepository
@@ -299,12 +253,14 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
{ {
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
db.Grades.Upsert(g); db.Grades.Upsert(g);
db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Grades.FindById(id) is { } grade) if (db.Grades.FindById(id) is { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.Grades.Delete(id); 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); if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
s.UpdatedAt = DateTime.UtcNow; s.UpdatedAt = DateTime.UtcNow;
db.GradingSchemes.Upsert(s); db.GradingSchemes.Upsert(s);
db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId) if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
ArchivedGroupWriteGuard.EnsureActive(db, groupId); ArchivedGroupWriteGuard.EnsureActive(db, groupId);
db.GradingSchemes.Delete(id); 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); ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
r.UpdatedAt = DateTime.UtcNow; r.UpdatedAt = DateTime.UtcNow;
db.ReportGrades.Upsert(r); db.ReportGrades.Upsert(r);
db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ReportGrades.FindById(id) is { } grade) if (db.ReportGrades.FindById(id) is { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.ReportGrades.Delete(id); 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); ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
u.UpdatedAt = DateTime.UtcNow; u.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(u); db.Units.Upsert(u);
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Units.FindById(id) is { } unit) if (db.Units.FindById(id) is { } unit)
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
db.Units.Delete(id); 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); ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
l.UpdatedAt = DateTime.UtcNow; l.UpdatedAt = DateTime.UtcNow;
db.Lessons.Upsert(l); db.Lessons.Upsert(l);
db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Lessons.FindById(id) is { } lesson) if (db.Lessons.FindById(id) is { } lesson)
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
db.Lessons.Delete(id); 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(); .OrderByDescending(d => d.Date).ToList();
public List<Documentation> GetAll() => public List<Documentation> GetAll() =>
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList(); 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) public void Delete(Guid id)
{ {
var doc = db.Documentation.FindById(id); var doc = db.Documentation.FindById(id);
@@ -405,13 +374,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
doc.IsDeleted = true; doc.IsDeleted = true;
doc.DeletedAt = DateTime.UtcNow; doc.DeletedAt = DateTime.UtcNow;
db.Documentation.Update(doc); 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) public void HardDelete(Guid id)
{ {
var doc = db.Documentation.FindById(id); db.CascadeHardDeleteDocumentation(id);
if (doc is not null) db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
db.Documentation.Delete(id);
} }
} }
@@ -421,8 +391,17 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList(); db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
public List<WorkTask> GetAll() => public List<WorkTask> GetAll() =>
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); 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 Save(WorkTask t)
public void Delete(Guid id) => db.Tasks.Delete(id); {
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 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(); db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
public List<TimeEntry> GetByTask(Guid id) => public List<TimeEntry> GetByTask(Guid id) =>
db.TimeEntries.Find(e => e.TaskId == id).ToList(); db.TimeEntries.Find(e => e.TaskId == id).ToList();
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e); public void Save(TimeEntry e)
public void Delete(Guid id) => db.TimeEntries.Delete(id); {
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 public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
@@ -447,14 +434,14 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
s.UpdatedAt = DateTime.UtcNow; s.UpdatedAt = DateTime.UtcNow;
db.ParticipationSessions.Upsert(s); db.ParticipationSessions.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ParticipationSessions.FindById(id) is { } session) if (db.ParticipationSessions.FindById(id) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
db.ParticipationSessions.Delete(id); db.CascadeDeleteParticipationSession(id);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList()) db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
db.ParticipationEntries.Delete(e.Id);
} }
} }
@@ -472,6 +459,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
e.UpdatedAt = DateTime.UtcNow; e.UpdatedAt = DateTime.UtcNow;
db.ParticipationEntries.Upsert(e); db.ParticipationEntries.Upsert(e);
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
} }
public void SaveMany(List<ParticipationEntry> entries) public void SaveMany(List<ParticipationEntry> entries)
{ {
@@ -481,13 +469,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var e in entries) e.UpdatedAt = now; foreach (var e in entries) e.UpdatedAt = now;
db.ParticipationEntries.Upsert(entries); db.ParticipationEntries.Upsert(entries);
foreach (var e in entries)
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
} }
public void DeleteBySession(Guid sessionId) public void DeleteBySession(Guid sessionId)
{ {
if (db.ParticipationSessions.FindById(sessionId) is { } session) if (db.ParticipationSessions.FindById(sessionId) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList()) foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
{
db.ParticipationEntries.Delete(e.Id); 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."); throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
a.UpdatedAt = DateTime.UtcNow; a.UpdatedAt = DateTime.UtcNow;
db.ParticipationAspects.Upsert(a); db.ParticipationAspects.Upsert(a);
db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId) if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
ArchivedGroupWriteGuard.EnsureActive(db, groupId); ArchivedGroupWriteGuard.EnsureActive(db, groupId);
db.ParticipationAspects.Delete(id); 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); ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
db.ParticipationSections.Upsert(s); db.ParticipationSections.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ParticipationSections.FindById(id) is { } section) if (db.ParticipationSections.FindById(id) is { } section)
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
db.ParticipationSections.Delete(id); 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."); throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
s.UpdatedAt = DateTime.UtcNow; s.UpdatedAt = DateTime.UtcNow;
db.Subjects.Upsert(s); db.Subjects.Upsert(s);
db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == 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."); throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
db.Subjects.Delete(id); 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."); throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
c.UpdatedAt = DateTime.UtcNow; c.UpdatedAt = DateTime.UtcNow;
db.ShorthandCodes.Upsert(c); 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 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."); throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
p.UpdatedAt = DateTime.UtcNow; p.UpdatedAt = DateTime.UtcNow;
db.AlternativeLessonPaths.Upsert(p); 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 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) if (occupied is not null && occupied.Id != slot.Id)
throw new InvalidOperationException("Diese Stunde ist bereits belegt."); throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
db.TimetableSlots.Upsert(slot); db.TimetableSlots.Upsert(slot);
db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.TimetableSlots.FindById(id) is { } slot) if (db.TimetableSlots.FindById(id) is { } slot)
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
db.TimetableSlots.Delete(id); db.TimetableSlots.Delete(id);
db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null);
} }
} }
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
{ {
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList(); public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday); public void Save(SchoolHoliday holiday)
public void Delete(Guid id) => db.SchoolHolidays.Delete(id); {
db.SchoolHolidays.Upsert(holiday);
db.OnChange?.Invoke(nameof(SchoolHoliday), holiday.Id.ToString(), "Save", holiday);
}
public void Delete(Guid id)
{
db.SchoolHolidays.Delete(id);
db.OnChange?.Invoke(nameof(SchoolHoliday), id.ToString(), "Delete", null);
}
} }
public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
@@ -646,8 +665,13 @@ public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepos
if (occupied is not null && occupied.Id != duty.Id) if (occupied is not null && occupied.Id != duty.Id)
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen."); throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
db.SupervisionDuties.Upsert(duty); 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 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> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList(); db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry); public void Save(SubstitutionEntry entry)
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id); {
db.SubstitutionEntries.Upsert(entry);
db.OnChange?.Invoke(nameof(SubstitutionEntry), entry.Id.ToString(), "Save", entry);
}
public void Delete(Guid id)
{
db.SubstitutionEntries.Delete(id);
db.OnChange?.Invoke(nameof(SubstitutionEntry), id.ToString(), "Delete", null);
}
} }
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
@@ -667,14 +699,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.OrderBy(d => d.SortOrder) .OrderBy(d => d.SortOrder)
.ToList(); .ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id); public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); } public void Save(CompetencyDomain d)
public void Delete(Guid id) => db.CompetencyDomains.Delete(id); {
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) public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
{ {
foreach (var d in db.CompetencyDomains foreach (var d in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.ToList()) .ToList())
{
db.CompetencyDomains.Delete(d.Id); 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) 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 foreach (var domain in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList()) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
{
db.CompetencyDomains.Delete(domain.Id); db.CompetencyDomains.Delete(domain.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
}
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var domain in domains) foreach (var domain in domains)
{ {
@@ -690,6 +737,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
domain.GradeLevel = gradeLevel; domain.GradeLevel = gradeLevel;
domain.UpdatedAt = now; domain.UpdatedAt = now;
db.CompetencyDomains.Upsert(domain); 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.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -26,6 +27,26 @@ public static class TestSupport
public static AiPlanningService BuildAiPlanningService() => new( public static AiPlanningService BuildAiPlanningService() => new(
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); 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 public class FakeStudents(List<Student> all) : IStudentRepository
@@ -2,6 +2,9 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
using System.Linq;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -10,7 +13,8 @@ public sealed class SettingsViewModelTests
{ {
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = 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, // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -26,7 +30,48 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), 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] [Fact]
@@ -102,7 +147,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), 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"; vm.SelectedStateName = "Bayern";
@@ -124,7 +170,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, 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].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45"; vm.PeriodTimes[0].EndText = "08:45";
@@ -150,7 +197,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, 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].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00"; 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>(); services.AddSingleton<AiPlanningService>();
// ── Sync (optional nur wenn Server konfiguriert) ──────────────────── // ── 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(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>())); services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
services.AddSingleton<byte[]>(_ => services.AddSingleton<byte[]>(_ =>
@@ -173,15 +177,26 @@ public static class AppBootstrapper
return key; return key;
}); });
var serverUrl = LoadServerUrl(appData); var serverUrl = syncSettings.ServerUrl;
var deviceId = LoadOrCreateDeviceId(appData); var deviceId = LoadOrCreateDeviceId(appData);
if (!string.IsNullOrEmpty(serverUrl)) 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( services.AddSingleton<SyncEngine>(sp => new SyncEngine(
sp.GetRequiredService<EventQueue>(), sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(), sp.GetRequiredService<ConflictResolver>(),
BuildHttp(serverUrl, appData), sp.GetRequiredService<EventApplier>(),
sp.GetRequiredService<AttachmentSyncer>(),
BuildHttp(serverUrl, syncSettings),
new SyncConfig new SyncConfig
{ {
ServerUrl = serverUrl, ServerUrl = serverUrl,
@@ -191,7 +206,7 @@ public static class AppBootstrapper
})); }));
services.AddSingleton<SnapshotService>(sp => new SnapshotService( services.AddSingleton<SnapshotService>(sp => new SnapshotService(
BuildHttp(serverUrl, appData), BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<LiteDbContext>(),
sp.GetRequiredService<byte[]>(), sp.GetRequiredService<byte[]>(),
DeviceType.Desktop, DbPath, keyPath)); DeviceType.Desktop, DbPath, keyPath));
@@ -223,30 +238,29 @@ public static class AppBootstrapper
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); 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 ───────────────────────────────────────────────────────── // ── 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 http = new HttpClient { BaseAddress = new Uri(url) };
var tokenPath = Path.Combine(appData, "auth.token"); var token = syncSettings.GetToken();
if (File.Exists(tokenPath)) if (token is not null)
http.DefaultRequestHeaders.Authorization = http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue( new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
"Bearer", File.ReadAllText(tokenPath).Trim());
return http; 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) private static string LoadOrCreateDeviceId(string appData)
{ {
var p = Path.Combine(appData, "device.id"); 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.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Sync;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
@@ -164,6 +165,17 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _aiIsLoggedIn; [ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = ""; [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 ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -172,6 +184,9 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -182,7 +197,8 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning) AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -204,6 +220,9 @@ public partial class SettingsViewModel : ObservableObject
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
_catalogImport = new CompetencyCatalogImportService(domainRepo); _catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects(); LoadSubjects();
LoadShorthandCodes(); LoadShorthandCodes();
@@ -221,6 +240,8 @@ public partial class SettingsViewModel : ObservableObject
LoadSupervisionDuties(); LoadSupervisionDuties();
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadSyncSettings();
LoadSyncConflicts();
} }
// ── Word-Briefvorlagen: Import und Validierung ────────────────────────── // ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
@@ -362,6 +383,86 @@ public partial class SettingsViewModel : ObservableObject
AiBalanceDisplay = ""; 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 ───────────────────────────────────── // ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes() 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 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 class SchoolHolidayItem(SchoolHoliday h)
{ {
public Guid Id { get; } = h.Id; public Guid Id { get; } = h.Id;
@@ -759,6 +759,83 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </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> </TabbedPage>
</Grid> </Grid>
</UserControl> </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<SyncEvent> _queue;
private readonly ILiteCollection<SyncMeta> _meta; private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts; private readonly ILiteCollection<ConflictEntry> _conflicts;
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
private long _currentSeq; private long _currentSeq;
public EventQueue(string path) public EventQueue(string path)
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
_queue = _db.GetCollection<SyncEvent>("queue"); _queue = _db.GetCollection<SyncEvent>("queue");
_meta = _db.GetCollection<SyncMeta>("meta"); _meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts"); _conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
_queue.EnsureIndex(x => x.SequenceNr); _queue.EnsureIndex(x => x.SequenceNr);
_currentSeq = _meta.FindById("seq")?.Value ?? 0; _currentSeq = _meta.FindById("seq")?.Value ?? 0;
} }
@@ -56,6 +59,25 @@ public class EventQueue : IDisposable
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c); public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList(); public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed); 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(); public void Dispose() => _db.Dispose();
} }
@@ -75,3 +97,9 @@ internal class SyncMeta
public long Value { get; set; } public long Value { get; set; }
public DateTime? Timestamp { get; set; } public DateTime? Timestamp { get; set; }
} }
internal class PendingAttachmentUpload
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string StorageId { get; set; } = "";
}
+11 -3
View File
@@ -11,6 +11,8 @@ public class SyncEngine : IDisposable
{ {
private readonly EventQueue _queue; private readonly EventQueue _queue;
private readonly ConflictResolver _resolver; private readonly ConflictResolver _resolver;
private readonly EventApplier _applier;
private readonly AttachmentSyncer _attachments;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly SyncConfig _config; private readonly SyncConfig _config;
private readonly Timer _timer; private readonly Timer _timer;
@@ -18,11 +20,13 @@ public class SyncEngine : IDisposable
public SyncStatus Status { get; private set; } = new(); public SyncStatus Status { get; private set; } = new();
public event Action<SyncStatus>? StatusChanged; public event Action<SyncStatus>? StatusChanged;
public SyncEngine(EventQueue queue, ConflictResolver resolver, public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
HttpClient http, SyncConfig config) AttachmentSyncer attachments, HttpClient http, SyncConfig config)
{ {
_queue = queue; _queue = queue;
_resolver = resolver; _resolver = resolver;
_applier = applier;
_attachments = attachments;
_http = http; _http = http;
_config = config; _config = config;
_timer = new Timer( _timer = new Timer(
@@ -40,6 +44,7 @@ public class SyncEngine : IDisposable
try try
{ {
var (pushed, _) = await PushAsync(); var (pushed, _) = await PushAsync();
await _attachments.UploadPendingAsync(_queue);
var (pulled, conflicts) = await PullAsync(); var (pulled, conflicts) = await PullAsync();
_queue.SetLastSyncAt(DateTime.UtcNow); _queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle); SetState(SyncState.Idle);
@@ -75,7 +80,10 @@ public class SyncEngine : IDisposable
foreach (var evt in resp.Events) foreach (var evt in resp.Events)
{ {
var c = _resolver.TryResolve(evt, _config.DeviceId); 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); _queue.SetLastServerSeq(resp.ServerSequenceNr);
return (resp.Events.Count, conflicts); 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+118 -11
View File
@@ -1299,30 +1299,137 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
## 10. Sync & Server ## 10. Sync & Server
Grundgerüst existiert in [LehrerApp.Sync](LehrerApp.Sync/) und [LehrerApp.Api](LehrerApp.Api/), Vollständig funktionsfähig: Outbound-Publishing (10.1.6), Inbound-Apply (10.1.7),
ist aber nur aktiv, wenn eine Server-URL konfiguriert ist. 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 Client
- [ ] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern. - [x] **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. **Umsetzung:** Neuer Tab "Synchronisation" in den Einstellungen. Neu
- [ ] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation. `LehrerApp.Desktop/Services/SyncSettingsService.cs` (Muster `AiSettingsService`: Token
- [ ] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand. AES-256-verschlüsselt über `SyncCrypto`, eigener rein lokaler Schlüssel — ersetzt die
- [ ] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist 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. 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 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)). ([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. - [ ] **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 Verschlüsselung
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase). - [ ] **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.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [ ] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen — - [ ] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht. 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"