Elternbriefe
Einseitige Kommunikation Schule → Eltern (Phase A aus docs/TASK-elternkommunikation.md). Nachrichten Eltern ↔ Lehrkraft (Phase B) sind nicht gebaut.
Datenmodell
| Modell | Zweck |
|---|---|
Announcement | Brief mit Status draft, scheduled, published, archived. correctsAnnouncementId verweist vom Nachtrag aufs Original. |
AnnouncementRecipient | Empfänger-Snapshot, eine Zeile je Kind, Guardian und Portal-Zugang. |
AnnouncementAttachment | Verknüpfung zu File mit Kategorie ANNOUNCEMENT_ATTACHMENT. |
Guardian ist pro Kind ein eigener Datensatz. Ein Elternteil mit zwei Kindern hat also zwei Guardian-Zeilen, aber denselben portalUserId. Zusammengeführt wird daher immer über portalUserId.
Zeilen ohne Portal-Zugang haben portalUserId = null und deliveredAt = null. Weil NULL im Unique-Index als verschieden gilt, sichert sie der partielle Index announcement_recipients_no_portal_user_key ab. Prisma bildet ihn nicht ab, er steht nur in der Migration.
Empfänger-Snapshot
publishAnnouncement (lib/announcements/publish.ts) löst die Empfänger auf und schreibt in einer Transaktion:
- Status per bedingtem
updateMany, das verhindert doppeltes Veröffentlichen durch Cron und Nutzer. AnnouncementRecipient-Zeilen.retentionUntilder Anhänge.- Outbox-Einträge
ANNOUNCEMENT, je Elternteil einer,studentId = null, Kinder impayload. - Audit-Zeile; der Snapshot steht als Zahl im
context.
Nach dem Veröffentlichen entscheidet nur noch der Snapshot, wer einen Brief bekommen hat. Ein Klassenwechsel ändert daran nichts. Ein Nachtrag übernimmt die Empfänger des Originals, nicht die aktuelle Zielgruppe.
Unveränderlichkeit ist serverseitig abgesichert: Updates und Löschungen tragen den Status im where, Anhänge sperren die Zeile per lockEditableAnnouncement.
Berechtigungen
| Recht | Wirkung |
|---|---|
announcements:read | Liste, Brief, Anhänge im Backend |
announcements:create / write / delete | Entwürfe. delete zusätzlich für Briefe nach Fristablauf |
announcements:publish | Veröffentlichen und Terminieren, denn ein Termin veröffentlicht später automatisch |
announcements:all-audiences | Jahrgang, Schule und fremde Klassen |
canTargetAudience (lib/announcements/audience.ts) prüft die Zielgruppe in jeder Server Action zusätzlich: Die eigene Klasse (SchoolClass.teacher_id) ist immer erlaubt, alles andere erfordert all-audiences. Dieselbe Prüfung begrenzt Auswertung, CSV-Export und Druckliste. Beim Bearbeiten gilt sie für alte und neue Zielgruppe.
Portal
Jede Abfrage geht über accessibleRecipientWhere (lib/portal/announcements.ts): Nutzer aus der Session, Brief veröffentlicht oder archiviert, Zugang zum Kind noch aktiv. Der Snapshot allein genügt nicht, ein deaktivierter Link beendet den Zugriff.
- Bestätigen und Antworten (
lib/portal/announcement-actions.ts) prüfen die Zugriffsstufe selbst,LIMITEDkann nur lesen. readAtsetzt erst der Browser (AnnouncementReadMarker). Die Schule stützt sich auf den Wert, ein Prefetch darf nicht zählen.- Nach der Frist ist nur noch der Widerruf einer Einwilligung möglich.
- Anhänge laufen über
/api/portal/announcements/[announcementId]/attachments/[attachmentId]. Fehlender Bezug ergibt 404, jeder Download schreibt dasFileAccessLog.
Die generische Route /api/files/upload lehnt die Kategorie ab. Der Upload läuft über /api/announcements/[id]/attachments, weil das 1-MB-Limit von Server Actions unter der erlaubten Dateigröße liegt.
Crons
/api/cron/publish-scheduled-announcements läuft täglich um 06:00 UTC: Er veröffentlicht fällige Briefe und legt die einmalige Erinnerung an. scheduledFor ist deshalb ein Datum (00:00 UTC), keine Uhrzeit. Scheitert ein Brief, bleibt er scheduled und wird am nächsten Tag erneut versucht.
Die Mails verschickt /api/cron/send-portal-notifications (17:30 UTC). Er arbeitet Batches ab, bis die Outbox leer oder das Zeitbudget von 45 Sekunden erreicht ist.
Aufbewahrung
Frist: Schuljahresende + 3 Jahre (lib/announcements/retention.ts). Abgelaufene Briefe werden nur markiert, deleteExpiredAnnouncement löscht von Hand. Anhänge laufen zusätzlich über den vorhandenen Cron check-expired-files.
Audit
Announcement und AnnouncementRecipient stehen auf der Allowlist. Veröffentlichen, Archivieren, Bestätigen und Antworten (Akteur portal) werden protokolliert. AnnouncementAttachment und readAt sind bewusst ausgenommen, siehe EXEMPTIONS in lib/audit/__tests__/write-coverage.test.ts.
Tests
e2e/backend/announcement-flow.spec.ts: gesamter Ablauf inklusive Mail und Klassenwechsel.e2e/portal/announcement-isolation.spec.ts: andere Klasse, anderer Mandant, deaktivierter Zugang, Entwurf; mit Positivkontrolle.e2e/backend/announcement-permissions.spec.ts: Klassenleitung ohne ADMIN-Rolle.
Im E2E-Lauf gibt es keinen Blob-Speicher. Erfolgreicher Upload und Download sind deshalb in Vitest mit gemocktem Speicher abgedeckt.