Compare commits
9 Commits
65e7ed94f6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c720e2df | |||
| 89011d64f7 | |||
| 8311b33fb3 | |||
| 85957ed5db | |||
| d9858084dd | |||
| 33409352ee | |||
| 3b8df0d294 | |||
| 09f6f5a5e1 | |||
| 97e37837ee |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
¬ifyParents, ¬ifyStudents, &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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Executable
+86
@@ -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) }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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`),
|
||||
}
|
||||
Reference in New Issue
Block a user