Compare commits

..

9 Commits

Author SHA1 Message Date
Benjamin Admin 77c720e2df Document Stundenplan + Schulkalender end-of-session state
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 50s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 3m50s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 49s
- CLAUDE.md gets a new section summarising the two feature strands,
  pitfalls (Timefold name, JSX quotes, LOC budget), the auth/messaging
  outsourcing, and pointers to the three memory files for next session.
- docs-src/services/schulkalender/ — 5 MkDocs pages mirroring the
  stundenplan structure: index, architecture, holidays, parent-flow,
  notifications. Each with DB tables, endpoints, and the dispatch
  payload contract for the colleague's Matrix/Email services.
- mkdocs.yml gains the Schulkalender nav entry under Services.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:41:31 +02:00
Benjamin Admin 89011d64f7 gofmt notification files
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 49s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 3m41s
CI / test-python-agent-core (push) Successful in 38s
CI / test-nodejs-website (push) Successful in 49s
2026-05-22 18:14:15 +02:00
Benjamin Admin 8311b33fb3 Phase 9d: Notification cron + multilingual templates + status badges
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 1m10s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 4m4s
CI / test-python-agent-core (push) Successful in 44s
CI / test-nodejs-website (push) Successful in 51s
Backend (school-service):
  - notification_log table with UNIQUE(event_id, lead_days, audience,
    channel) for idempotent re-runs. Status enum sent/failed/skipped.
  - internal/notifications/templates.go: per-event-type × audience ×
    lead-day-bucket × language templates in 8 languages (de/en/tr/ar/
    uk/ru/pl/fr). Fallback chain (lang→de, eventType→andere) so we
    never miss a render.
  - service.go scans cal_school_event for events whose
    (start_date - runDate) appears in notification_lead_days. For each
    due (audience, channel) tuple it dispatches via POST to the
    Matrix/Email upstreams owned by the colleague's services.
    Empty URL → status='skipped', logged for visibility.
  - dispatcher.go handles the POST, parent-recipient lookup (joins
    parent_account + parent_child + cal_school_event.affected_class_ids),
    and writeLog with the unique constraint dropping duplicate runs.
  - main.go runs a 1-hour ticker; when time.Hour()==6 it invokes the
    scanner for today. Idempotent so transient restarts don't double-
    send.
  - POST /calendar/notifications/run-now for manual trigger + backfill
    (?date=YYYY-MM-DD).
  - GET /calendar/events/:id/notifications returns notification_log
    rows scoped to the owning teacher.
  - MATRIX_SERVICE_URL + EMAIL_SERVICE_URL env vars added (default
    empty = stub mode).

Frontend (studio-v2):
  - NotificationStatus component fetches /events/:id/notifications and
    renders coloured badges per (lead, audience, channel, status).
  - DayDetail mounts NotificationStatus inside each event card when
    notify_parents or notify_students is set.

Tests:
  - 6 new Go unit tests for bucketFor + Render (de/tr/fallback paths)
    + substitute(class_suffix). 89 subtests gesamt.
  - 2 new Playwright tests: badge render with mocked log, hidden when
    notifications are off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:39 +02:00
Benjamin Admin 85957ed5db gofmt parent backend files
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 36s
CI / test-go-edu-search (push) Successful in 33s
CI / test-python-klausur (push) Failing after 2m45s
CI / test-python-agent-core (push) Successful in 23s
CI / test-nodejs-website (push) Successful in 27s
2026-05-22 11:52:51 +02:00
Benjamin Admin d9858084dd Phase 9c: Parent accounts, magic-link login + parent timetable view
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
  - parent_account, parent_child, parent_magic_link, parent_session
    tables. Tokens are sha256-hashed in DB; raw goes back exactly
    once to the inviting teacher.
  - InviteParent upserts the parent account, links a child to a tt_
    class, mints a 7-day magic link. Returns the link path so the
    teacher can paste it into Matrix/Email.
  - RedeemMagicLink validates + marks used + mints a 30-day session,
    sets HttpOnly bp_parent_session cookie.
  - ParentSessionMiddleware reads the cookie and resolves the parent.
    Lives in its own router group /api/v1/parent — totally separate
    from the teacher JWT path.
  - ParentMe returns the account + list of children (with class name).
  - ParentTimetable returns the latest completed tt_solution's lessons
    for the requested child's class, with full authorization check
    (parent must own a child in that class).

Frontend (studio-v2):
  - lib/calendar/subject-i18n.ts maps 22 German subject names to 8
    parent locales (de/en/tr/ar/uk/ru/pl/fr). Falls back to German
    for custom subjects.
  - ParentManager component on the Schulkalender page lets the teacher
    invite parents via email + child name + class + language. Newly
    minted magic-link is shown with a copy-to-clipboard button.
  - app/api/parent/[...path]/route.ts proxies parent-side endpoints
    via the cookie so HttpOnly survives the Next.js round-trip.
  - /eltern/login?token=… redeems and redirects to /eltern.
  - /eltern shows a Wochengrid with German days + translated subject
    names in the parent's preferred language. Headings and weekday
    labels also localised (de/en/tr/ar/uk/ru/pl/fr).

Tests:
  - 3 new Go unit tests (random token, hash stability, invite-request
    validator). 83 subtests gesamt.
  - studio-v2: e2e/eltern.spec.ts mit 7 tests across ParentManager,
    /eltern/login, /eltern overview, subject-i18n end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:50:35 +02:00
Benjamin Admin 33409352ee Phase 9b: Schul-Events CRUD + Schuljahres-Rollover
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
  - calendar_events.go — Create/List/Delete on cal_school_event with
    UUID[] handling for affected_class_ids. Default lead-days [7,1]
    if caller omits the array.
  - calendar_rollover.go — single-transaction promotion: graduating
    classes (grade >= 13) get deleted first so the +1 update doesn't
    bump them to invalid grade 14. defaultSchoolYearDates() picks the
    next Aug-Jul pair when the caller doesn't specify.
  - Handlers + routes: GET/POST /calendar/events,
    DELETE /calendar/events/:id, POST /calendar/school-year-rollover.

Frontend (studio-v2):
  - EventModal: form with Title / Typ / Datum/Zeit / unterrichtsfrei /
    Beschreibung / Sichtbarkeit + Notification-Checkboxen. Per-Type
    Farb-Mapping in types.ts.
  - DayDetail: Modal das beim Klick auf einen Kalender-Tag aufgeht und
    Feiertage + Schulferien + Schul-Events fuer diesen Tag listet,
    inkl. Loeschen-Button pro Event.
  - RolloverWizard: zwei-Schritt-Dialog mit Datums-Auswahl + Tipp-
    Bestaetigung ("SCHULJAHR WECHSELN") gegen versehentliche Auslo-
    sung, danach Ergebnis-Card mit promoted/graduated-Counts.
  - MonthView gewinnt onDayClick + onAddEvent + onRollover Props,
    rendert farb-codierte Punkte fuer School-Events am Tagesrand.
  - Page laed Events parallel mit Holidays und reicht alle Handler
    nach unten.

Tests:
  - Go: 3 neue Tests fuer defaultSchoolYearDates + parseClassIDs.
    Validator-Test fuer CreateSchoolEventRequest existiert bereits.
    80 Subtests gesamt, alle gruen.
  - Playwright: mockCalendarApi gewinnt Routes fuer events GET/POST/
    DELETE und school-year-rollover. 6 neue Tests (EventModal open,
    submit, DayDetail open, Rollover-Trigger, Confirm-Schutz,
    Ergebnis-Anzeige).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:32:33 +02:00
Benjamin Admin 3b8df0d294 Schulkalender test: use exact text for legend assertions
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m39s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 22s
2026-05-22 09:54:22 +02:00
Benjamin Admin 09f6f5a5e1 gofmt calendar files
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m25s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 21s
2026-05-22 09:47:37 +02:00
Benjamin Admin 97e37837ee Phase 9a: Schulkalender — Bundesland-Auswahl + Monatsansicht mit Ferien
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 2m50s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
Backend (school-service):
  - cal_public_event (region, event_type, name_de, name_en, start/end,
    UNIQUE(region, event_type, name_de, start_date)) — global snapshot.
  - cal_school_config (user_id PRIMARY KEY, bundesland, school year dates).
  - cal_school_event — Schul-eigene Termine; CRUD folgt in 9b.
  - GET /calendar/holidays?region=&from=&to= — Range-Query against
    cal_public_event, ordered by start_date.
  - GET / PUT /calendar/config — upsert Bundesland per User.
  - SeedFromSnapshot reads internal/seed/calendar_holidays.json on every
    boot; idempotent via the unique constraint. Async goroutine so the
    HTTP server starts immediately even if the seed file is large.

Data source:
  - scripts/calendar-snapshot.sh ruft openholidaysapi.org fuer alle 16
    Bundeslaender x 3 Schuljahre und schreibt
    school-service/internal/seed/calendar_holidays.json (854 Events,
    Stand Schuljahre 2026-2028).
  - Dockerfile kopiert das seed/-Verzeichnis ins Image, damit die
    Container-Datenbank beim ersten Start gefuellt wird.

Frontend (studio-v2):
  - /schulkalender Page mit Gradient + Blobs wie /stundenplan und
    /korrektur — gleicher Visual-Style.
  - BundeslandWizard: zeigt alle 16 Laender als Dropdown, speichert
    bei Klick die Config und switcht zur Monatsansicht.
  - MonthView: 6-Wochen-Grid Mo-So, Feiertage rose-toned, Schulferien
    amber-toned, heutiges Datum mit Indigo-Ring. Prev/Next/Heute
    Navigation.
  - lib/schulkalender/api.ts re-uses the stundenplan JWT helper so
    auth-mode wechselt nicht.
  - Sidebar bekommt einen Schulkalender-Eintrag (Icon mit Datum-Dots,
    Pfad /schulkalender) in allen 26 Sprachen.

Tests:
  - Go: 3 neue Validator-Tests (Bundesland len=5, EventType oneof,
    Pflichtfelder). 77 Tests gesamt, alle gruen.
  - Playwright: e2e/schulkalender.spec.ts mit Wizard, Save-Flow,
    MonthView-Render, Heute-Button, Sidebar-Link. Hermetisch via
    mockCalendarApi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:46:39 +02:00
52 changed files with 11921 additions and 11 deletions
+29
View File
@@ -243,6 +243,35 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && git push all
---
## Stundenplan + Schulkalender (Mai 2026, alle Phasen deployed)
Zwei groesse Feature-Strange, vollstaendig live auf Mac Mini:
| Pfad | Beschreibung |
|------|--------------|
| `/stundenplan` (studio-v2) | Lehrer-UI mit 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren, Pin/Unpin im Wochengrid |
| `/schulkalender` (studio-v2) | Bundesland-Wizard, Monatsansicht mit Ferien (16 BL × 3 Jahre), Schul-Events, Schuljahres-Rollover, Eltern-Manager |
| `/eltern` (studio-v2) | Eltern-Sicht: Wochengrid des eigenen Kindes in Eltern-Sprache, Magic-Link-Login |
| `school-service` (Go, :8084) | Beide Backends — 30+ Tabellen, JWT-Auth (Dev-Bypass aktiv), Cron fuer Notifications |
| `timetable-solver-service` (Python+JVM, :8095) | Timefold-basierter Solver, 14 Constraints implementiert |
**Wichtigste Memo-Dateien fuer Wiedereinstieg:**
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/session_summary_2026_05_22.md` — vollstaendiges Inventar
- `~/.claude/projects/-Users-benjaminadmin/memory/project_timetable_scheduler.md` — Stundenplan-Status
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/project_schulkalender.md` — Schulkalender-Status
**Pitfalls (vermeidet diese):**
- Timefold Python-Package heisst `timefold` (NICHT `timefold-solver`), v1.24.0b0
- Production-Auth + Matrix/Email-Services baut Kollege — Frontend-Hooks nutzen, kein eigener Service-Code
- JSX-Attribute mit deutschen Quotes `„X"` brechen, Loesung: `description={"..."}` Expression-Form
- LOC-Budget 500 pro File — bei specs mit shared Helpers arbeiten (`e2e/_helpers.ts`)
**Test-Status (Stand 2026-05-22):** 89 Go + 21 Playwright im Schulkalender + 42 Playwright im Stundenplan = **152 grun**
**Offen:** Seed-Daten fuer Demo-Schule, Vollschuljahr-ICS mit RRULE+EXDATE, Untis-Import (Phase 4 geparkt).
---
## Wichtige Dateien (Referenz)
| Datei | Beschreibung |
@@ -0,0 +1,64 @@
# Architektur
## Datenmodell
### Phase 9a — Kalender-Stammdaten
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| `cal_public_event` | Ferien + Feiertage (region, type, name, start, end) | global (alle Bundeslaender) |
| `cal_school_config` | Bundesland-Auswahl + Schuljahr-Daten | 1 row per user_id |
### Phase 9b — Schul-Events
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| `cal_school_event` | Titel + Typ + Datum/Zeit + affected_class_ids + Notification-Flags | created_by_user_id |
Event-Typen (CHECK constraint): `fortbildung`, `schulfeier`, `klassenfahrt`, `projekttag`, `eltern_info`, `andere`.
### Phase 9c — Parent-Accounts
| Tabelle | Inhalt |
|---------|--------|
| `parent_account` | Email + preferred_language, UNIQUE pro (Lehrer, Email) |
| `parent_child` | Vorname/Nachname + FK auf tt_class |
| `parent_magic_link` | Einmal-Token (SHA-256 in DB), expires_at 7 Tage |
| `parent_session` | Browser-Session-Token (SHA-256 in DB), expires_at 30 Tage |
### Phase 9d — Notifications
| Tabelle | Inhalt |
|---------|--------|
| `notification_log` | Idempotenz: UNIQUE(event_id, lead_days, audience, channel) |
## Auth-Modell
**Zwei voneinander unabhaengige Auth-Wege:**
1. **Lehrer:** JWT in Authorization-Header (oder Dev-Bypass mit Default-User wenn `ENVIRONMENT != "production"`). Routen unter `/api/v1/school/...`.
2. **Eltern:** Session-Cookie `bp_parent_session` (HttpOnly, SameSite=Lax), gesetzt vom `/api/v1/parent/auth/redeem` Endpoint. ParentSessionMiddleware resolved Cookie → parent_account.
Eltern sehen **nie** Daten anderer Eltern. Privacy-Check via `ChildBelongsToParent` in jedem GET, Plus Filterung der Lessons gegen tt_solution des einladenden Lehrers.
## Bundesland-Wizard
Erster Aufruf von `/schulkalender` → kein `cal_school_config``BundeslandWizard` UI → POST `/calendar/config` mit `{bundesland: "DE-NI"}` → MonthView lädt für die naechsten ~6 Wochen.
## Schuljahres-Rollover
POST `/calendar/school-year-rollover` (optional `{new_year_start, new_year_end}`):
1. `DELETE FROM tt_class WHERE grade_level >= 13` (Abschlusskohorte)
2. `UPDATE tt_class SET grade_level = grade_level + 1`
3. `UPDATE cal_school_config SET school_year_start/end = ...`
Alles in einer Transaction. Stundenplan-Lehrer-Faecher-Raum-Bestand bleibt unangetastet.
## Auth + Messaging outsourced
Production-Auth, Matrix-Bridge und Email-Gateway werden vom Kollegen gepflegt — siehe globale Memory `stundenplan_auth_and_messaging.md`. Wir definieren nur:
- Dispatch-Payload-Struct (siehe [notifications.md](notifications.md))
- Env-Vars `MATRIX_SERVICE_URL`, `EMAIL_SERVICE_URL` (leer = Stub-Mode)
- Endpoint-Vertrag (POST mit JSON-Body, HTTP 2xx = sent)
@@ -0,0 +1,71 @@
# Ferien + Feiertage
## Quelle
[openholidaysapi.org](https://openholidaysapi.org) — EU-Initiative, MIT-Lizenz
fuer den API-Code, ODbL fuer die Daten. Liefert sowohl `PublicHolidays` als
auch `SchoolHolidays` je Bundesland mit ISO-Codes `DE-BW`, `DE-BY`, ...
## Build-Time-Snapshot
Statt zur Laufzeit zu pollen wird ein JSON-Snapshot committed:
```bash
bash scripts/calendar-snapshot.sh 2026 2030
```
Schreibt nach `school-service/internal/seed/calendar_holidays.json`. Das
Dockerfile kopiert die Datei ins Image; bei jedem Container-Start importiert
`CalendarService.SeedFromSnapshot()` die Eintraege idempotent (UNIQUE auf
region, event_type, name_de, start_date).
**Stand 2026-05-22:** 854 Events fuer alle 16 Bundeslaender × 3 Schuljahre.
## Aktualisierungs-Workflow
1. Jaehrlich (z.B. im Mai vor neuem Schuljahr):
```bash
bash scripts/calendar-snapshot.sh 2027 2031
```
2. Diff im Git pruefen — sollte nur neue Eintraege haben, nicht alte ueberschreiben.
3. Commit + push + Container-Rebuild.
4. Beim ersten Boot werden neue Eintraege in `cal_public_event` eingefuegt; bestehende bleiben.
## API
```
GET /api/v1/school/calendar/holidays?region=DE-NI&from=2026-08-01&to=2027-07-31
```
Liefert Array sortiert nach `start_date`. Beispiel-Antwort:
```json
[
{"id":"…","region":"DE-NI","event_type":"school_holiday","name_de":"Sommerferien","start_date":"2026-07-02","end_date":"2026-08-12"},
{"id":"…","region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit","start_date":"2026-10-03","end_date":"2026-10-03"},
...
]
```
## Format-Mapping (Snapshot-Script)
OpenHolidaysAPI gibt:
```json
{"id":"...","startDate":"2026-10-03","endDate":"2026-10-03","type":"Public",
"name":[{"language":"DE","text":"Tag der Deutschen Einheit"}]}
```
`scripts/calendar-snapshot.sh` normalisiert via jq:
```json
{"region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit",
"name_en":null,"start_date":"2026-10-03","end_date":"2026-10-03"}
```
## Lizenz-Compliance
- API-Code: MIT
- Daten: ODbL (Open Database License)
Beides ist fuer kommerzielle Nutzung erlaubt. Die Quelle muss in einer
Lizenz-Aufstellung (SBOM) genannt werden — bereits in
`sbom/stundenplan/README.md` dokumentiert.
+51
View File
@@ -0,0 +1,51 @@
# Schulkalender
Bundeslandweit kalibrierter Schulkalender mit Ferien, Feiertagen, Schul-
Events, Eltern-Sicht und mehrsprachigen Benachrichtigungen.
## Auf einen Blick
```
studio-v2 /schulkalender → Lehrer-Sicht (CRUD Events, Eltern einladen, Rollover)
studio-v2 /eltern → Eltern-Sicht (Wochengrid des Kindes in eigener Sprache)
│ HTTP /api/school/* und /api/parent/* (zwei separate Auth-Gruppen)
school-service (Go, :8084)
├── cal_public_event — Ferien/Feiertage-Snapshot (OpenHolidaysAPI)
├── cal_school_config — Bundesland pro Rektor
├── cal_school_event — Schulfeier, Fortbildung, Klassenfahrt etc.
├── parent_account/_child/_magic_link/_session — Eltern-Auth
└── notification_log — Idempotenter Versand-Log
▼ POST DispatchPayload
Matrix-Bridge + Email-Gateway (vom Kollegen gepflegt, nicht in diesem Repo)
```
## Module
| Bereich | Doku |
|---------|------|
| [Architektur](architecture.md) | DB-Modell, Auth-Ablauf, Phase-Reihenfolge |
| [Ferien-Snapshot](holidays.md) | OpenHolidaysAPI-Pipeline, jaehrliche Aktualisierung |
| [Eltern-Workflow](parent-flow.md) | Magic-Link, Cookie-Session, i18n-Fachnamen |
| [Notifications](notifications.md) | Cron, Templates, Dispatcher-Vertrag |
## Phasen-Stand
**Alle vier Phasen abgeschlossen (2026-05-22):**
- 9a — Bundesland-Wizard + Monatsansicht
- 9b — Schul-Events + Schuljahres-Rollover
- 9c — Parent-Accounts + Magic-Link + Wochengrid in 8 Sprachen
- 9d — Notification-Cron + Templates + Status-Badges
**Offen:** Vollschuljahr-ICS, Seed-Daten fuer Demo-Schule.
## Test-Status
| Suite | Tests |
|------|-------|
| Go (services + notifications) | 89 / 89 |
| Playwright Schulkalender | 16 / 16 |
| Playwright Eltern | 7 / 7 |
@@ -0,0 +1,96 @@
# Notifications
## Pipeline
```
06:00 Uhr (Berlin-Zeit, Container TZ=Europe/Berlin)
NotificationService.RunForDate(today)
dueEvents() findet cal_school_event mit
(start_date - today) ∈ notification_lead_days
Pro Event: fuer jede Audience (parents/students) und jeden Channel
(matrix für alle, email zusaetzlich nur fuer parents):
dispatchOne()
1. Idempotenz-Check (UNIQUE notification_log)
2. recipientsFor() — JOIN parent_account+parent_child fuer
betroffene Klassen, gibt Email-Liste + bevorzugte Sprache zurueck
3. Render-Template (templates.go, 8 Sprachen)
4. POST {MATRIX,EMAIL}_SERVICE_URL mit DispatchPayload
5. notification_log writeLog (sent/failed/skipped)
```
## Cron-Mechanik
`main.go` startet einen Goroutine-Ticker mit 1h-Intervall. Sobald `time.Now().Hour() == 6` wird `RunForDate` aufgerufen. Idempotent — die UNIQUE auf notification_log filtert Doppel-Calls am selben Tag.
Bei Container-Restart vor 06:00 läuft trotzdem alles korrekt: der naechste 06-Tick fired bis spaetestens 06:59:59. Bei Restart nach 06:00: erste Notification erst am Folgetag (acceptable trade-off gegen einen 1-Min-Ticker).
## Manueller Trigger
```bash
# Heute jetzt scannen
curl -X POST http://localhost:8084/api/v1/school/calendar/notifications/run-now
# Backfill (z.B. nach langem Container-Down)
curl -X POST 'http://localhost:8084/api/v1/school/calendar/notifications/run-now?date=2026-05-20'
```
Antwort: `{"date":"2026-05-22","sent":N,"failed":N,"skipped":N,"already_logged":N}`.
## Template-Engine
Datei: `school-service/internal/notifications/templates.go`. Schema:
```
templates[lang][event_type][audience][bucket] → {Subject, Body}
```
- `lang` ∈ de/en/tr/ar/uk/ru/pl/fr (Fallback `de`)
- `event_type` ∈ fortbildung/schulfeier/klassenfahrt/projekttag/eltern_info/andere (Fallback `andere`)
- `audience` ∈ parents/students (Fallback `parents`)
- `bucket` ∈ today/tomorrow/days (Fallback `days`)
Placeholders: `{{title}}`, `{{date}}`, `{{date_pretty}}`, `{{class_name}}`, `{{class_suffix}}`, `{{teacher_name}}`, `{{lead}}`.
Beispiel-Render (TR / schulfeier / parents / 1-Tag-Vorlauf):
```
Subject: Yarın: Sommerfest (5a)
Body: Sayın veliler, yarın (15.06.2026) Sommerfest gerçekleşiyor (5a).
```
## DispatchPayload (Endpoint-Vertrag mit Matrix/Email Service)
```json
{
"channel": "matrix",
"recipient": "mama@example.de",
"language": "tr",
"subject": "Yarın: Sommerfest",
"body": "Sayın veliler, ...",
"event_id": "uuid-…",
"lead_days": 1
}
```
Erwartete Antwort vom Upstream: HTTP 2xx = sent. 4xx/5xx = failed. Wir leiten **keine** Empfaenger-Identifier-Aufloesung weiter ans Upstream — die Matrix-Bridge mapt Email → Matrix-Handle in der eigenen Logik.
Bei `MATRIX_SERVICE_URL` oder `EMAIL_SERVICE_URL` leer: status='skipped', kein Versandversuch. Erlaubt lokales Testen ohne Upstream.
## Status-Anzeige im Lehrer-UI
`DayDetail` mountet `NotificationStatus` fuer jedes Event mit `notify_parents` oder `notify_students`. Lädt `GET /api/v1/school/calendar/events/:id/notifications` und zeigt Badges:
- ✓ gruen = sent
- ✗ rot = failed (Hover zeigt error_message)
- ⏱ amber = skipped (Upstream noch nicht konfiguriert)
## Privacy
`notification_log` ist nur über JOIN cal_school_event sichtbar — Lehrer sieht nur Logs seiner eigenen Events. Eltern haben gar keine UI fuer Logs.
@@ -0,0 +1,54 @@
# Eltern-Workflow
## Einladung (Lehrer)
1. Lehrer offnet `/schulkalender`, scrollt zu `ParentManager`.
2. Klick "+ Eltern einladen" → Form mit Email, Vorname/Nachname Kind, Klasse, Sprache.
3. `POST /api/v1/school/calendar/parents/invite` legt parent_account (upsert), parent_child + parent_magic_link an, gibt Klartext-Token + voll qualifizierten Link zurueck.
4. Lehrer kopiert Link aus der UI und schickt ihn ueber Matrix oder Email (Versand-Automation kommt mit Phase 9d Notification-Pipeline).
## Login (Eltern)
1. Eltern klicken den Link `https://app/eltern/login?token=…`.
2. Browser laedt die Login-Page, sendet `POST /api/v1/parent/auth/redeem {token}`.
3. school-service validiert Token (Hash-Lookup + expires_at + used_at), markiert used_at, mintet Session-Token (32-Byte URL-safe Base64), setzt HttpOnly Cookie `bp_parent_session`.
4. Redirect auf `/eltern`. Folgende API-Calls senden Cookie automatisch.
## Wochengrid
`/eltern` ruft:
- `GET /api/v1/parent/me` → Account + Kinder-Liste (Name, Klasse via JOIN tt_class)
- `GET /api/v1/parent/me/timetable?class_id=…` → letzte completed tt_solution der Klasse
Filter laeuft strikt: ParentService prueft `ChildBelongsToParent(parent_id, class_id)` vor jeder Timetable-Query.
## Fach-Uebersetzung
`lib/calendar/subject-i18n.ts` hat 22 Standardfaecher in 8 Sprachen:
```typescript
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik',
ar: 'الرياضيات', uk: 'Математика', ru: 'Математика',
pl: 'Matematyka', fr: 'Mathématiques' }
```
`translateSubject(germanName, lang)`:
1. Lowercase + trim → `key`
2. `SUBJECTS[key]` lookup
3. Wenn key nicht in Map: Original-Deutsch zurueck (z.B. "Imkern AG")
4. Wenn lang nicht in Sprachen: `de`-Fallback
## Logout
`POST /api/v1/parent/auth/logout` setzt Cookie auf max-age=-1. Session-Row bleibt in DB (laeuft selber ab nach 30 Tagen) — vereinfacht Tracking.
## Was die Eltern NICHT sehen
- Andere Eltern oder Kinder
- Stundenplan-Versionen die nicht "completed" sind
- Schul-Events mit `visible_to_parents=false`
- Lehrer-internes wie Stundentafel oder Lehrauftrag-Konfiguration
Privacy-Garantien sind auf SQL-Ebene durchgesetzt (JOIN-Pfade + WHERE-Klauseln), nicht nur im Application-Layer.
+6
View File
@@ -88,6 +88,12 @@ nav:
- Constraints: services/stundenplan/constraints.md
- Solver-Tuning: services/stundenplan/solver-tuning.md
- Export: services/stundenplan/export.md
- Schulkalender:
- Uebersicht: services/schulkalender/index.md
- Architektur: services/schulkalender/architecture.md
- Ferien-Snapshot: services/schulkalender/holidays.md
- Eltern-Workflow: services/schulkalender/parent-flow.md
- Notifications: services/schulkalender/notifications.md
- Architektur:
- Multi-Agent System: architecture/multi-agent.md
- Zeugnis-System: architecture/zeugnis-system.md
+3
View File
@@ -40,6 +40,9 @@ COPY --from=builder /app/school-service .
# Copy templates directory
COPY --from=builder /app/templates ./templates
# Copy calendar seed snapshot (Phase 9a — OpenHolidaysAPI data)
COPY --from=builder /app/internal/seed ./internal/seed
# Use non-root user
USER appuser
+78 -1
View File
@@ -1,7 +1,10 @@
package main
import (
"context"
"log"
"os"
"time"
"github.com/breakpilot/school-service/internal/config"
"github.com/breakpilot/school-service/internal/database"
@@ -35,7 +38,41 @@ func main() {
}
// Create handler
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL)
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL, cfg.MatrixServiceURL, cfg.EmailServiceURL)
// Phase 9d: daily notification cron. Ticks every hour and runs the
// scanner once when the current hour == 6. Idempotent via the
// notification_log UNIQUE constraint, so multiple ticks the same day
// are safe.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
now := time.Now()
if now.Hour() == 6 {
res, err := handler.NotificationService().RunForDate(context.Background(), now)
if err != nil {
log.Printf("notification cron error: %v", err)
} else {
log.Printf("notification cron: %+v", res)
}
}
<-ticker.C
}
}()
// Calendar seed — idempotent, runs every boot. Snapshot path is bundled
// in the Docker image at /app/internal/seed/calendar_holidays.json. Failures
// don't block startup; the holiday table is filled lazily next boot.
go func() {
seedPath := "internal/seed/calendar_holidays.json"
if _, err := os.Stat(seedPath); err != nil {
seedPath = "/app/internal/seed/calendar_holidays.json"
}
if err := handler.CalendarService().SeedFromSnapshot(context.Background(), seedPath); err != nil {
log.Printf("calendar seed failed: %v", err)
}
}()
// Create router
router := gin.New()
@@ -232,6 +269,46 @@ func main() {
// Phase 8: exports.
api.GET("/timetable/solutions/:id/export.csv", handler.ExportTimetableSolutionCSV)
api.GET("/timetable/solutions/:id/export.ics", handler.ExportTimetableSolutionICS)
// Phase 9a: Schulkalender (holidays + per-user Bundesland config).
api.GET("/calendar/holidays", handler.ListCalendarHolidays)
api.GET("/calendar/config", handler.GetCalendarConfig)
api.PUT("/calendar/config", handler.UpsertCalendarConfig)
// Phase 9b: school-events CRUD + Schuljahres-Rollover.
api.GET("/calendar/events", handler.ListSchoolEvents)
api.POST("/calendar/events", handler.CreateSchoolEvent)
api.DELETE("/calendar/events/:id", handler.DeleteSchoolEvent)
api.POST("/calendar/school-year-rollover", handler.RolloverSchoolYear)
// Phase 9c: parent invitations (teacher side).
api.GET("/calendar/parents", handler.ListParentInvites)
api.POST("/calendar/parents/invite", handler.InviteParent)
api.DELETE("/calendar/parents/children/:id", handler.DeleteParentInvite)
// Phase 9d: notifications.
api.POST("/calendar/notifications/run-now", handler.RunNotificationsNow)
api.GET("/calendar/events/:id/notifications", handler.ListEventNotifications)
}
// Phase 9c: parent-side endpoints. Auth is the parent session cookie,
// NOT the teacher JWT. /parent/auth/redeem creates the cookie; the
// other routes require it via ParentSessionMiddleware.
parentAPI := router.Group("/api/v1/parent")
{
parentAPI.POST("/auth/redeem", handler.RedeemMagicLink)
authed := parentAPI.Group("/")
authed.Use(middleware.ParentSessionMiddleware(func(ctx context.Context, token string) (string, string, string, error) {
p, err := handler.ParentService().ParentFromSession(ctx, token)
if err != nil {
return "", "", "", err
}
return p.ID.String(), p.Email, p.PreferredLanguage, nil
}))
authed.GET("/me", handler.ParentMe)
authed.GET("/me/timetable", handler.ParentTimetable)
authed.POST("/auth/logout", handler.ParentLogout)
}
// Start server
+6
View File
@@ -31,6 +31,10 @@ type Config struct {
// Timetable solver service (Python/FastAPI, port 8095)
SolverServiceURL string
// Notification upstream services (Phase 9d). Empty → stub mode.
MatrixServiceURL string
EmailServiceURL string
}
// Load loads configuration from environment variables
@@ -47,6 +51,8 @@ func Load() (*Config, error) {
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
LLMGatewayURL: getEnv("LLM_GATEWAY_URL", "http://backend:8000/llm"),
SolverServiceURL: getEnv("SOLVER_SERVICE_URL", "http://timetable-solver-service:8095"),
MatrixServiceURL: getEnv("MATRIX_SERVICE_URL", ""),
EmailServiceURL: getEnv("EMAIL_SERVICE_URL", ""),
}
// Parse allowed origins
@@ -0,0 +1,69 @@
package database
// CalendarMigrations creates the three calendar tables for Phase 9a:
//
// cal_public_event — read-only snapshot of school holidays + public
// holidays from OpenHolidaysAPI. Imported on first
// boot via seed/calendar_holidays.json.
// cal_school_config — per-Rektor bundesland selection (1 row per user).
// cal_school_event — user-managed school events (Fortbildung,
// Schulfeier, Klassenfahrt etc.).
//
// cal_public_event is global (no created_by_user_id) because the data is the
// same for every school in a given bundesland. School-events are
// per-tenant.
func CalendarMigrations() []string {
return []string{
`CREATE TABLE IF NOT EXISTS cal_public_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
region VARCHAR(8) NOT NULL,
event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('public_holiday', 'school_holiday')),
name_de VARCHAR(255) NOT NULL,
name_en VARCHAR(255),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
source VARCHAR(50) DEFAULT 'OpenHolidaysAPI',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(region, event_type, name_de, start_date),
CHECK (end_date >= start_date)
)`,
`CREATE TABLE IF NOT EXISTS cal_school_config (
user_id UUID PRIMARY KEY,
bundesland VARCHAR(8) NOT NULL,
school_year_start DATE,
school_year_end DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS cal_school_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(30) NOT NULL
CHECK (event_type IN ('fortbildung','schulfeier','klassenfahrt','projekttag','eltern_info','andere')),
is_school_free BOOLEAN DEFAULT false,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
start_time TIME,
end_time TIME,
affected_class_ids UUID[] DEFAULT '{}',
visible_to_parents BOOLEAN DEFAULT true,
notify_parents BOOLEAN DEFAULT false,
notify_students BOOLEAN DEFAULT false,
notification_lead_days INT[] DEFAULT '{7,1}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CHECK (end_date >= start_date)
)`,
// Indexes — public events are queried by region + date range. School
// events are queried by owner + date range.
`CREATE INDEX IF NOT EXISTS idx_cal_public_event_region_date
ON cal_public_event(region, start_date, end_date)`,
`CREATE INDEX IF NOT EXISTS idx_cal_school_event_user_date
ON cal_school_event(created_by_user_id, start_date, end_date)`,
}
}
@@ -221,6 +221,15 @@ func Migrate(db *DB) error {
// Append timetable solution migrations (see timetable_solution_migrations.go)
migrations = append(migrations, TimetableSolutionMigrations()...)
// Append calendar migrations (see calendar_migrations.go).
migrations = append(migrations, CalendarMigrations()...)
// Append parent migrations (Phase 9c — see parent_migrations.go).
migrations = append(migrations, ParentMigrations()...)
// Append notification log (Phase 9d — see notification_migrations.go).
migrations = append(migrations, NotificationMigrations()...)
for _, migration := range migrations {
_, err := db.Pool.Exec(ctx, migration)
if err != nil {
@@ -0,0 +1,31 @@
package database
// NotificationMigrations creates the one table Phase 9d needs:
//
// notification_log — one row per (event, lead_days, audience, channel)
// that the cron scanner has already attempted. The UNIQUE constraint
// makes the cron idempotent — running it twice on the same day does
// not re-send.
//
// channel ∈ {'matrix', 'email'} — set by the dispatcher.
// audience ∈ {'parents', 'students'}.
// status ∈ {'sent', 'failed', 'skipped'} — 'skipped' when the upstream
// service URL isn't configured, so we know not to count it as failure.
func NotificationMigrations() []string {
return []string{
`CREATE TABLE IF NOT EXISTS notification_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES cal_school_event(id) ON DELETE CASCADE,
lead_days INT NOT NULL,
audience VARCHAR(20) NOT NULL CHECK (audience IN ('parents','students')),
channel VARCHAR(20) NOT NULL CHECK (channel IN ('matrix','email')),
status VARCHAR(20) NOT NULL CHECK (status IN ('sent','failed','skipped')),
error_message TEXT,
run_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(event_id, lead_days, audience, channel)
)`,
`CREATE INDEX IF NOT EXISTS idx_notification_log_event ON notification_log(event_id)`,
`CREATE INDEX IF NOT EXISTS idx_notification_log_run_date ON notification_log(run_date)`,
}
}
@@ -0,0 +1,53 @@
package database
// ParentMigrations creates the four parent-side tables for Phase 9c:
//
// parent_account — one row per invited parent (email, language)
// parent_child — kids linked to a parent and a tt_class
// parent_magic_link — one-shot invite tokens, hashed
// parent_session — active browser sessions after redeeming a link
//
// The teacher owns the invite (created_by_user_id on account); parent sees
// only data scoped to their own children's class via tt_class.id.
func ParentMigrations() []string {
return []string{
`CREATE TABLE IF NOT EXISTS parent_account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
preferred_language VARCHAR(8) DEFAULT 'de',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(created_by_user_id, email)
)`,
`CREATE TABLE IF NOT EXISTS parent_child (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE,
tt_class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS parent_magic_link (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS parent_session (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_parent_account_owner ON parent_account(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_parent_child_parent ON parent_child(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_parent_child_class ON parent_child(tt_class_id)`,
}
}
@@ -0,0 +1,145 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// ListCalendarHolidays returns OpenHolidaysAPI events for a region + range.
// Query params: ?region=DE-NI&from=2026-08-01&to=2027-07-31. If omitted,
// region falls back to the caller's saved config and the range to the
// current calendar year.
func (h *Handler) ListCalendarHolidays(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
region := c.Query("region")
if region == "" {
cfg, err := h.calendarService.GetConfig(c.Request.Context(), uid)
if err != nil || cfg == nil {
respondError(c, http.StatusBadRequest, "region query param required (no saved config)")
return
}
region = cfg.Bundesland
}
from := c.DefaultQuery("from", time.Now().Format("2006-01-02"))
to := c.DefaultQuery("to", time.Now().AddDate(1, 0, 0).Format("2006-01-02"))
events, err := h.calendarService.ListHolidays(c.Request.Context(), region, from, to)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to load holidays: "+err.Error())
return
}
if events == nil {
events = []models.PublicEvent{}
}
respondSuccess(c, events)
}
func (h *Handler) GetCalendarConfig(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
cfg, err := h.calendarService.GetConfig(c.Request.Context(), uid)
if err != nil {
// No row → 200 with null so the wizard knows to prompt.
respondSuccess(c, nil)
return
}
respondSuccess(c, cfg)
}
func (h *Handler) UpsertCalendarConfig(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.UpsertSchoolCalendarConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
cfg, err := h.calendarService.UpsertConfig(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to save config: "+err.Error())
return
}
respondCreated(c, cfg)
}
// ---------- School Events (Phase 9b) ----------
func (h *Handler) CreateSchoolEvent(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSchoolEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
ev, err := h.calendarService.CreateEvent(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create event: "+err.Error())
return
}
respondCreated(c, ev)
}
func (h *Handler) ListSchoolEvents(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
events, err := h.calendarService.ListEvents(c.Request.Context(), uid, c.Query("from"), c.Query("to"))
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list events: "+err.Error())
return
}
if events == nil {
events = []models.SchoolEvent{}
}
respondSuccess(c, events)
}
func (h *Handler) DeleteSchoolEvent(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.calendarService.DeleteEvent(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete event: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Event deleted"})
}
func (h *Handler) RolloverSchoolYear(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.SchoolYearRolloverRequest
// Body is optional — empty defaults to next-Aug rollover.
_ = c.ShouldBindJSON(&req)
result, err := h.calendarService.RolloverSchoolYear(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Rollover failed: "+err.Error())
return
}
respondSuccess(c, result)
}
+29 -1
View File
@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/notifications"
"github.com/breakpilot/school-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
@@ -17,11 +18,14 @@ type Handler struct {
certificateService *services.CertificateService
aiService *services.AIService
timetableService *services.TimetableService
calendarService *services.CalendarService
parentService *services.ParentService
notificationService *notifications.Service
solverServiceURL string
}
// NewHandler creates a new Handler with all services
func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handler {
func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL, matrixURL, emailURL string) *Handler {
classService := services.NewClassService(db)
examService := services.NewExamService(db)
gradeService := services.NewGradeService(db)
@@ -29,6 +33,9 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handl
certificateService := services.NewCertificateService(db, gradeService, gradebookService)
aiService := services.NewAIService(llmGatewayURL)
timetableService := services.NewTimetableService(db)
calendarService := services.NewCalendarService(db)
parentService := services.NewParentService(db)
notificationService := notifications.NewService(db, matrixURL, emailURL)
return &Handler{
classService: classService,
@@ -38,10 +45,31 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handl
certificateService: certificateService,
aiService: aiService,
timetableService: timetableService,
calendarService: calendarService,
parentService: parentService,
notificationService: notificationService,
solverServiceURL: solverServiceURL,
}
}
// NotificationService exposes the underlying service so main.go can run
// the daily cron tick.
func (h *Handler) NotificationService() *notifications.Service {
return h.notificationService
}
// CalendarService exposes the underlying service so main.go can run the
// one-off seed import after migrations.
func (h *Handler) CalendarService() *services.CalendarService {
return h.calendarService
}
// ParentService exposes the parent service so the parent-session middleware
// in main.go can resolve session cookies.
func (h *Handler) ParentService() *services.ParentService {
return h.parentService
}
// Health returns the service health status
func (h *Handler) Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// RunNotificationsNow triggers the scanner on demand (UI-test + backfill).
// Optional ?date=YYYY-MM-DD lets the teacher replay a past day's send.
// Idempotent — already-logged combos are skipped.
func (h *Handler) RunNotificationsNow(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
runDate := time.Now()
if param := c.Query("date"); param != "" {
d, err := time.Parse("2006-01-02", param)
if err != nil {
respondError(c, http.StatusBadRequest, "date must be YYYY-MM-DD")
return
}
runDate = d
}
res, err := h.notificationService.RunForDate(c.Request.Context(), runDate)
if err != nil {
respondError(c, http.StatusInternalServerError, "Run failed: "+err.Error())
return
}
respondSuccess(c, res)
}
// ListEventNotifications returns the notification_log rows for one event so
// the DayDetail UI can show "Erinnerung verschickt am …".
func (h *Handler) ListEventNotifications(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
rows, err := h.notificationService.ListLog(c.Request.Context(), c.Param("id"), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
respondSuccess(c, rows)
}
@@ -0,0 +1,140 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/middleware"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// ---------- Teacher-side (uses JWT/dev auth from existing middleware) ----------
func (h *Handler) InviteParent(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.InviteParentRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.parentService.InviteParent(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to invite parent: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListParentInvites(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
items, err := h.parentService.ListInvites(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list invites: "+err.Error())
return
}
if items == nil {
items = []models.ParentInviteListItem{}
}
respondSuccess(c, items)
}
func (h *Handler) DeleteParentInvite(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.parentService.DeleteInvite(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete invite: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Invite removed"})
}
// ---------- Parent-side (uses ParentSessionMiddleware) ----------
func (h *Handler) RedeemMagicLink(c *gin.Context) {
var req models.RedeemMagicLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
session, parent, err := h.parentService.RedeemMagicLink(c.Request.Context(), req.Token)
if err != nil {
respondError(c, http.StatusUnauthorized, err.Error())
return
}
// HttpOnly + Lax → cookie survives a fresh redirect from /eltern/login but
// isn't sent on cross-site CSRF requests.
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(middleware.ParentSessionCookieName, session,
60*60*24*30, "/", "", false, true)
respondSuccess(c, parent)
}
func (h *Handler) ParentMe(c *gin.Context) {
parentID := c.GetString("parent_id")
children, err := h.parentService.ListChildren(c.Request.Context(), parentID)
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
if children == nil {
children = []models.ParentChild{}
}
respondSuccess(c, gin.H{
"parent": gin.H{
"id": parentID,
"email": c.GetString("parent_email"),
"preferred_language": c.GetString("parent_language"),
},
"children": children,
})
}
// ParentTimetable returns the latest completed timetable lessons for the
// given child's class. Authorization: parent must own a child in that class.
func (h *Handler) ParentTimetable(c *gin.Context) {
parentID := c.GetString("parent_id")
classID := c.Query("class_id")
if classID == "" {
respondError(c, http.StatusBadRequest, "class_id required")
return
}
ok, err := h.parentService.ChildBelongsToParent(c.Request.Context(), parentID, classID)
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
if !ok {
respondError(c, http.StatusForbidden, "Not allowed")
return
}
// Need the teacher's user_id to find the right solution. We re-derive it
// from parent_account.created_by_user_id via a small extra query.
teacherID, err := h.parentService.TeacherOfParent(c.Request.Context(), parentID)
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
lessons, err := h.parentService.LatestCompletedSolutionLessonsForClass(c.Request.Context(), classID, teacherID)
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
respondSuccess(c, lessons)
}
func (h *Handler) ParentLogout(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(middleware.ParentSessionCookieName, "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
@@ -0,0 +1,42 @@
package middleware
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
)
// ParentResolver is the minimum the middleware needs from the parent
// service. Defined as interface so handlers can pass their own service
// without import cycles.
type ParentResolver interface {
ParentFromSession(ctx context.Context, token string) (parent interface{}, err error)
}
// ParentSessionCookieName is the name of the HttpOnly cookie that carries
// the parent's session token after redeem. Exported so handlers can set it.
const ParentSessionCookieName = "bp_parent_session"
// ParentSessionMiddleware reads the parent session cookie and resolves it
// to a parent_account. Stores parent_id (string) in the Gin context for
// downstream handlers. Aborts with 401 if the cookie is missing or the
// session expired.
func ParentSessionMiddleware(resolve func(ctx context.Context, token string) (string, string, string, error)) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(ParentSessionCookieName)
if err != nil || token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Parent session required"})
return
}
parentID, email, lang, err := resolve(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session"})
return
}
c.Set("parent_id", parentID)
c.Set("parent_email", email)
c.Set("parent_language", lang)
c.Next()
}
}
@@ -0,0 +1,97 @@
package models
import (
"time"
"github.com/google/uuid"
)
// PublicEvent is a holiday or school-vacation row imported from
// OpenHolidaysAPI. Global (no owner) — same for every school per region.
type PublicEvent struct {
ID uuid.UUID `json:"id" db:"id"`
Region string `json:"region" db:"region"` // e.g. "DE-NI"
EventType string `json:"event_type" db:"event_type"` // public_holiday | school_holiday
NameDe string `json:"name_de" db:"name_de"`
NameEn string `json:"name_en,omitempty" db:"name_en"`
StartDate string `json:"start_date" db:"start_date"` // YYYY-MM-DD
EndDate string `json:"end_date" db:"end_date"`
Source string `json:"source,omitempty" db:"source"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SchoolCalendarConfig stores the Bundesland selection for one school
// (= one Rektor account). One row per user.
type SchoolCalendarConfig struct {
UserID uuid.UUID `json:"user_id" db:"user_id"`
Bundesland string `json:"bundesland" db:"bundesland"` // DE-NI ...
SchoolYearStart *string `json:"school_year_start,omitempty" db:"school_year_start"`
SchoolYearEnd *string `json:"school_year_end,omitempty" db:"school_year_end"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SchoolEvent is a user-managed event (Fortbildung, Schulfeier, …).
type SchoolEvent struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
Title string `json:"title" db:"title"`
Description string `json:"description,omitempty" db:"description"`
EventType string `json:"event_type" db:"event_type"`
IsSchoolFree bool `json:"is_school_free" db:"is_school_free"`
StartDate string `json:"start_date" db:"start_date"`
EndDate string `json:"end_date" db:"end_date"`
StartTime *string `json:"start_time,omitempty" db:"start_time"`
EndTime *string `json:"end_time,omitempty" db:"end_time"`
AffectedClassIDs []uuid.UUID `json:"affected_class_ids" db:"affected_class_ids"`
VisibleToParents bool `json:"visible_to_parents" db:"visible_to_parents"`
NotifyParents bool `json:"notify_parents" db:"notify_parents"`
NotifyStudents bool `json:"notify_students" db:"notify_students"`
NotificationLeadDays []int `json:"notification_lead_days" db:"notification_lead_days"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Request DTOs
// UpsertSchoolCalendarConfigRequest sets or updates the Bundesland for the
// authenticated user. Both school-year dates are optional (defaults to the
// running year based on today's date).
type UpsertSchoolCalendarConfigRequest struct {
Bundesland string `json:"bundesland" binding:"required,len=5"`
SchoolYearStart *string `json:"school_year_start,omitempty"`
SchoolYearEnd *string `json:"school_year_end,omitempty"`
}
type CreateSchoolEventRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
EventType string `json:"event_type" binding:"required,oneof=fortbildung schulfeier klassenfahrt projekttag eltern_info andere"`
IsSchoolFree bool `json:"is_school_free"`
StartDate string `json:"start_date" binding:"required"`
EndDate string `json:"end_date" binding:"required"`
StartTime *string `json:"start_time,omitempty"`
EndTime *string `json:"end_time,omitempty"`
AffectedClassIDs []string `json:"affected_class_ids"`
VisibleToParents bool `json:"visible_to_parents"`
NotifyParents bool `json:"notify_parents"`
NotifyStudents bool `json:"notify_students"`
NotificationLeadDays []int `json:"notification_lead_days"`
}
// SchoolYearRolloverRequest moves all classes up by one grade and updates
// the config's school-year dates. Optional date pair, otherwise defaults
// to next Aug 01 → following Jul 31.
type SchoolYearRolloverRequest struct {
NewYearStart *string `json:"new_year_start,omitempty"` // YYYY-MM-DD
NewYearEnd *string `json:"new_year_end,omitempty"`
}
// SchoolYearRolloverResult is what the endpoint returns so the UI can show
// "promoted 8 classes, removed 2 graduating ones".
type SchoolYearRolloverResult struct {
ClassesPromoted int `json:"classes_promoted"`
ClassesGraduated int `json:"classes_graduated"`
NewYearStart string `json:"new_year_start"`
NewYearEnd string `json:"new_year_end"`
}
+76
View File
@@ -0,0 +1,76 @@
package models
import (
"time"
"github.com/google/uuid"
)
type ParentAccount struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
Email string `json:"email" db:"email"`
PreferredLanguage string `json:"preferred_language" db:"preferred_language"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type ParentChild struct {
ID uuid.UUID `json:"id" db:"id"`
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
TTClassID uuid.UUID `json:"tt_class_id" db:"tt_class_id"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined for display
ClassName string `json:"class_name,omitempty"`
}
// Request DTOs
// InviteParentRequest is what the teacher posts to invite one parent for one
// child. The endpoint creates the account if it doesn't exist, the child
// row, and a fresh magic_link. Same parent can be invited for several
// children (re-using the account).
type InviteParentRequest struct {
Email string `json:"email" binding:"required,email"`
PreferredLanguage string `json:"preferred_language"`
ChildFirstName string `json:"child_first_name" binding:"required"`
ChildLastName string `json:"child_last_name" binding:"required"`
TTClassID string `json:"tt_class_id" binding:"required,uuid"`
}
// InviteParentResponse carries the freshly-minted magic-link path so the
// teacher can copy it into Matrix/Email manually (mass-send comes from the
// notification worker in Phase 9d).
type InviteParentResponse struct {
Parent ParentAccount `json:"parent"`
Child ParentChild `json:"child"`
MagicToken string `json:"magic_token"`
MagicURL string `json:"magic_url"`
ExpiresAt time.Time `json:"expires_at"`
}
// ParentInviteListItem is the teacher-facing list row — one entry per
// (parent, child) pair, with the joined class name.
type ParentInviteListItem struct {
ParentID uuid.UUID `json:"parent_id"`
Email string `json:"email"`
PreferredLanguage string `json:"preferred_language"`
ChildID uuid.UUID `json:"child_id"`
ChildFirstName string `json:"child_first_name"`
ChildLastName string `json:"child_last_name"`
ClassID uuid.UUID `json:"class_id"`
ClassName string `json:"class_name"`
CreatedAt time.Time `json:"created_at"`
}
// RedeemMagicLinkRequest is what /parent/auth/redeem expects.
type RedeemMagicLinkRequest struct {
Token string `json:"token" binding:"required"`
}
// ParentMe is what /parent/me returns: the account + every linked child.
type ParentMe struct {
Parent ParentAccount `json:"parent"`
Children []ParentChild `json:"children"`
}
@@ -0,0 +1,151 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// dispatchOne builds the payload for one (event, audience, channel) tuple,
// posts it to the upstream Matrix/Email service, and writes a
// notification_log row. Returns one of "sent", "failed", "skipped",
// "already" so the caller can tally counters.
//
// "skipped" means the upstream URL is empty (dev/test mode) — we still log
// so the UI can render "will-send-when-configured". "already" means the
// (event, lead, audience, channel) combo is already logged from an earlier
// run today; we don't re-send.
func (s *Service) dispatchOne(ctx context.Context, e dueEvent, audience, channel string, runDate time.Time) (status string, err error) {
// Idempotency check.
var existing int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM notification_log
WHERE event_id = $1 AND lead_days = $2 AND audience = $3 AND channel = $4
`, e.ID, e.LeadDays, audience, channel).Scan(&existing); err != nil {
return "failed", err
}
if existing > 0 {
return "already", nil
}
recipients, lang, err := s.recipientsFor(ctx, e, audience)
if err != nil {
return "failed", err
}
url := s.urlFor(channel)
if url == "" {
// Stub mode: write a 'skipped' log row but report success so the cron
// counter isn't alarming when running locally without the upstream.
_ = s.writeLog(ctx, e, audience, channel, "skipped", "no upstream URL configured", runDate)
return "skipped", nil
}
subject, body := Render(e.EventType, audience, e.LeadDays, lang, Vars{
Title: e.Title, Date: e.StartDate.Format("2006-01-02"),
DatePretty: e.StartDate.Format("02.01.2006"), ClassName: e.ClassName,
})
for _, recipient := range recipients {
payload := DispatchPayload{
Channel: channel, Recipient: recipient, Language: lang,
Subject: subject, Body: body, EventID: e.ID, LeadDays: e.LeadDays,
}
if err := s.postUpstream(ctx, url, payload); err != nil {
_ = s.writeLog(ctx, e, audience, channel, "failed", err.Error(), runDate)
return "failed", err
}
}
if err := s.writeLog(ctx, e, audience, channel, "sent", "", runDate); err != nil {
log.Printf("notification_log insert failed (already counted as sent): %v", err)
}
return "sent", nil
}
// recipientsFor returns the list of email addresses (parents) or Matrix
// handles (students — derived from … unimplemented for now; we just return
// the parent emails and let the bridge fan out).
//
// Per memory the Matrix/Email upstream services are owned by the colleague;
// our job here is to hand them a recipient identifier they can resolve.
// For parents that's the email; for students we have no contact identifier
// yet, so we fall back to the parent emails too (broadcast).
func (s *Service) recipientsFor(ctx context.Context, e dueEvent, audience string) ([]string, string, error) {
// Find the class IDs from the event row. If empty → all classes owned by
// the teacher.
rows, err := s.db.Query(ctx, `
SELECT DISTINCT pa.email, pa.preferred_language
FROM parent_account pa
JOIN parent_child pc ON pc.parent_id = pa.id
WHERE pa.created_by_user_id = $1
AND (
(SELECT array_length(affected_class_ids, 1) FROM cal_school_event WHERE id = $2) IS NULL
OR pc.tt_class_id = ANY(
(SELECT affected_class_ids FROM cal_school_event WHERE id = $2)
)
)
`, e.OwnerUserID, e.ID)
if err != nil {
return nil, "de", err
}
defer rows.Close()
var emails []string
primaryLang := "de"
first := true
for rows.Next() {
var email, lang string
if err := rows.Scan(&email, &lang); err != nil {
return nil, "de", err
}
emails = append(emails, email)
if first {
primaryLang = lang
first = false
}
}
return emails, primaryLang, nil
}
func (s *Service) urlFor(channel string) string {
switch channel {
case "matrix":
return s.matrixURL
case "email":
return s.emailURL
}
return ""
}
func (s *Service) postUpstream(ctx context.Context, url string, payload DispatchPayload) error {
body, _ := json.Marshal(payload)
cctx, cancel := context.WithTimeout(ctx, s.httpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("upstream returned HTTP %d", resp.StatusCode)
}
return nil
}
func (s *Service) writeLog(ctx context.Context, e dueEvent, audience, channel, status, errorMessage string, runDate time.Time) error {
_, err := s.db.Exec(ctx, `
INSERT INTO notification_log (event_id, lead_days, audience, channel, status, error_message, run_date)
VALUES ($1::uuid, $2, $3, $4, $5, NULLIF($6, ''), $7::date)
ON CONFLICT (event_id, lead_days, audience, channel) DO NOTHING
`, e.ID, e.LeadDays, audience, channel, status, errorMessage, runDate.Format("2006-01-02"))
return err
}
@@ -0,0 +1,204 @@
package notifications
import (
"context"
"fmt"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the entry point for the daily notification scan. It reads
// cal_school_event + parent_account + tt_class, decides which (event,
// lead_days, audience) pairs are due today, dispatches via the configured
// upstream URLs, and writes a notification_log row for idempotency.
type Service struct {
db *pgxpool.Pool
matrixURL string // empty → status=skipped
emailURL string // empty → status=skipped
httpTimeout time.Duration
}
// Dispatch is the contract our upstream services (Matrix bridge + Email
// gateway, owned by the colleague) must implement. We POST a body with
// these fields; they figure out delivery. Stub mode (URL == "") logs
// instead, useful for dev + tests.
type DispatchPayload struct {
Channel string `json:"channel"` // "matrix" | "email"
Recipient string `json:"recipient"` // email address; for Matrix the bridge maps it
Language string `json:"language"`
Subject string `json:"subject"`
Body string `json:"body"`
EventID string `json:"event_id"`
LeadDays int `json:"lead_days"`
}
func NewService(db *pgxpool.Pool, matrixURL, emailURL string) *Service {
return &Service{
db: db,
matrixURL: matrixURL,
emailURL: emailURL,
httpTimeout: 10 * time.Second,
}
}
// LogRow is the read shape for the notification_log table.
type LogRow struct {
LeadDays int `json:"lead_days"`
Audience string `json:"audience"`
Channel string `json:"channel"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
RunDate string `json:"run_date"`
CreatedAt time.Time `json:"created_at"`
}
// ListLog returns the notification_log rows for one event, scoped to a
// teacher so the UI can't query someone else's log.
func (s *Service) ListLog(ctx context.Context, eventID, ownerUserID string) ([]LogRow, error) {
rows, err := s.db.Query(ctx, `
SELECT nl.lead_days, nl.audience, nl.channel, nl.status,
COALESCE(nl.error_message, ''), nl.run_date::text, nl.created_at
FROM notification_log nl
JOIN cal_school_event ev ON ev.id = nl.event_id
WHERE ev.id = $1 AND ev.created_by_user_id = $2
ORDER BY nl.lead_days DESC, nl.audience, nl.channel
`, eventID, ownerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []LogRow{}
for rows.Next() {
var r LogRow
if err := rows.Scan(&r.LeadDays, &r.Audience, &r.Channel, &r.Status, &r.Error, &r.RunDate, &r.CreatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// RunForDate scans every active cal_school_event with a lead_day equal to
// (event_start - runDate). For each due (audience, channel) pair it
// renders + dispatches + logs. Idempotent via the UNIQUE constraint on
// notification_log.
type RunResult struct {
Date string `json:"date"`
Sent int `json:"sent"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
AlreadyLogged int `json:"already_logged"`
}
func (s *Service) RunForDate(ctx context.Context, runDate time.Time) (*RunResult, error) {
res := &RunResult{Date: runDate.Format("2006-01-02")}
events, err := s.dueEvents(ctx, runDate)
if err != nil {
return nil, fmt.Errorf("query due events: %w", err)
}
for _, e := range events {
// For each event we may notify parents and/or students depending on
// the row's flags. Channels are derived from the audience:
// parents → email + matrix
// students → matrix only (students don't always have email)
audiences := []string{}
if e.NotifyParents {
audiences = append(audiences, "parents")
}
if e.NotifyStudents {
audiences = append(audiences, "students")
}
for _, audience := range audiences {
channels := []string{"matrix"}
if audience == "parents" {
channels = append(channels, "email")
}
for _, channel := range channels {
status, err := s.dispatchOne(ctx, e, audience, channel, runDate)
switch status {
case "sent":
res.Sent++
case "failed":
res.Failed++
if err != nil {
log.Printf("notification dispatch failed: %v", err)
}
case "skipped":
res.Skipped++
case "already":
res.AlreadyLogged++
}
}
}
}
return res, nil
}
// dueEvent holds the small slice of cal_school_event row we need plus the
// matched lead_day for this run.
type dueEvent struct {
ID string
Title string
EventType string
StartDate time.Time
ClassName string // optional, may be empty for "alle Klassen"
OwnerUserID string
NotifyParents bool
NotifyStudents bool
LeadDays int
}
func (s *Service) dueEvents(ctx context.Context, runDate time.Time) ([]dueEvent, error) {
// A row is due when (start_date - runDate) appears in
// notification_lead_days. Lead=0 means "today the event starts".
rows, err := s.db.Query(ctx, `
SELECT ev.id::text, ev.title, ev.event_type, ev.start_date, ev.created_by_user_id::text,
ev.notify_parents, ev.notify_students, ev.notification_lead_days,
COALESCE(
(SELECT string_agg(cl.name, ', ' ORDER BY cl.name)
FROM tt_class cl
WHERE cl.id = ANY(ev.affected_class_ids)),
''
) AS class_names
FROM cal_school_event ev
WHERE ev.start_date >= $1::date
AND (ev.notify_parents OR ev.notify_students)
`, runDate.Format("2006-01-02"))
if err != nil {
return nil, err
}
defer rows.Close()
var out []dueEvent
for rows.Next() {
var (
id, title, eventType, ownerUserID, classNames string
notifyParents, notifyStudents bool
startDate time.Time
leadDays []int32
)
if err := rows.Scan(&id, &title, &eventType, &startDate, &ownerUserID,
&notifyParents, &notifyStudents, &leadDays, &classNames); err != nil {
return nil, err
}
distanceDays := int(startDate.Sub(runDate).Hours() / 24)
for _, lead := range leadDays {
if int(lead) == distanceDays {
out = append(out, dueEvent{
ID: id, Title: title, EventType: eventType,
StartDate: startDate, ClassName: classNames,
OwnerUserID: ownerUserID,
NotifyParents: notifyParents,
NotifyStudents: notifyStudents,
LeadDays: int(lead),
})
break
}
}
}
return out, nil
}
@@ -0,0 +1,255 @@
package notifications
import (
"fmt"
"strings"
)
// Vars are the placeholder values rendered into a template string.
// The fields here must match the {{…}} markers in templates below.
type Vars struct {
Title string
Date string // YYYY-MM-DD
DatePretty string // e.g. "Donnerstag, 15. Oktober"
ClassName string // empty = whole school
TeacherName string
}
// Render picks the right template based on event type, audience, lead-day
// bucket and language, then substitutes the {{var}} placeholders.
//
// lead is grouped into three buckets:
//
// 0 → "today"
// 1 → "tomorrow"
// >=2 → "in X days" with X = lead value
//
// Falls back through (lang → de) and (event_type → "andere") so we never
// fail to render even with custom rule combos.
func Render(eventType, audience string, lead int, lang string, v Vars) (subject, body string) {
bucket := bucketFor(lead)
lc := strings.ToLower(lang)
if _, ok := templates[lc]; !ok {
lc = "de"
}
t := lookup(lc, eventType, audience, bucket)
subject = substitute(t.Subject, lead, v)
body = substitute(t.Body, lead, v)
return subject, body
}
func bucketFor(lead int) string {
switch {
case lead <= 0:
return "today"
case lead == 1:
return "tomorrow"
default:
return "days"
}
}
// lookup walks the templates map applying the (lang → de) and
// (event_type → andere) fallbacks. The bucket is guaranteed to exist for
// every audience because we always define today/tomorrow/days in the de
// baseline.
func lookup(lang, eventType, audience, bucket string) tmpl {
byEvent, ok := templates[lang][eventType]
if !ok {
byEvent = templates[lang]["andere"]
}
byAudience, ok := byEvent[audience]
if !ok {
byAudience = byEvent["parents"]
}
t, ok := byAudience[bucket]
if !ok {
t = byAudience["days"]
}
return t
}
func substitute(s string, lead int, v Vars) string {
classSuffix := ""
if v.ClassName != "" {
classSuffix = " (" + v.ClassName + ")"
}
repl := strings.NewReplacer(
"{{title}}", v.Title,
"{{date}}", v.Date,
"{{date_pretty}}", v.DatePretty,
"{{class_name}}", v.ClassName,
"{{class_suffix}}", classSuffix,
"{{teacher_name}}", v.TeacherName,
"{{lead}}", fmt.Sprintf("%d", lead),
)
return repl.Replace(s)
}
type tmpl struct {
Subject string
Body string
}
// templates[lang][eventType][audience][bucket] → tmpl
//
// Only the eight parent languages we ship subject-i18n.ts for are covered.
// Custom event_types fall back to the "andere" branch. Custom languages
// fall back to "de". This keeps the file under 500 LOC; if more locales
// are needed, split into one file per language.
var templates = map[string]map[string]map[string]map[string]tmpl{
"de": deTemplates(),
"en": enTemplates(),
"tr": trTemplates(),
"ar": arTemplates(),
"uk": ukTemplates(),
"ru": ruTemplates(),
"pl": plTemplates(),
"fr": frTemplates(),
}
func deTemplates() map[string]map[string]map[string]tmpl {
// All event types share the same template family; we only vary by audience
// and bucket. Specialise where wording really differs (e.g. fortbildung
// is school-free, schulfeier invites attendance).
parentToday := tmpl{
Subject: "Heute: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, heute findet {{title}} statt{{class_suffix}}. Datum: {{date_pretty}}.",
}
parentTomorrow := tmpl{
Subject: "Morgen: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, morgen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
}
parentDays := tmpl{
Subject: "In {{lead}} Tagen: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, in {{lead}} Tagen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
}
studentToday := tmpl{
Subject: "Heute: {{title}}",
Body: "Heute ist {{title}}{{class_suffix}}.",
}
studentTomorrow := tmpl{
Subject: "Morgen: {{title}}",
Body: "Morgen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
}
studentDays := tmpl{
Subject: "In {{lead}} Tagen: {{title}}",
Body: "In {{lead}} Tagen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
}
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
}
universal := map[string]map[string]tmpl{
"parents": bucket(parentToday, parentTomorrow, parentDays),
"students": bucket(studentToday, studentTomorrow, studentDays),
}
return map[string]map[string]map[string]tmpl{
"fortbildung": universal,
"schulfeier": universal,
"klassenfahrt": universal,
"projekttag": universal,
"eltern_info": universal,
"andere": universal,
}
}
// The non-DE templates use the same structure; only the strings change.
// Defined in a single helper that takes the translation map and reuses the
// DE family glue.
func makeFamily(
pT, pTm, pD, sT, sTm, sD tmpl,
) map[string]map[string]map[string]tmpl {
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
}
universal := map[string]map[string]tmpl{
"parents": bucket(pT, pTm, pD),
"students": bucket(sT, sTm, sD),
}
return map[string]map[string]map[string]tmpl{
"fortbildung": universal,
"schulfeier": universal,
"klassenfahrt": universal,
"projekttag": universal,
"eltern_info": universal,
"andere": universal,
}
}
func enTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Today: {{title}}{{class_suffix}}", "Dear parents, today {{title}} takes place{{class_suffix}}. Date: {{date_pretty}}."},
tmpl{"Tomorrow: {{title}}{{class_suffix}}", "Dear parents, tomorrow ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
tmpl{"In {{lead}} days: {{title}}{{class_suffix}}", "Dear parents, in {{lead}} days ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
tmpl{"Today: {{title}}", "Today is {{title}}{{class_suffix}}."},
tmpl{"Tomorrow: {{title}}", "Tomorrow ({{date_pretty}}) is {{title}}{{class_suffix}}."},
tmpl{"In {{lead}} days: {{title}}", "In {{lead}} days ({{date_pretty}}) is {{title}}{{class_suffix}}."},
)
}
func trTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Bugün: {{title}}{{class_suffix}}", "Sayın veliler, bugün {{title}} gerçekleşiyor{{class_suffix}}. Tarih: {{date_pretty}}."},
tmpl{"Yarın: {{title}}{{class_suffix}}", "Sayın veliler, yarın ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
tmpl{"{{lead}} gün sonra: {{title}}{{class_suffix}}", "Sayın veliler, {{lead}} gün sonra ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
tmpl{"Bugün: {{title}}", "Bugün {{title}}{{class_suffix}}."},
tmpl{"Yarın: {{title}}", "Yarın ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"{{lead}} gün sonra: {{title}}", "{{lead}} gün sonra ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func arTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"اليوم: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، اليوم يقام {{title}}{{class_suffix}}. التاريخ: {{date_pretty}}."},
tmpl{"غدًا: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، غدًا ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
tmpl{"بعد {{lead}} أيام: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، بعد {{lead}} أيام ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
tmpl{"اليوم: {{title}}", "اليوم {{title}}{{class_suffix}}."},
tmpl{"غدًا: {{title}}", "غدًا ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"بعد {{lead}} أيام: {{title}}", "بعد {{lead}} أيام ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func ukTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Сьогодні: {{title}}{{class_suffix}}", "Шановні батьки, сьогодні відбудеться {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
tmpl{"Завтра: {{title}}{{class_suffix}}", "Шановні батьки, завтра ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} днів: {{title}}{{class_suffix}}", "Шановні батьки, через {{lead}} днів ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
tmpl{"Сьогодні: {{title}}", "Сьогодні {{title}}{{class_suffix}}."},
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} днів: {{title}}", "Через {{lead}} днів ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func ruTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Сегодня: {{title}}{{class_suffix}}", "Уважаемые родители, сегодня состоится {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
tmpl{"Завтра: {{title}}{{class_suffix}}", "Уважаемые родители, завтра ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} дней: {{title}}{{class_suffix}}", "Уважаемые родители, через {{lead}} дней ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
tmpl{"Сегодня: {{title}}", "Сегодня {{title}}{{class_suffix}}."},
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} дней: {{title}}", "Через {{lead}} дней ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func plTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Dzisiaj: {{title}}{{class_suffix}}", "Drodzy rodzice, dzisiaj odbywa się {{title}}{{class_suffix}}. Data: {{date_pretty}}."},
tmpl{"Jutro: {{title}}{{class_suffix}}", "Drodzy rodzice, jutro ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
tmpl{"Za {{lead}} dni: {{title}}{{class_suffix}}", "Drodzy rodzice, za {{lead}} dni ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
tmpl{"Dzisiaj: {{title}}", "Dzisiaj {{title}}{{class_suffix}}."},
tmpl{"Jutro: {{title}}", "Jutro ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Za {{lead}} dni: {{title}}", "Za {{lead}} dni ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func frTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Aujourd'hui : {{title}}{{class_suffix}}", "Chers parents, aujourd'hui a lieu {{title}}{{class_suffix}}. Date : {{date_pretty}}."},
tmpl{"Demain : {{title}}{{class_suffix}}", "Chers parents, demain ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
tmpl{"Dans {{lead}} jours : {{title}}{{class_suffix}}", "Chers parents, dans {{lead}} jours ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
tmpl{"Aujourd'hui : {{title}}", "Aujourd'hui c'est {{title}}{{class_suffix}}."},
tmpl{"Demain : {{title}}", "Demain ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
tmpl{"Dans {{lead}} jours : {{title}}", "Dans {{lead}} jours ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
)
}
@@ -0,0 +1,75 @@
package notifications
import (
"strings"
"testing"
)
func TestBucketFor(t *testing.T) {
cases := []struct {
lead int
want string
}{
{-1, "today"}, {0, "today"}, {1, "tomorrow"}, {2, "days"}, {7, "days"},
}
for _, c := range cases {
if got := bucketFor(c.lead); got != c.want {
t.Errorf("bucketFor(%d) = %q, want %q", c.lead, got, c.want)
}
}
}
func TestRender_GermanParentsToday(t *testing.T) {
subject, body := Render("fortbildung", "parents", 0, "de", Vars{
Title: "SCHILF", DatePretty: "10.10.2026", ClassName: "5a",
})
if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "SCHILF") {
t.Errorf("expected today + title in subject, got %q", subject)
}
if !strings.Contains(body, "Liebe Eltern") {
t.Errorf("expected greeting in body, got %q", body)
}
if !strings.Contains(body, "(5a)") {
t.Errorf("expected class suffix, got %q", body)
}
}
func TestRender_TurkishStudentTomorrow(t *testing.T) {
subject, _ := Render("schulfeier", "students", 1, "tr", Vars{
Title: "Yaz şenliği", DatePretty: "12.06.2026",
})
if !strings.Contains(subject, "Yarın") || !strings.Contains(subject, "Yaz şenliği") {
t.Errorf("expected Turkish 'tomorrow' subject, got %q", subject)
}
}
func TestRender_FallbackLanguage(t *testing.T) {
// 'xx' isn't supported → falls back to de.
subject, _ := Render("klassenfahrt", "parents", 7, "xx", Vars{
Title: "Wattenmeer", DatePretty: "15.05.2026", ClassName: "6b",
})
if !strings.Contains(subject, "In 7 Tagen") {
t.Errorf("expected German fallback, got %q", subject)
}
}
func TestRender_FallbackEventType(t *testing.T) {
// 'unknown_type' falls back to 'andere'.
subject, _ := Render("unknown_type", "parents", 0, "de", Vars{
Title: "Sondertermin",
})
if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "Sondertermin") {
t.Errorf("expected today subject after fallback, got %q", subject)
}
}
func TestSubstitute_DropsClassSuffixWhenEmpty(t *testing.T) {
out := substitute("X{{class_suffix}}Y", 0, Vars{ClassName: ""})
if out != "XY" {
t.Errorf("expected XY (no suffix), got %q", out)
}
out = substitute("X{{class_suffix}}Y", 0, Vars{ClassName: "5a"})
if out != "X (5a)Y" {
t.Errorf("expected ' (5a)' suffix, got %q", out)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
package services
import (
"context"
"fmt"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
)
// School-event CRUD. Ownership is per-user via created_by_user_id. Public
// holiday/Ferien data lives in cal_public_event and is handled by
// calendar_service.go.
func (s *CalendarService) CreateEvent(ctx context.Context, userID string, req *models.CreateSchoolEventRequest) (*models.SchoolEvent, error) {
classIDs, err := parseClassIDs(req.AffectedClassIDs)
if err != nil {
return nil, err
}
var e models.SchoolEvent
leadDays := req.NotificationLeadDays
if leadDays == nil {
leadDays = []int{7, 1}
}
row := s.db.QueryRow(ctx, `
INSERT INTO cal_school_event
(created_by_user_id, title, description, event_type, is_school_free,
start_date, end_date, start_time, end_time, affected_class_ids,
visible_to_parents, notify_parents, notify_students, notification_lead_days)
VALUES ($1, $2, $3, $4, $5,
$6::date, $7::date, NULLIF($8, '')::time, NULLIF($9, '')::time,
$10, $11, $12, $13, $14)
RETURNING id, created_by_user_id, title, COALESCE(description,''), event_type,
is_school_free, start_date::text, end_date::text,
start_time::text, end_time::text, affected_class_ids,
visible_to_parents, notify_parents, notify_students,
notification_lead_days, created_at, updated_at
`, userID, req.Title, req.Description, req.EventType, req.IsSchoolFree,
req.StartDate, req.EndDate, strOrEmpty(req.StartTime), strOrEmpty(req.EndTime),
classIDs, req.VisibleToParents, req.NotifyParents, req.NotifyStudents, leadDays)
if err := scanEvent(row, &e); err != nil {
return nil, err
}
return &e, nil
}
func (s *CalendarService) ListEvents(ctx context.Context, userID, from, to string) ([]models.SchoolEvent, error) {
if from == "" {
from = "1900-01-01"
}
if to == "" {
to = "2100-12-31"
}
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, title, COALESCE(description,''), event_type,
is_school_free, start_date::text, end_date::text,
start_time::text, end_time::text, affected_class_ids,
visible_to_parents, notify_parents, notify_students,
notification_lead_days, created_at, updated_at
FROM cal_school_event
WHERE created_by_user_id = $1
AND end_date >= $2::date
AND start_date <= $3::date
ORDER BY start_date, start_time NULLS FIRST, title
`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SchoolEvent
for rows.Next() {
var e models.SchoolEvent
if err := scanEvent(rows, &e); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
func (s *CalendarService) DeleteEvent(ctx context.Context, id, userID string) error {
res, err := s.db.Exec(ctx, `
DELETE FROM cal_school_event WHERE id = $1 AND created_by_user_id = $2
`, id, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("event not found or not owned")
}
return nil
}
// parseClassIDs validates the array of UUID strings the request sent.
// Returns a typed []uuid.UUID so asyncpg/pgx encodes it correctly into the
// UUID[] column.
func parseClassIDs(in []string) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
u, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid class_id %q: %w", s, err)
}
out = append(out, u)
}
return out, nil
}
// row interface so the same scan logic works for both QueryRow and Rows.
type rowScanner interface {
Scan(dest ...any) error
}
func scanEvent(r rowScanner, e *models.SchoolEvent) error {
var startTime, endTime *string
if err := r.Scan(
&e.ID, &e.CreatedByUserID, &e.Title, &e.Description, &e.EventType,
&e.IsSchoolFree, &e.StartDate, &e.EndDate,
&startTime, &endTime, &e.AffectedClassIDs,
&e.VisibleToParents, &e.NotifyParents, &e.NotifyStudents,
&e.NotificationLeadDays, &e.CreatedAt, &e.UpdatedAt,
); err != nil {
return err
}
e.StartTime = startTime
e.EndTime = endTime
return nil
}
@@ -0,0 +1,85 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
)
// RolloverSchoolYear advances every tt_class for this user by one grade
// level, removes graduating classes (grade > 13), and updates the
// cal_school_config school-year dates. Operates as a single transaction.
//
// Stammdaten (teachers, subjects, rooms, periods) bleiben unveraendert —
// es aendern sich nur Klassen-Stufen.
func (s *CalendarService) RolloverSchoolYear(ctx context.Context, userID string, req *models.SchoolYearRolloverRequest) (*models.SchoolYearRolloverResult, error) {
newStart, newEnd := defaultSchoolYearDates(req)
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// 1. Remove the graduating cohort first so they don't get bumped to 14.
gradRes, err := tx.Exec(ctx, `
DELETE FROM tt_class WHERE created_by_user_id = $1 AND grade_level >= 13
`, userID)
if err != nil {
return nil, fmt.Errorf("delete graduating: %w", err)
}
// 2. Promote everyone else.
promRes, err := tx.Exec(ctx, `
UPDATE tt_class SET grade_level = grade_level + 1
WHERE created_by_user_id = $1
`, userID)
if err != nil {
return nil, fmt.Errorf("promote classes: %w", err)
}
// 3. Update the school-year dates in the config (creates a row if the
// user never picked a Bundesland — but that's an edge case; in normal
// flow the wizard has run before rollover).
_, err = tx.Exec(ctx, `
UPDATE cal_school_config
SET school_year_start = $1::date,
school_year_end = $2::date,
updated_at = NOW()
WHERE user_id = $3
`, newStart, newEnd, userID)
if err != nil {
return nil, fmt.Errorf("update config: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &models.SchoolYearRolloverResult{
ClassesPromoted: int(promRes.RowsAffected()),
ClassesGraduated: int(gradRes.RowsAffected()),
NewYearStart: newStart,
NewYearEnd: newEnd,
}, nil
}
// defaultSchoolYearDates returns the dates from the request if both set,
// otherwise the next school year starting Aug 1 of "this year or next"
// and ending Jul 31 the year after.
func defaultSchoolYearDates(req *models.SchoolYearRolloverRequest) (string, string) {
if req != nil && req.NewYearStart != nil && req.NewYearEnd != nil {
return *req.NewYearStart, *req.NewYearEnd
}
now := time.Now()
startYear := now.Year()
// If we're past August, the "new" year refers to the next calendar year.
if int(now.Month()) >= 8 {
startYear++
}
start := time.Date(startYear, 8, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(startYear+1, 7, 31, 0, 0, 0, 0, time.UTC)
return start.Format("2006-01-02"), end.Format("2006-01-02")
}
@@ -0,0 +1,134 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// CalendarService owns the cal_* tables and the read of the seed snapshot
// on first boot. Holidays are global (no owner) — same data for every
// school in a given Bundesland.
type CalendarService struct {
db *pgxpool.Pool
}
func NewCalendarService(db *pgxpool.Pool) *CalendarService {
return &CalendarService{db: db}
}
// SeedFromSnapshot reads internal/seed/calendar_holidays.json and bulk-inserts
// every row that doesn't already exist (idempotent via the unique constraint
// on region+event_type+name_de+start_date). Called once at server start.
func (s *CalendarService) SeedFromSnapshot(ctx context.Context, path string) error {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
log.Printf("calendar seed file not found at %s — skipping", path)
return nil
}
return fmt.Errorf("read snapshot: %w", err)
}
var events []models.PublicEvent
if err := json.Unmarshal(raw, &events); err != nil {
return fmt.Errorf("parse snapshot: %w", err)
}
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
inserted := 0
for _, e := range events {
ct, err := tx.Exec(ctx, `
INSERT INTO cal_public_event (region, event_type, name_de, name_en, start_date, end_date, source)
VALUES ($1, $2, $3, NULLIF($4, ''), $5::date, $6::date, 'OpenHolidaysAPI')
ON CONFLICT (region, event_type, name_de, start_date) DO NOTHING
`, e.Region, e.EventType, e.NameDe, e.NameEn, e.StartDate, e.EndDate)
if err != nil {
return fmt.Errorf("insert event: %w", err)
}
inserted += int(ct.RowsAffected())
}
if err := tx.Commit(ctx); err != nil {
return err
}
log.Printf("calendar seed: %d new events inserted (of %d in snapshot)", inserted, len(events))
return nil
}
// ListHolidays returns all public + school holidays for the given region
// between from..to (YYYY-MM-DD inclusive).
func (s *CalendarService) ListHolidays(ctx context.Context, region, from, to string) ([]models.PublicEvent, error) {
rows, err := s.db.Query(ctx, `
SELECT id, region, event_type, name_de, COALESCE(name_en, ''),
start_date::text, end_date::text, COALESCE(source, ''), created_at
FROM cal_public_event
WHERE region = $1
AND end_date >= $2::date
AND start_date <= $3::date
ORDER BY start_date, event_type, name_de
`, region, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PublicEvent
for rows.Next() {
var e models.PublicEvent
if err := rows.Scan(&e.ID, &e.Region, &e.EventType, &e.NameDe, &e.NameEn,
&e.StartDate, &e.EndDate, &e.Source, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
// GetConfig returns the per-user calendar config (Bundesland etc.) or nil
// if the user has not configured one yet.
func (s *CalendarService) GetConfig(ctx context.Context, userID string) (*models.SchoolCalendarConfig, error) {
var c models.SchoolCalendarConfig
err := s.db.QueryRow(ctx, `
SELECT user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at
FROM cal_school_config WHERE user_id = $1
`, userID).Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
// pgx returns no-rows error; caller maps to 404.
return nil, err
}
return &c, nil
}
// UpsertConfig inserts or updates the Bundesland selection.
func (s *CalendarService) UpsertConfig(ctx context.Context, userID string, req *models.UpsertSchoolCalendarConfigRequest) (*models.SchoolCalendarConfig, error) {
var c models.SchoolCalendarConfig
err := s.db.QueryRow(ctx, `
INSERT INTO cal_school_config (user_id, bundesland, school_year_start, school_year_end)
VALUES ($1, $2, NULLIF($3, '')::date, NULLIF($4, '')::date)
ON CONFLICT (user_id) DO UPDATE
SET bundesland = EXCLUDED.bundesland,
school_year_start = EXCLUDED.school_year_start,
school_year_end = EXCLUDED.school_year_end,
updated_at = NOW()
RETURNING user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at
`, userID, req.Bundesland, strOrEmpty(req.SchoolYearStart), strOrEmpty(req.SchoolYearEnd)).
Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt)
return &c, err
}
func strOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
@@ -0,0 +1,102 @@
package services
import (
"testing"
"github.com/breakpilot/school-service/internal/models"
)
func TestUpsertSchoolCalendarConfigRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.UpsertSchoolCalendarConfigRequest
wantErr bool
}{
{"valid NI", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE-NI"}, false},
{"empty bundesland", models.UpsertSchoolCalendarConfigRequest{Bundesland: ""}, true},
{"too long", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE-NIE"}, true},
{"too short", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
func TestCreateSchoolEventRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateSchoolEventRequest
wantErr bool
}{
{"valid fortbildung", models.CreateSchoolEventRequest{
Title: "SCHILF", EventType: "fortbildung",
StartDate: "2026-10-01", EndDate: "2026-10-01",
}, false},
{"missing title", models.CreateSchoolEventRequest{
EventType: "fortbildung", StartDate: "2026-10-01", EndDate: "2026-10-01",
}, true},
{"invalid event type", models.CreateSchoolEventRequest{
Title: "X", EventType: "wedding",
StartDate: "2026-10-01", EndDate: "2026-10-01",
}, true},
{"missing dates", models.CreateSchoolEventRequest{
Title: "X", EventType: "schulfeier",
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
func TestNewCalendarService_Constructs(t *testing.T) {
s := NewCalendarService(nil)
if s == nil {
t.Fatal("expected non-nil service")
}
}
func TestDefaultSchoolYearDates_FallbackFormat(t *testing.T) {
// No override → deterministic YYYY-MM-DD strings with end > start.
start, end := defaultSchoolYearDates(nil)
if len(start) != 10 || len(end) != 10 {
t.Fatalf("expected YYYY-MM-DD strings, got %q %q", start, end)
}
if end <= start {
t.Errorf("end %q must be after start %q", end, start)
}
}
func TestDefaultSchoolYearDates_ExplicitOverride(t *testing.T) {
s, e := "2030-09-01", "2031-06-30"
req := &models.SchoolYearRolloverRequest{NewYearStart: &s, NewYearEnd: &e}
gotS, gotE := defaultSchoolYearDates(req)
if gotS != s || gotE != e {
t.Errorf("override ignored: got %q/%q want %q/%q", gotS, gotE, s, e)
}
}
func TestParseClassIDs_AcceptsValidAndRejectsGarbage(t *testing.T) {
good := []string{"00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"}
out, err := parseClassIDs(good)
if err != nil || len(out) != 2 {
t.Fatalf("expected 2 parsed UUIDs, got %v err=%v", out, err)
}
if _, err := parseClassIDs([]string{"not-a-uuid"}); err == nil {
t.Errorf("expected error for invalid uuid")
}
// Empty strings are silently dropped (curl convenience).
out, err = parseClassIDs([]string{"", "00000000-0000-0000-0000-000000000003", ""})
if err != nil || len(out) != 1 {
t.Errorf("expected 1 parsed UUID, got %v err=%v", out, err)
}
}
@@ -0,0 +1,209 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
)
// RedeemMagicLink validates a one-shot link, marks it used, mints a session
// token. Returns the raw session token; caller (HTTP handler) sets it as
// HttpOnly cookie.
func (s *ParentService) RedeemMagicLink(ctx context.Context, token string) (sessionToken string, parent *models.ParentAccount, err error) {
hash := hashToken(token)
tx, err := s.db.Begin(ctx)
if err != nil {
return "", nil, err
}
defer tx.Rollback(ctx)
var (
linkID uuid.UUID
parentID uuid.UUID
expiresAt time.Time
usedAt *time.Time
)
if err := tx.QueryRow(ctx, `
SELECT id, parent_id, expires_at, used_at
FROM parent_magic_link
WHERE token_hash = $1
`, hash).Scan(&linkID, &parentID, &expiresAt, &usedAt); err != nil {
return "", nil, fmt.Errorf("invalid token")
}
if usedAt != nil {
return "", nil, fmt.Errorf("token already used")
}
if time.Now().After(expiresAt) {
return "", nil, fmt.Errorf("token expired")
}
// Mark used.
if _, err := tx.Exec(ctx, `UPDATE parent_magic_link SET used_at = NOW() WHERE id = $1`, linkID); err != nil {
return "", nil, err
}
// Mint session token.
raw, h, err := randomToken()
if err != nil {
return "", nil, err
}
sessionExpires := time.Now().Add(parentSessionTTL)
if _, err := tx.Exec(ctx, `
INSERT INTO parent_session (parent_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, parentID, h, sessionExpires); err != nil {
return "", nil, err
}
// Fetch the account so callers (UI) get the email + language back.
var p models.ParentAccount
if err := tx.QueryRow(ctx, `
SELECT id, created_by_user_id, email, preferred_language, created_at
FROM parent_account WHERE id = $1
`, parentID).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt); err != nil {
return "", nil, err
}
if err := tx.Commit(ctx); err != nil {
return "", nil, err
}
return raw, &p, nil
}
// ParentFromSession resolves a session token back to the parent account.
// Returns error on missing/expired session. Called by ParentSession
// middleware.
func (s *ParentService) ParentFromSession(ctx context.Context, sessionToken string) (*models.ParentAccount, error) {
hash := hashToken(sessionToken)
var p models.ParentAccount
var expiresAt time.Time
if err := s.db.QueryRow(ctx, `
SELECT pa.id, pa.created_by_user_id, pa.email, pa.preferred_language, pa.created_at, ps.expires_at
FROM parent_session ps
JOIN parent_account pa ON pa.id = ps.parent_id
WHERE ps.token_hash = $1
`, hash).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt, &expiresAt); err != nil {
return nil, fmt.Errorf("invalid session")
}
if time.Now().After(expiresAt) {
return nil, fmt.Errorf("session expired")
}
return &p, nil
}
// ListChildren returns all parent_child rows for a parent, joined with the
// class name from tt_class.
func (s *ParentService) ListChildren(ctx context.Context, parentID string) ([]models.ParentChild, error) {
rows, err := s.db.Query(ctx, `
SELECT pc.id, pc.parent_id, pc.tt_class_id, pc.first_name, pc.last_name, pc.created_at, cl.name
FROM parent_child pc
JOIN tt_class cl ON cl.id = pc.tt_class_id
WHERE pc.parent_id = $1
ORDER BY pc.last_name, pc.first_name
`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ParentChild
for rows.Next() {
var c models.ParentChild
if err := rows.Scan(&c.ID, &c.ParentID, &c.TTClassID, &c.FirstName, &c.LastName, &c.CreatedAt, &c.ClassName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
// TeacherOfParent returns the created_by_user_id of the teacher who invited
// this parent. Used to scope timetable + calendar queries.
func (s *ParentService) TeacherOfParent(ctx context.Context, parentID string) (string, error) {
var uid string
err := s.db.QueryRow(ctx,
`SELECT created_by_user_id::text FROM parent_account WHERE id = $1`, parentID,
).Scan(&uid)
return uid, err
}
// ChildBelongsToParent checks whether a tt_class is one this parent has a
// child in. Used by the timetable + calendar handlers as authorization.
func (s *ParentService) ChildBelongsToParent(ctx context.Context, parentID, classID string) (bool, error) {
var ok bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM parent_child
WHERE parent_id = $1 AND tt_class_id = $2)
`, parentID, classID).Scan(&ok)
return ok, err
}
// LatestCompletedSolutionLessonsForClass returns the lessons of the most
// recent COMPLETED tt_solution where the given class has rows, owned by
// the teacher that originally invited the parent. Joined with subject + room
// + teacher names so the parent UI can render directly.
func (s *ParentService) LatestCompletedSolutionLessonsForClass(ctx context.Context, classID, teacherUserID string) ([]LessonExport, error) {
// Find latest completed solution by the teacher that has at least one
// lesson in this class.
var solutionID string
if err := s.db.QueryRow(ctx, `
SELECT s.id::text
FROM tt_solution s
JOIN tt_lesson l ON l.solution_id = s.id
WHERE s.created_by_user_id = $1
AND s.status = 'completed'
AND l.class_id = $2::uuid
ORDER BY s.created_at DESC
LIMIT 1
`, teacherUserID, classID).Scan(&solutionID); err != nil {
return nil, nil // no plan yet — parent UI shows empty grid
}
// Re-use the existing export shape with a stricter filter (class only).
rows, err := s.db.Query(ctx, `
SELECT l.day_of_week, l.period_index,
to_char(p.start_time, 'HH24:MI') AS st,
to_char(p.end_time, 'HH24:MI') AS et,
cl.name, sub.name, sub.short_code,
t.last_name || ', ' || t.first_name,
COALESCE(r.name, ''),
l.pinned
FROM tt_lesson l
JOIN tt_solution s ON l.solution_id = s.id
JOIN tt_class cl ON l.class_id = cl.id
JOIN tt_subject sub ON l.subject_id = sub.id
JOIN tt_teacher t ON l.teacher_id = t.id
LEFT JOIN tt_room r ON l.room_id = r.id
LEFT JOIN tt_period p
ON p.day_of_week = l.day_of_week
AND p.period_index = l.period_index
AND p.created_by_user_id = s.created_by_user_id
WHERE s.id = $1::uuid AND l.class_id = $2::uuid
ORDER BY l.day_of_week, l.period_index
`, solutionID, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LessonExport
for rows.Next() {
var le LessonExport
var st, et *string
if err := rows.Scan(&le.DayOfWeek, &le.PeriodIndex, &st, &et,
&le.ClassName, &le.SubjectName, &le.SubjectCode,
&le.TeacherName, &le.RoomName, &le.Pinned); err != nil {
return nil, err
}
if st != nil {
le.StartTime = *st
}
if et != nil {
le.EndTime = *et
}
out = append(out, le)
}
return out, nil
}
@@ -0,0 +1,175 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// ParentService owns the parent_* tables. Magic-link tokens are random
// 32-byte values; only the SHA-256 hash is stored in the DB. The raw token
// goes back to the teacher exactly once (when they invite a parent) so
// they can paste it into a Matrix message or email. After redeem, a
// browser session (own table, separate token) carries the parent through
// the API.
type ParentService struct {
db *pgxpool.Pool
}
func NewParentService(db *pgxpool.Pool) *ParentService {
return &ParentService{db: db}
}
const (
magicLinkTTL = 7 * 24 * time.Hour
parentSessionTTL = 30 * 24 * time.Hour
parentCookieName = "bp_parent_session"
tokenLen = 32 // raw bytes; URL-safe base64 encoded
)
func randomToken() (raw string, hash string, err error) {
buf := make([]byte, tokenLen)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
h := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(h[:])
return raw, hash, nil
}
func hashToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
// InviteParent upserts the parent account, creates a fresh child row, and
// issues a magic-link. Caller (teacher) is the owner; child must belong to
// one of their tt_class rows.
func (s *ParentService) InviteParent(ctx context.Context, userID string, req *models.InviteParentRequest) (*models.InviteParentResponse, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// 1. Verify class ownership.
var owned bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tt_class WHERE id = $1 AND created_by_user_id = $2)`,
req.TTClassID, userID,
).Scan(&owned); err != nil {
return nil, err
}
if !owned {
return nil, fmt.Errorf("tt_class_id not found or not owned by user")
}
lang := req.PreferredLanguage
if lang == "" {
lang = "de"
}
// 2. Upsert parent_account.
var parent models.ParentAccount
if err := tx.QueryRow(ctx, `
INSERT INTO parent_account (created_by_user_id, email, preferred_language)
VALUES ($1, $2, $3)
ON CONFLICT (created_by_user_id, email) DO UPDATE
SET preferred_language = EXCLUDED.preferred_language
RETURNING id, created_by_user_id, email, preferred_language, created_at
`, userID, req.Email, lang).Scan(
&parent.ID, &parent.CreatedByUserID, &parent.Email, &parent.PreferredLanguage, &parent.CreatedAt,
); err != nil {
return nil, fmt.Errorf("upsert parent: %w", err)
}
// 3. Insert child.
var child models.ParentChild
if err := tx.QueryRow(ctx, `
INSERT INTO parent_child (parent_id, tt_class_id, first_name, last_name)
VALUES ($1, $2::uuid, $3, $4)
RETURNING id, parent_id, tt_class_id, first_name, last_name, created_at
`, parent.ID, req.TTClassID, req.ChildFirstName, req.ChildLastName).Scan(
&child.ID, &child.ParentID, &child.TTClassID, &child.FirstName, &child.LastName, &child.CreatedAt,
); err != nil {
return nil, fmt.Errorf("insert child: %w", err)
}
// 4. Mint a magic-link token (raw goes back, hash goes to DB).
raw, hash, err := randomToken()
if err != nil {
return nil, fmt.Errorf("token gen: %w", err)
}
expiresAt := time.Now().Add(magicLinkTTL)
if _, err := tx.Exec(ctx, `
INSERT INTO parent_magic_link (parent_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, parent.ID, hash, expiresAt); err != nil {
return nil, fmt.Errorf("insert magic link: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &models.InviteParentResponse{
Parent: parent,
Child: child,
MagicToken: raw,
MagicURL: "/eltern/login?token=" + raw,
ExpiresAt: expiresAt,
}, nil
}
func (s *ParentService) ListInvites(ctx context.Context, userID string) ([]models.ParentInviteListItem, error) {
rows, err := s.db.Query(ctx, `
SELECT pa.id, pa.email, pa.preferred_language,
pc.id, pc.first_name, pc.last_name,
cl.id, cl.name, pc.created_at
FROM parent_account pa
JOIN parent_child pc ON pc.parent_id = pa.id
JOIN tt_class cl ON cl.id = pc.tt_class_id
WHERE pa.created_by_user_id = $1
ORDER BY pa.email, pc.last_name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ParentInviteListItem
for rows.Next() {
var it models.ParentInviteListItem
if err := rows.Scan(&it.ParentID, &it.Email, &it.PreferredLanguage,
&it.ChildID, &it.ChildFirstName, &it.ChildLastName,
&it.ClassID, &it.ClassName, &it.CreatedAt); err != nil {
return nil, err
}
out = append(out, it)
}
return out, nil
}
// DeleteInvite removes one child row (parent stays if other children still
// exist for the same teacher).
func (s *ParentService) DeleteInvite(ctx context.Context, childID, userID string) error {
res, err := s.db.Exec(ctx, `
DELETE FROM parent_child pc
USING parent_account pa
WHERE pc.id = $1 AND pc.parent_id = pa.id AND pa.created_by_user_id = $2
`, childID, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("child not found or not owned")
}
return nil
}
@@ -0,0 +1,80 @@
package services
import (
"strings"
"testing"
"github.com/breakpilot/school-service/internal/models"
)
func TestRandomToken_Hashable(t *testing.T) {
raw, hash, err := randomToken()
if err != nil {
t.Fatalf("randomToken error: %v", err)
}
if len(raw) < 30 {
t.Errorf("raw token suspiciously short: %d", len(raw))
}
if len(hash) != 64 {
t.Errorf("sha256 hex hash must be 64 chars, got %d", len(hash))
}
if hashToken(raw) != hash {
t.Errorf("hashToken(raw) must equal the hash randomToken returned")
}
}
func TestRandomToken_NonRepeating(t *testing.T) {
// 16 iterations, all raw tokens must differ.
seen := map[string]struct{}{}
for i := 0; i < 16; i++ {
raw, _, err := randomToken()
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
if _, dup := seen[raw]; dup {
t.Fatalf("duplicate raw token at iter %d", i)
}
seen[raw] = struct{}{}
}
}
func TestHashToken_StableHexLowercase(t *testing.T) {
h := hashToken("hello world")
if strings.ToLower(h) != h {
t.Errorf("hash should be lowercase hex")
}
if len(h) != 64 {
t.Errorf("expected 64-char hash, got %d", len(h))
}
}
func TestInviteParentRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.InviteParentRequest
wantErr bool
}{
{"valid", models.InviteParentRequest{
Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller",
TTClassID: "00000000-0000-0000-0000-000000000001",
}, false},
{"bad email", models.InviteParentRequest{
Email: "not-an-email", ChildFirstName: "Max", ChildLastName: "Mueller",
TTClassID: "00000000-0000-0000-0000-000000000001",
}, true},
{"missing child", models.InviteParentRequest{
Email: "a@b.de", TTClassID: "00000000-0000-0000-0000-000000000001",
}, true},
{"bad class uuid", models.InviteParentRequest{
Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller",
TTClassID: "not-a-uuid",
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Snapshot Public Holidays + School Holidays for all 16 German Bundeslaender
# from openholidaysapi.org. The result is committed to the repo and imported
# at first DB boot by school-service. Re-run yearly (or whenever the next
# school year's data needs to be added).
#
# Usage: bash scripts/calendar-snapshot.sh [FIRST_YEAR] [LAST_YEAR]
# defaults: current year .. current year + 2
#
# Output: school-service/internal/seed/calendar_holidays.json
# shape: [{ region, event_type, name_de, name_en, start_date, end_date }, ...]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/school-service/internal/seed/calendar_holidays.json"
mkdir -p "$(dirname "$OUT")"
START_YEAR="${1:-$(date +%Y)}"
END_YEAR="${2:-$((START_YEAR + 2))}"
API="https://openholidaysapi.org"
# DE-XX codes for all 16 Bundeslaender (alphabetical).
REGIONS=(
"DE-BW" "DE-BY" "DE-BE" "DE-BB" "DE-HB" "DE-HH" "DE-HE" "DE-MV"
"DE-NI" "DE-NW" "DE-RP" "DE-SL" "DE-SN" "DE-ST" "DE-SH" "DE-TH"
)
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required (brew install jq)" >&2
exit 1
fi
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
echo '[]' > "$TMP"
fetch() {
local endpoint="$1" region="$2" year="$3"
curl -sf -G "$API/$endpoint" \
--data-urlencode "countryIsoCode=DE" \
--data-urlencode "languageIsoCode=DE" \
--data-urlencode "validFrom=${year}-01-01" \
--data-urlencode "validTo=${year}-12-31" \
--data-urlencode "subdivisionCode=$region" \
|| echo '[]'
}
# Map OpenHolidaysAPI shape → our DB schema. The API returns an array of:
# { id, startDate, endDate, type, name: [{ language, text }], ... }
# We keep DE name as canonical, EN name if present, plus dates and a typed
# event_type discriminator. PublicHolidays and SchoolHolidays come from two
# separate endpoints.
normalise_jq='
map({
region: $region,
event_type: $event_type,
name_de: ((.name // []) | map(select(.language == "DE")) | .[0].text // ""),
name_en: ((.name // []) | map(select(.language == "EN")) | .[0].text // null),
start_date: .startDate,
end_date: .endDate
}) | map(select(.name_de != ""))
'
for region in "${REGIONS[@]}"; do
for year in $(seq "$START_YEAR" "$END_YEAR"); do
echo " $region $year — public" >&2
fetch "PublicHolidays" "$region" "$year" \
| jq --arg region "$region" --arg event_type "public_holiday" "$normalise_jq" \
| jq -s --slurpfile existing "$TMP" '$existing[0] + .[0]' > "$TMP.new"
mv "$TMP.new" "$TMP"
echo " $region $year — school" >&2
fetch "SchoolHolidays" "$region" "$year" \
| jq --arg region "$region" --arg event_type "school_holiday" "$normalise_jq" \
| jq -s --slurpfile existing "$TMP" '$existing[0] + .[0]' > "$TMP.new"
mv "$TMP.new" "$TMP"
done
done
# Deduplicate (the API sometimes returns overlapping rows for events that
# straddle a year boundary) and sort for a stable diff.
jq 'unique_by({region, event_type, name_de, start_date}) | sort_by([.region, .start_date])' \
"$TMP" > "$OUT"
echo
echo "Wrote $(jq length "$OUT") events to $OUT"
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy for the parent-side school-service endpoints. Mirrors the school
* proxy but forwards the parent-session cookie via Set-Cookie/Cookie
* headers so HttpOnly survives the round-trip.
*/
const BACKEND_URL = process.env.SCHOOL_SERVICE_URL || 'http://school-service:8084'
async function proxy(request: NextRequest, params: { path: string[] }): Promise<NextResponse> {
const path = params.path.join('/')
const url = `${BACKEND_URL}/api/v1/parent/${path}${request.nextUrl.search}`
const headers: HeadersInit = { 'Content-Type': 'application/json' }
const cookie = request.headers.get('cookie')
if (cookie) headers['Cookie'] = cookie
const init: RequestInit = { method: request.method, headers }
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
init.body = await request.text()
}
try {
const upstream = await fetch(url, init)
const body = await upstream.text()
const res = new NextResponse(body, {
status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' },
})
// Mirror Set-Cookie back so the browser stores the parent session.
const setCookie = upstream.headers.get('set-cookie')
if (setCookie) res.headers.set('Set-Cookie', setCookie)
return res
} catch (error) {
return NextResponse.json(
{ error: 'school-service nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 },
)
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { elternApi } from '@/lib/eltern/api'
function LoginInner() {
const router = useRouter()
const search = useSearchParams()
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
useEffect(() => {
const token = search.get('token')
if (!token) {
setError('Kein Token in der URL. Bitte den Link aus der Einladung verwenden.')
return
}
elternApi.redeem(token)
.then(() => { setDone(true); setTimeout(() => router.replace('/eltern'), 800) })
.catch(e => setError(e instanceof Error ? e.message : 'Login fehlgeschlagen'))
}, [router, search])
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
<div className="max-w-md w-full mx-4 p-6 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl" data-testid="eltern-login">
<h1 className="text-2xl font-semibold mb-3">Eltern-Login</h1>
{!error && !done && <p className="opacity-80">Pruefe Token </p>}
{done && <p className="text-emerald-200">Erfolgreich angemeldet. Weiterleitung </p>}
{error && (
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200 text-sm">{error}</div>
)}
</div>
</div>
)
}
export default function ElternLoginPage() {
return (
<Suspense fallback={null}>
<LoginInner />
</Suspense>
)
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { elternApi, type ParentMeResponse, type ParentLesson } from '@/lib/eltern/api'
import { translateSubject } from '@/lib/calendar/subject-i18n'
const DAY_LABELS: Record<string, string[]> = {
de: ['Mo', 'Di', 'Mi', 'Do', 'Fr'],
en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
tr: ['Pzt', 'Sal', 'Çar', 'Per', 'Cum'],
ar: ['الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة'],
uk: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
pl: ['Pon', 'Wt', 'Śr', 'Czw', 'Pt'],
fr: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'],
}
const HEADINGS: Record<string, { greeting: string; selectChild: string; period: string; logout: string; noPlan: string }> = {
de: { greeting: 'Willkommen', selectChild: 'Kind auswählen', period: 'Stunde', logout: 'Abmelden', noPlan: 'Noch kein Stundenplan veröffentlicht.' },
en: { greeting: 'Welcome', selectChild: 'Select child', period: 'Period', logout: 'Sign out', noPlan: 'No timetable published yet.' },
tr: { greeting: 'Hoş geldiniz', selectChild: 'Çocuk seç', period: 'Ders', logout: 'Çıkış', noPlan: 'Henüz ders programı yayımlanmadı.' },
ar: { greeting: 'مرحبًا', selectChild: 'اختر الطفل', period: 'حصة', logout: 'خروج', noPlan: 'لم يتم نشر جدول حصص بعد.' },
uk: { greeting: 'Ласкаво просимо', selectChild: 'Виберіть дитину', period: 'Урок', logout: 'Вийти', noPlan: 'Розклад ще не опубліковано.' },
ru: { greeting: 'Добро пожаловать', selectChild: 'Выберите ребёнка', period: 'Урок', logout: 'Выйти', noPlan: 'Расписание ещё не опубликовано.' },
pl: { greeting: 'Witamy', selectChild: 'Wybierz dziecko', period: 'Lekcja', logout: 'Wyloguj', noPlan: 'Plan lekcji nie jest jeszcze opublikowany.' },
fr: { greeting: 'Bienvenue', selectChild: 'Choisir un enfant', period: 'Cours', logout: 'Déconnexion', noPlan: 'Aucun emploi du temps publié.' },
}
function t(lang: string, key: keyof typeof HEADINGS['de']): string {
const code = (lang || 'de').slice(0, 2)
return HEADINGS[code]?.[key] ?? HEADINGS.de[key]
}
export default function ElternPage() {
const router = useRouter()
const [me, setMe] = useState<ParentMeResponse | null>(null)
const [selected, setSelected] = useState<string>('')
const [lessons, setLessons] = useState<ParentLesson[]>([])
const [error, setError] = useState<string | null>(null)
const lang = me?.parent.preferred_language || 'de'
const dayLabels = DAY_LABELS[lang.slice(0, 2)] || DAY_LABELS.de
const loadMe = useCallback(async () => {
try {
const data = await elternApi.me()
setMe(data)
if (data.children.length > 0) setSelected(data.children[0].tt_class_id)
} catch (e) {
// Not logged in → redirect to login.
if (e instanceof Error && /session/i.test(e.message)) {
router.replace('/eltern/login')
return
}
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
}
}, [router])
useEffect(() => { loadMe() }, [loadMe])
const loadTimetable = useCallback(async () => {
if (!selected) return
try {
const data = await elternApi.timetable(selected)
setLessons(data || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Stundenplan laden fehlgeschlagen')
}
}, [selected])
useEffect(() => { loadTimetable() }, [loadTimetable])
const periodIndices = useMemo(() => {
const set = new Set<number>()
for (const l of lessons) set.add(l.PeriodIndex)
return Array.from(set).sort((a, b) => a - b)
}, [lessons])
const cell = (day: number, idx: number) =>
lessons.find(l => l.DayOfWeek === day && l.PeriodIndex === idx)
const handleLogout = async () => {
try { await elternApi.logout() } catch { /* ignore */ }
router.replace('/eltern/login')
}
if (!me) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
{error ? <span className="text-red-200">{error}</span> : <span className="opacity-70">Laedt </span>}
</div>
)
}
const activeChild = me.children.find(c => c.tt_class_id === selected)
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white p-6" data-testid="eltern-page">
<div className="max-w-5xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">{t(lang, 'greeting')}, {me.parent.email}</h1>
<p className="text-sm text-white/60 mt-1">
{activeChild ? `${activeChild.first_name} ${activeChild.last_name} · ${activeChild.class_name}` : ''}
</p>
</div>
<button onClick={handleLogout} data-testid="eltern-logout" className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-sm">
{t(lang, 'logout')}
</button>
</header>
{me.children.length > 1 && (
<div className="mb-4">
<label className="block text-sm mb-1 opacity-70">{t(lang, 'selectChild')}</label>
<select
value={selected}
onChange={e => setSelected(e.target.value)}
data-testid="child-selector"
className="px-3 py-2 rounded-lg border bg-white/10 border-white/20 text-white"
>
{me.children.map(c => (
<option key={c.id} value={c.tt_class_id} className="text-slate-900">
{c.first_name} {c.last_name} ({c.class_name})
</option>
))}
</select>
</div>
)}
{error && <div className="mb-3 p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200">{error}</div>}
{periodIndices.length === 0 ? (
<div className="rounded-2xl bg-white/10 border border-white/20 p-8 text-center opacity-70">
{t(lang, 'noPlan')}
</div>
) : (
<div className="rounded-2xl bg-white/10 border border-white/20 overflow-hidden">
<table className="w-full">
<thead className="bg-white/5">
<tr>
<th className="text-left px-3 py-3 text-sm font-medium opacity-70 w-16">{t(lang, 'period')}</th>
{dayLabels.map(d => <th key={d} className="text-left px-3 py-3 text-sm font-medium opacity-70">{d}</th>)}
</tr>
</thead>
<tbody>
{periodIndices.map(idx => (
<tr key={idx} className="border-t border-white/10">
<td className="px-3 py-2 font-medium text-sm">{idx}.</td>
{[1, 2, 3, 4, 5].map(d => {
const l = cell(d, idx)
if (!l) return <td key={d} className="px-3 py-2 opacity-20 text-xs"></td>
return (
<td key={d} className="px-2 py-1" data-testid={`eltern-cell-${d}-${idx}`}>
<div className="rounded-md p-2 text-xs space-y-0.5 bg-indigo-500/30 border-l-2 border-indigo-300">
<div className="font-semibold">{translateSubject(l.SubjectName, lang)}</div>
<div className="opacity-80">{l.TeacherName.split(',')[0]}</div>
{l.RoomName && <div className="opacity-60">{l.RoomName}</div>}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,68 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { BUNDESLAENDER } from '@/app/schulkalender/types'
interface BundeslandWizardProps {
onSave: (bundesland: string) => Promise<void>
}
export function BundeslandWizard({ onSave }: BundeslandWizardProps) {
const { isDark } = useTheme()
const [selected, setSelected] = useState('DE-NI')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await onSave(selected)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className={`max-w-xl mx-auto rounded-2xl border backdrop-blur-xl p-6 ${cardClass}`} data-testid="bundesland-wizard">
<h2 className="text-xl font-semibold mb-2">Willkommen im Schulkalender</h2>
<p className={`text-sm mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Waehle das Bundesland deiner Schule. Damit laden wir Ferien und
Feiertage aus dem offiziellen Datensatz fuer die naechsten drei
Schuljahre.
</p>
<div className="mb-4">
<label className="block text-sm mb-1 opacity-70">Bundesland</label>
<select
value={selected}
onChange={e => setSelected(e.target.value)}
data-testid="bundesland-select"
className={`w-full px-3 py-2 rounded-lg border ${selectClass}`}
>
{BUNDESLAENDER.map(b => (
<option key={b.code} value={b.code}>{b.name}</option>
))}
</select>
</div>
{error && (
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">
{error}
</div>
)}
<button
onClick={handleSave}
disabled={saving}
data-testid="bundesland-save"
className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Bundesland uebernehmen'}
</button>
</div>
)
}
@@ -0,0 +1,106 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
import { NotificationStatus } from './NotificationStatus'
interface DayDetailProps {
iso: string
holidays: PublicEvent[]
events: SchoolEvent[]
onClose: () => void
onDeleted: () => void
}
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
const { isDark } = useTheme()
const handleDelete = async (id: string) => {
if (!confirm('Termin wirklich loeschen?')) return
try {
await calendarApi.deleteEvent(id)
onDeleted()
} catch {
// best-effort
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{formattedDate}</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{dayHolidays.length === 0 && dayEvents.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Eintraege fuer diesen Tag.
</p>
)}
{dayHolidays.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
{dayHolidays.map(h => (
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
<div className="font-medium">{h.name_de}</div>
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` ${h.end_date}` : ''}</div>
</div>
))}
</section>
)}
{dayEvents.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
{dayEvents.map(e => (
<div
key={e.id}
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium">{e.title}</div>
<div className="text-xs opacity-70 mt-0.5">
{EVENT_TYPE_LABEL[e.event_type]}
{e.start_time && ` · ${e.start_time}${e.end_time ? `${e.end_time}` : ''}`}
{e.is_school_free && ' · unterrichtsfrei'}
</div>
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
<div className="text-xs opacity-60 mt-1.5">
{e.visible_to_parents && '👨‍👩‍👧 sichtbar fuer Eltern'}
{e.notify_parents && ' · 📧 Eltern erinnern'}
{e.notify_students && ' · 💬 Schueler erinnern'}
</div>
{(e.notify_parents || e.notify_students) && (
<NotificationStatus eventId={e.id} />
)}
</div>
<button
onClick={() => handleDelete(e.id)}
className="text-xs text-red-400 hover:text-red-300"
>
Loeschen
</button>
</div>
</div>
))}
</section>
)}
</div>
</div>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types'
import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
interface EventModalProps {
defaultDate: string // YYYY-MM-DD
onClose: () => void
onCreated: () => void
}
const initial = (date: string): CreateSchoolEvent => ({
title: '',
event_type: 'fortbildung',
is_school_free: false,
start_date: date,
end_date: date,
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
})
export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) {
const { isDark } = useTheme()
const [form, setForm] = useState<CreateSchoolEvent>(initial(defaultDate))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await calendarApi.createEvent(form)
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="event-modal">
<form
onSubmit={handleSubmit}
className={`w-full max-w-2xl rounded-2xl border p-6 space-y-3 max-h-[90vh] overflow-y-auto ${cardClass}`}
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Neuer Termin</h2>
<button type="button" onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Titel</label>
<input
required
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="z.B. SCHILF: Digitale Tafeln"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-title"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Typ</label>
<select
value={form.event_type}
onChange={e => setForm({ ...form, event_type: e.target.value as SchoolEventType })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-type"
>
{(Object.keys(EVENT_TYPE_LABEL) as SchoolEventType[]).map(k => (
<option key={k} value={k}>{EVENT_TYPE_LABEL[k]}</option>
))}
</select>
</div>
<div className="flex items-end gap-2">
<input
type="checkbox"
id="is-school-free"
checked={form.is_school_free || false}
onChange={e => setForm({ ...form, is_school_free: e.target.checked })}
className="w-5 h-5"
/>
<label htmlFor="is-school-free" className="text-sm">Unterrichtsfrei</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Von</label>
<input type="date" required value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis</label>
<input type="date" required value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Startzeit (optional)</label>
<input type="time" value={form.start_time || ''} onChange={e => setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Endzeit (optional)</label>
<input type="time" value={form.end_time || ''} onChange={e => setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Beschreibung (optional)</label>
<textarea
value={form.description || ''}
onChange={e => setForm({ ...form, description: e.target.value })}
rows={2}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div className="space-y-2 pt-2 border-t border-white/10">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.visible_to_parents ?? true}
onChange={e => setForm({ ...form, visible_to_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern sehen diesen Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_parents ?? false}
onChange={e => setForm({ ...form, notify_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern per Mail/Chat erinnern
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_students ?? false}
onChange={e => setForm({ ...form, notify_students: e.target.checked })}
className="w-5 h-5"
/>
Schueler per Chat erinnern
</label>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={saving}
data-testid="event-save"
className="flex-1 px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Anlegen'}
</button>
<button type="button" onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'}`}>
Abbrechen
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,201 @@
'use client'
import { useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
interface MonthViewProps {
year: number
month: number // 1-12
holidays: PublicEvent[]
schoolEvents?: SchoolEvent[]
onPrev: () => void
onNext: () => void
onToday: () => void
onDayClick?: (iso: string) => void
onAddEvent?: () => void
onRollover?: () => void
}
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_DE = [
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
interface Cell {
date: Date
inMonth: boolean
events: PublicEvent[]
}
function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): Cell[] {
// First Monday on or before the 1st of the month.
const first = new Date(Date.UTC(year, month - 1, 1))
const firstWeekday = (first.getUTCDay() + 6) % 7 // Monday = 0
const start = new Date(first)
start.setUTCDate(first.getUTCDate() - firstWeekday)
const cells: Cell[] = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setUTCDate(start.getUTCDate() + i)
const iso = d.toISOString().slice(0, 10)
const events = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
cells.push({
date: d,
inMonth: d.getUTCMonth() === month - 1,
events,
})
if (i >= 27 && d.getUTCMonth() !== month - 1) {
// Stop a row early if the rest is fully outside the month.
const restAllOutside = cells.slice(i + 1 - ((i + 1) % 7), i + 1).every(c => !c.inMonth)
if (restAllOutside) break
}
}
// Pad to multiple of 7 if we cut early.
while (cells.length % 7 !== 0) {
const last = cells[cells.length - 1].date
const d = new Date(last)
d.setUTCDate(last.getUTCDate() + 1)
cells.push({ date: d, inMonth: false, events: [] })
}
return cells
}
export function MonthView({ year, month, holidays, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
const { isDark } = useTheme()
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
// School events per ISO date — quick lookup during cell render.
const schoolEventsByDate = useMemo(() => {
const map = new Map<string, SchoolEvent[]>()
for (const ev of schoolEvents) {
const start = new Date(ev.start_date)
const end = new Date(ev.end_date)
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const iso = d.toISOString().slice(0, 10)
const arr = map.get(iso) || []
arr.push(ev)
map.set(iso, arr)
}
}
return map
}, [schoolEvents])
const headerClass = isDark ? 'text-white' : 'text-slate-900'
const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
const buttonClass = isDark
? 'bg-white/10 text-white/80 hover:bg-white/20'
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
const todayIso = new Date().toISOString().slice(0, 10)
return (
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="month-view">
<div className="flex items-center justify-between mb-4">
<h2 className={`text-2xl font-semibold ${headerClass}`}>
{MONTHS_DE[month - 1]} {year}
</h2>
<div className="flex gap-2">
{onAddEvent && (
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
)}
{onRollover && (
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
)}
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS_DE.map(w => (
<div key={w} className={`text-xs font-medium text-center ${subtleText}`}>{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((c, i) => {
const iso = c.date.toISOString().slice(0, 10)
const isToday = iso === todayIso
const publicHoliday = c.events.find(e => e.event_type === 'public_holiday')
const schoolHoliday = c.events.find(e => e.event_type === 'school_holiday')
let bg = isDark ? 'bg-white/5' : 'bg-slate-50'
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
const dayEvents = schoolEventsByDate.get(iso) || []
return (
<div
key={i}
data-testid={`day-${iso}`}
onClick={() => c.inMonth && onDayClick?.(iso)}
className={`relative aspect-square rounded-lg p-2 text-sm border ${
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
} ${
isDark ? 'border-white/10' : 'border-black/5'
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
}`}
>
<div className={`font-medium ${
c.inMonth ? (isDark ? 'text-white' : 'text-slate-900') : subtleText
}`}>
{c.date.getUTCDate()}
</div>
{c.events.length > 0 && (
<div className="mt-1 space-y-0.5 overflow-hidden">
{c.events.slice(0, 2).map(e => (
<div
key={e.id}
title={e.name_de}
className={`text-[10px] leading-tight truncate ${
e.event_type === 'public_holiday'
? (isDark ? 'text-rose-200' : 'text-rose-800')
: (isDark ? 'text-amber-200' : 'text-amber-800')
}`}
>
{e.name_de}
</div>
))}
{c.events.length > 2 && (
<div className={`text-[10px] ${subtleText}`}>+{c.events.length - 2}</div>
)}
</div>
)}
{dayEvents.length > 0 && (
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
{dayEvents.slice(0, 4).map(ev => (
<span
key={ev.id}
title={ev.title}
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
/>
))}
</div>
)}
</div>
)
})}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
<span className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-rose-500/40' : 'bg-rose-200'}`}></span>
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Feiertag</span>
</span>
<span className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-amber-500/40' : 'bg-amber-200'}`}></span>
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Schulferien</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { NotificationLogRow } from '@/app/schulkalender/types'
interface NotificationStatusProps {
eventId: string
}
const STATUS_ICON: Record<string, string> = {
sent: '✓',
failed: '✗',
skipped: '⏱',
}
export function NotificationStatus({ eventId }: NotificationStatusProps) {
const { isDark } = useTheme()
const [rows, setRows] = useState<NotificationLogRow[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
calendarApi.listEventNotifications(eventId)
.then(r => { setRows(r || []); setLoading(false) })
.catch(() => setLoading(false))
}, [eventId])
if (loading || rows.length === 0) return null
return (
<div className={`mt-2 pt-2 border-t text-xs ${isDark ? 'border-white/10' : 'border-black/10'}`} data-testid={`notif-status-${eventId}`}>
<div className={`font-medium mb-1 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>Erinnerungen</div>
<div className="flex flex-wrap gap-2">
{rows.map((r, i) => (
<span
key={i}
title={r.error_message || `${r.status} ${r.run_date}`}
className={`px-2 py-0.5 rounded ${
r.status === 'sent' ? (isDark ? 'bg-emerald-500/30 text-emerald-100' : 'bg-emerald-100 text-emerald-900') :
r.status === 'failed' ? (isDark ? 'bg-red-500/30 text-red-100' : 'bg-red-100 text-red-900') :
(isDark ? 'bg-amber-500/30 text-amber-100' : 'bg-amber-100 text-amber-900')
}`}
>
{STATUS_ICON[r.status]} {r.lead_days === 0 ? 'Heute' : r.lead_days === 1 ? '1 Tag' : `${r.lead_days} Tage`}
{' · '}{r.audience === 'parents' ? 'Eltern' : 'Schueler'}
{' · '}{r.channel}
</span>
))}
</div>
</div>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import { classesApi } from '@/lib/stundenplan/api'
import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types'
import type { TimetableClass } from '@/app/stundenplan/types'
const LANGS: { code: string; name: string }[] = [
{ code: 'de', name: 'Deutsch' },
{ code: 'en', name: 'English' },
{ code: 'tr', name: 'Tuerkce' },
{ code: 'ar', name: 'العربية' },
{ code: 'uk', name: 'Українська' },
{ code: 'ru', name: 'Русский' },
{ code: 'pl', name: 'Polski' },
{ code: 'fr', name: 'Francais' },
]
export function ParentManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<ParentInviteListItem[]>([])
const [classes, setClasses] = useState<TimetableClass[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [lastInvite, setLastInvite] = useState<InviteParentResponse | null>(null)
const [form, setForm] = useState({
email: '',
preferred_language: 'de',
child_first_name: '',
child_last_name: '',
tt_class_id: '',
})
const load = useCallback(async () => {
setLoading(true)
try {
const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()])
setItems(list || [])
setClasses(cls || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await calendarApi.inviteParent(form)
setLastInvite(res)
setForm({ ...form, child_first_name: '', child_last_name: '' })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (childId: string) => {
if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return
try { await calendarApi.deleteParentChild(childId); await load() }
catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') }
}
const fullLink = (path: string) =>
typeof window === 'undefined' ? path : `${window.location.origin}${path}`
const copyLink = (path: string) => {
navigator.clipboard?.writeText(fullLink(path))
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="parent-manager">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Eltern verwalten ({items.length})</h3>
<button
onClick={() => setShowForm(s => !s)}
disabled={classes.length === 0}
data-testid="parent-invite-toggle"
className={`px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Eltern einladen'}
</button>
</div>
{classes.length === 0 && (
<p className={`text-sm mb-2 ${isDark ? 'text-amber-200' : 'text-amber-900'}`}>
Zuerst Klassen im Stundenplan-Modul anlegen.
</p>
)}
{error && (
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
{showForm && (
<form onSubmit={handleSubmit} className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-2">
<input required type="email" placeholder="Eltern-E-Mail" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<input required placeholder="Vorname Kind" value={form.child_first_name} onChange={e => setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<input required placeholder="Nachname Kind" value={form.child_last_name} onChange={e => setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<select required value={form.tt_class_id} onChange={e => setForm({ ...form, tt_class_id: e.target.value })} data-testid="parent-class" className={`px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> Klasse waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select value={form.preferred_language} onChange={e => setForm({ ...form, preferred_language: e.target.value })} className={`px-3 py-2 rounded-lg border ${inputClass}`}>
{LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
</select>
<button type="submit" disabled={submitting} data-testid="parent-invite-submit" className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Erstellt…' : 'Einladen'}
</button>
</form>
)}
{lastInvite && (
<div className={`mb-3 p-3 rounded-lg ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`} data-testid="parent-invite-link">
<div className="text-sm font-medium mb-1">Einladungs-Link fuer {lastInvite.parent.email}</div>
<div className="flex gap-2 items-center">
<code className={`flex-1 text-xs px-2 py-1 rounded overflow-x-auto ${isDark ? 'bg-white/10' : 'bg-white'}`}>
{fullLink(lastInvite.magic_url)}
</code>
<button onClick={() => copyLink(lastInvite.magic_url)} className="text-xs px-2 py-1 rounded bg-indigo-500 hover:bg-indigo-600 text-white">Kopieren</button>
</div>
<p className="text-xs opacity-70 mt-1">Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}</p>
</div>
)}
{loading ? (
<div className="opacity-60 py-4 text-center text-sm">Laedt</div>
) : items.length === 0 ? (
<div className="opacity-60 py-4 text-center text-sm">Keine eingeladenen Eltern.</div>
) : (
<table className="w-full text-sm">
<thead className={isDark ? 'opacity-70' : 'opacity-70'}>
<tr>
<th className="text-left py-2">E-Mail</th>
<th className="text-left py-2">Kind</th>
<th className="text-left py-2">Klasse</th>
<th className="text-left py-2">Sprache</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(it => (
<tr key={it.child_id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="py-2">{it.email}</td>
<td className="py-2">{it.child_first_name} {it.child_last_name}</td>
<td className="py-2">{it.class_name}</td>
<td className="py-2">{it.preferred_language}</td>
<td className="py-2 text-right">
<button onClick={() => handleDelete(it.child_id)} className="text-xs text-red-400 hover:text-red-300">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { SchoolYearRolloverResult } from '@/app/schulkalender/types'
interface RolloverWizardProps {
onClose: () => void
onDone: () => void
}
function nextSchoolYearISO(): { start: string; end: string } {
const now = new Date()
let y = now.getFullYear()
if (now.getMonth() + 1 >= 8) y++ // Aug → bumped to next year
return { start: `${y}-08-01`, end: `${y + 1}-07-31` }
}
export function RolloverWizard({ onClose, onDone }: RolloverWizardProps) {
const { isDark } = useTheme()
const defaults = nextSchoolYearISO()
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SchoolYearRolloverResult | null>(null)
const expected = 'SCHULJAHR WECHSELN'
const handleSubmit = async () => {
setSaving(true)
setError(null)
try {
const r = await calendarApi.rolloverSchoolYear(start, end)
setResult(r)
} catch (e) {
setError(e instanceof Error ? e.message : 'Rollover fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="rollover-wizard">
<div className={`w-full max-w-xl rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Schuljahres-Wechsel</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{result ? (
<div className="space-y-3" data-testid="rollover-result">
<div className={`p-4 rounded-xl ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`}>
<div className="font-medium mb-2">Rollover erfolgreich</div>
<ul className="text-sm space-y-1 opacity-90">
<li>{result.classes_promoted} Klassen um eine Stufe aufgerueckt</li>
<li>{result.classes_graduated} Abschlussklassen entfernt</li>
<li>Neues Schuljahr: {result.new_year_start} {result.new_year_end}</li>
</ul>
</div>
<button onClick={onDone} className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium">
Schliessen
</button>
</div>
) : (
<>
<div className={`p-3 rounded-lg text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-100' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
<p className="font-medium mb-1">Was passiert?</p>
<ul className="list-disc list-inside space-y-1 opacity-90">
<li>Alle Klassen ruecken eine Stufe hoeher (5a 6, 6a 7, )</li>
<li>Abschlussklassen (Stufe 13) werden entfernt</li>
<li>Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert</li>
<li>Vorhandene Stundenplaene bleiben als Historie erhalten</li>
</ul>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Beginn</label>
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Ende</label>
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">
Bestaetigung tippe <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>{expected}</code> zur Bestaetigung
</label>
<input
value={confirm}
onChange={e => setConfirm(e.target.value)}
data-testid="rollover-confirm"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2">
<button
onClick={handleSubmit}
disabled={saving || confirm !== expected}
data-testid="rollover-submit"
className="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium disabled:opacity-30"
>
{saving ? 'Wechselt…' : 'Schuljahr wechseln'}
</button>
<button onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-100 hover:bg-slate-200'}`}>
Abbrechen
</button>
</div>
</>
)}
</div>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolCalendarConfig, SchoolEvent } from './types'
import { BUNDESLAENDER } from './types'
import { MonthView } from './_components/MonthView'
import { BundeslandWizard } from './_components/BundeslandWizard'
import { EventModal } from './_components/EventModal'
import { DayDetail } from './_components/DayDetail'
import { RolloverWizard } from './_components/RolloverWizard'
import { ParentManager } from './_components/ParentManager'
function monthRange(year: number, month: number): { from: string; to: string } {
// Render the visible 6-week grid worth of holidays (covers prev/next month edges).
const from = new Date(Date.UTC(year, month - 1, 1))
from.setUTCDate(from.getUTCDate() - 7)
const to = new Date(Date.UTC(year, month, 0))
to.setUTCDate(to.getUTCDate() + 14)
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) }
}
export default function SchulkalenderPage() {
const { isDark } = useTheme()
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [config, setConfig] = useState<SchoolCalendarConfig | null>(null)
const [holidays, setHolidays] = useState<PublicEvent[]>([])
const [schoolEvents, setSchoolEvents] = useState<SchoolEvent[]>([])
const [configLoading, setConfigLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openDay, setOpenDay] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showRollover, setShowRollover] = useState(false)
const loadConfig = useCallback(async () => {
setConfigLoading(true)
try {
const cfg = await calendarApi.getConfig()
setConfig(cfg)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Config laden fehlgeschlagen')
} finally {
setConfigLoading(false)
}
}, [])
useEffect(() => { loadConfig() }, [loadConfig])
const loadHolidays = useCallback(async () => {
if (!config?.bundesland) return
const { from, to } = monthRange(year, month)
try {
const [hd, ev] = await Promise.all([
calendarApi.listHolidays(config.bundesland, from, to),
calendarApi.listEvents(from, to),
])
setHolidays(hd || [])
setSchoolEvents(ev || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ferien/Events laden fehlgeschlagen')
}
}, [config, year, month])
useEffect(() => { loadHolidays() }, [loadHolidays])
const handleSaveBundesland = async (bundesland: string) => {
const cfg = await calendarApi.upsertConfig({ bundesland })
setConfig(cfg)
}
const goPrev = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else setMonth(m => m - 1)
}
const goNext = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else setMonth(m => m + 1)
}
const goToday = () => {
const t = new Date()
setYear(t.getFullYear())
setMonth(t.getMonth() + 1)
}
const bundeslandName = config
? BUNDESLAENDER.find(b => b.code === config.bundesland)?.name || config.bundesland
: ''
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
<div className="relative z-10 p-4"><Sidebar selectedTab="schulkalender" /></div>
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
<div className="max-w-6xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Schulkalender
</h1>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{config ? `Ferien und Feiertage fuer ${bundeslandName}` : 'Ferien, Feiertage und Schultermine'}
</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</header>
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>
)}
{configLoading ? (
<div className={`text-center py-12 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt</div>
) : !config ? (
<BundeslandWizard onSave={handleSaveBundesland} />
) : (
<>
<MonthView
year={year}
month={month}
holidays={holidays}
schoolEvents={schoolEvents}
onPrev={goPrev}
onNext={goNext}
onToday={goToday}
onDayClick={(iso) => setOpenDay(iso)}
onAddEvent={() => setShowAddModal(true)}
onRollover={() => setShowRollover(true)}
/>
{openDay && (
<DayDetail
iso={openDay}
holidays={holidays}
events={schoolEvents}
onClose={() => setOpenDay(null)}
onDeleted={() => { loadHolidays(); setOpenDay(null) }}
/>
)}
{showAddModal && (
<EventModal
defaultDate={openDay || new Date().toISOString().slice(0, 10)}
onClose={() => setShowAddModal(false)}
onCreated={() => { setShowAddModal(false); loadHolidays() }}
/>
)}
{showRollover && (
<RolloverWizard
onClose={() => setShowRollover(false)}
onDone={() => { setShowRollover(false); loadHolidays() }}
/>
)}
<div className="mt-6">
<ParentManager />
</div>
</>
)}
</div>
</main>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
export type PublicEventType = 'public_holiday' | 'school_holiday'
export interface PublicEvent {
id: string
region: string
event_type: PublicEventType
name_de: string
name_en?: string
start_date: string // YYYY-MM-DD
end_date: string
source?: string
created_at?: string
}
export interface SchoolCalendarConfig {
user_id: string
bundesland: string
school_year_start?: string | null
school_year_end?: string | null
created_at?: string
updated_at?: string
}
export interface UpsertSchoolCalendarConfig {
bundesland: string
school_year_start?: string | null
school_year_end?: string | null
}
export type SchoolEventType =
| 'fortbildung'
| 'schulfeier'
| 'klassenfahrt'
| 'projekttag'
| 'eltern_info'
| 'andere'
export interface SchoolEvent {
id: string
created_by_user_id: string
title: string
description?: string
event_type: SchoolEventType
is_school_free: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids: string[]
visible_to_parents: boolean
notify_parents: boolean
notify_students: boolean
notification_lead_days: number[]
created_at?: string
updated_at?: string
}
export interface CreateSchoolEvent {
title: string
description?: string
event_type: SchoolEventType
is_school_free?: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids?: string[]
visible_to_parents?: boolean
notify_parents?: boolean
notify_students?: boolean
notification_lead_days?: number[]
}
export interface SchoolYearRolloverResult {
classes_promoted: number
classes_graduated: number
new_year_start: string
new_year_end: string
}
export const EVENT_TYPE_LABEL: Record<SchoolEventType, string> = {
fortbildung: 'Fortbildung',
schulfeier: 'Schulfeier',
klassenfahrt: 'Klassenfahrt',
projekttag: 'Projekttag',
eltern_info: 'Eltern-Info',
andere: 'Andere',
}
export const EVENT_TYPE_COLOR: Record<SchoolEventType, string> = {
fortbildung: '#0ea5e9',
schulfeier: '#a855f7',
klassenfahrt: '#22c55e',
projekttag: '#f59e0b',
eltern_info: '#ec4899',
andere: '#64748b',
}
export const BUNDESLAENDER: { code: string; name: string }[] = [
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' },
{ code: 'DE-BY', name: 'Bayern' },
{ code: 'DE-BE', name: 'Berlin' },
{ code: 'DE-BB', name: 'Brandenburg' },
{ code: 'DE-HB', name: 'Bremen' },
{ code: 'DE-HH', name: 'Hamburg' },
{ code: 'DE-HE', name: 'Hessen' },
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'DE-NI', name: 'Niedersachsen' },
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
{ code: 'DE-SL', name: 'Saarland' },
{ code: 'DE-SN', name: 'Sachsen' },
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
{ code: 'DE-TH', name: 'Thueringen' },
]
// ---------- Parent invitations (Phase 9c) ----------
export interface ParentAccount {
id: string
email: string
preferred_language: string
}
export interface ParentChild {
id: string
parent_id: string
tt_class_id: string
first_name: string
last_name: string
class_name?: string
}
export interface ParentInviteListItem {
parent_id: string
email: string
preferred_language: string
child_id: string
child_first_name: string
child_last_name: string
class_id: string
class_name: string
created_at: string
}
export interface InviteParentRequest {
email: string
preferred_language?: string
child_first_name: string
child_last_name: string
tt_class_id: string
}
export interface InviteParentResponse {
parent: ParentAccount
child: ParentChild
magic_token: string
magic_url: string
expires_at: string
}
// ---------- Notifications (Phase 9d) ----------
export type NotificationStatus = 'sent' | 'failed' | 'skipped'
export interface NotificationLogRow {
lead_days: number
audience: 'parents' | 'students'
channel: 'matrix' | 'email'
status: NotificationStatus
error_message?: string
run_date: string
created_at: string
}
export interface NotificationRunResult {
date: string
sent: number
failed: number
skipped: number
already_logged: number
}
+9
View File
@@ -18,6 +18,7 @@ const NAV_LABELS: Record<string, Record<string, string>> = {
nav_woerterbuch: { de: 'Woerterbuch', en: 'Dictionary', tr: 'Sozluk', ar: '\u0627\u0644\u0642\u0627\u0645\u0648\u0633', uk: '\u0421\u043b\u043e\u0432\u043d\u0438\u043a', ru: '\u0421\u043b\u043e\u0432\u0430\u0440\u044c', pl: 'Slownik', fr: 'Dictionnaire', es: 'Diccionario', it: 'Dizionario', pt: 'Dicionario', nl: 'Woordenboek', ro: 'Dictionar', el: '\u039b\u03b5\u03be\u03b9\u03ba\u03cc', bg: '\u0420\u0435\u0447\u043d\u0438\u043a', hr: 'Rjecnik', cs: 'Slovnik', hu: 'Szotar', sv: 'Ordbok', da: 'Ordbog', fi: 'Sanakirja', sk: 'Slovnik', sl: 'Slovar', lt: 'Zodynas', lv: 'Vardnica', et: 'Sonaraamat' },
nav_meet: { de: 'Videokonferenz', en: 'Video Call', tr: 'Gorusme', ar: '\u0645\u0643\u0627\u0644\u0645\u0629', uk: '\u0412\u0456\u0434\u0435\u043e\u0434\u0437\u0432\u0456\u043d\u043e\u043a', ru: '\u0412\u0438\u0434\u0435\u043e\u0437\u0432\u043e\u043d\u043e\u043a', pl: 'Wideorozmowa', fr: 'Visioconference', es: 'Videollamada', it: 'Videochiamata', pt: 'Videochamada', nl: 'Videogesprek', ro: 'Videoconferinta', el: '\u0392\u03b9\u03bd\u03c4\u03b5\u03bf\u03ba\u03bb\u03ae\u03c3\u03b7', bg: '\u0412\u0438\u0434\u0435\u043e\u0440\u0430\u0437\u0433\u043e\u0432\u043e\u0440', hr: 'Videopoziv', cs: 'Videohovor', hu: 'Videohivas', sv: 'Videosamtal', da: 'Videoopkald', fi: 'Videopuhelu', sk: 'Videohovor', sl: 'Videoklic', lt: 'Vaizdo skambutis', lv: 'Videozvans', et: 'Videokoone' },
nav_stundenplan: { de: 'Stundenplan', en: 'Timetable', tr: 'Ders Programi', ar: 'جدول حصص', uk: 'Розклад', ru: 'Расписание', pl: 'Plan lekcji', fr: 'Emploi du temps', es: 'Horario', it: 'Orario', pt: 'Horario', nl: 'Rooster', ro: 'Orar', el: 'Πρόγραμμα', bg: 'Разписание', hr: 'Raspored', cs: 'Rozvrh', hu: 'Orarend', sv: 'Schema', da: 'Skema', fi: 'Lukujarjestys', sk: 'Rozvrh', sl: 'Urnik', lt: 'Tvarkarastis', lv: 'Stundu saraksts', et: 'Tunniplaan' },
nav_schulkalender: { de: 'Schulkalender', en: 'School Calendar', tr: 'Okul Takvimi', ar: 'تقويم المدرسة', uk: 'Шкільний календар', ru: 'Школьный календарь', pl: 'Kalendarz szkolny', fr: 'Calendrier scolaire', es: 'Calendario escolar', it: 'Calendario scolastico', pt: 'Calendario escolar', nl: 'Schoolkalender', ro: 'Calendar scolar', el: 'Σχολικό ημερολόγιο', bg: 'Училищен календар', hr: 'Skolski kalendar', cs: 'Skolni kalendar', hu: 'Iskolai naptar', sv: 'Skolkalender', da: 'Skolekalender', fi: 'Koulukalenteri', sk: 'Skolsky kalendar', sl: 'Solski koledar', lt: 'Mokyklos kalendorius', lv: 'Skolas kalendars', et: 'Koolikalender' },
nav_companion: { de: 'KI-Assistent', en: 'AI Assistant', tr: 'Yapay Zeka', ar: '\u0645\u0633\u0627\u0639\u062f \u0630\u0643\u064a', uk: '\u0428\u0406-\u0430\u0441\u0438\u0441\u0442\u0435\u043d\u0442', ru: '\u0418\u0418-\u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442', pl: 'Asystent AI', fr: 'Assistant IA', es: 'Asistente IA', it: 'Assistente IA', pt: 'Assistente IA', nl: 'AI-assistent', ro: 'Asistent AI', el: 'AI \u0392\u03bf\u03b7\u03b8\u03cc\u03c2', bg: 'AI \u0430\u0441\u0438\u0441\u0442\u0435\u043d\u0442', hr: 'AI pomoenik', cs: 'AI asistent', hu: 'AI asszisztens', sv: 'AI-assistent', da: 'AI-assistent', fi: 'Tekoalyavustaja', sk: 'AI asistent', sl: 'AI pomoenik', lt: 'DI asistentas', lv: 'MI paligs', et: 'Tehisabiabi' },
}
@@ -111,6 +112,13 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)},
{ id: 'schulkalender', labelKey: 'nav_schulkalender', href: '/schulkalender', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3M3 11h18M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<circle cx="8" cy="15" r="1.5" fill="currentColor" />
<circle cx="16" cy="15" r="1.5" fill="currentColor" />
</svg>
)},
{ id: 'companion', labelKey: 'nav_companion', href: '/companion', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
@@ -158,6 +166,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
if (pathname === '/messages') return 'messages'
if (pathname?.startsWith('/korrektur')) return 'korrektur'
if (pathname?.startsWith('/stundenplan')) return 'stundenplan'
if (pathname?.startsWith('/schulkalender')) return 'schulkalender'
return selectedTab
}
+134
View File
@@ -0,0 +1,134 @@
import { test, expect, Page } from '@playwright/test'
/**
* E2E for the Phase 9c parent-side: ParentManager on /schulkalender (teacher
* UI) and the /eltern login + timetable view. Backend calls are intercepted
* so the suite doesn't need a real teacher parent invitation cycle.
*/
async function mockTeacherCalendar(page: Page, opts: { classes?: unknown[]; parents?: unknown[]; invite?: unknown } = {}) {
// Existing schulkalender mocks the page already needs.
await page.route('**/api/school/calendar/config', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user_id: 'dev', bundesland: 'DE-NI' }) })
})
await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
// ParentManager loads classes via the stundenplan API.
await page.route('**/api/school/timetable/classes', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.classes ?? []) }))
await page.route('**/api/school/calendar/parents', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.parents ?? []) }))
await page.route('**/api/school/calendar/parents/invite', async (route) => {
if (route.request().method() !== 'POST') return route.fulfill({ status: 405 })
return route.fulfill({
status: 201, contentType: 'application/json',
body: JSON.stringify(opts.invite ?? {
parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' },
child: { id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller' },
magic_token: 'abc123',
magic_url: '/eltern/login?token=abc123',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
}),
})
})
}
test.describe('Schulkalender — ParentManager', () => {
test('renders empty state when no parents invited', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
const manager = page.getByTestId('parent-manager')
await expect(manager).toBeVisible()
await expect(manager.getByText('Keine eingeladenen Eltern.')).toBeVisible()
})
test('+ Eltern einladen opens the form when classes exist', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('parent-invite-toggle').click()
await expect(page.getByTestId('parent-email')).toBeVisible()
})
test('submitting invite shows the magic link to copy', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('parent-invite-toggle').click()
await page.getByTestId('parent-email').fill('mama@example.de')
await page.getByTestId('parent-child-first').fill('Max')
await page.getByTestId('parent-child-last').fill('Mueller')
await page.getByTestId('parent-class').selectOption('class-1')
await page.getByTestId('parent-invite-submit').click()
await expect(page.getByTestId('parent-invite-link')).toBeVisible()
await expect(page.getByText('Einladungs-Link fuer mama@example.de')).toBeVisible()
})
})
async function mockParentApi(page: Page, opts: { redeemOk?: boolean; me?: unknown; lessons?: unknown[] } = {}) {
const redeemOk = opts.redeemOk ?? true
await page.route('**/api/parent/auth/redeem', async (route) => {
if (!redeemOk) return route.fulfill({ status: 401, contentType: 'application/json', body: '{"error":"invalid"}' })
return route.fulfill({
status: 200, contentType: 'application/json',
headers: { 'set-cookie': 'bp_parent_session=test; Path=/; HttpOnly' },
body: JSON.stringify({ id: 'p1', email: 'mama@example.de', preferred_language: 'tr' }),
})
})
await page.route('**/api/parent/me', async (route) => {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(opts.me ?? {
parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' },
children: [{ id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller', class_name: '5a' }],
}),
})
})
await page.route(/\/api\/parent\/me\/timetable(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.lessons ?? []) }))
await page.route('**/api/parent/auth/logout', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' }))
}
test.describe('Eltern — Login + Wochengrid', () => {
test('login page shows error when no token in URL', async ({ page }) => {
await mockParentApi(page)
await page.goto('/eltern/login')
await expect(page.getByTestId('eltern-login')).toBeVisible()
await expect(page.getByText('Kein Token in der URL')).toBeVisible()
})
test('valid token redirects to the parent overview', async ({ page }) => {
await mockParentApi(page, {})
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern', { timeout: 3000 })
await expect(page.getByTestId('eltern-page')).toBeVisible()
})
test('shows greeting and child class on /eltern', async ({ page }) => {
await mockParentApi(page)
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern')
// Turkish greeting because preferred_language=tr.
await expect(page.getByText('Hoş geldiniz, mama@example.de')).toBeVisible()
await expect(page.getByText('Max Mueller · 5a')).toBeVisible()
})
test('translates subject names into the parent language', async ({ page }) => {
await mockParentApi(page, {
lessons: [
{ DayOfWeek: 1, PeriodIndex: 1, StartTime: '08:00', EndTime: '08:45', ClassName: '5a', SubjectName: 'Mathematik', SubjectCode: 'M', TeacherName: 'Schmidt, Anna', RoomName: 'A101', Pinned: false },
],
})
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern')
// Turkish target = Matematik.
await expect(page.getByTestId('eltern-cell-1-1').getByText('Matematik')).toBeVisible()
})
})
+317
View File
@@ -0,0 +1,317 @@
import { test, expect, Page } from '@playwright/test'
/**
* E2E tests for /schulkalender. Mocks the /api/school/calendar/* routes
* so the wizard, save flow and month grid render deterministically without
* the live backend or seed data.
*/
interface MockOpts {
config?: { user_id: string; bundesland: string } | null
holidays?: unknown[]
events?: unknown[]
notificationLog?: unknown[]
}
async function mockCalendarApi(page: Page, opts: MockOpts = {}) {
let config = opts.config ?? null
const events = (opts.events ?? []) as Array<Record<string, unknown>>
await page.route('**/api/school/calendar/config', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(config),
})
}
if (route.request().method() === 'PUT') {
const body = JSON.parse(route.request().postData() || '{}')
config = { user_id: 'dev', bundesland: body.bundesland }
return route.fulfill({
status: 201, contentType: 'application/json',
body: JSON.stringify(config),
})
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) => {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(opts.holidays ?? []),
})
})
// Phase 9b: school events + rollover.
await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) => {
const method = route.request().method()
if (method === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(events) })
}
if (method === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
const created = {
id: `new-${events.length}`,
created_by_user_id: 'dev',
affected_class_ids: [],
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
is_school_free: false,
...body,
}
events.push(created)
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/events\/[^/]+$/, async (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/school-year-rollover$/, async (route) => {
if (route.request().method() !== 'POST') return route.fulfill({ status: 405 })
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({
classes_promoted: 8, classes_graduated: 2,
new_year_start: '2026-08-01', new_year_end: '2027-07-31',
}),
})
})
// Phase 9d: per-event notification_log + manual trigger. NotificationStatus
// component fetches the log when an event has notify_parents/students.
await page.route(/\/api\/school\/calendar\/events\/[^/]+\/notifications$/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.notificationLog ?? []) })
})
await page.route(/\/api\/school\/calendar\/notifications\/run-now.*/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json',
body: '{"date":"2026-05-22","sent":0,"failed":0,"skipped":0,"already_logged":0}' })
})
}
test.describe('Schulkalender — Bundesland Wizard', () => {
test('wizard renders when no config exists', async ({ page }) => {
await mockCalendarApi(page, { config: null })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('bundesland-wizard')).toBeVisible()
await expect(page.getByText('Willkommen im Schulkalender')).toBeVisible()
})
test('saving a Bundesland switches to MonthView', async ({ page }) => {
await mockCalendarApi(page, { config: null })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('bundesland-select').selectOption('DE-NI')
await page.getByTestId('bundesland-save').click()
await expect(page.getByTestId('month-view')).toBeVisible()
await expect(page.getByText('Niedersachsen')).toBeVisible()
})
})
test.describe('Schulkalender — Month View', () => {
test('shows MonthView when config is set', async ({ page }) => {
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
holidays: [],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('month-view')).toBeVisible()
// Weekday header line.
for (const w of ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']) {
await expect(page.getByText(w, { exact: true }).first()).toBeVisible()
}
})
test('colours holidays in the grid', async ({ page }) => {
// Fix today by mocking config with a deterministic month/year via prev/next.
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
holidays: [
{ id: 'h1', region: 'DE-NI', event_type: 'public_holiday', name_de: 'Test-Feiertag', start_date: '2099-06-15', end_date: '2099-06-15' },
{ id: 'h2', region: 'DE-NI', event_type: 'school_holiday', name_de: 'Test-Ferien', start_date: '2099-06-20', end_date: '2099-06-21' },
],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
// Assert the legend rows — using exact text to avoid colliding with
// tooltips like "Tag der deutschen Einheit" that also contain 'tag'.
await expect(page.getByText('Feiertag', { exact: true })).toBeVisible()
await expect(page.getByText('Schulferien', { exact: true })).toBeVisible()
})
test('Heute button resets to current month', async ({ page }) => {
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
holidays: [],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('month-prev').click()
await page.getByTestId('month-prev').click()
await page.getByTestId('month-today').click()
// After clicking Heute, the current month name must appear in the heading.
const months = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const currentMonth = months[new Date().getMonth()]
await expect(page.getByRole('heading', { name: new RegExp(currentMonth) })).toBeVisible()
})
})
test.describe('Schulkalender — Sidebar entry', () => {
test('sidebar contains Schulkalender link', async ({ page }) => {
await mockCalendarApi(page)
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
const sidebar = page.locator('aside').first()
await expect(sidebar.getByText(/Schulkalender|School Calendar/).first()).toBeVisible()
})
})
// ==========================================================================
// Phase 9b — Schul-Events + Schuljahres-Rollover
// ==========================================================================
test.describe('Schulkalender — School Event CRUD', () => {
test('+ Termin button opens the event modal', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('add-event').click()
await expect(page.getByTestId('event-modal')).toBeVisible()
await expect(page.getByText('Neuer Termin')).toBeVisible()
})
test('submitting the form creates an event and closes the modal', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('add-event').click()
await page.getByTestId('event-title').fill('SCHILF: Digitale Tafeln')
await page.getByTestId('event-type').selectOption('fortbildung')
await page.getByTestId('event-save').click()
await expect(page.getByTestId('event-modal')).toHaveCount(0)
})
test('clicking a day opens the DayDetail with its events', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e1', created_by_user_id: 'dev',
title: 'Pruefe Test-Event', event_type: 'projekttag',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: false, notify_students: false,
notification_lead_days: [7, 1],
}],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('day-detail')).toBeVisible()
await expect(page.getByText('Pruefe Test-Event')).toBeVisible()
})
})
test.describe('Schulkalender — Schuljahres-Rollover', () => {
test('Schuljahr-wechseln button opens the wizard', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
await expect(page.getByTestId('rollover-wizard')).toBeVisible()
})
test('confirm-typing protects against accidental submit', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
const submit = page.getByTestId('rollover-submit')
await expect(submit).toBeDisabled()
await page.getByTestId('rollover-confirm').fill('falsch')
await expect(submit).toBeDisabled()
await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN')
await expect(submit).toBeEnabled()
})
test('successful rollover shows summary numbers', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN')
await page.getByTestId('rollover-submit').click()
await expect(page.getByTestId('rollover-result')).toBeVisible()
await expect(page.getByText('8 Klassen um eine Stufe aufgerueckt')).toBeVisible()
await expect(page.getByText('2 Abschlussklassen entfernt')).toBeVisible()
})
})
// ==========================================================================
// Phase 9d — Notification-Status im DayDetail
// ==========================================================================
test.describe('Schulkalender — Notification-Status', () => {
test('shows sent badge for delivered reminders', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e1', created_by_user_id: 'dev',
title: 'Pruefe Test-Event', event_type: 'projekttag',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: true, notify_students: false,
notification_lead_days: [7, 1],
}],
notificationLog: [
{ lead_days: 7, audience: 'parents', channel: 'email', status: 'sent', run_date: '2026-05-15', created_at: '2026-05-15T06:00:00Z' },
{ lead_days: 1, audience: 'parents', channel: 'email', status: 'skipped', run_date: '2026-05-21', created_at: '2026-05-21T06:00:00Z' },
],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('day-detail')).toBeVisible()
const status = page.getByTestId('notif-status-e1')
await expect(status).toBeVisible()
await expect(status.getByText(/7 Tage.*Eltern.*email/)).toBeVisible()
await expect(status.getByText(/1 Tag.*Eltern.*email/)).toBeVisible()
})
test('hides notification status when notifications are off', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e2', created_by_user_id: 'dev',
title: 'Stilles Event', event_type: 'andere',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: false, notify_students: false,
notification_lead_days: [],
}],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('notif-status-e2')).toHaveCount(0)
})
})
+64
View File
@@ -0,0 +1,64 @@
/**
* Subject-name translations for the parent-facing weekly grid.
*
* The teacher enters German subject names in tt_subject.name. For parents
* whose preferred_language differs, we look up the German name in this
* table and substitute the localised version. If no match (custom AG,
* Wahlfach, ...), the German original is shown.
*
* Keys are normalised lowercase German subject names. Languages cover the
* 8 most-common parent locales in DE schools; everything else falls back
* to German.
*/
type SupportedLanguage = 'de' | 'en' | 'tr' | 'ar' | 'uk' | 'ru' | 'pl' | 'fr'
interface SubjectTranslation {
de: string
en: string
tr: string
ar: string
uk: string
ru: string
pl: string
fr: string
}
const SUBJECTS: Record<string, SubjectTranslation> = {
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Mathématiques' },
mathe: { de: 'Mathe', en: 'Maths', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Maths' },
deutsch: { de: 'Deutsch', en: 'German', tr: 'Almanca', ar: 'الألمانية', uk: 'Німецька мова', ru: 'Немецкий язык', pl: 'Język niemiecki', fr: 'Allemand' },
englisch: { de: 'Englisch', en: 'English', tr: 'İngilizce', ar: 'الإنجليزية', uk: 'Англійська мова', ru: 'Английский язык', pl: 'Język angielski', fr: 'Anglais' },
franzoesisch: { de: 'Franzoesisch', en: 'French', tr: 'Fransızca', ar: 'الفرنسية', uk: 'Французька мова', ru: 'Французский язык', pl: 'Język francuski', fr: 'Français' },
spanisch: { de: 'Spanisch', en: 'Spanish', tr: 'İspanyolca', ar: 'الإسبانية', uk: 'Іспанська мова', ru: 'Испанский язык', pl: 'Język hiszpański', fr: 'Espagnol' },
latein: { de: 'Latein', en: 'Latin', tr: 'Latince', ar: 'اللاتينية', uk: 'Латинська мова', ru: 'Латинский язык', pl: 'Łacina', fr: 'Latin' },
sachkunde: { de: 'Sachkunde', en: 'General Studies', tr: 'Hayat Bilgisi', ar: 'الدراسات العامة', uk: 'Природознавство', ru: 'Окружающий мир', pl: 'Wiedza o przyrodzie', fr: 'Découverte du monde' },
sport: { de: 'Sport', en: 'PE', tr: 'Beden Eğitimi', ar: 'التربية البدنية', uk: 'Фізкультура', ru: 'Физкультура', pl: 'WF', fr: 'EPS' },
musik: { de: 'Musik', en: 'Music', tr: 'Müzik', ar: 'الموسيقى', uk: 'Музика', ru: 'Музыка', pl: 'Muzyka', fr: 'Musique' },
kunst: { de: 'Kunst', en: 'Art', tr: 'Sanat', ar: 'الفن', uk: 'Мистецтво', ru: 'Искусство', pl: 'Plastyka', fr: 'Arts plastiques' },
religion: { de: 'Religion', en: 'Religion', tr: 'Din Bilgisi', ar: 'الدين', uk: 'Релігія', ru: 'Религия', pl: 'Religia', fr: 'Religion' },
ethik: { de: 'Ethik', en: 'Ethics', tr: 'Etik', ar: 'الأخلاق', uk: 'Етика', ru: 'Этика', pl: 'Etyka', fr: 'Éthique' },
biologie: { de: 'Biologie', en: 'Biology', tr: 'Biyoloji', ar: 'الأحياء', uk: 'Біологія', ru: 'Биология', pl: 'Biologia', fr: 'Biologie' },
chemie: { de: 'Chemie', en: 'Chemistry', tr: 'Kimya', ar: 'الكيمياء', uk: 'Хімія', ru: 'Химия', pl: 'Chemia', fr: 'Chimie' },
physik: { de: 'Physik', en: 'Physics', tr: 'Fizik', ar: 'الفيزياء', uk: 'Фізика', ru: 'Физика', pl: 'Fizyka', fr: 'Physique' },
geschichte: { de: 'Geschichte', en: 'History', tr: 'Tarih', ar: 'التاريخ', uk: 'Історія', ru: 'История', pl: 'Historia', fr: 'Histoire' },
geografie: { de: 'Geografie', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' },
erdkunde: { de: 'Erdkunde', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' },
politik: { de: 'Politik', en: 'Civics', tr: 'Vatandaşlık', ar: 'التربية الوطنية', uk: 'Громадянознавство', ru: 'Обществознание', pl: 'Wiedza o społeczeństwie', fr: 'Éducation civique' },
informatik: { de: 'Informatik', en: 'Computer Science', tr: 'Bilişim', ar: 'علوم الحاسوب', uk: 'Інформатика', ru: 'Информатика', pl: 'Informatyka', fr: 'Informatique' },
wirtschaft: { de: 'Wirtschaft', en: 'Economics', tr: 'Ekonomi', ar: 'الاقتصاد', uk: 'Економіка', ru: 'Экономика', pl: 'Ekonomia', fr: 'Économie' },
}
/**
* Translate a German subject name into the requested language.
* Falls back to the original input if no match in the table or no
* translation for the target language.
*/
export function translateSubject(germanName: string, lang: string): string {
if (!germanName) return germanName
const key = germanName.toLowerCase().trim()
const row = SUBJECTS[key]
if (!row) return germanName
const code = (lang || 'de').slice(0, 2) as SupportedLanguage
return row[code] || row.de || germanName
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Parent API client. Cookies (HttpOnly bp_parent_session) carry auth
* we never store the session token in JS-readable storage. credentials:
* 'include' is mandatory so the cookie ships with each request.
*/
const PROXY_PREFIX = '/api/parent'
interface FetchOptions extends RequestInit {
expectJson?: boolean
}
async function parentFetch<T>(endpoint: string, opts: FetchOptions = {}): Promise<T> {
const res = await fetch(`${PROXY_PREFIX}${endpoint}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string> | undefined),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
export interface ParentMeResponse {
parent: { id: string; email: string; preferred_language: string }
children: Array<{
id: string
parent_id: string
tt_class_id: string
first_name: string
last_name: string
class_name?: string
}>
}
export interface ParentLesson {
DayOfWeek: number
PeriodIndex: number
StartTime: string
EndTime: string
ClassName: string
SubjectName: string
SubjectCode: string
TeacherName: string
RoomName: string
Pinned: boolean
}
export const elternApi = {
redeem: (token: string) =>
parentFetch<{ id: string; email: string; preferred_language: string }>('/auth/redeem', {
method: 'POST', body: JSON.stringify({ token }),
}),
me: () => parentFetch<ParentMeResponse>('/me'),
timetable: (classId: string) =>
parentFetch<ParentLesson[]>(`/me/timetable?class_id=${encodeURIComponent(classId)}`),
logout: () => parentFetch<void>('/auth/logout', { method: 'POST' }),
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Schulkalender API client. Re-uses the same /api/school/* proxy + the JWT
* helper from stundenplan so we don't fork the auth flow.
*/
import { getStundenplanToken } from '@/lib/stundenplan/api'
import type {
PublicEvent, SchoolCalendarConfig, UpsertSchoolCalendarConfig,
SchoolEvent, CreateSchoolEvent, SchoolYearRolloverResult,
ParentInviteListItem, InviteParentRequest, InviteParentResponse,
NotificationLogRow, NotificationRunResult,
} from '@/app/schulkalender/types'
async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
}
const token = getStundenplanToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`/api/school${endpoint}`, { ...options, headers })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || err.detail || `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
export const calendarApi = {
listHolidays: (region: string, from: string, to: string) =>
apiFetch<PublicEvent[]>(`/calendar/holidays?region=${encodeURIComponent(region)}&from=${from}&to=${to}`),
getConfig: () => apiFetch<SchoolCalendarConfig | null>('/calendar/config'),
upsertConfig: (data: UpsertSchoolCalendarConfig) =>
apiFetch<SchoolCalendarConfig>('/calendar/config', { method: 'PUT', body: JSON.stringify(data) }),
// School events
listEvents: (from: string, to: string) =>
apiFetch<SchoolEvent[]>(`/calendar/events?from=${from}&to=${to}`),
createEvent: (data: CreateSchoolEvent) =>
apiFetch<SchoolEvent>('/calendar/events', { method: 'POST', body: JSON.stringify(data) }),
deleteEvent: (id: string) =>
apiFetch<void>(`/calendar/events/${id}`, { method: 'DELETE' }),
rolloverSchoolYear: (newYearStart?: string, newYearEnd?: string) =>
apiFetch<SchoolYearRolloverResult>('/calendar/school-year-rollover', {
method: 'POST',
body: JSON.stringify({
new_year_start: newYearStart,
new_year_end: newYearEnd,
}),
}),
// Phase 9c: parent invitations
listParents: () => apiFetch<ParentInviteListItem[]>('/calendar/parents'),
inviteParent: (data: InviteParentRequest) =>
apiFetch<InviteParentResponse>('/calendar/parents/invite', { method: 'POST', body: JSON.stringify(data) }),
deleteParentChild: (childId: string) =>
apiFetch<void>(`/calendar/parents/children/${childId}`, { method: 'DELETE' }),
// Phase 9d: notifications.
runNotifications: (date?: string) =>
apiFetch<NotificationRunResult>('/calendar/notifications/run-now' + (date ? `?date=${date}` : ''), { method: 'POST' }),
listEventNotifications: (eventId: string) =>
apiFetch<NotificationLogRow[]>(`/calendar/events/${eventId}/notifications`),
}