feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent

Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:13:14 +02:00
co-authored by Claude Sonnet 5
parent 331033db5c
commit 038337997f
29 changed files with 1886 additions and 22 deletions
+13 -8
View File
@@ -28,6 +28,11 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
EventQueue? versions = null)
{
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
private static readonly Dictionary<string, Func<string, IHasAttachments?>> AttachmentDeserializers = new()
{
[nameof(Documentation)] = json => JsonSerializer.Deserialize<Documentation>(json),
[nameof(Lesson)] = json => JsonSerializer.Deserialize<Lesson>(json),
};
public async Task ApplyAsync(SyncEvent evt)
{
@@ -41,8 +46,9 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
{
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
handler(db, evt.Operation, evt.EntityId, json);
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
await DownloadMissingAttachmentsAsync(json);
if (evt.Operation != "Delete" && http is not null &&
AttachmentDeserializers.TryGetValue(evt.EntityType, out var deserialize))
await DownloadMissingAttachmentsAsync(deserialize(json));
// evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen
// ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten
// eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4).
@@ -72,13 +78,12 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
}
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
// Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln
// nachladen. Gegenstück zum Upload in AttachmentSyncer.
private async Task DownloadMissingAttachmentsAsync(string json)
// Anwenden der Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln nachladen.
// Gegenstück zum Upload in AttachmentSyncer.
private async Task DownloadMissingAttachmentsAsync(IHasAttachments? entity)
{
var doc = JsonSerializer.Deserialize<Documentation>(json);
if (doc is null) return;
foreach (var attachment in doc.Attachments)
if (entity is null) return;
foreach (var attachment in entity.Attachments)
{
if (db.Attachments.Exists(attachment.StorageId)) continue;
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
+2 -2
View File
@@ -25,8 +25,8 @@ public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKe
// Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen),
// sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
if (payload is Documentation doc)
foreach (var attachment in doc.Attachments)
if (payload is IHasAttachments withAttachments)
foreach (var attachment in withAttachments.Attachments)
queue.QueueAttachmentUpload(attachment.StorageId);
}
}