Zum Hauptinhalt springen

Elternbriefe

Einseitige Kommunikation Schule → Eltern (Phase A aus docs/TASK-elternkommunikation.md). Nachrichten Eltern ↔ Lehrkraft (Phase B) sind nicht gebaut.

Datenmodell​

ModellZweck
AnnouncementBrief mit Status draft, scheduled, published, archived. correctsAnnouncementId verweist vom Nachtrag aufs Original.
AnnouncementRecipientEmpfänger-Snapshot, eine Zeile je Kind, Guardian und Portal-Zugang.
AnnouncementAttachmentVerknü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:

  1. Status per bedingtem updateMany, das verhindert doppeltes Veröffentlichen durch Cron und Nutzer.
  2. AnnouncementRecipient-Zeilen.
  3. retentionUntil der Anhänge.
  4. Outbox-Einträge ANNOUNCEMENT, je Elternteil einer, studentId = null, Kinder im payload.
  5. 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​

RechtWirkung
announcements:readListe, Brief, Anhänge im Backend
announcements:create / write / deleteEntwürfe. delete zusätzlich für Briefe nach Fristablauf
announcements:publishVeröffentlichen und Terminieren, denn ein Termin veröffentlicht später automatisch
announcements:all-audiencesJahrgang, 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, LIMITED kann nur lesen.
  • readAt setzt 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 das FileAccessLog.

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.