From 216d5d22801c46cda3739dff431b2a47c5cb7302 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 29 Mar 2026 23:47:31 +0200 Subject: [PATCH] Initial --- .DS_Store | Bin 0 -> 6148 bytes Directory.Build.props | 25 ++ Directory.Packages.props | 34 +++ Dockerfile.api | 20 ++ LehrerApp.Api/Endpoints/Endpoints.cs | 147 +++++++++++ .../Endpoints/ReadableSnapshotEndpoints.cs | 115 ++++++++ LehrerApp.Api/EventStore.cs | 144 ++++++++++ LehrerApp.Api/LehrerApp.Api.csproj | 19 ++ LehrerApp.Api/PlainEventStore.cs | 50 ++++ LehrerApp.Api/Program.cs | 57 ++++ LehrerApp.Api/ReadableSnapshotStore.cs | 72 +++++ LehrerApp.Api/SnapshotStore.cs | 155 +++++++++++ LehrerApp.Api/appsettings.json | 12 + LehrerApp.Core/Interfaces/IRepositories.cs | 97 +++++++ LehrerApp.Core/LehrerApp.Core.csproj | 8 + LehrerApp.Core/Models/Exam.cs | 47 ++++ LehrerApp.Core/Models/LearningGroup.cs | 33 +++ LehrerApp.Core/Models/Planning.cs | 57 ++++ LehrerApp.Core/Models/ReadableSnapshot.cs | 52 ++++ LehrerApp.Core/Models/Student.cs | 26 ++ LehrerApp.Core/Models/Workload.cs | 70 +++++ LehrerApp.Core/Services/GradingService.cs | 90 +++++++ LehrerApp.Core/Services/SchoolYearService.cs | 48 ++++ LehrerApp.Data/LehrerApp.Data.csproj | 15 ++ LehrerApp.Data/LiteDbContext.cs | 117 +++++++++ .../Repositories/GroupExamRepositories.cs | 95 +++++++ .../Repositories/OtherRepositories.cs | 140 ++++++++++ .../Repositories/StudentRepository.cs | 38 +++ LehrerApp.Desktop/App.axaml | 18 ++ LehrerApp.Desktop/App.axaml.cs | 33 +++ LehrerApp.Desktop/AppBootstrapper.cs | 181 +++++++++++++ LehrerApp.Desktop/LehrerApp.Desktop.csproj | 32 +++ LehrerApp.Desktop/Program.cs | 16 ++ .../ViewModels/DashboardViewModel.cs | 136 ++++++++++ .../ViewModels/DevicePairingViewModel.cs | 158 +++++++++++ .../Groups/AddGroupDialogViewModel.cs | 90 +++++++ .../ViewModels/Groups/GroupViewModels.cs | 208 +++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 87 +++++++ .../ViewModels/Students/StudentViewModels.cs | 223 ++++++++++++++++ .../ViewModels/SyncStatusViewModel.cs | 63 +++++ LehrerApp.Desktop/Views/DashboardView.axaml | 115 ++++++++ LehrerApp.Desktop/Views/DataTemplates.axaml | 38 +++ .../Views/DevicePairingDialog.axaml | 185 +++++++++++++ .../Views/DevicePairingDialog.axaml.cs | 34 +++ .../Views/Groups/AddGroupDialog.axaml | 109 ++++++++ .../Views/Groups/AddGroupDialog.axaml.cs | 33 +++ .../Views/Groups/GroupDetailView.axaml | 129 +++++++++ .../Views/Groups/GroupListView.axaml | 100 +++++++ LehrerApp.Desktop/Views/Groups/GroupViews.cs | 13 + LehrerApp.Desktop/Views/MainWindow.axaml | 108 ++++++++ LehrerApp.Desktop/Views/MainWindow.axaml.cs | 11 + LehrerApp.Desktop/Views/NavButton.axaml | 38 +++ LehrerApp.Desktop/Views/NavButton.axaml.cs | 69 +++++ LehrerApp.Desktop/Views/PlaceholderView.axaml | 13 + .../Views/Students/StudentDetailView.axaml | 148 +++++++++++ .../Views/Students/StudentListView.axaml | 63 +++++ .../Views/Students/StudentViews.cs | 13 + LehrerApp.Desktop/Views/SyncStatusBar.axaml | 24 ++ .../Views/SyncStatusBar.axaml.cs | 20 ++ LehrerApp.Desktop/Views/ViewCodeBehind.cs | 17 ++ LehrerApp.Desktop/app.manifest | 11 + LehrerApp.Sync/ConflictResolver.cs | 64 +++++ LehrerApp.Sync/Crypto/SyncCrypto.cs | 163 ++++++++++++ LehrerApp.Sync/EventApplier.cs | 214 +++++++++++++++ LehrerApp.Sync/EventQueue.cs | 129 +++++++++ LehrerApp.Sync/LehrerApp.Sync.csproj | 18 ++ LehrerApp.Sync/Models/PlainSyncModels.cs | 30 +++ LehrerApp.Sync/Models/SnapshotModels.cs | 61 +++++ LehrerApp.Sync/Models/SyncModels.cs | 66 +++++ LehrerApp.Sync/ReadableSnapshotService.cs | 155 +++++++++++ LehrerApp.Sync/SnapshotService.cs | 245 ++++++++++++++++++ LehrerApp.Sync/SyncEngine.cs | 186 +++++++++++++ LehrerApp.sln | 28 ++ docker-compose.yml | 18 ++ global.json | 6 + 75 files changed, 5702 insertions(+) create mode 100644 .DS_Store create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 Dockerfile.api create mode 100644 LehrerApp.Api/Endpoints/Endpoints.cs create mode 100644 LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs create mode 100644 LehrerApp.Api/EventStore.cs create mode 100644 LehrerApp.Api/LehrerApp.Api.csproj create mode 100644 LehrerApp.Api/PlainEventStore.cs create mode 100644 LehrerApp.Api/Program.cs create mode 100644 LehrerApp.Api/ReadableSnapshotStore.cs create mode 100644 LehrerApp.Api/SnapshotStore.cs create mode 100644 LehrerApp.Api/appsettings.json create mode 100644 LehrerApp.Core/Interfaces/IRepositories.cs create mode 100644 LehrerApp.Core/LehrerApp.Core.csproj create mode 100644 LehrerApp.Core/Models/Exam.cs create mode 100644 LehrerApp.Core/Models/LearningGroup.cs create mode 100644 LehrerApp.Core/Models/Planning.cs create mode 100644 LehrerApp.Core/Models/ReadableSnapshot.cs create mode 100644 LehrerApp.Core/Models/Student.cs create mode 100644 LehrerApp.Core/Models/Workload.cs create mode 100644 LehrerApp.Core/Services/GradingService.cs create mode 100644 LehrerApp.Core/Services/SchoolYearService.cs create mode 100644 LehrerApp.Data/LehrerApp.Data.csproj create mode 100644 LehrerApp.Data/LiteDbContext.cs create mode 100644 LehrerApp.Data/Repositories/GroupExamRepositories.cs create mode 100644 LehrerApp.Data/Repositories/OtherRepositories.cs create mode 100644 LehrerApp.Data/Repositories/StudentRepository.cs create mode 100644 LehrerApp.Desktop/App.axaml create mode 100644 LehrerApp.Desktop/App.axaml.cs create mode 100644 LehrerApp.Desktop/AppBootstrapper.cs create mode 100644 LehrerApp.Desktop/LehrerApp.Desktop.csproj create mode 100644 LehrerApp.Desktop/Program.cs create mode 100644 LehrerApp.Desktop/ViewModels/DashboardViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/DevicePairingViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/AddGroupDialogViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs create mode 100644 LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs create mode 100644 LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs create mode 100644 LehrerApp.Desktop/Views/DashboardView.axaml create mode 100644 LehrerApp.Desktop/Views/DataTemplates.axaml create mode 100644 LehrerApp.Desktop/Views/DevicePairingDialog.axaml create mode 100644 LehrerApp.Desktop/Views/DevicePairingDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/GroupListView.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/GroupViews.cs create mode 100644 LehrerApp.Desktop/Views/MainWindow.axaml create mode 100644 LehrerApp.Desktop/Views/MainWindow.axaml.cs create mode 100644 LehrerApp.Desktop/Views/NavButton.axaml create mode 100644 LehrerApp.Desktop/Views/NavButton.axaml.cs create mode 100644 LehrerApp.Desktop/Views/PlaceholderView.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentDetailView.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentListView.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentViews.cs create mode 100644 LehrerApp.Desktop/Views/SyncStatusBar.axaml create mode 100644 LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs create mode 100644 LehrerApp.Desktop/Views/ViewCodeBehind.cs create mode 100644 LehrerApp.Desktop/app.manifest create mode 100644 LehrerApp.Sync/ConflictResolver.cs create mode 100644 LehrerApp.Sync/Crypto/SyncCrypto.cs create mode 100644 LehrerApp.Sync/EventApplier.cs create mode 100644 LehrerApp.Sync/EventQueue.cs create mode 100644 LehrerApp.Sync/LehrerApp.Sync.csproj create mode 100644 LehrerApp.Sync/Models/PlainSyncModels.cs create mode 100644 LehrerApp.Sync/Models/SnapshotModels.cs create mode 100644 LehrerApp.Sync/Models/SyncModels.cs create mode 100644 LehrerApp.Sync/ReadableSnapshotService.cs create mode 100644 LehrerApp.Sync/SnapshotService.cs create mode 100644 LehrerApp.Sync/SyncEngine.cs create mode 100644 LehrerApp.sln create mode 100644 docker-compose.yml create mode 100644 global.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a456f80750007e694d3173de272be9a3c5eef24f GIT binary patch literal 6148 zcmeHK%}xR_5S}6jB8X^W!g0MTiEpstdNA=|!VAcsimWW*=RmydD|q)Md_w(Z+UOQa zycwf2r2V>WznS*yZcB;Cw3mZ3q6QIFkQmFm$O6XgoHnfFYHC0uKKitvjApc=)<$ow zPz6+h-=+Y6yGNAJ2w&}xir;UWmZ(i>fNyz<)(rXlD$V-6H0y!ITgDsG$-5XzM9rY5 z8|uRI82LS7%(we9$7}u$5j_*`5F{Ur=|A%NiBA2f?_Btkzie^r`Pblqz}%&v_2j0&1CBL9OtdJ6PKZ z_5G-)|CRYGp z8P#Ga^B)7|L=Izzxka?VjHLoC)wnB$v2@f&u3zjhw`l2P+~vc#$j04Jj6{e3NV=1W zEo!a`r~ hj$ + + + net9.0 + enable + enable + latest + false + + + true + + + + + CS8618 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..42c0fab --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,34 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..8dfc199 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY ["LehrerApp.Api/LehrerApp.Api.csproj", "LehrerApp.Api/"] +COPY ["LehrerApp.Sync/LehrerApp.Sync.csproj", "LehrerApp.Sync/"] +COPY ["LehrerApp.Core/LehrerApp.Core.csproj", "LehrerApp.Core/"] +COPY ["LehrerApp.Data/LehrerApp.Data.csproj", "LehrerApp.Data/"] +RUN dotnet restore "LehrerApp.Api/LehrerApp.Api.csproj" +COPY . . +RUN dotnet publish "LehrerApp.Api/LehrerApp.Api.csproj" \ + -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +RUN mkdir -p /app/data +ENTRYPOINT ["dotnet", "LehrerApp.Api.dll"] diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs new file mode 100644 index 0000000..25f32ee --- /dev/null +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -0,0 +1,147 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using LehrerApp.Sync.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace LehrerApp.Api; + +public static class Endpoints +{ + // ── Auth ────────────────────────────────────────────────────────────────── + + public static void MapAuthEndpoints(this WebApplication app, string jwtSecret) + { + app.MapPost("/api/auth/login", (LoginRequest req) => + { + // TODO: Passwort-Hash gegen DB prüfen + if (string.IsNullOrWhiteSpace(req.Username) || + string.IsNullOrWhiteSpace(req.Password)) + return Results.Unauthorized(); + + var token = GenerateJwt(req.Username, jwtSecret); + return Results.Ok(new { token, userId = req.Username }); + }); + + app.MapPost("/api/auth/register", (RegisterRequest req) => + { + // TODO: User anlegen, Passwort hashen (BCrypt) + if (string.IsNullOrWhiteSpace(req.Username) || + req.Password.Length < 12) + return Results.BadRequest( + "Passwort muss mindestens 12 Zeichen haben."); + + var token = GenerateJwt(req.Username, jwtSecret); + return Results.Ok(new { token, userId = req.Username }); + }); + } + + // ── Sync ────────────────────────────────────────────────────────────────── + + public static void MapSyncEndpoints(this WebApplication app) + { + var sync = app.MapGroup("/api/sync").RequireAuthorization(); + + sync.MapPost("/push", ( + [FromBody] List events, + ClaimsPrincipal user, + EventStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + var result = store.Push(userId, events); + return Results.Ok(result); + }); + + sync.MapGet("/pull", ( + [FromQuery] long since, + [FromQuery] string deviceId, + ClaimsPrincipal user, + EventStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + var result = store.Pull(userId, since, deviceId); + return Results.Ok(result); + }); + + sync.MapGet("/status", (ClaimsPrincipal user) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + return Results.Ok(new { userId, timestamp = DateTime.UtcNow }); + }); + } + + // ── Snapshot ────────────────────────────────────────────────────────────── + + public static void MapSnapshotEndpoints(this WebApplication app) + { + var snap = app.MapGroup("/api/snapshot").RequireAuthorization(); + + // Sender: Snapshot hochladen → Einmal-Code erhalten + snap.MapPost("/upload", ( + [FromBody] SnapshotUploadRequest request, + ClaimsPrincipal user, + SnapshotStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(request.EncryptedPayload)) + return Results.BadRequest("Kein Payload."); + + var result = store.Store(userId, request); + return Results.Ok(result); + }); + + // Empfänger: Snapshot per Code abrufen (Einmal-Verwendung) + snap.MapGet("/{code}", ( + string code, + ClaimsPrincipal user, + SnapshotStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + var result = store.Retrieve(userId, code); + if (result is null) + return Results.NotFound( + "Snapshot nicht gefunden, abgelaufen oder bereits verwendet."); + + return Results.Ok(result); + }); + } + + // ── JWT ─────────────────────────────────────────────────────────────────── + + private static string GenerateJwt(string userId, string secret) + { + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(secret)); + var creds = new SigningCredentials( + key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + claims: + [ + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(JwtRegisteredClaimNames.Jti, + Guid.NewGuid().ToString()), + ], + expires: DateTime.UtcNow.AddDays(30), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + +public record LoginRequest(string Username, string Password); +public record RegisterRequest(string Username, string Password, string DisplayName); + +// Ergänzung – wird unten in der bestehenden Datei angefügt +// In Program.cs: app.MapReadableSnapshotEndpoints(); und app.MapPlainSyncEndpoints(); diff --git a/LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs b/LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs new file mode 100644 index 0000000..8f87e6a --- /dev/null +++ b/LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs @@ -0,0 +1,115 @@ +using System.Security.Claims; +using LehrerApp.Core.Models; +using LehrerApp.Sync.Models; +using Microsoft.AspNetCore.Mvc; + +namespace LehrerApp.Api; + +public static class ReadableSnapshotEndpoints +{ + // ── Readable Snapshot ───────────────────────────────────────────────────── + + public static void MapReadableSnapshotEndpoints(this WebApplication app) + { + var snap = app.MapGroup("/api/snapshot/readable") + .RequireAuthorization(); + + // Desktop → Server: aktuellen Snapshot hochladen + snap.MapPost("/", ( + [FromBody] ReadableSnapshot snapshot, + ClaimsPrincipal user, + ReadableSnapshotStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + snapshot.ExportedAt = DateTime.UtcNow; + store.Store(userId, snapshot); + + return Results.Ok(new + { + exportedAt = snapshot.ExportedAt, + studentCount = snapshot.Meta.StudentCount, + groupCount = snapshot.Meta.GroupCount, + }); + }); + + // WebApp → Server: Snapshot laden + snap.MapGet("/", ( + ClaimsPrincipal user, + ReadableSnapshotStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + var snapshot = store.Load(userId); + if (snapshot is null) + return Results.NotFound( + "Kein Snapshot vorhanden. " + + "Bitte zuerst den Desktop-Client mit dem Server verbinden."); + + return Results.Ok(snapshot); + }); + + // Nur Metadaten – für Freshness-Check der WebApp + snap.MapGet("/meta", ( + ClaimsPrincipal user, + ReadableSnapshotStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + var meta = store.LoadMeta(userId); + if (meta is null) return Results.NotFound(); + + return Results.Ok(meta); + }); + } + + // ── Plain Sync (WebApp → EventStore) ───────────────────────────────────── + + public static void MapPlainSyncEndpoints(this WebApplication app) + { + var sync = app.MapGroup("/api/sync/plain") + .RequireAuthorization(); + + // WebApp schreibt Events (Klartext) – werden beim Desktop-Pull abgeholt + sync.MapPost("/push", ( + [FromBody] List events, + ClaimsPrincipal user, + PlainEventStore store) => + { + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return Results.Unauthorized(); + + // Nur erlaubte EntityTypes für WebApp-Schreibzugriff + var allowed = new HashSet + { + "Grade", "ExamResult", "WorkTask", "Lesson" + }; + + var rejected = events + .Where(e => !allowed.Contains(e.EntityType)) + .Select(e => e.EventId) + .ToList(); + + var permitted = events + .Where(e => allowed.Contains(e.EntityType)) + .ToList(); + + PlainPushResponse result; + if (permitted.Count > 0) + result = store.Push(userId, permitted); + else + result = new PlainPushResponse { Success = true }; + + // Abgelehnte Events mit Grund zurückmelden + result = result with + { + RejectedEventIds = [.. result.RejectedEventIds, .. rejected], + }; + + return Results.Ok(result); + }); + } +} diff --git a/LehrerApp.Api/EventStore.cs b/LehrerApp.Api/EventStore.cs new file mode 100644 index 0000000..2f7a33c --- /dev/null +++ b/LehrerApp.Api/EventStore.cs @@ -0,0 +1,144 @@ +using LiteDB; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Api; + +/// +/// Server-seitiger Event-Speicher. +/// Speichert verschlüsselte Events pro User – versteht den Payload nicht. +/// Eine LiteDB-Datei pro User in dataPath/{userId}.db +/// +public class EventStore +{ + private readonly string _dataPath; + private readonly Dictionary _dbs = new(); + private readonly Lock _lock = new(); + + public EventStore(string dataPath) + { + _dataPath = dataPath; + Directory.CreateDirectory(dataPath); + } + + // ── Push: Events vom Client entgegennehmen ──────────────────────────────── + + public PushResponse Push(string userId, List events) + { + var db = GetDb(userId); + var col = db.GetCollection("events"); + col.EnsureIndex(x => x.ServerSequenceNr); + + var conflictIds = new List(); + long serverSeq = GetLastSequenceNr(col); + + foreach (var evt in events.OrderBy(e => e.Timestamp)) + { + // Konflikt: anderes Gerät hat dieselbe Entity kürzlich geändert + var recent = col.FindOne(e => + e.EntityType == evt.EntityType && + e.EntityId == evt.EntityId && + e.DeviceId != evt.DeviceId && + e.Timestamp > evt.Timestamp.AddSeconds(-30)); + + if (recent is not null) + { + conflictIds.Add(evt.EventId); + continue; + } + + col.Insert(new ServerEvent + { + EventId = evt.EventId, + DeviceId = evt.DeviceId, + DeviceType = evt.DeviceType, + Timestamp = evt.Timestamp, + ClientSequenceNr = evt.SequenceNr, + ServerSequenceNr = ++serverSeq, + EntityType = evt.EntityType, + EntityId = evt.EntityId, + Operation = evt.Operation, + Payload = evt.Payload, // verschlüsselt – Server liest nicht + }); + } + + return new PushResponse + { + Success = true, + ServerSequenceNr = serverSeq, + ConflictingEventIds = conflictIds, + }; + } + + // ── Pull: neue Events für Client bereitstellen ──────────────────────────── + + public PullResponse Pull(string userId, long since, string requestingDeviceId) + { + var db = GetDb(userId); + var col = db.GetCollection("events"); + + // Nur Events anderer Geräte zurückgeben + var events = col + .Find(e => e.ServerSequenceNr > since && e.DeviceId != requestingDeviceId) + .OrderBy(e => e.ServerSequenceNr) + .Take(500) + .Select(e => new SyncEvent + { + EventId = e.EventId, + DeviceId = e.DeviceId, + DeviceType = e.DeviceType, + Timestamp = e.Timestamp, + SequenceNr = e.ServerSequenceNr, + EntityType = e.EntityType, + EntityId = e.EntityId, + Operation = e.Operation, + Payload = e.Payload, + }) + .ToList(); + + return new PullResponse + { + Events = events, + ServerSequenceNr = GetLastSequenceNr(col), + }; + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private LiteDatabase GetDb(string userId) + { + lock (_lock) + { + if (!_dbs.TryGetValue(userId, out var db)) + { + // Sanitize userId für Dateinamen + var safeName = string.Concat(userId + .Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')); + var path = Path.Combine(_dataPath, $"{safeName}.db"); + db = new LiteDatabase(path); + _dbs[userId] = db; + } + return db; + } + } + + private static long GetLastSequenceNr(ILiteCollection col) + { + var last = col.FindOne(Query.All(nameof(ServerEvent.ServerSequenceNr), Query.Descending)); + return last?.ServerSequenceNr ?? 0; + } +} + +// Internes Dokument im Server-EventStore +internal class ServerEvent +{ + public Guid EventId { get; set; } + public string DeviceId { get; set; } = ""; + public DeviceType DeviceType { get; set; } + public DateTime Timestamp { get; set; } + public long ClientSequenceNr { get; set; } + public long ServerSequenceNr { get; set; } + public string EntityType { get; set; } = ""; + public string EntityId { get; set; } = ""; + public string Operation { get; set; } = ""; + public string Payload { get; set; } = ""; // AES-256 – Server liest nicht +} diff --git a/LehrerApp.Api/LehrerApp.Api.csproj b/LehrerApp.Api/LehrerApp.Api.csproj new file mode 100644 index 0000000..e57a59c --- /dev/null +++ b/LehrerApp.Api/LehrerApp.Api.csproj @@ -0,0 +1,19 @@ + + + net9.0 + enable + enable + latest + + Linux + + + + + + + + + + + diff --git a/LehrerApp.Api/PlainEventStore.cs b/LehrerApp.Api/PlainEventStore.cs new file mode 100644 index 0000000..bf9d23d --- /dev/null +++ b/LehrerApp.Api/PlainEventStore.cs @@ -0,0 +1,50 @@ +using LiteDB; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Api; + +/// +/// Speichert Klartext-Events der WebApp/Companion im EventStore. +/// Diese werden vom Desktop-Client beim nächsten Pull abgeholt +/// und wie normale Events angewendet – nur ohne Entschlüsselung. +/// +/// Technisch gesehen ist das nur eine dünne Schicht über dem +/// bestehenden EventStore – Plain-Events werden einfach mit +/// DeviceType.Companion markiert und landen im selben Stream. +/// +public class PlainEventStore +{ + private readonly EventStore _eventStore; + + public PlainEventStore(EventStore eventStore) + { + _eventStore = eventStore; + } + + public PlainPushResponse Push(string userId, List events) + { + // Plain-Events in normale SyncEvents umwandeln + // DeviceType.Companion → Desktop-Client entschlüsselt nicht + var syncEvents = events.Select(e => new SyncEvent + { + EventId = e.EventId, + DeviceId = e.DeviceId, + DeviceType = DeviceType.Companion, // ← Signal: kein Decrypt nötig + Timestamp = e.Timestamp, + SequenceNr = 0, // vom EventStore vergeben + EntityType = e.EntityType, + EntityId = e.EntityId, + Operation = e.Operation, + Payload = e.Payload, // Klartext JSON + }).ToList(); + + var result = _eventStore.Push(userId, syncEvents); + + return new PlainPushResponse + { + Success = result.Success, + ServerSequenceNr = result.ServerSequenceNr, + RejectedEventIds = result.ConflictingEventIds, + }; + } +} diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs new file mode 100644 index 0000000..a14fc31 --- /dev/null +++ b/LehrerApp.Api/Program.cs @@ -0,0 +1,57 @@ +using System.Text; +using LehrerApp.Api; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// ── Kestrel ─────────────────────────────────────────────────────────────────── +builder.WebHost.UseKestrel(options => +{ + var port = builder.Configuration.GetValue("Api:Port", 5000); + options.ListenAnyIP(port); +}); + +// ── JWT Auth ────────────────────────────────────────────────────────────────── +var jwtSecret = builder.Configuration["JWT_SECRET"] + ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(jwtSecret)), + ValidateIssuer = false, + ValidateAudience = false, + ClockSkew = TimeSpan.FromMinutes(5), + }; + }); + +builder.Services.AddAuthorization(); + +// ── Services ────────────────────────────────────────────────────────────────── +var dataPath = builder.Configuration["Api:DataPath"] ?? "./data"; + +builder.Services.AddSingleton(_ => new EventStore(dataPath)); +builder.Services.AddSingleton(_ => new SnapshotStore(dataPath)); +builder.Services.AddSingleton( + _ => new ReadableSnapshotStore(dataPath)); +builder.Services.AddSingleton(sp => + new PlainEventStore(sp.GetRequiredService())); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// ── Endpoints ───────────────────────────────────────────────────────────────── +app.MapAuthEndpoints(jwtSecret); +app.MapSyncEndpoints(); +app.MapSnapshotEndpoints(); +app.MapReadableSnapshotEndpoints(); +app.MapPlainSyncEndpoints(); + +app.Run(); diff --git a/LehrerApp.Api/ReadableSnapshotStore.cs b/LehrerApp.Api/ReadableSnapshotStore.cs new file mode 100644 index 0000000..db23aba --- /dev/null +++ b/LehrerApp.Api/ReadableSnapshotStore.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using LehrerApp.Core.Models; + +namespace LehrerApp.Api; + +/// +/// Server-seitiger Speicher für lesbare Snapshots. +/// Eine JSON-Datei pro User – wird bei jedem Export überschrieben. +/// Kein Ablauf-Datum – der letzte Stand bleibt bis zum nächsten Export. +/// +public class ReadableSnapshotStore +{ + private readonly string _dataPath; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public ReadableSnapshotStore(string dataPath) + { + _dataPath = Path.Combine(dataPath, "readable"); + Directory.CreateDirectory(_dataPath); + } + + // ── Speichern ───────────────────────────────────────────────────────────── + + public void Store(string userId, ReadableSnapshot snapshot) + { + var path = SnapshotPath(userId); + var json = JsonSerializer.Serialize(snapshot, JsonOptions); + File.WriteAllText(path, json); + } + + // ── Laden ───────────────────────────────────────────────────────────────── + + public ReadableSnapshot? Load(string userId) + { + var path = SnapshotPath(userId); + if (!File.Exists(path)) return null; + + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return null; + } + } + + /// + /// Gibt nur die Metadaten zurück – für schnelle Freshness-Prüfung. + /// + public SnapshotMeta? LoadMeta(string userId) + { + var snapshot = Load(userId); + return snapshot?.Meta; + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private string SnapshotPath(string userId) + { + // Sanitize userId + var safe = string.Concat( + userId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')); + return Path.Combine(_dataPath, $"{safe}.json"); + } +} diff --git a/LehrerApp.Api/SnapshotStore.cs b/LehrerApp.Api/SnapshotStore.cs new file mode 100644 index 0000000..768b0cf --- /dev/null +++ b/LehrerApp.Api/SnapshotStore.cs @@ -0,0 +1,155 @@ +using LiteDB; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Api; + +/// +/// Server-seitiger Snapshot-Speicher. +/// Speichert: +/// - EncryptedPayload: mit Sync-Schlüssel verschlüsselte LiteDB +/// - EncryptedSyncKey: mit Code-Key (PBKDF2) verschlüsselter Sync-Schlüssel +/// +/// Der Server versteht beides nicht – er reicht es nur weiter. +/// TTL: 24h, Einmal-Verwendung. +/// +public class SnapshotStore : IDisposable +{ + private readonly LiteDatabase _db; + private readonly ILiteCollection _col; + private readonly Timer _cleanupTimer; + + private static readonly string[] Animals = + [ + "TIGER", "ADLER", "DACHS", "LUCHS", "FALKE", + "IGEL", "ELCH", "FUCHS", "RABE", "WOLF", + "BISON", "LAMM", "EULE", "BIBER", "STORCH", + "HIRSCH","OTTER", "MARDER","KRANICH","LACHS", + ]; + + private static readonly string[] Colors = + [ + "BLAU", "GRUEN", "ROT", "GOLD", "GRAU", + "CYAN", "ROSA", "LILA", "SAND", "MINT", + "SMARAGD","KORALLE","INDIGO","AMBER","JADE", + ]; + + public SnapshotStore(string dataPath) + { + Directory.CreateDirectory(dataPath); + _db = new LiteDatabase(Path.Combine(dataPath, "snapshots.db")); + _col = _db.GetCollection("snapshots"); + _col.EnsureIndex(x => x.Code); + _col.EnsureIndex(x => x.ExpiresAt); + + _cleanupTimer = new Timer( + _ => Cleanup(), + null, + TimeSpan.FromHours(1), + TimeSpan.FromHours(1)); + } + + // ── Upload ──────────────────────────────────────────────────────────────── + + /// + /// Speichert Snapshot und verschlüsselten Sync-Schlüssel. + /// Wenn für denselben User bereits ein Snapshot existiert, wird er ersetzt. + /// + public SnapshotUploadResponse Store( + string userId, + SnapshotUploadRequest request) + { + // Alten Snapshot desselben Users überschreiben + _col.DeleteMany(e => e.UserId == userId); + + var code = GenerateCode(); + var entry = new SnapshotEntry + { + Code = code, + UserId = userId, + EncryptedPayload = request.EncryptedPayload, + EncryptedSyncKey = request.EncryptedSyncKey, + SourceDeviceType = request.DeviceType, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddHours(24), + }; + + _col.Insert(entry); + + return new SnapshotUploadResponse + { + Code = code, + ExpiresAt = entry.ExpiresAt, + }; + } + + // ── Download (Einmal-Verwendung) ────────────────────────────────────────── + + public SnapshotDownloadResponse? Retrieve(string userId, string code) + { + var entry = _col.FindOne(e => + e.UserId == userId && + e.Code == code.ToUpperInvariant()); + + if (entry is null) return null; + + if (entry.ExpiresAt < DateTime.UtcNow) + { + _col.Delete(entry.Id); + return null; + } + + // Einmal-Verwendung: sofort löschen + _col.Delete(entry.Id); + + return new SnapshotDownloadResponse + { + EncryptedPayload = entry.EncryptedPayload, + EncryptedSyncKey = entry.EncryptedSyncKey, + CreatedAt = entry.CreatedAt, + SourceDeviceType = entry.SourceDeviceType, + }; + } + + // ── Bereinigung ─────────────────────────────────────────────────────────── + + private void Cleanup() + { + var now = DateTime.UtcNow; + _col.DeleteMany(e => e.ExpiresAt < now); + } + + // ── Code-Generierung ────────────────────────────────────────────────────── + + private static string GenerateCode() + { + var rng = Random.Shared; + var animal = Animals[rng.Next(Animals.Length)]; + var number = rng.Next(10, 99); + var color = Colors[rng.Next(Colors.Length)]; + return $"{animal}-{number}-{color}"; + } + + public void Dispose() + { + _cleanupTimer.Dispose(); + _db.Dispose(); + } +} + +internal class SnapshotEntry +{ + public ObjectId Id { get; set; } = ObjectId.NewObjectId(); + public string Code { get; set; } = ""; + public string UserId { get; set; } = ""; + public string EncryptedPayload { get; set; } = ""; + + /// + /// Sync-Schlüssel verschlüsselt mit PBKDF2(Code). + /// Leer wenn noch nicht gesetzt (zweistufiger Upload). + /// + public string EncryptedSyncKey { get; set; } = ""; + + public DeviceType SourceDeviceType { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime ExpiresAt { get; set; } +} diff --git a/LehrerApp.Api/appsettings.json b/LehrerApp.Api/appsettings.json new file mode 100644 index 0000000..881c17a --- /dev/null +++ b/LehrerApp.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Api": { + "Port": 5000, + "DataPath": "./data" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs new file mode 100644 index 0000000..f28cbba --- /dev/null +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -0,0 +1,97 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Interfaces; + +public interface IStudentRepository +{ + Student? GetById(Guid id); + List GetAll(bool includeInactive = false); + List GetByGroup(Guid groupId, string schoolYear); + void Save(Student student); + void Delete(Guid id); +} + +public interface IGroupRepository +{ + LearningGroup? GetById(Guid id); + List GetAll(); + List GetBySchoolYear(string schoolYear); + void Save(LearningGroup group); + void Delete(Guid id); +} + +public interface IEnrollmentRepository +{ + List GetByStudent(Guid studentId); + List GetByGroup(Guid groupId); + List GetByGroupAndYear(Guid groupId, string schoolYear); + void Save(Enrollment enrollment); + void Delete(Guid id); +} + +public interface IExamRepository +{ + Exam? GetById(Guid id); + List GetByGroup(Guid groupId); + void Save(Exam exam); + void Delete(Guid id); +} + +public interface IExamResultRepository +{ + List GetByExam(Guid examId); + List GetByStudent(Guid studentId); + ExamResult? GetByExamAndStudent(Guid examId, Guid studentId); + void Save(ExamResult result); + void SaveMany(List results); +} + +public interface IGradeRepository +{ + List GetByStudentAndGroup(Guid studentId, Guid groupId); + List GetByGroup(Guid groupId); + void Save(Grade grade); + void Delete(Guid id); +} + +public interface IUnitRepository +{ + Unit? GetById(Guid id); + List GetByGroup(Guid groupId); + void Save(Unit unit); + void Delete(Guid id); +} + +public interface ILessonRepository +{ + List GetByUnit(Guid unitId); + List GetByGroupAndDate(Guid groupId, DateOnly date); + List GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to); + void Save(Lesson lesson); + void Delete(Guid id); +} + +public interface IDocumentationRepository +{ + List GetByStudent(Guid studentId); + List GetByStudentAndType(Guid studentId, DocumentationType type); + void Save(Documentation doc); + void Delete(Guid id); +} + +public interface IWorkTaskRepository +{ + List GetByStatus(WorkTaskStatus status); + List GetAll(); + void Save(WorkTask task); + void Delete(Guid id); +} + +public interface ITimeEntryRepository +{ + List GetByDate(DateOnly date); + List GetByDateRange(DateOnly from, DateOnly to); + List GetByTask(Guid taskId); + void Save(TimeEntry entry); + void Delete(Guid id); +} diff --git a/LehrerApp.Core/LehrerApp.Core.csproj b/LehrerApp.Core/LehrerApp.Core.csproj new file mode 100644 index 0000000..2396cd7 --- /dev/null +++ b/LehrerApp.Core/LehrerApp.Core.csproj @@ -0,0 +1,8 @@ + + + net9.0 + enable + enable + latest + + diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs new file mode 100644 index 0000000..37779fe --- /dev/null +++ b/LehrerApp.Core/Models/Exam.cs @@ -0,0 +1,47 @@ +namespace LehrerApp.Core.Models; + +public class Exam +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public string Title { get; set; } = ""; // "Arbeit Nr. 2 – Redox" + public DateOnly Date { get; set; } + public string Subject { get; set; } = ""; + public int? ExamNumber { get; set; } + public List Tasks { get; set; } = []; // nested – kein JOIN + public List GradingKey { get; set; } = []; + public ExamStatus Status { get; set; } = ExamStatus.Planned; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class ExamTask +{ + public int Nr { get; set; } + public string? Title { get; set; } + public double MaxPoints { get; set; } + public double Weight { get; set; } = 1.0; +} + +public class GradingKeyEntry +{ + public string Grade { get; set; } = ""; // "1","2"... oder "15","14"... + public double MinPercent { get; set; } +} + +// Ergebnisse separat – für Notenspiegel ohne Aufgabentext laden +public class ExamResult +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ExamId { get; set; } + public Guid StudentId { get; set; } + public List Points { get; set; } = []; // Index = Aufgabe Nr - 1 + public double TotalPoints { get; set; } + public string? Grade { get; set; } + public bool Absent { get; set; } + public string? Comment { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public enum ExamStatus { Planned, Conducted, Graded, Returned } diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs new file mode 100644 index 0000000..152ec3e --- /dev/null +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -0,0 +1,33 @@ +namespace LehrerApp.Core.Models; + +public class LearningGroup +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; // "10E", "Q1 Chemie" + public GroupType Type { get; set; } + public string? Subject { get; set; } // "Chemie", "Mathematik" + public string SchoolYear { get; set; } = ""; // "2024/25" + public int GradeLevel { get; set; } // 5, 10, 11, 12 ... + public GradingSystem GradingSystem { get; set; } + public int? HoursPerWeek { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class Enrollment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + public string SchoolYear { get; set; } = ""; + public DateOnly EnrolledAt { get; set; } = DateOnly.FromDateTime(DateTime.Today); + public DateOnly? LeftAt { get; set; } // bei Wechsel mid-year +} + +public enum GroupType { Class, Course } + +public enum GradingSystem +{ + Grades1To6, // Noten 1–6 (Sek I) + Points0To15, // Punkte 0–15 (Oberstufe) +} diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs new file mode 100644 index 0000000..00b2a89 --- /dev/null +++ b/LehrerApp.Core/Models/Planning.cs @@ -0,0 +1,57 @@ +namespace LehrerApp.Core.Models; + +// ── Sonstige Noten ──────────────────────────────────────────────────────────── + +public class Grade +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + public string SchoolYear { get; set; } = ""; + public GradeCategory Category { get; set; } + public string Value { get; set; } = ""; // "2", "11", "+" je nach System + public DateOnly Date { get; set; } + public double Weight { get; set; } = 1.0; + public string? Note { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public enum GradeCategory { Oral, Homework, Participation, Project, Other } + +// ── Unterrichtsplanung ──────────────────────────────────────────────────────── + +public class Unit // Unterrichtseinheit / Reihe +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public string Title { get; set; } = ""; // "Ionenbindung" + public string Subject { get; set; } = ""; + public string SchoolYear { get; set; } = ""; + public DateOnly? StartDate { get; set; } + public DateOnly? EndDate { get; set; } + public List Competencies { get; set; } = []; + public UnitStatus Status { get; set; } = UnitStatus.Planned; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class Lesson // Einzelstunde +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UnitId { get; set; } + public Guid GroupId { get; set; } // Redundanz für schnelle Abfragen + public DateOnly Date { get; set; } + public int? LessonNumber { get; set; } + public string Topic { get; set; } = ""; + public string? Phase { get; set; } // "Einstieg", "Erarbeitung" ... + public List Methods { get; set; } = []; + public List Materials { get; set; } = []; + public string? Homework { get; set; } + public string? Reflection { get; set; } + public LessonStatus Status { get; set; } = LessonStatus.Planned; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public enum UnitStatus { Planned, Active, Completed } +public enum LessonStatus { Planned, Conducted } diff --git a/LehrerApp.Core/Models/ReadableSnapshot.cs b/LehrerApp.Core/Models/ReadableSnapshot.cs new file mode 100644 index 0000000..2b5ba9e --- /dev/null +++ b/LehrerApp.Core/Models/ReadableSnapshot.cs @@ -0,0 +1,52 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Models; + +/// +/// Lesbarer Snapshot der lokalen Datenbank für die WebApp. +/// Wird periodisch vom Desktop exportiert und auf dem Server hinterlegt. +/// +/// Bewusst NICHT enthalten: +/// - Documentation (vertraulich, bleibt lokal) +/// - Vollständige Notizen (nur Noten-Übersichten) +/// - Sync-interne Daten +/// +public class ReadableSnapshot +{ + public DateTime ExportedAt { get; set; } = DateTime.UtcNow; + public string SchoolYear { get; set; } = ""; + + public List Students { get; set; } = []; + public List Groups { get; set; } = []; + public List Enrollments { get; set; } = []; + + // Klausuren mit Aufgaben aber ohne Ergebnisse + public List Exams { get; set; } = []; + + // Ergebnisse separat – kann bei Bedarf weggelassen werden + public List ExamResults { get; set; } = []; + + // Sonstige Noten + public List Grades { get; set; } = []; + + // Unterrichtsplanung + public List Units { get; set; } = []; + public List Lessons { get; set; } = []; + + // Aufgaben (kein Zeiterfassungs-Detail) + public List Tasks { get; set; } = []; + + /// + /// Metadaten für die WebApp – wie alt ist der Snapshot? + /// + public SnapshotMeta Meta { get; set; } = new(); +} + +public class SnapshotMeta +{ + public int StudentCount { get; set; } + public int GroupCount { get; set; } + public int ExamCount { get; set; } + public DateTime OldestData { get; set; } + public string ExportedByDevice { get; set; } = ""; +} diff --git a/LehrerApp.Core/Models/Student.cs b/LehrerApp.Core/Models/Student.cs new file mode 100644 index 0000000..e454e39 --- /dev/null +++ b/LehrerApp.Core/Models/Student.cs @@ -0,0 +1,26 @@ +namespace LehrerApp.Core.Models; + +public class Student +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string FullName => $"{LastName}, {FirstName}"; + public DateOnly? DateOfBirth { get; set; } + public Gender? Gender { get; set; } + public List Contacts { get; set; } = []; + public string? Notes { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class Contact +{ + public string Name { get; set; } = ""; + public string Relation { get; set; } = ""; // "Mutter", "Vater", "Vormund" + public string? Phone { get; set; } + public string? Email { get; set; } +} + +public enum Gender { M, W, D } diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs new file mode 100644 index 0000000..6e21845 --- /dev/null +++ b/LehrerApp.Core/Models/Workload.cs @@ -0,0 +1,70 @@ +namespace LehrerApp.Core.Models; + +// ── Schülerdokumentation ────────────────────────────────────────────────────── + +public class Documentation +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid? GroupId { get; set; } // optional – nicht immer kursbezogen + public DocumentationType Type { get; set; } + public DateOnly Date { get; set; } + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public List Participants { get; set; } = []; // für Gesprächsnotizen + public AbsenceData? AbsenceData { get; set; } + public SupportData? SupportData { get; set; } + public bool IsConfidential { get; set; } // DSGVO: besonders schützenswert + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class AbsenceData +{ + public int LessonCount { get; set; } + public bool Excused { get; set; } + public string? Reason { get; set; } +} + +public class SupportData +{ + public List Measures { get; set; } = []; + public DateOnly? ReviewDate { get; set; } + public SupportStatus Status { get; set; } = SupportStatus.Active; +} + +public enum DocumentationType { Conversation, Incident, SupportPlan, Absence } +public enum SupportStatus { Active, Completed, Paused } + +// ── Arbeitszeit & Aufgaben ──────────────────────────────────────────────────── + +public class WorkTask +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = ""; + public TaskCategory Category { get; set; } + public Guid? GroupId { get; set; } + public DateOnly? DueDate { get; set; } + public int? EstimatedMinutes { get; set; } + public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class TimeEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? TaskId { get; set; } + public string Category { get; set; } = ""; + public Guid? GroupId { get; set; } + public DateOnly Date { get; set; } + public TimeOnly? StartTime { get; set; } + public TimeOnly? EndTime { get; set; } + public int DurationMinutes { get; set; } + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other } +public enum WorkTaskStatus { Open, InProgress, Done } diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs new file mode 100644 index 0000000..b1a238b --- /dev/null +++ b/LehrerApp.Core/Services/GradingService.cs @@ -0,0 +1,90 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public class GradingService +{ + /// + /// Berechnet die Note aus Punkten anhand des Notenschlüssels der Klausur. + /// + public string CalculateGrade(double achieved, double maximum, List key) + { + if (maximum <= 0) return "-"; + var percent = achieved / maximum * 100.0; + + return key + .OrderByDescending(k => k.MinPercent) + .FirstOrDefault(k => percent >= k.MinPercent) + ?.Grade ?? key.OrderBy(k => k.MinPercent).First().Grade; + } + + /// + /// Standardnotenschlüssel 1–6 nach NRW-Schema (anpassbar). + /// + public static List DefaultKey1To6() => + [ + new() { Grade = "1", MinPercent = 87.5 }, + new() { Grade = "2", MinPercent = 75.0 }, + new() { Grade = "3", MinPercent = 62.5 }, + new() { Grade = "4", MinPercent = 50.0 }, + new() { Grade = "5", MinPercent = 25.0 }, + new() { Grade = "6", MinPercent = 0.0 }, + ]; + + /// + /// Standardnotenschlüssel 0–15 Punkte (Oberstufe). + /// + public static List DefaultKey0To15() => + [ + new() { Grade = "15", MinPercent = 95.0 }, + new() { Grade = "14", MinPercent = 90.0 }, + new() { Grade = "13", MinPercent = 85.0 }, + new() { Grade = "12", MinPercent = 80.0 }, + new() { Grade = "11", MinPercent = 75.0 }, + new() { Grade = "10", MinPercent = 70.0 }, + new() { Grade = "9", MinPercent = 65.0 }, + new() { Grade = "8", MinPercent = 60.0 }, + new() { Grade = "7", MinPercent = 55.0 }, + new() { Grade = "6", MinPercent = 50.0 }, + new() { Grade = "5", MinPercent = 45.0 }, + new() { Grade = "4", MinPercent = 40.0 }, + new() { Grade = "3", MinPercent = 33.0 }, + new() { Grade = "2", MinPercent = 27.0 }, + new() { Grade = "1", MinPercent = 20.0 }, + new() { Grade = "0", MinPercent = 0.0 }, + ]; + + /// + /// Berechnet den Notendurchschnitt aus einer Liste von Notenwerten (1-6 System). + /// + public double AverageGrade1To6(List grades) + { + var numeric = grades + .Select(g => int.TryParse(g, out var n) ? (int?)n : null) + .Where(n => n.HasValue) + .Select(n => n!.Value) + .ToList(); + + return numeric.Count == 0 ? 0 : numeric.Average(); + } + + /// + /// Berechnet den Notendurchschnitt aus gewichteten Noten. + /// + public double WeightedAverage(List<(string Grade, double Weight)> grades) + { + var numeric = grades + .Select(g => ( + Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, + g.Weight)) + .Where(g => g.Value.HasValue) + .ToList(); + + if (numeric.Count == 0) return 0; + + var totalWeight = numeric.Sum(g => g.Weight); + var weightedSum = numeric.Sum(g => g.Value!.Value * g.Weight); + + return totalWeight == 0 ? 0 : weightedSum / totalWeight; + } +} diff --git a/LehrerApp.Core/Services/SchoolYearService.cs b/LehrerApp.Core/Services/SchoolYearService.cs new file mode 100644 index 0000000..e22012e --- /dev/null +++ b/LehrerApp.Core/Services/SchoolYearService.cs @@ -0,0 +1,48 @@ +namespace LehrerApp.Core.Services; + +public class SchoolYearService +{ + /// + /// Gibt das aktuelle Schuljahr zurück, z.B. "2024/25". + /// Schuljahr beginnt am 1. August. + /// + public string CurrentSchoolYear() + { + var now = DateTime.Today; + var startYear = now.Month >= 8 ? now.Year : now.Year - 1; + return FormatSchoolYear(startYear); + } + + public string FormatSchoolYear(int startYear) => + $"{startYear}/{(startYear + 1) % 100:D2}"; + + /// + /// Gibt den Beginn des Schuljahres zurück (1. August). + /// + public DateOnly SchoolYearStart(string schoolYear) + { + var year = int.Parse(schoolYear.Split('/')[0]); + return new DateOnly(year, 8, 1); + } + + /// + /// Gibt das Ende des Schuljahres zurück (31. Juli des Folgejahres). + /// + public DateOnly SchoolYearEnd(string schoolYear) + { + var year = int.Parse(schoolYear.Split('/')[0]) + 1; + return new DateOnly(year, 7, 31); + } + + /// + /// Gibt die letzten N Schuljahre zurück (für Dropdowns). + /// + public List RecentSchoolYears(int count = 5) + { + var now = DateTime.Today; + var currentStart = now.Month >= 8 ? now.Year : now.Year - 1; + return Enumerable.Range(0, count) + .Select(i => FormatSchoolYear(currentStart - i)) + .ToList(); + } +} diff --git a/LehrerApp.Data/LehrerApp.Data.csproj b/LehrerApp.Data/LehrerApp.Data.csproj new file mode 100644 index 0000000..c1ac396 --- /dev/null +++ b/LehrerApp.Data/LehrerApp.Data.csproj @@ -0,0 +1,15 @@ + + + net9.0 + enable + enable + latest + + + + + + + + + diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs new file mode 100644 index 0000000..5f5d344 --- /dev/null +++ b/LehrerApp.Data/LiteDbContext.cs @@ -0,0 +1,117 @@ +using LiteDB; +using LehrerApp.Core.Models; + +namespace LehrerApp.Data; + +/// +/// Zentrale Datenbankverbindung. Einmalig als Singleton registrieren. +/// Eine Instanz = eine .db Datei = ein Nutzer. +/// +public class LiteDbContext : IDisposable +{ + private readonly LiteDatabase _db; + + public LiteDbContext(string databasePath) + { + // WAL-Modus für bessere Performance bei Concurrent Reads + var connectionString = new ConnectionString(databasePath) + { + Connection = ConnectionType.Shared, + }; + + _db = new LiteDatabase(connectionString); + EnsureIndexes(); + } + + // ── Collections ────────────────────────────────────────────────────────── + + public ILiteCollection Students => + _db.GetCollection("students"); + + public ILiteCollection Groups => + _db.GetCollection("groups"); + + public ILiteCollection Enrollments => + _db.GetCollection("enrollments"); + + public ILiteCollection Exams => + _db.GetCollection("exams"); + + public ILiteCollection ExamResults => + _db.GetCollection("exam_results"); + + public ILiteCollection Grades => + _db.GetCollection("grades"); + + public ILiteCollection Units => + _db.GetCollection("units"); + + public ILiteCollection Lessons => + _db.GetCollection("lessons"); + + public ILiteCollection Documentation => + _db.GetCollection("documentation"); + + public ILiteCollection Tasks => + _db.GetCollection("tasks"); + + public ILiteCollection TimeEntries => + _db.GetCollection("time_entries"); + + // ── Transaktionen ───────────────────────────────────────────────────────── + + public T Transaction(Func action) => _db.BeginTrans() ? action() : action(); + + public void Checkpoint() => _db.Checkpoint(); + + // ── Backup ─────────────────────────────────────────────────────────────── + + public void BackupTo(string targetPath) + { + _db.Checkpoint(); + File.Copy(_db.UserVersion.ToString(), targetPath, overwrite: true); + } + + // ── Indizes ────────────────────────────────────────────────────────────── + + private void EnsureIndexes() + { + Students.EnsureIndex(x => x.LastName); + Students.EnsureIndex(x => x.IsActive); + + Groups.EnsureIndex(x => x.SchoolYear); + Groups.EnsureIndex(x => x.Type); + + Enrollments.EnsureIndex(x => x.StudentId); + Enrollments.EnsureIndex(x => x.GroupId); + Enrollments.EnsureIndex(x => x.SchoolYear); + + Exams.EnsureIndex(x => x.GroupId); + Exams.EnsureIndex(x => x.Date); + Exams.EnsureIndex(x => x.Status); + + ExamResults.EnsureIndex(x => x.ExamId); + ExamResults.EnsureIndex(x => x.StudentId); + + Grades.EnsureIndex(x => x.StudentId); + Grades.EnsureIndex(x => x.GroupId); + + Units.EnsureIndex(x => x.GroupId); + Units.EnsureIndex(x => x.Status); + + Lessons.EnsureIndex(x => x.UnitId); + Lessons.EnsureIndex(x => x.GroupId); + Lessons.EnsureIndex(x => x.Date); + + Documentation.EnsureIndex(x => x.StudentId); + Documentation.EnsureIndex(x => x.Type); + + Tasks.EnsureIndex(x => x.Status); + Tasks.EnsureIndex(x => x.DueDate); + + TimeEntries.EnsureIndex(x => x.Date); + TimeEntries.EnsureIndex(x => x.TaskId); + } + + public void Dispose() => _db.Dispose(); +} diff --git a/LehrerApp.Data/Repositories/GroupExamRepositories.cs b/LehrerApp.Data/Repositories/GroupExamRepositories.cs new file mode 100644 index 0000000..7667558 --- /dev/null +++ b/LehrerApp.Data/Repositories/GroupExamRepositories.cs @@ -0,0 +1,95 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Data.Repositories; + +public class GroupRepository(LiteDbContext db) : IGroupRepository +{ + public LearningGroup? GetById(Guid id) => + db.Groups.FindById(id); + + public List GetAll() => + db.Groups.FindAll() + .OrderBy(g => g.SchoolYear) + .ThenBy(g => g.Name) + .ToList(); + + public List GetBySchoolYear(string schoolYear) => + db.Groups.Find(g => g.SchoolYear == schoolYear) + .OrderBy(g => g.Name) + .ToList(); + + public void Save(LearningGroup group) + { + group.UpdatedAt = DateTime.UtcNow; + db.Groups.Upsert(group); + } + + public void Delete(Guid id) => + db.Groups.Delete(id); +} + +public class EnrollmentRepository(LiteDbContext db) : IEnrollmentRepository +{ + public List GetByStudent(Guid studentId) => + db.Enrollments.Find(e => e.StudentId == studentId).ToList(); + + public List GetByGroup(Guid groupId) => + db.Enrollments.Find(e => e.GroupId == groupId).ToList(); + + public List GetByGroupAndYear(Guid groupId, string schoolYear) => + db.Enrollments + .Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear) + .ToList(); + + public void Save(Enrollment enrollment) => + db.Enrollments.Upsert(enrollment); + + public void Delete(Guid id) => + db.Enrollments.Delete(id); +} + +public class ExamRepository(LiteDbContext db) : IExamRepository +{ + public Exam? GetById(Guid id) => + db.Exams.FindById(id); + + public List GetByGroup(Guid groupId) => + db.Exams.Find(e => e.GroupId == groupId) + .OrderByDescending(e => e.Date) + .ToList(); + + public void Save(Exam exam) + { + exam.UpdatedAt = DateTime.UtcNow; + db.Exams.Upsert(exam); + } + + public void Delete(Guid id) => + db.Exams.Delete(id); +} + +public class ExamResultRepository(LiteDbContext db) : IExamResultRepository +{ + public List GetByExam(Guid examId) => + db.ExamResults.Find(r => r.ExamId == examId).ToList(); + + public List GetByStudent(Guid studentId) => + db.ExamResults.Find(r => r.StudentId == studentId).ToList(); + + public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) => + db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId); + + public void Save(ExamResult result) + { + result.UpdatedAt = DateTime.UtcNow; + db.ExamResults.Upsert(result); + } + + public void SaveMany(List results) + { + var now = DateTime.UtcNow; + foreach (var r in results) r.UpdatedAt = now; + db.ExamResults.Upsert(results); + } +} diff --git a/LehrerApp.Data/Repositories/OtherRepositories.cs b/LehrerApp.Data/Repositories/OtherRepositories.cs new file mode 100644 index 0000000..f37bfbe --- /dev/null +++ b/LehrerApp.Data/Repositories/OtherRepositories.cs @@ -0,0 +1,140 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Data.Repositories; + +public class GradeRepository(LiteDbContext db) : IGradeRepository +{ + public List GetByStudentAndGroup(Guid studentId, Guid groupId) => + db.Grades + .Find(g => g.StudentId == studentId && g.GroupId == groupId) + .OrderBy(g => g.Date) + .ToList(); + + public List GetByGroup(Guid groupId) => + db.Grades.Find(g => g.GroupId == groupId) + .OrderBy(g => g.Date) + .ToList(); + + public void Save(Grade grade) => + db.Grades.Upsert(grade); + + public void Delete(Guid id) => + db.Grades.Delete(id); +} + +public class UnitRepository(LiteDbContext db) : IUnitRepository +{ + public Unit? GetById(Guid id) => + db.Units.FindById(id); + + public List GetByGroup(Guid groupId) => + db.Units.Find(u => u.GroupId == groupId) + .OrderBy(u => u.StartDate) + .ToList(); + + public void Save(Unit unit) + { + unit.UpdatedAt = DateTime.UtcNow; + db.Units.Upsert(unit); + } + + public void Delete(Guid id) => + db.Units.Delete(id); +} + +public class LessonRepository(LiteDbContext db) : ILessonRepository +{ + public List GetByUnit(Guid unitId) => + db.Lessons.Find(l => l.UnitId == unitId) + .OrderBy(l => l.Date) + .ToList(); + + public List GetByGroupAndDate(Guid groupId, DateOnly date) => + db.Lessons.Find(l => l.GroupId == groupId && l.Date == date).ToList(); + + public List GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to) => + db.Lessons + .Find(l => l.GroupId == groupId && l.Date >= from && l.Date <= to) + .OrderBy(l => l.Date) + .ToList(); + + public void Save(Lesson lesson) + { + lesson.UpdatedAt = DateTime.UtcNow; + db.Lessons.Upsert(lesson); + } + + public void Delete(Guid id) => + db.Lessons.Delete(id); +} + +public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository +{ + public List GetByStudent(Guid studentId) => + db.Documentation.Find(d => d.StudentId == studentId) + .OrderByDescending(d => d.Date) + .ToList(); + + public List GetByStudentAndType(Guid studentId, DocumentationType type) => + db.Documentation + .Find(d => d.StudentId == studentId && d.Type == type) + .OrderByDescending(d => d.Date) + .ToList(); + + public void Save(Documentation doc) + { + doc.UpdatedAt = DateTime.UtcNow; + db.Documentation.Upsert(doc); + } + + public void Delete(Guid id) => + db.Documentation.Delete(id); +} + +public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository +{ + public List GetByStatus(WorkTaskStatus status) => + db.Tasks.Find(t => t.Status == status) + .OrderBy(t => t.DueDate) + .ToList(); + + public List GetAll() => + db.Tasks.FindAll() + .OrderBy(t => t.Status) + .ThenBy(t => t.DueDate) + .ToList(); + + public void Save(WorkTask task) + { + task.UpdatedAt = DateTime.UtcNow; + db.Tasks.Upsert(task); + } + + public void Delete(Guid id) => + db.Tasks.Delete(id); +} + +public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository +{ + public List GetByDate(DateOnly date) => + db.TimeEntries.Find(e => e.Date == date) + .OrderBy(e => e.StartTime) + .ToList(); + + public List GetByDateRange(DateOnly from, DateOnly to) => + db.TimeEntries + .Find(e => e.Date >= from && e.Date <= to) + .OrderBy(e => e.Date) + .ThenBy(e => e.StartTime) + .ToList(); + + public List GetByTask(Guid taskId) => + db.TimeEntries.Find(e => e.TaskId == taskId).ToList(); + + public void Save(TimeEntry entry) => + db.TimeEntries.Upsert(entry); + + public void Delete(Guid id) => + db.TimeEntries.Delete(id); +} diff --git a/LehrerApp.Data/Repositories/StudentRepository.cs b/LehrerApp.Data/Repositories/StudentRepository.cs new file mode 100644 index 0000000..6560824 --- /dev/null +++ b/LehrerApp.Data/Repositories/StudentRepository.cs @@ -0,0 +1,38 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Data.Repositories; + +public class StudentRepository(LiteDbContext db) : IStudentRepository +{ + public Student? GetById(Guid id) => + db.Students.FindById(id); + + public List GetAll(bool includeInactive = false) => + includeInactive + ? db.Students.FindAll().OrderBy(s => s.LastName).ToList() + : db.Students.Find(s => s.IsActive).OrderBy(s => s.LastName).ToList(); + + public List GetByGroup(Guid groupId, string schoolYear) + { + // Enrollment als Bindeglied – Schüler-IDs der Gruppe ermitteln + var studentIds = db.Enrollments + .Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear) + .Select(e => e.StudentId) + .ToHashSet(); + + return db.Students + .Find(s => studentIds.Contains(s.Id)) + .OrderBy(s => s.LastName) + .ToList(); + } + + public void Save(Student student) + { + student.UpdatedAt = DateTime.UtcNow; + db.Students.Upsert(student); + } + + public void Delete(Guid id) => + db.Students.Delete(id); +} diff --git a/LehrerApp.Desktop/App.axaml b/LehrerApp.Desktop/App.axaml new file mode 100644 index 0000000..75f2d13 --- /dev/null +++ b/LehrerApp.Desktop/App.axaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs new file mode 100644 index 0000000..bd09008 --- /dev/null +++ b/LehrerApp.Desktop/App.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using LehrerApp.Desktop.ViewModels; +using LehrerApp.Desktop.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop; + +public class App : Application +{ + public static IServiceProvider Services { get; private set; } = null!; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + Services = AppBootstrapper.BuildServices(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = Services.GetRequiredService(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs new file mode 100644 index 0000000..23c66a1 --- /dev/null +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -0,0 +1,181 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Services; +using LehrerApp.Data; +using LehrerApp.Data.Repositories; +using LehrerApp.Desktop.ViewModels; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Sync; +using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop; + +public static class AppBootstrapper +{ + public static IServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + // ── Pfade ───────────────────────────────────────────────────────────── + var appData = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "LehrerApp"); + Directory.CreateDirectory(appData); + + var dbPath = Path.Combine(appData, "lehrerapp.db"); + var queuePath = Path.Combine(appData, "syncqueue.db"); + var keyPath = Path.Combine(appData, "sync.key"); + + // ── Datenbank ───────────────────────────────────────────────────────── + services.AddSingleton(_ => new LiteDbContext(dbPath)); + + // ── Repositories ────────────────────────────────────────────────────── + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ── Core Services ───────────────────────────────────────────────────── + services.AddSingleton(); + services.AddSingleton(); + + // ── Sync ────────────────────────────────────────────────────────────── + services.AddSingleton(_ => new EventQueue(queuePath)); + services.AddSingleton(sp => + new ConflictResolver(sp.GetRequiredService())); + + // Schlüssel laden oder neu generieren + services.AddSingleton(_ => + { + var key = SyncCrypto.LoadKey(keyPath) ?? SyncCrypto.GenerateKey(); + SyncCrypto.SaveKey(key, keyPath); + return key; + }); + + // EventApplier – wendet gezogene Events auf LiteDB an + services.AddSingleton(sp => new EventApplier( + key: sp.GetRequiredService(), + students: sp.GetRequiredService(), + groups: sp.GetRequiredService(), + enrollments: sp.GetRequiredService(), + exams: sp.GetRequiredService(), + examResults: sp.GetRequiredService(), + grades: sp.GetRequiredService(), + units: sp.GetRequiredService(), + lessons: sp.GetRequiredService(), + docs: sp.GetRequiredService(), + tasks: sp.GetRequiredService(), + timeEntries: sp.GetRequiredService())); + + // SyncEngine + ReadableSnapshotService nur wenn Server konfiguriert + var serverUrl = LoadServerUrl(appData); + var deviceId = LoadOrCreateDeviceId(appData); + + services.AddSingleton(sp => + { + if (string.IsNullOrEmpty(serverUrl)) return null; + + var http = BuildHttpClient(serverUrl, appData); + var config = new SyncConfig + { + ServerUrl = serverUrl, + DeviceId = deviceId, + DeviceType = DeviceType.Desktop, + AutoSyncIntervalMinutes = 5, + }; + return new SyncEngine( + sp.GetRequiredService(), + sp.GetRequiredService(), + http, + config); + }); + + services.AddSingleton(sp => + { + if (string.IsNullOrEmpty(serverUrl)) return null; + + return new ReadableSnapshotService( + http: BuildHttpClient(serverUrl, appData), + students: sp.GetRequiredService(), + groups: sp.GetRequiredService(), + enrollments: sp.GetRequiredService(), + exams: sp.GetRequiredService(), + examResults: sp.GetRequiredService(), + grades: sp.GetRequiredService(), + units: sp.GetRequiredService(), + lessons: sp.GetRequiredService(), + tasks: sp.GetRequiredService(), + schoolYear: sp.GetRequiredService(), + deviceId: deviceId); + }); + + services.AddSingleton(sp => + { + if (string.IsNullOrEmpty(serverUrl)) return null; + + return new SnapshotService( + http: BuildHttpClient(serverUrl, appData), + db: sp.GetRequiredService(), + syncKey: sp.GetRequiredService(), + deviceType: DeviceType.Desktop, + dbPath: dbPath, + keyPath: keyPath); + }); + + // ── ViewModels ──────────────────────────────────────────────────────── + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); + services.AddTransient(sp => + new DevicePairingViewModel( + sp.GetRequiredService() + ?? throw new InvalidOperationException( + "Kein Server konfiguriert."))); + + return services.BuildServiceProvider(); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static HttpClient BuildHttpClient(string serverUrl, string appData) + { + var http = new HttpClient { BaseAddress = new Uri(serverUrl) }; + var tokenPath = Path.Combine(appData, "auth.token"); + if (File.Exists(tokenPath)) + { + var token = File.ReadAllText(tokenPath).Trim(); + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue( + "Bearer", token); + } + return http; + } + + private static string LoadServerUrl(string appData) + { + var path = Path.Combine(appData, "server.txt"); + return File.Exists(path) ? File.ReadAllText(path).Trim() : ""; + } + + private static string LoadOrCreateDeviceId(string appData) + { + var path = Path.Combine(appData, "device.id"); + if (File.Exists(path)) return File.ReadAllText(path).Trim(); + var id = Guid.NewGuid().ToString(); + File.WriteAllText(path, id); + return id; + } +} diff --git a/LehrerApp.Desktop/LehrerApp.Desktop.csproj b/LehrerApp.Desktop/LehrerApp.Desktop.csproj new file mode 100644 index 0000000..954e177 --- /dev/null +++ b/LehrerApp.Desktop/LehrerApp.Desktop.csproj @@ -0,0 +1,32 @@ + + + WinExe + net9.0 + enable + enable + latest + true + app.manifest + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Program.cs b/LehrerApp.Desktop/Program.cs new file mode 100644 index 0000000..acd39a9 --- /dev/null +++ b/LehrerApp.Desktop/Program.cs @@ -0,0 +1,16 @@ +using Avalonia; + +namespace LehrerApp.Desktop; + +class Program +{ + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..b33f2e3 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -0,0 +1,136 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels; + +public partial class DashboardViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly ILessonRepository _lessons; + private readonly IWorkTaskRepository _tasks; + private readonly SchoolYearService _schoolYearService; + + [ObservableProperty] private string _greeting = ""; + [ObservableProperty] private string _currentDate = ""; + [ObservableProperty] private string _currentSchoolYear = ""; + + public ObservableCollection TodaysLessons { get; } = []; + public ObservableCollection OpenTasks { get; } = []; + public ObservableCollection CurrentGroups { get; } = []; + + public DashboardViewModel( + IGroupRepository groups, + ILessonRepository lessons, + IWorkTaskRepository tasks, + SchoolYearService schoolYearService) + { + _groups = groups; + _lessons = lessons; + _tasks = tasks; + _schoolYearService = schoolYearService; + + Load(); + } + + private void Load() + { + var now = DateTime.Now; + var today = DateOnly.FromDateTime(now); + + CurrentDate = now.ToString("dddd, d. MMMM yyyy", + new System.Globalization.CultureInfo("de-DE")); + CurrentSchoolYear = _schoolYearService.CurrentSchoolYear(); + Greeting = now.Hour < 12 ? "Guten Morgen" : + now.Hour < 18 ? "Guten Tag" : "Guten Abend"; + + // Heutige Stunden + TodaysLessons.Clear(); + var schoolYear = _schoolYearService.CurrentSchoolYear(); + var allGroups = _groups.GetBySchoolYear(schoolYear) + .ToDictionary(g => g.Id); + + var todayLessons = allGroups.Keys + .SelectMany(gid => _lessons.GetByGroupAndDate(gid, today)) + .OrderBy(l => l.LessonNumber) + .ToList(); + + foreach (var lesson in todayLessons) + { + if (!allGroups.TryGetValue(lesson.GroupId, out var group)) continue; + TodaysLessons.Add(new LessonItem + { + GroupName = group.Name, + Topic = lesson.Topic, + Status = lesson.Status, + }); + } + + // Offene Aufgaben (mit Fälligkeit) + OpenTasks.Clear(); + var openTasks = _tasks.GetByStatus(WorkTaskStatus.Open) + .Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress)) + .OrderBy(t => t.DueDate ?? DateOnly.MaxValue) + .Take(5); + + foreach (var task in openTasks) + { + var isOverdue = task.DueDate.HasValue && task.DueDate < today; + var isDueSoon = task.DueDate.HasValue && + task.DueDate >= today && + task.DueDate <= today.AddDays(3); + + OpenTasks.Add(new TaskItem + { + Title = task.Title, + DueDate = task.DueDate?.ToString("dd.MM.") ?? "", + IsOverdue = isOverdue, + IsDueSoon = isDueSoon, + Status = task.Status, + }); + } + + // Aktuelle Lerngruppen als Chips + CurrentGroups.Clear(); + foreach (var group in _groups.GetBySchoolYear(schoolYear) + .OrderBy(g => g.Name)) + { + CurrentGroups.Add(new GroupChip + { + GroupId = group.Id, + Name = group.Name, + Subject = group.Subject ?? "", + }); + } + } + + [RelayCommand] + private void Refresh() => Load(); +} + +// Anzeigemodelle für Dashboard-Widgets +public class LessonItem +{ + public string GroupName { get; set; } = ""; + public string Topic { get; set; } = ""; + public LessonStatus Status { get; set; } +} + +public class TaskItem +{ + public string Title { get; set; } = ""; + public string DueDate { get; set; } = ""; + public bool IsOverdue { get; set; } + public bool IsDueSoon { get; set; } + public WorkTaskStatus Status { get; set; } +} + +public class GroupChip +{ + public Guid GroupId { get; set; } + public string Name { get; set; } = ""; + public string Subject { get; set; } = ""; +} diff --git a/LehrerApp.Desktop/ViewModels/DevicePairingViewModel.cs b/LehrerApp.Desktop/ViewModels/DevicePairingViewModel.cs new file mode 100644 index 0000000..95a2bba --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/DevicePairingViewModel.cs @@ -0,0 +1,158 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Data; +using LehrerApp.Sync; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Desktop.ViewModels; + +/// +/// Steuert den Dialog zum Einrichten eines neuen Geräts. +/// +/// Zwei Modi: +/// Sender → "Neues Gerät hinzufügen" – erzeugt Snapshot und zeigt Code +/// Empfänger → "Mit bestehendem Account verbinden" – Code eingeben, Restore +/// +public partial class DevicePairingViewModel : ObservableObject +{ + private readonly SnapshotService _snapshotService; + + // ── Gemeinsamer State ───────────────────────────────────────────────────── + + [ObservableProperty] private PairingMode _mode = PairingMode.SelectMode; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private int _progressPercent; + [ObservableProperty] private bool _isError; + [ObservableProperty] private bool _isComplete; + + // ── Sender-State ───────────────────────────────────────────────────────── + + [ObservableProperty] private string _generatedCode = ""; + [ObservableProperty] private DateTime _codeExpiresAt; + [ObservableProperty] private string _codeExpiresText = ""; + + // ── Empfänger-State ─────────────────────────────────────────────────────── + + [ObservableProperty] private string _inputCode = ""; + + public DevicePairingViewModel(SnapshotService snapshotService) + { + _snapshotService = snapshotService; + _snapshotService.ProgressChanged += OnProgress; + } + + // ── Navigation zwischen Modi ────────────────────────────────────────────── + + [RelayCommand] + private void SelectSender() => Mode = PairingMode.Sender; + + [RelayCommand] + private void SelectReceiver() => Mode = PairingMode.Receiver; + + [RelayCommand] + private void Back() + { + Mode = PairingMode.SelectMode; + Reset(); + } + + // ── Sender: Snapshot erstellen ──────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(CanCreateSnapshot))] + private async Task CreateSnapshotAsync() + { + IsBusy = true; + IsError = false; + IsComplete = false; + + try + { + var result = await _snapshotService.CreateAndUploadAsync(); + + GeneratedCode = result.Code; + CodeExpiresAt = result.ExpiresAt; + CodeExpiresText = $"Gültig bis {result.ExpiresAt:HH:mm} Uhr " + + $"({result.ExpiresAt:dd.MM.yyyy})"; + Mode = PairingMode.SenderShowCode; + } + catch (Exception ex) + { + IsError = true; + StatusMessage = $"Fehler: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private bool CanCreateSnapshot() => !IsBusy; + + // ── Empfänger: Snapshot wiederherstellen ────────────────────────────────── + + [RelayCommand(CanExecute = nameof(CanRestore))] + private async Task RestoreAsync(string targetDbPath) + { + IsBusy = true; + IsError = false; + IsComplete = false; + + try + { + // Schlüssel wird aus dem Code extrahiert und lokal gespeichert + // → App-Neustart lädt ihn automatisch + await _snapshotService.RestoreFromCodeAsync( + InputCode.Trim(), targetDbPath); + + IsComplete = true; + StatusMessage = "Erfolgreich! Bitte die App neu starten."; + Mode = PairingMode.ReceiverDone; + } + catch (SnapshotNotFoundException ex) + { + IsError = true; + StatusMessage = ex.Message; + } + catch (Exception ex) + { + IsError = true; + StatusMessage = $"Fehler beim Wiederherstellen: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private bool CanRestore() => + !IsBusy && InputCode.Trim().Length >= 5; + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private void OnProgress(SnapshotProgress progress) + { + StatusMessage = progress.Message; + ProgressPercent = progress.PercentComplete; + } + + private void Reset() + { + StatusMessage = ""; + ProgressPercent = 0; + IsError = false; + IsComplete = false; + GeneratedCode = ""; + InputCode = ""; + IsBusy = false; + } +} + +public enum PairingMode +{ + SelectMode, // Startbildschirm: Sender oder Empfänger wählen + Sender, // Sender bereit, Snapshot erstellen + SenderShowCode, // Code wird angezeigt + Receiver, // Code-Eingabe + ReceiverDone, // Erfolgreich wiederhergestellt +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/AddGroupDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/AddGroupDialogViewModel.cs new file mode 100644 index 0000000..356e17c --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/AddGroupDialogViewModel.cs @@ -0,0 +1,90 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class AddGroupDialogViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly SchoolYearService _schoolYearService; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _subject = ""; + [ObservableProperty] private GroupType _type = GroupType.Course; + [ObservableProperty] private int _gradeLevel = 10; + [ObservableProperty] private GradingSystem _gradingSystem = GradingSystem.Grades1To6; + [ObservableProperty] private int? _hoursPerWeek; + [ObservableProperty] private string _selectedSchoolYear = ""; + [ObservableProperty] private string _validationMessage = ""; + + public List SchoolYears { get; } + public List GroupTypes { get; } = + [ + new(GroupType.Course, "Fachkurs"), + new(GroupType.Class, "Klassenverband"), + ]; + public List GradingSystems { get; } = + [ + new(GradingSystem.Grades1To6, "Noten 1–6 (Sek I)"), + new(GradingSystem.Points0To15, "Punkte 0–15 (Oberstufe)"), + ]; + + // Wird vom Dialog aufgerufen wenn OK geklickt wurde + public LearningGroup? Result { get; private set; } + + public AddGroupDialogViewModel( + IGroupRepository groups, + SchoolYearService schoolYearService) + { + _groups = groups; + _schoolYearService = schoolYearService; + SchoolYears = schoolYearService.RecentSchoolYears(3); + SelectedSchoolYear = schoolYearService.CurrentSchoolYear(); + + // Automatisch Notensystem vorschlagen wenn Klassenstufe sich ändert + PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(GradeLevel)) + GradingSystem = GradeLevel >= 11 + ? GradingSystem.Points0To15 + : GradingSystem.Grades1To6; + }; + } + + [RelayCommand] + private bool Save() + { + // Validierung + if (string.IsNullOrWhiteSpace(Name)) + { + ValidationMessage = "Bitte einen Namen eingeben."; + return false; + } + if (GradeLevel is < 1 or > 13) + { + ValidationMessage = "Klassenstufe muss zwischen 1 und 13 liegen."; + return false; + } + + Result = new LearningGroup + { + Name = Name.Trim(), + Subject = string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim(), + Type = Type, + GradeLevel = GradeLevel, + GradingSystem = GradingSystem, + SchoolYear = SelectedSchoolYear, + HoursPerWeek = HoursPerWeek, + }; + + _groups.Save(Result); + ValidationMessage = ""; + return true; + } +} + +public record GroupTypeItem(GroupType Value, string Label); +public record GradingSystemItem(GradingSystem Value, string Label); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs new file mode 100644 index 0000000..c8f445e --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -0,0 +1,208 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Gruppenliste ────────────────────────────────────────────────────────────── + +public partial class GroupListViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly SchoolYearService _schoolYearService; + + [ObservableProperty] private string _selectedSchoolYear = ""; + [ObservableProperty] private string _searchText = ""; + [ObservableProperty] private GroupListItem? _selectedGroup; + + public ObservableCollection SchoolYears { get; } = []; + public ObservableCollection Groups { get; } = []; + + public GroupListViewModel( + IGroupRepository groups, + SchoolYearService schoolYearService) + { + _groups = groups; + _schoolYearService = schoolYearService; + + foreach (var y in schoolYearService.RecentSchoolYears()) + SchoolYears.Add(y); + + SelectedSchoolYear = schoolYearService.CurrentSchoolYear(); + LoadGroups(); + } + + partial void OnSelectedSchoolYearChanged(string value) => LoadGroups(); + partial void OnSearchTextChanged(string value) => LoadGroups(); + + private void LoadGroups() + { + Groups.Clear(); + var all = _groups.GetBySchoolYear(SelectedSchoolYear); + + var filtered = string.IsNullOrWhiteSpace(SearchText) + ? all + : all.Where(g => + g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || + (g.Subject?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false)); + + foreach (var g in filtered) + Groups.Add(new GroupListItem(g)); + } + + [RelayCommand] + private void AddGroup() + { + // TODO: Dialog öffnen + } + + [RelayCommand] + private void Refresh() => LoadGroups(); +} + +public class GroupListItem +{ + public Guid Id { get; } + public string Name { get; } + public string Subject { get; } + public string SchoolYear { get; } + public int GradeLevel { get; } + public string TypeLabel { get; } + public string GradingLabel { get; } + + public GroupListItem(LearningGroup g) + { + Id = g.Id; + Name = g.Name; + Subject = g.Subject ?? ""; + SchoolYear = g.SchoolYear; + GradeLevel = g.GradeLevel; + TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs"; + GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 + ? "Noten 1–6" + : "Punkte 0–15"; + } +} + +// ── Gruppendetail (Tab-Navigation) ──────────────────────────────────────────── + +public partial class GroupDetailViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly IStudentRepository _students; + private readonly IEnrollmentRepository _enrollments; + private readonly IExamRepository _exams; + private readonly SchoolYearService _schoolYearService; + + [ObservableProperty] private LearningGroup? _group; + [ObservableProperty] private string _groupTitle = ""; + [ObservableProperty] private int _studentCount; + [ObservableProperty] private GroupTab _activeTab = GroupTab.Overview; + + // Sub-ViewModels für Tabs – lazy geladen + public ObservableCollection Students { get; } = []; + public ObservableCollection Exams { get; } = []; + + public GroupDetailViewModel( + IGroupRepository groups, + IStudentRepository students, + IEnrollmentRepository enrollments, + IExamRepository exams, + SchoolYearService schoolYearService) + { + _groups = groups; + _students = students; + _enrollments = enrollments; + _exams = exams; + _schoolYearService = schoolYearService; + } + + public void LoadGroup(Guid groupId) + { + Group = _groups.GetById(groupId); + if (Group is null) return; + + GroupTitle = $"{Group.Name} · {Group.SchoolYear}"; + LoadStudents(); + LoadExams(); + } + + [RelayCommand] + private void SwitchTab(GroupTab tab) + { + ActiveTab = tab; + } + + private void LoadStudents() + { + if (Group is null) return; + Students.Clear(); + + var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear); + StudentCount = enrolled.Count; + + foreach (var s in enrolled) + Students.Add(new StudentSummary(s)); + } + + private void LoadExams() + { + if (Group is null) return; + Exams.Clear(); + + foreach (var e in _exams.GetByGroup(Group.Id)) + Exams.Add(new ExamSummary(e)); + } + + [RelayCommand] + private void AddStudent() + { + // TODO: Schüler-Auswahl-Dialog + } + + [RelayCommand] + private void AddExam() + { + // TODO: Klausur-Erstellen-Dialog + } +} + +public enum GroupTab { Overview, Students, Exams, Grades, Planner, Documentation } + +public class StudentSummary +{ + public Guid Id { get; } + public string FullName { get; } + + public StudentSummary(Core.Models.Student s) + { + Id = s.Id; + FullName = s.FullName; + } +} + +public class ExamSummary +{ + public Guid Id { get; } + public string Title { get; } + public string Date { get; } + public string Status { get; } + + public ExamSummary(Core.Models.Exam e) + { + Id = e.Id; + Title = e.Title; + Date = e.Date.ToString("dd.MM.yyyy"); + Status = e.Status switch + { + ExamStatus.Planned => "Geplant", + ExamStatus.Conducted => "Durchgeführt", + ExamStatus.Graded => "Korrigiert", + ExamStatus.Returned => "Zurückgegeben", + _ => "", + }; + } +} diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..63cef66 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,87 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop.ViewModels; + +public partial class MainWindowViewModel : ObservableObject +{ + private readonly IServiceProvider _services; + + [ObservableProperty] + private ObservableObject? _currentPage; + + [ObservableProperty] + private NavItem _activeNavItem = NavItem.Dashboard; + + // ── Fehlende Property die im MainWindow.axaml referenziert wird ─────────── + [ObservableProperty] + private string _currentSchoolYear = ""; + + public MainWindowViewModel( + IServiceProvider services, + DashboardViewModel dashboard, + SchoolYearService schoolYearService) + { + _services = services; + CurrentPage = dashboard; + CurrentSchoolYear = schoolYearService.CurrentSchoolYear(); + } + + // ── Navigation ──────────────────────────────────────────────────────────── + + [RelayCommand] + private void NavigateTo(NavItem item) + { + ActiveNavItem = item; + CurrentPage = item switch + { + NavItem.Dashboard => _services.GetRequiredService(), + NavItem.Groups => _services.GetRequiredService(), + NavItem.Students => _services.GetRequiredService(), + NavItem.Exams => CreatePlaceholder("Klausuren"), + NavItem.Planner => CreatePlaceholder("Unterrichtsplanung"), + NavItem.Workload => CreatePlaceholder("Arbeitszeit"), + NavItem.Settings => CreatePlaceholder("Einstellungen"), + _ => CurrentPage, + }; + } + + public void NavigateToGroup(Guid groupId) + { + ActiveNavItem = NavItem.Groups; + var vm = _services.GetRequiredService(); + vm.LoadGroup(groupId); + CurrentPage = vm; + } + + public void NavigateToStudent(Guid studentId) + { + ActiveNavItem = NavItem.Students; + var vm = _services.GetRequiredService(); + vm.LoadStudent(studentId); + CurrentPage = vm; + } + + private static PlaceholderViewModel CreatePlaceholder(string title) => + new() { Title = title }; +} + +public enum NavItem +{ + Dashboard, + Groups, + Students, + Exams, + Planner, + Workload, + Settings, +} + +public partial class PlaceholderViewModel : ObservableObject +{ + [ObservableProperty] private string _title = ""; +} diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs new file mode 100644 index 0000000..fe1e3d5 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -0,0 +1,223 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Students; + +// ── Schülerliste ────────────────────────────────────────────────────────────── + +public partial class StudentListViewModel : ObservableObject +{ + private readonly IStudentRepository _students; + + [ObservableProperty] private string _searchText = ""; + [ObservableProperty] private bool _showInactive; + [ObservableProperty] private StudentListItem? _selectedStudent; + + public ObservableCollection Students { get; } = []; + + public StudentListViewModel(IStudentRepository students) + { + _students = students; + LoadStudents(); + } + + partial void OnSearchTextChanged(string value) => LoadStudents(); + partial void OnShowInactiveChanged(bool value) => LoadStudents(); + + private void LoadStudents() + { + Students.Clear(); + var all = _students.GetAll(includeInactive: ShowInactive); + + var filtered = string.IsNullOrWhiteSpace(SearchText) + ? all + : all.Where(s => + s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || + s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + + foreach (var s in filtered) + Students.Add(new StudentListItem(s)); + } + + [RelayCommand] + private void AddStudent() + { + // TODO: Neuer-Schüler-Dialog + } + + [RelayCommand] + private void Refresh() => LoadStudents(); +} + +public class StudentListItem +{ + public Guid Id { get; } + public string FullName { get; } + public string DateOfBirth { get; } + public bool IsActive { get; } + + public StudentListItem(Student s) + { + Id = s.Id; + FullName = s.FullName; + DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? ""; + IsActive = s.IsActive; + } +} + +// ── Schülerdetail ───────────────────────────────────────────────────────────── + +public partial class StudentDetailViewModel : ObservableObject +{ + private readonly IStudentRepository _students; + private readonly IEnrollmentRepository _enrollments; + private readonly IGroupRepository _groups; + private readonly IGradeRepository _grades; + private readonly IDocumentationRepository _docs; + + [ObservableProperty] private Student? _student; + [ObservableProperty] private string _studentTitle = ""; + [ObservableProperty] private StudentTab _activeTab = StudentTab.Overview; + [ObservableProperty] private bool _isEditing; + + // Bearbeitbare Felder + [ObservableProperty] private string _editFirstName = ""; + [ObservableProperty] private string _editLastName = ""; + [ObservableProperty] private string _editNotes = ""; + + public ObservableCollection Enrollments { get; } = []; + public ObservableCollection Grades { get; } = []; + public ObservableCollection Documentation { get; } = []; + + public StudentDetailViewModel( + IStudentRepository students, + IEnrollmentRepository enrollments, + IGroupRepository groups, + IGradeRepository grades, + IDocumentationRepository docs) + { + _students = students; + _enrollments = enrollments; + _groups = groups; + _grades = grades; + _docs = docs; + } + + public void LoadStudent(Guid studentId) + { + Student = _students.GetById(studentId); + if (Student is null) return; + + StudentTitle = Student.FullName; + EditFirstName = Student.FirstName; + EditLastName = Student.LastName; + EditNotes = Student.Notes ?? ""; + + LoadEnrollments(); + LoadDocs(); + } + + [RelayCommand] + private void SwitchTab(StudentTab tab) => ActiveTab = tab; + + [RelayCommand] + private void StartEdit() => IsEditing = true; + + [RelayCommand] + private void CancelEdit() + { + if (Student is null) return; + EditFirstName = Student.FirstName; + EditLastName = Student.LastName; + EditNotes = Student.Notes ?? ""; + IsEditing = false; + } + + [RelayCommand] + private void SaveEdit() + { + if (Student is null) return; + Student.FirstName = EditFirstName; + Student.LastName = EditLastName; + Student.Notes = string.IsNullOrWhiteSpace(EditNotes) ? null : EditNotes; + _students.Save(Student); + StudentTitle = Student.FullName; + IsEditing = false; + } + + private void LoadEnrollments() + { + if (Student is null) return; + Enrollments.Clear(); + + var enrollments = _enrollments.GetByStudent(Student.Id); + var groupIds = enrollments.Select(e => e.GroupId).Distinct(); + var groupMap = groupIds + .Select(id => _groups.GetById(id)) + .Where(g => g is not null) + .ToDictionary(g => g!.Id); + + foreach (var e in enrollments.OrderByDescending(e => e.SchoolYear)) + { + if (!groupMap.TryGetValue(e.GroupId, out var group)) continue; + Enrollments.Add(new EnrollmentEntry + { + SchoolYear = e.SchoolYear, + GroupName = group.Name, + Subject = group.Subject ?? "", + }); + } + } + + private void LoadDocs() + { + if (Student is null) return; + Documentation.Clear(); + + foreach (var doc in _docs.GetByStudent(Student.Id)) + { + Documentation.Add(new DocEntry + { + Date = doc.Date.ToString("dd.MM.yyyy"), + Title = doc.Title, + TypeLabel = doc.Type switch + { + DocumentationType.Conversation => "Gespräch", + DocumentationType.Incident => "Vorkommnis", + DocumentationType.SupportPlan => "Förderplan", + DocumentationType.Absence => "Fehlzeit", + _ => "", + }, + IsConfidential = doc.IsConfidential, + }); + } + } +} + +public enum StudentTab { Overview, Grades, Documentation } + +public class EnrollmentEntry +{ + public string SchoolYear { get; set; } = ""; + public string GroupName { get; set; } = ""; + public string Subject { get; set; } = ""; +} + +public class GradeEntry +{ + public string Date { get; set; } = ""; + public string Value { get; set; } = ""; + public string Category { get; set; } = ""; + public string GroupName { get; set; } = ""; +} + +public class DocEntry +{ + public string Date { get; set; } = ""; + public string Title { get; set; } = ""; + public string TypeLabel { get; set; } = ""; + public bool IsConfidential { get; set; } +} diff --git a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs new file mode 100644 index 0000000..d46038b --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs @@ -0,0 +1,63 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Sync; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Desktop.ViewModels; + +public partial class SyncStatusViewModel : ObservableObject +{ + private readonly SyncEngine? _engine; + + [ObservableProperty] private string _statusText = "Kein Server konfiguriert"; + [ObservableProperty] private string _lastSyncText = ""; + [ObservableProperty] private int _pendingCount; + [ObservableProperty] private int _conflictCount; + [ObservableProperty] private bool _isSyncing; + [ObservableProperty] private bool _hasConflicts; + [ObservableProperty] private bool _isServerConfigured; + + public SyncStatusViewModel(SyncEngine? engine) + { + _engine = engine; + IsServerConfigured = engine is not null; + + if (_engine is not null) + { + _engine.StatusChanged += OnStatusChanged; + OnStatusChanged(_engine.Status); + } + } + + private void OnStatusChanged(SyncStatus status) + { + IsSyncing = status.State == SyncState.Syncing; + HasConflicts = status.ConflictCount > 0; + PendingCount = status.PendingEvents; + ConflictCount = status.ConflictCount; + + StatusText = status.State switch + { + SyncState.Idle => PendingCount > 0 + ? $"{PendingCount} ausstehend" + : "Synchronisiert", + SyncState.Syncing => "Synchronisiere...", + SyncState.Offline => "Offline", + SyncState.Error => $"Fehler: {status.ErrorMessage}", + _ => "", + }; + + LastSyncText = status.LastSyncAt.HasValue + ? $"Zuletzt: {status.LastSyncAt:HH:mm}" + : "Noch nie synchronisiert"; + } + + [RelayCommand(CanExecute = nameof(CanSyncNow))] + private async Task SyncNow() + { + if (_engine is null) return; + await _engine.SyncNowAsync(isAutomatic: false); + } + + private bool CanSyncNow() => _engine is not null && !IsSyncing; +} diff --git a/LehrerApp.Desktop/Views/DashboardView.axaml b/LehrerApp.Desktop/Views/DashboardView.axaml new file mode 100644 index 0000000..d839a24 --- /dev/null +++ b/LehrerApp.Desktop/Views/DashboardView.axaml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/DataTemplates.axaml b/LehrerApp.Desktop/Views/DataTemplates.axaml new file mode 100644 index 0000000..de87ad2 --- /dev/null +++ b/LehrerApp.Desktop/Views/DataTemplates.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/DevicePairingDialog.axaml b/LehrerApp.Desktop/Views/DevicePairingDialog.axaml new file mode 100644 index 0000000..4d41c42 --- /dev/null +++ b/LehrerApp.Desktop/Views/DevicePairingDialog.axaml @@ -0,0 +1,185 @@ + + + + + + + + + + Möchtest du ein neues Gerät mit deinem bestehenden Datenbestand + verbinden, oder dieses Gerät als erstes Gerät einrichten? + + + + + diff --git a/LehrerApp.Desktop/Views/NavButton.axaml.cs b/LehrerApp.Desktop/Views/NavButton.axaml.cs new file mode 100644 index 0000000..132e149 --- /dev/null +++ b/LehrerApp.Desktop/Views/NavButton.axaml.cs @@ -0,0 +1,69 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using System.Windows.Input; +using LehrerApp.Desktop.ViewModels; + +namespace LehrerApp.Desktop.Views; + +public partial class NavButton : UserControl +{ + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), ""); + + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label), ""); + + public static readonly StyledProperty ItemProperty = + AvaloniaProperty.Register(nameof(Item)); + + public static readonly StyledProperty ActiveItemProperty = + AvaloniaProperty.Register(nameof(ActiveItem)); + + public static readonly StyledProperty CommandProperty = + AvaloniaProperty.Register(nameof(Command)); + + public static readonly StyledProperty CommandParameterProperty = + AvaloniaProperty.Register(nameof(CommandParameter)); + + public string Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public NavItem Item + { + get => GetValue(ItemProperty); + set => SetValue(ItemProperty, value); + } + + public NavItem ActiveItem + { + get => GetValue(ActiveItemProperty); + set => SetValue(ActiveItemProperty, value); + } + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + public NavButton() + { + InitializeComponent(); + } +} diff --git a/LehrerApp.Desktop/Views/PlaceholderView.axaml b/LehrerApp.Desktop/Views/PlaceholderView.axaml new file mode 100644 index 0000000..8676445 --- /dev/null +++ b/LehrerApp.Desktop/Views/PlaceholderView.axaml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml new file mode 100644 index 0000000..6c71bf7 --- /dev/null +++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs b/LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs new file mode 100644 index 0000000..f2e943c --- /dev/null +++ b/LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; +using LehrerApp.Desktop.ViewModels; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop.Views; + +public partial class SyncStatusBar : UserControl +{ + public SyncStatusBar() + { + InitializeComponent(); + + // DataContext aus DI holen sobald die View geladen ist + Loaded += (_, _) => + { + if (DataContext is null) + DataContext = App.Services.GetRequiredService(); + }; + } +} diff --git a/LehrerApp.Desktop/Views/ViewCodeBehind.cs b/LehrerApp.Desktop/Views/ViewCodeBehind.cs new file mode 100644 index 0000000..e9c9c99 --- /dev/null +++ b/LehrerApp.Desktop/Views/ViewCodeBehind.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; + +namespace LehrerApp.Desktop.Views; + +// Code-behind Stubs für Views ohne eigene .axaml.cs +// NavButton → eigene NavButton.axaml.cs +// SyncStatusBar → eigene SyncStatusBar.axaml.cs + +public partial class DashboardView : UserControl +{ + public DashboardView() => InitializeComponent(); +} + +public partial class PlaceholderView : UserControl +{ + public PlaceholderView() => InitializeComponent(); +} diff --git a/LehrerApp.Desktop/app.manifest b/LehrerApp.Desktop/app.manifest new file mode 100644 index 0000000..1127e37 --- /dev/null +++ b/LehrerApp.Desktop/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/LehrerApp.Sync/ConflictResolver.cs b/LehrerApp.Sync/ConflictResolver.cs new file mode 100644 index 0000000..b6ee2d9 --- /dev/null +++ b/LehrerApp.Sync/ConflictResolver.cs @@ -0,0 +1,64 @@ +using LehrerApp.Sync.Models; + +namespace LehrerApp.Sync; + +/// +/// Entscheidet bei kollidierenden Events welcher gewinnt. +/// Desktop hat Vorrang vor Companion. Bei Gleichstand gewinnt der spätere Timestamp. +/// +public class ConflictResolver +{ + private readonly EventQueue _queue; + + public ConflictResolver(EventQueue queue) + { + _queue = queue; + } + + /// + /// Prüft ob ein Remote-Event mit einem lokalen Event kollidiert. + /// Gibt null zurück wenn kein Konflikt besteht. + /// + public ConflictEntry? TryResolve(SyncEvent remoteEvent, string localDeviceId) + { + // Lokalen Event für dieselbe Entity suchen + var localEvent = _queue.GetPending() + .FirstOrDefault(e => + e.EntityType == remoteEvent.EntityType && + e.EntityId == remoteEvent.EntityId && + e.DeviceId != remoteEvent.DeviceId); + + if (localEvent is null) + return null; // kein Konflikt – Remote-Event einfach anwenden + + // Konflikt aufgelöst – Gewinner bestimmen + var winner = DetermineWinner(localEvent, remoteEvent); + var resolution = winner == localEvent ? "LocalWon" : "RemoteWon"; + + // Wenn Remote gewinnt: lokalen Event aus Queue entfernen + if (winner == remoteEvent) + _queue.Acknowledge([localEvent.EventId]); + + return new ConflictEntry + { + LocalEvent = localEvent, + RemoteEvent = remoteEvent, + Resolution = resolution, + }; + } + + private static SyncEvent DetermineWinner(SyncEvent local, SyncEvent remote) + { + // Regel 1: Desktop schlägt Companion + if (local.DeviceType == DeviceType.Desktop && + remote.DeviceType == DeviceType.Companion) + return local; + + if (remote.DeviceType == DeviceType.Desktop && + local.DeviceType == DeviceType.Companion) + return remote; + + // Regel 2: Späterer Timestamp gewinnt + return local.Timestamp >= remote.Timestamp ? local : remote; + } +} diff --git a/LehrerApp.Sync/Crypto/SyncCrypto.cs b/LehrerApp.Sync/Crypto/SyncCrypto.cs new file mode 100644 index 0000000..778f50a --- /dev/null +++ b/LehrerApp.Sync/Crypto/SyncCrypto.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace LehrerApp.Sync.Crypto; + +/// +/// AES-256-GCM Verschlüsselung für Sync-Payloads. +/// Der Schlüssel verlässt nie den Client – der Server sieht nur Ciphertext. +/// +public class SyncCrypto +{ + private const int KeySize = 32; // 256 bit + private const int NonceSize = 12; // 96 bit – GCM Standard + private const int TagSize = 16; // 128 bit Auth-Tag + + // PBKDF2-Parameter für Schlüsselableitung aus Einmal-Code + private const int Pbkdf2Iterations = 100_000; + private const int Pbkdf2KeySize = 32; // 256 bit + + // ── Schlüsselverwaltung ─────────────────────────────────────────────────── + + public static byte[] GenerateKey() + { + var key = new byte[KeySize]; + RandomNumberGenerator.Fill(key); + return key; + } + + public static string KeyToBase64(byte[] key) => + Convert.ToBase64String(key); + + public static byte[] KeyFromBase64(string base64) => + Convert.FromBase64String(base64); + + // ── Schlüssel aus Einmal-Code ableiten (PBKDF2) ─────────────────────────── + + /// + /// Leitet einen symmetrischen Schlüssel aus dem Einmal-Code ab. + /// PBKDF2 macht Brute-Force bei kurzem Code teuer. + /// + /// Salt ist fest definiert (kein Geheimnis, nur Domänentrennung). + /// Der Code selbst ist das Geheimnis – 24h TTL schützt vor Angriffsfenstern. + /// + public static byte[] DeriveKeyFromCode(string code) + { + // Normalisieren: Groß, Leerzeichen entfernen + var normalized = code.Trim().ToUpperInvariant(); + var codeBytes = Encoding.UTF8.GetBytes(normalized); + + // Salt: App-spezifisch, öffentlich bekannt – verhindert + // Rainbow-Tables gegen andere Anwendungen + var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1"); + + return Rfc2898DeriveBytes.Pbkdf2( + codeBytes, + salt, + Pbkdf2Iterations, + HashAlgorithmName.SHA256, + Pbkdf2KeySize); + } + + /// + /// Verschlüsselt den Sync-Schlüssel mit dem Code-Key. + /// Ergebnis wird als Base64 auf dem Server hinterlegt. + /// + public static string EncryptKeyWithCode(byte[] syncKey, string code) + { + var codeKey = DeriveKeyFromCode(code); + var encrypted = Encrypt(syncKey, codeKey); + return Convert.ToBase64String(encrypted); + } + + /// + /// Entschlüsselt den Sync-Schlüssel mit dem Code. + /// Wirft CryptographicException wenn der Code falsch ist. + /// + public static byte[] DecryptKeyWithCode(string encryptedKeyBase64, string code) + { + var codeKey = DeriveKeyFromCode(code); + var encrypted = Convert.FromBase64String(encryptedKeyBase64); + return Decrypt(encrypted, codeKey); + } + + // ── Ver-/Entschlüsselung ────────────────────────────────────────────────── + + /// + /// Verschlüsselt mit AES-256-GCM. + /// Format: [Nonce 12 Bytes][Ciphertext][Auth-Tag 16 Bytes] + /// + public static byte[] Encrypt(byte[] plaintext, byte[] key) + { + var nonce = new byte[NonceSize]; + RandomNumberGenerator.Fill(nonce); + + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + var result = new byte[NonceSize + ciphertext.Length + TagSize]; + nonce.CopyTo(result, 0); + ciphertext.CopyTo(result, NonceSize); + tag.CopyTo(result, NonceSize + ciphertext.Length); + + return result; + } + + public static byte[] Decrypt(byte[] encrypted, byte[] key) + { + if (encrypted.Length < NonceSize + TagSize) + throw new CryptographicException("Ungültiges verschlüsseltes Format."); + + var nonce = encrypted[..NonceSize]; + var tag = encrypted[^TagSize..]; + var ciphertext = encrypted[NonceSize..^TagSize]; + var plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return plaintext; + } + + // ── Komfort-Methoden für JSON-Payloads ──────────────────────────────────── + + public static string EncryptObject(T obj, byte[] key) + { + var json = JsonSerializer.SerializeToUtf8Bytes(obj); + var encrypted = Encrypt(json, key); + return Convert.ToBase64String(encrypted); + } + + public static T? DecryptObject(string base64, byte[] key) + { + var encrypted = Convert.FromBase64String(base64); + var json = Decrypt(encrypted, key); + return JsonSerializer.Deserialize(json); + } + + // ── Schlüsselspeicherung ────────────────────────────────────────────────── + + public static void SaveKey(byte[] key, string keyFilePath) + { + var dir = Path.GetDirectoryName(keyFilePath); + if (dir != null) Directory.CreateDirectory(dir); + + File.WriteAllText(keyFilePath, KeyToBase64(key)); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(keyFilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + public static byte[]? LoadKey(string keyFilePath) + { + if (!File.Exists(keyFilePath)) return null; + return KeyFromBase64(File.ReadAllText(keyFilePath).Trim()); + } +} diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs new file mode 100644 index 0000000..b90c810 --- /dev/null +++ b/LehrerApp.Sync/EventApplier.cs @@ -0,0 +1,214 @@ +using System.Text; +using System.Text.Json; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Sync; + +/// +/// Wendet vom Server gezogene Events auf die lokale LiteDB an. +/// +/// Unterscheidet zwischen: +/// - Verschlüsselten Desktop-Events → erst entschlüsseln, dann anwenden +/// - Klartext-Events von WebApp/Companion → direkt anwenden +/// +public class EventApplier +{ + private readonly byte[] _key; + + // Repositories für alle Entity-Typen + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly IEnrollmentRepository _enrollments; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _examResults; + private readonly IGradeRepository _grades; + private readonly IUnitRepository _units; + private readonly ILessonRepository _lessons; + private readonly IDocumentationRepository _docs; + private readonly IWorkTaskRepository _tasks; + private readonly ITimeEntryRepository _timeEntries; + + public EventApplier( + byte[] key, + IStudentRepository students, + IGroupRepository groups, + IEnrollmentRepository enrollments, + IExamRepository exams, + IExamResultRepository examResults, + IGradeRepository grades, + IUnitRepository units, + ILessonRepository lessons, + IDocumentationRepository docs, + IWorkTaskRepository tasks, + ITimeEntryRepository timeEntries) + { + _key = key; + _students = students; + _groups = groups; + _enrollments = enrollments; + _exams = exams; + _examResults = examResults; + _grades = grades; + _units = units; + _lessons = lessons; + _docs = docs; + _tasks = tasks; + _timeEntries = timeEntries; + } + + // ── Hauptmethode ────────────────────────────────────────────────────────── + + public void Apply(SyncEvent evt) + { + var json = ResolvePayload(evt); + if (string.IsNullOrWhiteSpace(json)) return; + + try + { + ApplyJson(evt.EntityType, evt.Operation, json); + } + catch (Exception ex) + { + // Einzelne fehlerhafte Events nicht die ganze Sync-Session abbrechen + Console.Error.WriteLine( + $"EventApplier: Fehler bei {evt.EntityType}/{evt.Operation} " + + $"({evt.EventId}): {ex.Message}"); + } + } + + public void ApplyAll(IEnumerable events) + { + // Chronologisch anwenden + foreach (var evt in events.OrderBy(e => e.Timestamp)) + Apply(evt); + } + + // ── Payload auflösen ────────────────────────────────────────────────────── + + private string ResolvePayload(SyncEvent evt) + { + // Klartext-Events von WebApp/Companion + if (evt.DeviceType == DeviceType.Companion) + return evt.Payload; + + // Verschlüsselte Events vom Desktop entschlüsseln + try + { + var encrypted = Convert.FromBase64String(evt.Payload); + var decrypted = SyncCrypto.Decrypt(encrypted, _key); + return Encoding.UTF8.GetString(decrypted); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"EventApplier: Entschlüsselung fehlgeschlagen " + + $"({evt.EventId}): {ex.Message}"); + return ""; + } + } + + // ── Entity-spezifische Anwendung ────────────────────────────────────────── + + private void ApplyJson(string entityType, string operation, string json) + { + switch (entityType) + { + case "Student": + ApplyEntity(operation, json, + e => _students.Save(e), + id => _students.Delete(id)); + break; + + case "LearningGroup": + ApplyEntity(operation, json, + e => _groups.Save(e), + id => _groups.Delete(id)); + break; + + case "Enrollment": + ApplyEntity(operation, json, + e => _enrollments.Save(e), + id => _enrollments.Delete(id)); + break; + + case "Exam": + ApplyEntity(operation, json, + e => _exams.Save(e), + id => _exams.Delete(id)); + break; + + case "ExamResult": + ApplyEntity(operation, json, + e => _examResults.Save(e), + _ => { }); // ExamResults werden nicht einzeln gelöscht + break; + + case "Grade": + ApplyEntity(operation, json, + e => _grades.Save(e), + id => _grades.Delete(id)); + break; + + case "Unit": + ApplyEntity(operation, json, + e => _units.Save(e), + id => _units.Delete(id)); + break; + + case "Lesson": + ApplyEntity(operation, json, + e => _lessons.Save(e), + id => _lessons.Delete(id)); + break; + + case "Documentation": + ApplyEntity(operation, json, + e => _docs.Save(e), + id => _docs.Delete(id)); + break; + + case "WorkTask": + ApplyEntity(operation, json, + e => _tasks.Save(e), + id => _tasks.Delete(id)); + break; + + case "TimeEntry": + ApplyEntity(operation, json, + e => _timeEntries.Save(e), + id => _timeEntries.Delete(id)); + break; + + default: + Console.Error.WriteLine( + $"EventApplier: Unbekannter EntityType '{entityType}'"); + break; + } + } + + private static void ApplyEntity( + string operation, + string json, + Action save, + Action delete) where T : class + { + switch (operation) + { + case "Upsert": + case "Create": + case "Update": + var entity = JsonSerializer.Deserialize(json); + if (entity is not null) save(entity); + break; + + case "Delete": + // Payload ist bei Delete nur die ID + if (Guid.TryParse(json.Trim('"'), out var id)) + delete(id); + break; + } + } +} diff --git a/LehrerApp.Sync/EventQueue.cs b/LehrerApp.Sync/EventQueue.cs new file mode 100644 index 0000000..7054436 --- /dev/null +++ b/LehrerApp.Sync/EventQueue.cs @@ -0,0 +1,129 @@ +using LiteDB; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Sync; + +/// +/// Lokale Event-Queue in LiteDB. +/// Jede Datenänderung erzeugt einen Event – dieser wird hier gepuffert +/// bis er erfolgreich zum Server gepusht wurde. +/// +public class EventQueue : IDisposable +{ + private readonly LiteDatabase _db; + private readonly ILiteCollection _queue; + private readonly ILiteCollection _meta; + private readonly ILiteCollection _conflicts; + private long _currentSequenceNr; + + public EventQueue(string queueDbPath) + { + _db = new LiteDatabase(queueDbPath); + _queue = _db.GetCollection("queue"); + _meta = _db.GetCollection("meta"); + _conflicts = _db.GetCollection("conflicts"); + + _queue.EnsureIndex(x => x.SequenceNr); + _queue.EnsureIndex(x => x.Timestamp); + + // Letzte SequenceNr wiederherstellen + var meta = _meta.FindById("seq"); + _currentSequenceNr = meta?.Value ?? 0; + } + + // ── Events einreihen ────────────────────────────────────────────────────── + + public SyncEvent Enqueue( + string entityType, + string entityId, + string operation, + string encryptedPayload, + string deviceId, + DeviceType deviceType) + { + var evt = new SyncEvent + { + DeviceId = deviceId, + DeviceType = deviceType, + Timestamp = DateTime.UtcNow, + SequenceNr = ++_currentSequenceNr, + EntityType = entityType, + EntityId = entityId, + Operation = operation, + Payload = encryptedPayload, + }; + + _queue.Insert(evt); + _meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSequenceNr }); + + return evt; + } + + // ── Ausstehende Events ──────────────────────────────────────────────────── + + public List GetPending(int maxBatch = 100) => + _queue.Find(Query.All(nameof(SyncEvent.SequenceNr))) + .Take(maxBatch) + .ToList(); + + public int PendingCount() => _queue.Count(); + + // ── Bestätigung nach erfolgreichem Push ─────────────────────────────────── + + public void Acknowledge(IEnumerable eventIds) + { + foreach (var id in eventIds) + _queue.Delete(id); + } + + // ── Letzte Sync-Metadaten ───────────────────────────────────────────────── + + public long GetLastServerSequenceNr() => + _meta.FindById("serverSeq")?.Value ?? 0; + + public void SetLastServerSequenceNr(long nr) => + _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr }); + + public DateTime? GetLastSyncAt() + { + var meta = _meta.FindById("lastSync"); + return meta?.Timestamp; + } + + public void SetLastSyncAt(DateTime timestamp) => + _meta.Upsert(new SyncMeta + { + Id = "lastSync", + Value = 0, + Timestamp = timestamp + }); + + // ── Konflikte ───────────────────────────────────────────────────────────── + + public void AddConflict(ConflictEntry conflict) => + _conflicts.Insert(conflict); + + public List GetUnreviewedConflicts() => + _conflicts.Find(c => !c.Reviewed).ToList(); + + public int ConflictCount() => + _conflicts.Count(c => !c.Reviewed); + + public void MarkConflictReviewed(Guid id) + { + var conflict = _conflicts.FindById(id); + if (conflict is null) return; + conflict.Reviewed = true; + _conflicts.Update(conflict); + } + + public void Dispose() => _db.Dispose(); +} + +// Internes Hilfsdokument für Metadaten +internal class SyncMeta +{ + public string Id { get; set; } = ""; + public long Value { get; set; } + public DateTime? Timestamp { get; set; } +} diff --git a/LehrerApp.Sync/LehrerApp.Sync.csproj b/LehrerApp.Sync/LehrerApp.Sync.csproj new file mode 100644 index 0000000..9ae7b35 --- /dev/null +++ b/LehrerApp.Sync/LehrerApp.Sync.csproj @@ -0,0 +1,18 @@ + + + net9.0 + enable + enable + latest + + + + + + + + + + + diff --git a/LehrerApp.Sync/Models/PlainSyncModels.cs b/LehrerApp.Sync/Models/PlainSyncModels.cs new file mode 100644 index 0000000..2356751 --- /dev/null +++ b/LehrerApp.Sync/Models/PlainSyncModels.cs @@ -0,0 +1,30 @@ +namespace LehrerApp.Sync.Models; + +/// +/// Event das die WebApp oder Companion-App erzeugt. +/// Payload ist NICHT verschlüsselt – JWT schützt den Transport. +/// Der Desktop-Client erkennt PlainEvents am DeviceType +/// und wendet sie ohne Entschlüsselung an. +/// +public class PlainSyncEvent +{ + public Guid EventId { get; init; } = Guid.NewGuid(); + public string DeviceId { get; init; } = ""; + public DeviceType DeviceType { get; init; } = DeviceType.Companion; + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + public string EntityType { get; init; } = ""; // "Grade", "Exam", ... + public string EntityId { get; init; } = ""; + public string Operation { get; init; } = ""; // "Upsert", "Delete" + public string Payload { get; init; } = ""; // JSON, Klartext +} + +/// +/// Antwort auf einen Plain-Push. +/// +public class PlainPushResponse +{ + public bool Success { get; init; } + public long ServerSequenceNr { get; init; } + public List RejectedEventIds { get; init; } = []; + public string? ErrorMessage { get; init; } +} diff --git a/LehrerApp.Sync/Models/SnapshotModels.cs b/LehrerApp.Sync/Models/SnapshotModels.cs new file mode 100644 index 0000000..cb6e6b7 --- /dev/null +++ b/LehrerApp.Sync/Models/SnapshotModels.cs @@ -0,0 +1,61 @@ +namespace LehrerApp.Sync.Models; + +/// +/// Anfrage zum Upload eines initialen Snapshots. +/// Enthält sowohl den verschlüsselten DB-Snapshot +/// als auch den mit dem Code verschlüsselten Sync-Schlüssel. +/// +public class SnapshotUploadRequest +{ + /// + /// AES-256-GCM verschlüsselter Dump der lokalen LiteDB. + /// Verschlüsselt mit dem Sync-Schlüssel des Nutzers. + /// Der Server versteht den Inhalt nicht. + /// + public string EncryptedPayload { get; init; } = ""; + + /// + /// Der Sync-Schlüssel, verschlüsselt mit dem aus dem + /// Einmal-Code abgeleiteten Key (PBKDF2). + /// Ermöglicht dem Empfänger den Schlüssel ohne Vorabübertragung + /// zu rekonstruieren – nur der Code wird benötigt. + /// + public string EncryptedSyncKey { get; init; } = ""; + + public DeviceType DeviceType { get; init; } +} + +/// +/// Antwort nach erfolgreichem Snapshot-Upload. +/// +public class SnapshotUploadResponse +{ + /// + /// Menschenlesbarer Einmal-Code. + /// Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU" + /// Dient gleichzeitig zum Abrufen UND zum Entschlüsseln des Sync-Schlüssels. + /// + public string Code { get; init; } = ""; + + public DateTime ExpiresAt { get; init; } +} + +/// +/// Antwort beim Abrufen eines Snapshots per Code. +/// Nach dem ersten Abruf wird der Snapshot vom Server gelöscht. +/// +public class SnapshotDownloadResponse +{ + /// Der verschlüsselte DB-Snapshot. + public string EncryptedPayload { get; init; } = ""; + + /// + /// Der mit dem Code verschlüsselte Sync-Schlüssel. + /// Empfänger: Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln + /// → Sync-Key → EncryptedPayload entschlüsseln. + /// + public string EncryptedSyncKey { get; init; } = ""; + + public DateTime CreatedAt { get; init; } + public DeviceType SourceDeviceType { get; init; } +} diff --git a/LehrerApp.Sync/Models/SyncModels.cs b/LehrerApp.Sync/Models/SyncModels.cs new file mode 100644 index 0000000..f67ae65 --- /dev/null +++ b/LehrerApp.Sync/Models/SyncModels.cs @@ -0,0 +1,66 @@ +namespace LehrerApp.Sync.Models; + +/// +/// Ein einzelnes Ereignis im Event-Log. +/// Wird verschlüsselt zum Server übertragen – der Server versteht den Payload nicht. +/// +public class SyncEvent +{ + public Guid EventId { get; init; } = Guid.NewGuid(); + public string DeviceId { get; init; } = ""; + public DeviceType DeviceType { get; init; } + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + public long SequenceNr { get; init; } // monoton steigend pro Gerät + public string EntityType { get; init; } = ""; // "Student", "Exam", "Grade" ... + public string EntityId { get; init; } = ""; + public string Operation { get; init; } = ""; // "Create", "Update", "Delete" + public string Payload { get; init; } = ""; // JSON, AES-256 verschlüsselt +} + +/// +/// Eintrag im lokalen Konflikt-Log – für UI-Anzeige. +/// +public class ConflictEntry +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTime DetectedAt { get; init; } = DateTime.UtcNow; + public SyncEvent LocalEvent { get; init; } = null!; + public SyncEvent RemoteEvent { get; init; } = null!; + public string Resolution { get; init; } = ""; // "LocalWon", "RemoteWon", "Pending" + public bool Reviewed { get; set; } +} + +/// +/// Antwort des Servers auf einen Pull-Request. +/// +public class PullResponse +{ + public List Events { get; init; } = []; + public long ServerSequenceNr { get; init; } +} + +/// +/// Antwort des Servers auf einen Push-Request. +/// +public class PushResponse +{ + public bool Success { get; init; } + public long ServerSequenceNr { get; init; } + public List ConflictingEventIds { get; init; } = []; +} + +public enum DeviceType { Desktop, Companion } + +/// +/// Sync-Status für UI-Anzeige. +/// +public class SyncStatus +{ + public SyncState State { get; set; } = SyncState.Idle; + public DateTime? LastSyncAt { get; set; } + public int PendingEvents { get; set; } + public int ConflictCount { get; set; } + public string? ErrorMessage { get; set; } +} + +public enum SyncState { Idle, Syncing, Error, Offline } diff --git a/LehrerApp.Sync/ReadableSnapshotService.cs b/LehrerApp.Sync/ReadableSnapshotService.cs new file mode 100644 index 0000000..a55c059 --- /dev/null +++ b/LehrerApp.Sync/ReadableSnapshotService.cs @@ -0,0 +1,155 @@ +using System.Net.Http.Json; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Sync; + +/// +/// Exportiert einen lesbaren Snapshot der lokalen LiteDB +/// und pusht ihn zur API – wo er für die WebApp abrufbar ist. +/// +/// Wird automatisch nach jedem Sync-Zyklus ausgeführt +/// und kann manuell angestoßen werden. +/// +public class ReadableSnapshotService +{ + private readonly HttpClient _http; + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly IEnrollmentRepository _enrollments; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _examResults; + private readonly IGradeRepository _grades; + private readonly IUnitRepository _units; + private readonly ILessonRepository _lessons; + private readonly IWorkTaskRepository _tasks; + private readonly SchoolYearService _schoolYear; + private readonly string _deviceId; + + public event Action? StatusChanged; + + public ReadableSnapshotService( + HttpClient http, + IStudentRepository students, + IGroupRepository groups, + IEnrollmentRepository enrollments, + IExamRepository exams, + IExamResultRepository examResults, + IGradeRepository grades, + IUnitRepository units, + ILessonRepository lessons, + IWorkTaskRepository tasks, + SchoolYearService schoolYear, + string deviceId) + { + _http = http; + _students = students; + _groups = groups; + _enrollments = enrollments; + _exams = exams; + _examResults = examResults; + _grades = grades; + _units = units; + _lessons = lessons; + _tasks = tasks; + _schoolYear = schoolYear; + _deviceId = deviceId; + } + + // ── Export ──────────────────────────────────────────────────────────────── + + public async Task ExportAndPushAsync( + CancellationToken ct = default) + { + StatusChanged?.Invoke("Snapshot wird erstellt…"); + + try + { + var snapshot = BuildSnapshot(); + + StatusChanged?.Invoke("Snapshot wird übertragen…"); + var response = await _http.PostAsJsonAsync( + "/api/snapshot/readable", snapshot, ct); + + if (!response.IsSuccessStatusCode) + { + StatusChanged?.Invoke( + $"Übertragung fehlgeschlagen: {response.StatusCode}"); + return false; + } + + StatusChanged?.Invoke( + $"Snapshot übertragen – {snapshot.Meta.StudentCount} Schüler, " + + $"{snapshot.Meta.GroupCount} Gruppen"); + return true; + } + catch (Exception ex) + { + StatusChanged?.Invoke($"Fehler: {ex.Message}"); + return false; + } + } + + // ── Snapshot aufbauen ───────────────────────────────────────────────────── + + private ReadableSnapshot BuildSnapshot() + { + var schoolYear = _schoolYear.CurrentSchoolYear(); + var groups = _groups.GetBySchoolYear(schoolYear); + var groupIds = groups.Select(g => g.Id).ToHashSet(); + + var students = _students.GetAll(); + var enrollments = groups + .SelectMany(g => _enrollments.GetByGroupAndYear(g.Id, schoolYear)) + .ToList(); + + var exams = groupIds + .SelectMany(gid => _exams.GetByGroup(gid)) + .ToList(); + + var examResults = exams + .SelectMany(e => _examResults.GetByExam(e.Id)) + .ToList(); + + var grades = groupIds + .SelectMany(gid => _grades.GetByGroup(gid)) + .ToList(); + + var units = groupIds + .SelectMany(gid => _units.GetByGroup(gid)) + .ToList(); + + var lessons = units + .SelectMany(u => _lessons.GetByUnit(u.Id)) + .ToList(); + + var tasks = _tasks.GetAll() + .Where(t => t.Status != WorkTaskStatus.Done) + .ToList(); + + var snapshot = new ReadableSnapshot + { + SchoolYear = schoolYear, + Students = students, + Groups = groups, + Enrollments = enrollments, + Exams = exams, + ExamResults = examResults, + Grades = grades, + Units = units, + Lessons = lessons, + Tasks = tasks, + Meta = new SnapshotMeta + { + StudentCount = students.Count, + GroupCount = groups.Count, + ExamCount = exams.Count, + ExportedByDevice = _deviceId, + OldestData = DateTime.UtcNow.AddYears(-1), // vereinfacht + }, + }; + + return snapshot; + } +} diff --git a/LehrerApp.Sync/SnapshotService.cs b/LehrerApp.Sync/SnapshotService.cs new file mode 100644 index 0000000..933a350 --- /dev/null +++ b/LehrerApp.Sync/SnapshotService.cs @@ -0,0 +1,245 @@ +using System.Net.Http.Json; +using LehrerApp.Data; +using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Sync; + +/// +/// Verwaltet den initialen Snapshot-Austausch beim Einrichten eines neuen Geräts. +/// +/// Ablauf Sender (Desktop): +/// 1. LiteDB checkpoint + lesen +/// 2. Mit Sync-Schlüssel (AES-256) verschlüsseln → EncryptedPayload +/// 3. Sync-Schlüssel mit Code-Key (PBKDF2 aus Einmal-Code) verschlüsseln → EncryptedSyncKey +/// 4. Beides zum Server pushen → Einmal-Code erhalten +/// 5. Code dem Nutzer anzeigen – das ist alles was nötig ist +/// +/// Ablauf Empfänger (neues Gerät): +/// 1. Einmal-Code eingeben +/// 2. EncryptedPayload + EncryptedSyncKey laden +/// 3. Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln → Sync-Schlüssel +/// 4. Sync-Schlüssel lokal speichern (sync.key) +/// 5. EncryptedPayload mit Sync-Schlüssel entschlüsseln → LiteDB +/// 6. Normaler Event-Sync startet +/// +public class SnapshotService +{ + private readonly HttpClient _http; + private readonly LiteDbContext _db; + private readonly byte[] _syncKey; + private readonly DeviceType _deviceType; + private readonly string _dbPath; + private readonly string _keyPath; + + public event Action? ProgressChanged; + + public SnapshotService( + HttpClient http, + LiteDbContext db, + byte[] syncKey, + DeviceType deviceType, + string dbPath, + string keyPath) + { + _http = http; + _db = db; + _syncKey = syncKey; + _deviceType = deviceType; + _dbPath = dbPath; + _keyPath = keyPath; + } + + // ── Sender ──────────────────────────────────────────────────────────────── + + /// + /// Erstellt Snapshot + verschlüsselten Schlüssel und lädt beides hoch. + /// Der zurückgegebene Code ist das einzige was der Empfänger braucht. + /// + public async Task CreateAndUploadAsync( + CancellationToken ct = default) + { + // 1. DB sauber schreiben + Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…"); + _db.Checkpoint(); + + // 2. DB-Bytes lesen + Report(SnapshotStep.Reading, "Datenbank wird gelesen…"); + var dbBytes = await File.ReadAllBytesAsync(_dbPath, ct); + + // 3. Snapshot mit Sync-Schlüssel verschlüsseln + Report(SnapshotStep.Encrypting, "Datenbank wird verschlüsselt…"); + var encryptedPayload = Convert.ToBase64String( + SyncCrypto.Encrypt(dbBytes, _syncKey)); + + // 4. Hochladen – Server generiert den Einmal-Code + // Der EncryptedSyncKey wird NACH dem Upload erzeugt, + // weil wir dafür den Code brauchen den der Server zurückgibt. + // Deshalb: zweistufiger Upload. + Report(SnapshotStep.Uploading, "Erster Upload – Code wird angefordert…"); + + // Schritt 4a: Initialer Upload ohne EncryptedSyncKey → Code erhalten + var initRequest = new SnapshotUploadRequest + { + EncryptedPayload = encryptedPayload, + EncryptedSyncKey = "", // noch leer + DeviceType = _deviceType, + }; + + var initResponse = await _http.PostAsJsonAsync( + "/api/snapshot/upload", initRequest, ct); + initResponse.EnsureSuccessStatusCode(); + + var initResult = await initResponse.Content + .ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Leere Server-Antwort."); + + // Schritt 4b: Sync-Schlüssel mit dem erhaltenen Code verschlüsseln + Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt und ergänzt…"); + var encryptedSyncKey = SyncCrypto.EncryptKeyWithCode( + _syncKey, initResult.Code); + + // Schritt 4c: Vollständiger Upload mit EncryptedSyncKey + var fullRequest = new SnapshotUploadRequest + { + EncryptedPayload = encryptedPayload, + EncryptedSyncKey = encryptedSyncKey, + DeviceType = _deviceType, + }; + + var fullResponse = await _http.PostAsJsonAsync( + "/api/snapshot/upload", fullRequest, ct); + fullResponse.EnsureSuccessStatusCode(); + + var result = await fullResponse.Content + .ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Leere Server-Antwort."); + + Report(SnapshotStep.Done, $"Bereit. Code: {result.Code}"); + return result; + } + + // ── Empfänger ───────────────────────────────────────────────────────────── + + /// + /// Lädt Snapshot und Schlüssel anhand des Einmal-Codes. + /// Extrahiert den Sync-Schlüssel, speichert ihn lokal, + /// und baut die neue LiteDB auf. + /// + public async Task RestoreFromCodeAsync( + string code, + string targetDbPath, + CancellationToken ct = default) + { + var sanitized = code.Trim().ToUpperInvariant(); + + // 1. Snapshot + verschlüsselten Schlüssel laden + Report(SnapshotStep.Downloading, "Snapshot wird geladen…"); + var response = await _http.GetAsync( + $"/api/snapshot/{sanitized}", ct); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + throw new SnapshotNotFoundException( + $"Kein Snapshot für Code '{sanitized}'. " + + "Abgelaufen oder bereits verwendet."); + + response.EnsureSuccessStatusCode(); + + var download = await response.Content + .ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Leere Server-Antwort."); + + // 2. Sync-Schlüssel aus dem Code extrahieren + Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…"); + byte[] syncKey; + try + { + syncKey = SyncCrypto.DecryptKeyWithCode( + download.EncryptedSyncKey, sanitized); + } + catch (System.Security.Cryptography.CryptographicException) + { + throw new InvalidOperationException( + "Schlüssel-Entschlüsselung fehlgeschlagen. " + + "Ist der Code korrekt eingegeben?"); + } + + // 3. Sync-Schlüssel lokal speichern – ab jetzt kann normal gesynct werden + SyncCrypto.SaveKey(syncKey, _keyPath); + + // 4. LiteDB entschlüsseln + Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…"); + byte[] dbBytes; + try + { + var encrypted = Convert.FromBase64String(download.EncryptedPayload); + dbBytes = SyncCrypto.Decrypt(encrypted, syncKey); + } + catch (System.Security.Cryptography.CryptographicException) + { + // Sollte nicht passieren wenn der Schlüssel korrekt war + throw new InvalidOperationException( + "Datenbank-Entschlüsselung fehlgeschlagen. " + + "Der Snapshot könnte beschädigt sein."); + } + + // 5. LiteDB schreiben + Report(SnapshotStep.Writing, "Datenbank wird geschrieben…"); + var dir = Path.GetDirectoryName(targetDbPath); + if (dir != null) Directory.CreateDirectory(dir); + + if (File.Exists(targetDbPath)) + { + var backup = targetDbPath + + $".backup-{DateTime.Now:yyyyMMdd-HHmmss}"; + File.Move(targetDbPath, backup); + } + + await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct); + + Report(SnapshotStep.Done, + $"Wiederhergestellt vom {download.CreatedAt:dd.MM.yyyy HH:mm}. " + + "Bitte App neu starten."); + + // Neuen Sync-Schlüssel zurückgeben – AppBootstrapper muss ihn neu laden + return syncKey; + } + + private void Report(SnapshotStep step, string message) => + ProgressChanged?.Invoke(new SnapshotProgress(step, message)); +} + +// ── Fortschritts-Modelle ────────────────────────────────────────────────────── + +public record SnapshotProgress(SnapshotStep Step, string Message) +{ + public int PercentComplete => Step switch + { + SnapshotStep.Checkpointing => 10, + SnapshotStep.Reading => 20, + SnapshotStep.Encrypting => 35, + SnapshotStep.Uploading => 60, + SnapshotStep.Downloading => 35, + SnapshotStep.Decrypting => 65, + SnapshotStep.Writing => 85, + SnapshotStep.Done => 100, + _ => 0, + }; + + public bool IsComplete => Step == SnapshotStep.Done; +} + +public enum SnapshotStep +{ + Idle, + Checkpointing, + Reading, + Encrypting, + Uploading, + Downloading, + Decrypting, + Writing, + Done, +} + +public class SnapshotNotFoundException(string message) : Exception(message); diff --git a/LehrerApp.Sync/SyncEngine.cs b/LehrerApp.Sync/SyncEngine.cs new file mode 100644 index 0000000..b0a5f09 --- /dev/null +++ b/LehrerApp.Sync/SyncEngine.cs @@ -0,0 +1,186 @@ +using System.Net.Http.Json; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Sync; + +/// +/// Orchestriert Push, Pull, EventApply und Snapshot-Export. +/// Automatisch alle N Minuten + manuell auslösbar. +/// +public class SyncEngine : IDisposable +{ + private readonly EventQueue _queue; + private readonly ConflictResolver _resolver; + private readonly EventApplier? _applier; + private readonly ReadableSnapshotService? _snapshotExport; + private readonly HttpClient _http; + private readonly SyncConfig _config; + private readonly Timer _timer; + + public SyncStatus Status { get; private set; } = new(); + public event Action? StatusChanged; + + public SyncEngine( + EventQueue queue, + ConflictResolver resolver, + HttpClient http, + SyncConfig config, + EventApplier? applier = null, + ReadableSnapshotService? snapshotExport = null) + { + _queue = queue; + _resolver = resolver; + _http = http; + _config = config; + _applier = applier; + _snapshotExport = snapshotExport; + + _timer = new Timer( + async _ => await SyncNowAsync(isAutomatic: true), + null, + TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes), + TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes)); + + UpdateStatus(); + } + + // ── Öffentliche API ─────────────────────────────────────────────────────── + + public async Task SyncNowAsync(bool isAutomatic = false) + { + if (Status.State == SyncState.Syncing) + return new SyncResult { Skipped = true, Reason = "Sync bereits aktiv" }; + + SetState(SyncState.Syncing); + + try + { + // 1. Ausstehende Events pushen + var pushResult = await PushAsync(); + + // 2. Neue Events vom Server holen + var pullResult = await PullAsync(); + + // 3. Geholte Events auf LiteDB anwenden + if (_applier is not null && pullResult.Events.Count > 0) + _applier.ApplyAll(pullResult.Events); + + // 4. Nach erfolgreichem Sync: lesbaren Snapshot exportieren + // (nur bei automatischem Sync oder explizit – nicht bei jedem + // manuellen Push um Traffic zu sparen) + if (_snapshotExport is not null && (isAutomatic || pushResult.Pushed > 0)) + await _snapshotExport.ExportAndPushAsync(); + + _queue.SetLastSyncAt(DateTime.UtcNow); + SetState(SyncState.Idle); + + return new SyncResult + { + Success = true, + EventsPushed = pushResult.Pushed, + EventsPulled = pullResult.Events.Count, + Conflicts = pullResult.Conflicts, + }; + } + catch (HttpRequestException) + { + SetState(SyncState.Offline); + return new SyncResult { Success = false, Reason = "Server nicht erreichbar" }; + } + catch (Exception ex) + { + SetState(SyncState.Error, ex.Message); + return new SyncResult { Success = false, Reason = ex.Message }; + } + } + + // ── Push ────────────────────────────────────────────────────────────────── + + private async Task<(int Pushed, int Conflicts)> PushAsync() + { + var pending = _queue.GetPending(maxBatch: 200); + if (pending.Count == 0) return (0, 0); + + var response = await _http.PostAsJsonAsync("/api/sync/push", pending); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + if (result is null) return (0, 0); + + var sentIds = pending + .Where(e => !result.ConflictingEventIds.Contains(e.EventId)) + .Select(e => e.EventId); + _queue.Acknowledge(sentIds); + _queue.SetLastServerSequenceNr(result.ServerSequenceNr); + + return (pending.Count - result.ConflictingEventIds.Count, + result.ConflictingEventIds.Count); + } + + // ── Pull ────────────────────────────────────────────────────────────────── + + private async Task<(List Events, int Conflicts)> PullAsync() + { + var since = _queue.GetLastServerSequenceNr(); + var response = await _http.GetFromJsonAsync( + $"/api/sync/pull?since={since}&deviceId={_config.DeviceId}"); + + if (response is null || response.Events.Count == 0) + return ([], 0); + + var conflicts = 0; + foreach (var remoteEvent in response.Events) + { + var conflict = _resolver.TryResolve(remoteEvent, _config.DeviceId); + if (conflict is not null) + { + _queue.AddConflict(conflict); + conflicts++; + } + } + + _queue.SetLastServerSequenceNr(response.ServerSequenceNr); + return (response.Events, conflicts); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private void SetState(SyncState state, string? error = null) + { + Status = new SyncStatus + { + State = state, + LastSyncAt = _queue.GetLastSyncAt(), + PendingEvents = _queue.PendingCount(), + ConflictCount = _queue.ConflictCount(), + ErrorMessage = error, + }; + StatusChanged?.Invoke(Status); + } + + private void UpdateStatus() => SetState(Status.State); + + public void Dispose() + { + _timer.Dispose(); + _queue.Dispose(); + } +} + +public class SyncConfig +{ + public string ServerUrl { get; set; } = ""; + public string DeviceId { get; set; } = ""; + public DeviceType DeviceType { get; set; } = DeviceType.Desktop; + public int AutoSyncIntervalMinutes { get; set; } = 5; +} + +public class SyncResult +{ + public bool Success { get; set; } + public bool Skipped { get; set; } + public string? Reason { get; set; } + public int EventsPushed { get; set; } + public int EventsPulled { get; set; } + public int Conflicts { get; set; } +} diff --git a/LehrerApp.sln b/LehrerApp.sln new file mode 100644 index 0000000..d5514ab --- /dev/null +++ b/LehrerApp.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Core", "LehrerApp.Core\LehrerApp.Core.csproj", "{A1000001-0000-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data", "LehrerApp.Data\LehrerApp.Data.csproj", "{A1000002-0000-0000-0000-000000000002}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.Api\LehrerApp.Api.csproj", "{A1000004-0000-0000-0000-000000000004}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU + EndGlobalSection +EndGlobal diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ace1b67 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile.api + ports: + - "5000:5000" + volumes: + - ./data:/app/data + environment: + - JWT_SECRET=${JWT_SECRET} + - ASPNETCORE_ENVIRONMENT=Production + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/sync/status"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/global.json b/global.json new file mode 100644 index 0000000..ba2296a --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestPatch" + } +}