Compare commits

..

20 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:46:39 +02:00
Benjamin Admin 65e7ed94f6 gofmt middleware.go
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 40s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m27s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 22s
2026-05-22 08:58:11 +02:00
Benjamin Admin 306886a42b Phase 8: CSV + ICS export, print view, MkDocs docs, SBOM + dev-mode auth
Auth (Test-Mode):
  - middleware.AuthMiddleware now takes a devMode flag. In dev,
    requests without Authorization fall back to a deterministic dev
    UUID (00000000-...-001) and role=teacher. ENVIRONMENT=production
    re-enables the strict 401 path.
  - main.go wires devMode = cfg.Environment != "production".
  - page.tsx replaces the red 'Anmeldung noch nicht integriert' banner
    with a softer Testumgebung notice; the manual-token form moves
    behind a nested details block.

Export endpoints (school-service):
  - LoadExportLessons joins tt_lesson with tt_period for wall-clock
    times; one query feeds both CSV and ICS.
  - WriteCSV streams 10 columns including pinned flag.
  - WriteICS emits one VEVENT per lesson anchored to a Monday — caller
    overridable via ?start=YYYY-MM-DD. RFC 5545 escapes for ',', ';',
    '\n' in icsEscape().
  - NextMonday helper for the default anchor.
  - GET /timetable/solutions/:id/export.{csv,ics} handlers attach
    Content-Disposition: attachment so browsers download instead of
    rendering.

Frontend:
  - lib/stundenplan/api.ts downloadSolutionExport() fetches as blob,
    triggers a synthetic <a download> click, and forwards the JWT when
    present.
  - PlanView gains CSV / ICS / Drucken buttons next to the perspective
    selector. The toolbar carries class 'no-print' so window.print()
    yields only the grid.
  - globals.css @media print rule hides chrome, forces white
    background, gives the table proper borders for A4.

Docs:
  - docs-src/services/stundenplan/{index,architecture,constraints,
    solver-tuning,export}.md with nav entry in mkdocs.yml under
    Services → Stundenplaner.
  - sbom/stundenplan/README.md lists manually-verified key dependencies
    and the policy reference. scripts/stundenplan-sbom.sh generates
    full machine-readable inventories via go-licenses + pip-licenses
    + license-checker when those tools are available.

Tests:
  - internal/services/timetable_exports_test.go: 4 unit tests covering
    CSV column layout + quoting, ICS structure + DTSTART formatting,
    icsEscape special chars, NextMonday weekday math.
  - studio-v2/e2e/stundenplan-export.spec.ts split out of the main spec
    file (LOC budget) — 3 tests for button render, CSV download,
    ICS download.
  - mockSchoolApi extended with export.csv + export.ics routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:57:07 +02:00
Benjamin Admin bf5ea860cc Phase 7: pinning, plan versions, solver budget + UX polish
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 23s
Backend (school-service):
  - tt_solution gains parent_solution_id (self-FK, ON DELETE SET NULL)
    and seconds_limit columns via ALTER TABLE IF NOT EXISTS.
  - CreateTimetableSolutionRequest accepts optional parent_solution_id
    and seconds_limit (5-600s) with binding validation.
  - CreateSolution checks parent ownership before INSERT so users can't
    fork another tenant's plan.
  - New PUT /timetable/lessons/:id/pin endpoint; ownership enforced via
    the lesson's solution.created_by_user_id JOIN.

Solver:
  - Lesson.pinned now carries @PlanningPin so Timefold leaves locked
    cells untouched during the search.
  - build_problem() takes optional parent_solution_id; if set, copies
    pinned (class_id, subject_id, day, period, room) tuples onto fresh
    Lesson objects via greedy first-fit matching. Surplus pinned rows
    from curriculum changes are silently dropped.
  - _build_factory(seconds) replaces the module-level factory so each
    job honours its tt_solution.seconds_limit override.
  - persist_solution writes lesson.pinned back so subsequent re-solves
    inherit it.

Frontend (studio-v2):
  - SolutionList grows three knobs in the create-form: Basieren auf
    (parent dropdown, only completed solutions, disabled when none),
    Sekunden-Limit (5-600), and the existing Name.
  - PlanView cells get a pin/unpin button with optimistic update and
    rollback on error. Pinned cells gain an amber ring.
  - types.ts + api.ts mirror the new fields; lessonsApi.pin(id, bool).
  - HelpPanel: collapsible 6-step Bedienungsanleitung explaining the
    setup-to-plan workflow. Anchored at the top of /stundenplan above
    the dev token banner.
  - page.tsx switches to the same gradient + animated-blob background
    used on /korrektur so /stundenplan stops looking like a slate-900
    test page.
  - JWT dev banner gets a step-by-step explanation of how to grab the
    token from DevTools and a non-blocking success indicator (no more
    alert()).

Tests:
  - school-service: 6 new validator cases for parent_solution_id +
    seconds_limit boundaries. 73 subtests total, all green.
  - studio-v2: mockSchoolApi adds PUT /lessons/:id/pin route. 5 new
    Playwright tests across two suites (parent-selector visibility +
    options, seconds-limit input, pin button render, pin-icon flip).
    Existing tests adjusted to the new help panel + JWT banner wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:19:39 +02:00
Benjamin Admin 612ecec6d9 Phase 6: Plan-Ansicht — solution list + weekly grid view
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 27s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m18s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 23s
Frontend additions in studio-v2:
  - types.ts adds TimetableSolution, TimetableLesson, SolutionStatus,
    CreateTimetableSolution mirroring the Go models.
  - lib/stundenplan/api.ts adds solutionsApi with list/get/create/remove/
    lessons. Solve trigger is POST /timetable/solutions — school-service
    forwards to the solver-service over the Docker network.
  - _components/plan/SolutionList: table of past solves with status
    badges, hard/soft score, Anzeigen + Loeschen buttons, and a
    'Neuen Plan generieren' trigger. Auto-polls every 4 s while any
    solution is pending/running, clears the interval otherwise.
  - _components/plan/PlanView: Mo–Fr × period weekly grid. Three
    perspectives (Klasse / Lehrer / Raum) toggleable via test-id'd
    buttons; selector below lists every unique resource with at least
    one lesson. Cells colour-coded by tt_subject.color.
  - _components/plan/PlanHub orchestrates list + view; default tab in
    page.tsx switches from 'klassen' to 'plan'.

Tests:
  - mockSchoolApi helper extracted to e2e/_helpers.ts so the spec file
    stays under 500 LOC. Helper now also mocks /solutions GET/POST/DELETE
    and /solutions/:id/lessons; solutions kept in a closure so POST
    appears in the next GET.
  - 8 new tests across two suites: SolutionList empty state, list
    render, completed-vs-failed Anzeigen visibility, solve trigger;
    PlanView placeholder when no selection, grid render, perspective
    switching.
  - Existing Klassen CRUD tests now click the Klassen tab first
    (Plan is the new default landing tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:03:55 +02:00
Benjamin Admin 0744769d88 Split DB-driven constraints into hard + soft variants
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 27s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 25s
Timefold's penalize() emits one score axis per constraint, so the
'penalize as hard OR soft based on is_hard' pattern needs two
constraints. Each rule type now has _hard (filters is_hard=True,
penalises HardSoftScore.ONE_HARD) and _soft (filters is_hard=False,
penalises ONE_SOFT × max(weight, 1)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:40:10 +02:00
Benjamin Admin d3f311a32e Wrap score-director config in ScoreDirectorFactoryConfig
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m31s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 21s
Timefold's SolverConfig expects a typed config object, not a dict —
plain dicts hit AttributeError when the wrapper tries to materialise
the Java side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:33:34 +02:00
Benjamin Admin 77650e8092 Use 'timefold' (not 'timefold-solver') and revert to ARM platform
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 36s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
The Python distribution is published on PyPI as 'timefold' — only the
Java module is called 'timefold-solver'. With the correct package the
ARM64 wheel resolves cleanly, so revert the linux/amd64 workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:28:13 +02:00
Benjamin Admin 22f08a232d Pin solver-service to linux/amd64 (no ARM wheels on PyPI)
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m16s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 25s
timefold-solver only publishes x86_64 wheels — pip resolves to no
candidate on linux/arm64. Switch the compose entry to linux/amd64
so the image builds via QEMU/Rosetta on the Mac Mini. The cloud
target for this service is x86_64 anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:22:34 +02:00
Benjamin Admin 1f2f304724 Use default-jdk-headless on python:slim instead of temurin:alpine
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 36s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m34s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 23s
ARM64 has no eclipse-temurin:17-jdk-alpine manifest. Switch to the
Debian-based python:3.11-slim and install OpenJDK via apt so the
image builds on both Mac Mini (ARM) and x86_64 hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:19:03 +02:00
Benjamin Admin 53cfe9238f Apply gofmt to timetable_solution_migrations.go
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 38s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:18:00 +02:00
Benjamin Admin f042f2896b Phase 5: Timefold timetable-solver-service + solution persistence
school-service additions:
  - tt_solution + tt_lesson migration. tt_lesson carries three UNIQUEs
    (solution+class, solution+teacher, solution+room per slot) so the
    DB itself rejects any double-booking the solver might emit by
    mistake.
  - Solution CRUD + GET solutions/:id/lessons endpoint with joined
    class/subject/teacher/room names for display.
  - POST /timetable/solutions creates the row then fires off the
    solver-service via HTTP (5s timeout, mark failed if unreachable).
  - SOLVER_SERVICE_URL config wired through main.go/handlers.

New service timetable-solver-service:
  - Python 3.11 + FastAPI + Timefold Solver 1.21 (Apache-2.0). Dockerfile
    bundles OpenJDK 17 since Timefold for Python is a JPype bridge.
  - app/domain.py — Timefold @planning_entity Lesson with timeslot+room
    as PlanningVariables; @planning_solution Timetable holds problem
    facts (rooms/teachers/etc.) AND rule-fact collections.
  - app/rules.py — frozen dataclasses mirroring 6 of the 15 tt_
    constraint_* tables initially.
  - app/constraints.py — ConstraintProvider with 3 universal hard
    constraints (no double-booking) + 5 DB-driven constraints
    (teacher_unavailable_day/window, teacher_excluded_room,
    room_unavailable, room_requires_type) + 1 quality soft constraint
    (subject_preferred_period). Remaining 9 constraint types ready to
    plug in via the same join pattern.
  - app/repository.py — async loaders for stammdaten + rules; builds
    one Lesson per (curriculum row × weekly_hours), skipping rows
    without a tt_assignment teacher.
  - app/runner.py — runs solver in ThreadPoolExecutor so the FastAPI
    event loop stays responsive. Updates tt_solution status
    pending→running→completed|infeasible|failed.
  - app/main.py — POST /api/v1/solve (202 Accepted, background task),
    GET /api/v1/jobs/{id}, /health. School-service polls tt_solution
    directly instead of GET /jobs for the typical case.
  - docker-compose.yml adds the service on port 8095, depending on
    core-health-check.

Tests:
  - school-service: validator test for CreateTimetableSolutionRequest
    (allows empty name).
  - solver-service: tests/test_domain.py + tests/test_rules.py cover
    construction + hashability of the planning facts. Full solve flow
    deferred to Phase 8 integration with seed data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:16:52 +02:00
95 changed files with 15679 additions and 198 deletions
+29
View File
@@ -243,6 +243,35 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && git push all
---
## Stundenplan + Schulkalender (Mai 2026, alle Phasen deployed)
Zwei groesse Feature-Strange, vollstaendig live auf Mac Mini:
| Pfad | Beschreibung |
|------|--------------|
| `/stundenplan` (studio-v2) | Lehrer-UI mit 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren, Pin/Unpin im Wochengrid |
| `/schulkalender` (studio-v2) | Bundesland-Wizard, Monatsansicht mit Ferien (16 BL × 3 Jahre), Schul-Events, Schuljahres-Rollover, Eltern-Manager |
| `/eltern` (studio-v2) | Eltern-Sicht: Wochengrid des eigenen Kindes in Eltern-Sprache, Magic-Link-Login |
| `school-service` (Go, :8084) | Beide Backends — 30+ Tabellen, JWT-Auth (Dev-Bypass aktiv), Cron fuer Notifications |
| `timetable-solver-service` (Python+JVM, :8095) | Timefold-basierter Solver, 14 Constraints implementiert |
**Wichtigste Memo-Dateien fuer Wiedereinstieg:**
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/session_summary_2026_05_22.md` — vollstaendiges Inventar
- `~/.claude/projects/-Users-benjaminadmin/memory/project_timetable_scheduler.md` — Stundenplan-Status
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/project_schulkalender.md` — Schulkalender-Status
**Pitfalls (vermeidet diese):**
- Timefold Python-Package heisst `timefold` (NICHT `timefold-solver`), v1.24.0b0
- Production-Auth + Matrix/Email-Services baut Kollege — Frontend-Hooks nutzen, kein eigener Service-Code
- JSX-Attribute mit deutschen Quotes `„X"` brechen, Loesung: `description={"..."}` Expression-Form
- LOC-Budget 500 pro File — bei specs mit shared Helpers arbeiten (`e2e/_helpers.ts`)
**Test-Status (Stand 2026-05-22):** 89 Go + 21 Playwright im Schulkalender + 42 Playwright im Stundenplan = **152 grun**
**Offen:** Seed-Daten fuer Demo-Schule, Vollschuljahr-ICS mit RRULE+EXDATE, Untis-Import (Phase 4 geparkt).
---
## Wichtige Dateien (Referenz)
| Datei | Beschreibung |
+20
View File
@@ -289,6 +289,26 @@ services:
ENVIRONMENT: ${ENVIRONMENT:-development}
ALLOWED_ORIGINS: "*"
LLM_GATEWAY_URL: http://backend-lehrer:8001/llm
SOLVER_SERVICE_URL: http://timetable-solver-service:8095
depends_on:
core-health-check:
condition: service_completed_successfully
restart: unless-stopped
networks:
- breakpilot-network
timetable-solver-service:
build:
context: ./timetable-solver-service
dockerfile: Dockerfile
container_name: bp-lehrer-timetable-solver
platform: linux/arm64
ports:
- "8095:8095"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}
SOLVER_SECONDS_LIMIT: ${SOLVER_SECONDS_LIMIT:-60}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
core-health-check:
condition: service_completed_successfully
@@ -0,0 +1,64 @@
# Architektur
## Datenmodell
### Phase 9a — Kalender-Stammdaten
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| `cal_public_event` | Ferien + Feiertage (region, type, name, start, end) | global (alle Bundeslaender) |
| `cal_school_config` | Bundesland-Auswahl + Schuljahr-Daten | 1 row per user_id |
### Phase 9b — Schul-Events
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| `cal_school_event` | Titel + Typ + Datum/Zeit + affected_class_ids + Notification-Flags | created_by_user_id |
Event-Typen (CHECK constraint): `fortbildung`, `schulfeier`, `klassenfahrt`, `projekttag`, `eltern_info`, `andere`.
### Phase 9c — Parent-Accounts
| Tabelle | Inhalt |
|---------|--------|
| `parent_account` | Email + preferred_language, UNIQUE pro (Lehrer, Email) |
| `parent_child` | Vorname/Nachname + FK auf tt_class |
| `parent_magic_link` | Einmal-Token (SHA-256 in DB), expires_at 7 Tage |
| `parent_session` | Browser-Session-Token (SHA-256 in DB), expires_at 30 Tage |
### Phase 9d — Notifications
| Tabelle | Inhalt |
|---------|--------|
| `notification_log` | Idempotenz: UNIQUE(event_id, lead_days, audience, channel) |
## Auth-Modell
**Zwei voneinander unabhaengige Auth-Wege:**
1. **Lehrer:** JWT in Authorization-Header (oder Dev-Bypass mit Default-User wenn `ENVIRONMENT != "production"`). Routen unter `/api/v1/school/...`.
2. **Eltern:** Session-Cookie `bp_parent_session` (HttpOnly, SameSite=Lax), gesetzt vom `/api/v1/parent/auth/redeem` Endpoint. ParentSessionMiddleware resolved Cookie → parent_account.
Eltern sehen **nie** Daten anderer Eltern. Privacy-Check via `ChildBelongsToParent` in jedem GET, Plus Filterung der Lessons gegen tt_solution des einladenden Lehrers.
## Bundesland-Wizard
Erster Aufruf von `/schulkalender` → kein `cal_school_config``BundeslandWizard` UI → POST `/calendar/config` mit `{bundesland: "DE-NI"}` → MonthView lädt für die naechsten ~6 Wochen.
## Schuljahres-Rollover
POST `/calendar/school-year-rollover` (optional `{new_year_start, new_year_end}`):
1. `DELETE FROM tt_class WHERE grade_level >= 13` (Abschlusskohorte)
2. `UPDATE tt_class SET grade_level = grade_level + 1`
3. `UPDATE cal_school_config SET school_year_start/end = ...`
Alles in einer Transaction. Stundenplan-Lehrer-Faecher-Raum-Bestand bleibt unangetastet.
## Auth + Messaging outsourced
Production-Auth, Matrix-Bridge und Email-Gateway werden vom Kollegen gepflegt — siehe globale Memory `stundenplan_auth_and_messaging.md`. Wir definieren nur:
- Dispatch-Payload-Struct (siehe [notifications.md](notifications.md))
- Env-Vars `MATRIX_SERVICE_URL`, `EMAIL_SERVICE_URL` (leer = Stub-Mode)
- Endpoint-Vertrag (POST mit JSON-Body, HTTP 2xx = sent)
@@ -0,0 +1,71 @@
# Ferien + Feiertage
## Quelle
[openholidaysapi.org](https://openholidaysapi.org) — EU-Initiative, MIT-Lizenz
fuer den API-Code, ODbL fuer die Daten. Liefert sowohl `PublicHolidays` als
auch `SchoolHolidays` je Bundesland mit ISO-Codes `DE-BW`, `DE-BY`, ...
## Build-Time-Snapshot
Statt zur Laufzeit zu pollen wird ein JSON-Snapshot committed:
```bash
bash scripts/calendar-snapshot.sh 2026 2030
```
Schreibt nach `school-service/internal/seed/calendar_holidays.json`. Das
Dockerfile kopiert die Datei ins Image; bei jedem Container-Start importiert
`CalendarService.SeedFromSnapshot()` die Eintraege idempotent (UNIQUE auf
region, event_type, name_de, start_date).
**Stand 2026-05-22:** 854 Events fuer alle 16 Bundeslaender × 3 Schuljahre.
## Aktualisierungs-Workflow
1. Jaehrlich (z.B. im Mai vor neuem Schuljahr):
```bash
bash scripts/calendar-snapshot.sh 2027 2031
```
2. Diff im Git pruefen — sollte nur neue Eintraege haben, nicht alte ueberschreiben.
3. Commit + push + Container-Rebuild.
4. Beim ersten Boot werden neue Eintraege in `cal_public_event` eingefuegt; bestehende bleiben.
## API
```
GET /api/v1/school/calendar/holidays?region=DE-NI&from=2026-08-01&to=2027-07-31
```
Liefert Array sortiert nach `start_date`. Beispiel-Antwort:
```json
[
{"id":"…","region":"DE-NI","event_type":"school_holiday","name_de":"Sommerferien","start_date":"2026-07-02","end_date":"2026-08-12"},
{"id":"…","region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit","start_date":"2026-10-03","end_date":"2026-10-03"},
...
]
```
## Format-Mapping (Snapshot-Script)
OpenHolidaysAPI gibt:
```json
{"id":"...","startDate":"2026-10-03","endDate":"2026-10-03","type":"Public",
"name":[{"language":"DE","text":"Tag der Deutschen Einheit"}]}
```
`scripts/calendar-snapshot.sh` normalisiert via jq:
```json
{"region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit",
"name_en":null,"start_date":"2026-10-03","end_date":"2026-10-03"}
```
## Lizenz-Compliance
- API-Code: MIT
- Daten: ODbL (Open Database License)
Beides ist fuer kommerzielle Nutzung erlaubt. Die Quelle muss in einer
Lizenz-Aufstellung (SBOM) genannt werden — bereits in
`sbom/stundenplan/README.md` dokumentiert.
+51
View File
@@ -0,0 +1,51 @@
# Schulkalender
Bundeslandweit kalibrierter Schulkalender mit Ferien, Feiertagen, Schul-
Events, Eltern-Sicht und mehrsprachigen Benachrichtigungen.
## Auf einen Blick
```
studio-v2 /schulkalender → Lehrer-Sicht (CRUD Events, Eltern einladen, Rollover)
studio-v2 /eltern → Eltern-Sicht (Wochengrid des Kindes in eigener Sprache)
│ HTTP /api/school/* und /api/parent/* (zwei separate Auth-Gruppen)
school-service (Go, :8084)
├── cal_public_event — Ferien/Feiertage-Snapshot (OpenHolidaysAPI)
├── cal_school_config — Bundesland pro Rektor
├── cal_school_event — Schulfeier, Fortbildung, Klassenfahrt etc.
├── parent_account/_child/_magic_link/_session — Eltern-Auth
└── notification_log — Idempotenter Versand-Log
▼ POST DispatchPayload
Matrix-Bridge + Email-Gateway (vom Kollegen gepflegt, nicht in diesem Repo)
```
## Module
| Bereich | Doku |
|---------|------|
| [Architektur](architecture.md) | DB-Modell, Auth-Ablauf, Phase-Reihenfolge |
| [Ferien-Snapshot](holidays.md) | OpenHolidaysAPI-Pipeline, jaehrliche Aktualisierung |
| [Eltern-Workflow](parent-flow.md) | Magic-Link, Cookie-Session, i18n-Fachnamen |
| [Notifications](notifications.md) | Cron, Templates, Dispatcher-Vertrag |
## Phasen-Stand
**Alle vier Phasen abgeschlossen (2026-05-22):**
- 9a — Bundesland-Wizard + Monatsansicht
- 9b — Schul-Events + Schuljahres-Rollover
- 9c — Parent-Accounts + Magic-Link + Wochengrid in 8 Sprachen
- 9d — Notification-Cron + Templates + Status-Badges
**Offen:** Vollschuljahr-ICS, Seed-Daten fuer Demo-Schule.
## Test-Status
| Suite | Tests |
|------|-------|
| Go (services + notifications) | 89 / 89 |
| Playwright Schulkalender | 16 / 16 |
| Playwright Eltern | 7 / 7 |
@@ -0,0 +1,96 @@
# Notifications
## Pipeline
```
06:00 Uhr (Berlin-Zeit, Container TZ=Europe/Berlin)
NotificationService.RunForDate(today)
dueEvents() findet cal_school_event mit
(start_date - today) ∈ notification_lead_days
Pro Event: fuer jede Audience (parents/students) und jeden Channel
(matrix für alle, email zusaetzlich nur fuer parents):
dispatchOne()
1. Idempotenz-Check (UNIQUE notification_log)
2. recipientsFor() — JOIN parent_account+parent_child fuer
betroffene Klassen, gibt Email-Liste + bevorzugte Sprache zurueck
3. Render-Template (templates.go, 8 Sprachen)
4. POST {MATRIX,EMAIL}_SERVICE_URL mit DispatchPayload
5. notification_log writeLog (sent/failed/skipped)
```
## Cron-Mechanik
`main.go` startet einen Goroutine-Ticker mit 1h-Intervall. Sobald `time.Now().Hour() == 6` wird `RunForDate` aufgerufen. Idempotent — die UNIQUE auf notification_log filtert Doppel-Calls am selben Tag.
Bei Container-Restart vor 06:00 läuft trotzdem alles korrekt: der naechste 06-Tick fired bis spaetestens 06:59:59. Bei Restart nach 06:00: erste Notification erst am Folgetag (acceptable trade-off gegen einen 1-Min-Ticker).
## Manueller Trigger
```bash
# Heute jetzt scannen
curl -X POST http://localhost:8084/api/v1/school/calendar/notifications/run-now
# Backfill (z.B. nach langem Container-Down)
curl -X POST 'http://localhost:8084/api/v1/school/calendar/notifications/run-now?date=2026-05-20'
```
Antwort: `{"date":"2026-05-22","sent":N,"failed":N,"skipped":N,"already_logged":N}`.
## Template-Engine
Datei: `school-service/internal/notifications/templates.go`. Schema:
```
templates[lang][event_type][audience][bucket] → {Subject, Body}
```
- `lang` ∈ de/en/tr/ar/uk/ru/pl/fr (Fallback `de`)
- `event_type` ∈ fortbildung/schulfeier/klassenfahrt/projekttag/eltern_info/andere (Fallback `andere`)
- `audience` ∈ parents/students (Fallback `parents`)
- `bucket` ∈ today/tomorrow/days (Fallback `days`)
Placeholders: `{{title}}`, `{{date}}`, `{{date_pretty}}`, `{{class_name}}`, `{{class_suffix}}`, `{{teacher_name}}`, `{{lead}}`.
Beispiel-Render (TR / schulfeier / parents / 1-Tag-Vorlauf):
```
Subject: Yarın: Sommerfest (5a)
Body: Sayın veliler, yarın (15.06.2026) Sommerfest gerçekleşiyor (5a).
```
## DispatchPayload (Endpoint-Vertrag mit Matrix/Email Service)
```json
{
"channel": "matrix",
"recipient": "mama@example.de",
"language": "tr",
"subject": "Yarın: Sommerfest",
"body": "Sayın veliler, ...",
"event_id": "uuid-…",
"lead_days": 1
}
```
Erwartete Antwort vom Upstream: HTTP 2xx = sent. 4xx/5xx = failed. Wir leiten **keine** Empfaenger-Identifier-Aufloesung weiter ans Upstream — die Matrix-Bridge mapt Email → Matrix-Handle in der eigenen Logik.
Bei `MATRIX_SERVICE_URL` oder `EMAIL_SERVICE_URL` leer: status='skipped', kein Versandversuch. Erlaubt lokales Testen ohne Upstream.
## Status-Anzeige im Lehrer-UI
`DayDetail` mountet `NotificationStatus` fuer jedes Event mit `notify_parents` oder `notify_students`. Lädt `GET /api/v1/school/calendar/events/:id/notifications` und zeigt Badges:
- ✓ gruen = sent
- ✗ rot = failed (Hover zeigt error_message)
- ⏱ amber = skipped (Upstream noch nicht konfiguriert)
## Privacy
`notification_log` ist nur über JOIN cal_school_event sichtbar — Lehrer sieht nur Logs seiner eigenen Events. Eltern haben gar keine UI fuer Logs.
@@ -0,0 +1,54 @@
# Eltern-Workflow
## Einladung (Lehrer)
1. Lehrer offnet `/schulkalender`, scrollt zu `ParentManager`.
2. Klick "+ Eltern einladen" → Form mit Email, Vorname/Nachname Kind, Klasse, Sprache.
3. `POST /api/v1/school/calendar/parents/invite` legt parent_account (upsert), parent_child + parent_magic_link an, gibt Klartext-Token + voll qualifizierten Link zurueck.
4. Lehrer kopiert Link aus der UI und schickt ihn ueber Matrix oder Email (Versand-Automation kommt mit Phase 9d Notification-Pipeline).
## Login (Eltern)
1. Eltern klicken den Link `https://app/eltern/login?token=…`.
2. Browser laedt die Login-Page, sendet `POST /api/v1/parent/auth/redeem {token}`.
3. school-service validiert Token (Hash-Lookup + expires_at + used_at), markiert used_at, mintet Session-Token (32-Byte URL-safe Base64), setzt HttpOnly Cookie `bp_parent_session`.
4. Redirect auf `/eltern`. Folgende API-Calls senden Cookie automatisch.
## Wochengrid
`/eltern` ruft:
- `GET /api/v1/parent/me` → Account + Kinder-Liste (Name, Klasse via JOIN tt_class)
- `GET /api/v1/parent/me/timetable?class_id=…` → letzte completed tt_solution der Klasse
Filter laeuft strikt: ParentService prueft `ChildBelongsToParent(parent_id, class_id)` vor jeder Timetable-Query.
## Fach-Uebersetzung
`lib/calendar/subject-i18n.ts` hat 22 Standardfaecher in 8 Sprachen:
```typescript
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik',
ar: 'الرياضيات', uk: 'Математика', ru: 'Математика',
pl: 'Matematyka', fr: 'Mathématiques' }
```
`translateSubject(germanName, lang)`:
1. Lowercase + trim → `key`
2. `SUBJECTS[key]` lookup
3. Wenn key nicht in Map: Original-Deutsch zurueck (z.B. "Imkern AG")
4. Wenn lang nicht in Sprachen: `de`-Fallback
## Logout
`POST /api/v1/parent/auth/logout` setzt Cookie auf max-age=-1. Session-Row bleibt in DB (laeuft selber ab nach 30 Tagen) — vereinfacht Tracking.
## Was die Eltern NICHT sehen
- Andere Eltern oder Kinder
- Stundenplan-Versionen die nicht "completed" sind
- Schul-Events mit `visible_to_parents=false`
- Lehrer-internes wie Stundentafel oder Lehrauftrag-Konfiguration
Privacy-Garantien sind auf SQL-Ebene durchgesetzt (JOIN-Pfade + WHERE-Klauseln), nicht nur im Application-Layer.
@@ -0,0 +1,76 @@
# Architektur + Datenmodell
## Verantwortung pro Service
### school-service (Go/Gin)
- Persistenz fuer alle Stammdaten + Constraints + Solutions
- API-Gateway fuer studio-v2: validiert, ownership-checked, faked Auth im
Dev-Mode
- Trigger-Aufruf an solver-service nach POST /solutions
### timetable-solver-service (Python/FastAPI + Timefold)
- Liest Problem aus PG via asyncpg
- Baut Timefold-Domain (Lessons, Timeslots, Rooms, Rules)
- Loest im ThreadPoolExecutor (Solver ist CPU-gebunden)
- Schreibt Loesung direkt nach tt_lesson, updated tt_solution.status
### studio-v2 (Next.js)
- `/stundenplan` Tab-Page mit 9 Tabs
- Next.js API-Route `/api/school/*` proxied zu school-service
- Solution-Polling alle 4 s wenn Solve laeuft
## Datenmodell
### Stammdaten (7 Tabellen)
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| tt_class | Klassen (Name, Klassenstufe) | created_by_user_id |
| tt_period | Zeitraster (Mo-So × Stunde × Start/Ende) | created_by_user_id |
| tt_room | Raeume (Name, Typ, Kapazitaet, Aufzug) | created_by_user_id |
| tt_subject | Faecher (Name, Kuerzel, Farbe, RoomType) | created_by_user_id |
| tt_teacher | Lehrer als planbare Ressource | created_by_user_id |
| tt_curriculum | Klasse × Fach → Wochenstunden | indirect via tt_class |
| tt_assignment | Lehrer × Klasse × Fach | indirect via tt_teacher |
### Constraints (15 Tabellen)
Pro Tabelle die Felder: `is_hard` (bool), `weight` (0-100), `active` (bool),
`note` (TEXT), `created_by_user_id`. Aufgeteilt nach Parent-Entitaet:
- Lehrer: `unavailable_day`, `unavailable_window`, `max_hours_day`,
`max_hours_week`, `excluded_subject`, `excluded_room`
- Fach: `min_day_gap`, `max_consecutive`, `contiguous_when_repeated`,
`preferred_period`, `double_lesson`
- Klasse: `max_hours_day`, `no_gaps`
- Raum: `requires_type`, `unavailable`
### Solutions (2 Tabellen, Phase 5+7)
| Tabelle | Inhalt |
|---------|--------|
| tt_solution | Solve-Run: Status, hard/soft Score, parent_solution_id, seconds_limit |
| tt_lesson | Eine Stunde im Plan (class, subject, teacher, room, day, period, pinned) |
`tt_lesson` hat drei `UNIQUE`-Constraints, die der DB-Layer selbst Konflikt-
Lessons ablehnen laesst:
- `(solution_id, class_id, day, period)` — Klasse nicht doppelt
- `(solution_id, teacher_id, day, period)` — Lehrer nicht doppelt
- `(solution_id, room_id, day, period)` — Raum nicht doppelt
Damit kann ein fehlerhafter Solver-Output nicht in Daten landen, die das UI
inkonsistent darstellt.
## Ownership-Modell
Alles ist single-tenant pro `created_by_user_id`. CRUD-Endpoints filtern via
`WHERE EXISTS (SELECT 1 FROM tt_<parent> WHERE id = $X AND created_by_user_id
= $user)`. Cross-Tenant-Zugriff ist auf SQL-Ebene ausgeschlossen.
Im Dev-Mode injiziert `AuthMiddleware` einen festen UUID, damit Tests ohne
JWT laufen koennen. Production-Build (`ENVIRONMENT=production`) deaktiviert
den Bypass — JWT wird Pflicht.
@@ -0,0 +1,71 @@
# Constraint-Referenz
Jeder Constraint-Eintrag im UI legt eine Row in der korrespondierenden
`tt_constraint_*` Tabelle an. Der Solver liest sie als Problem-Facts und
joined sie gegen die Lessons.
## Gemeinsame Felder
| Feld | Typ | Bedeutung |
|------|-----|-----------|
| `is_hard` | bool | true = Solver muss einhalten (HardScore -1 pro Verstoss). false = Soft-Penalty (SoftScore -weight pro Verstoss) |
| `weight` | int 0-100 | Multiplikator fuer Soft-Penalties; bei Hard ignoriert |
| `active` | bool | inaktive Rows werden vom Solver ignoriert |
| `note` | TEXT | Freier Begruendungstext fuer den Rektor |
## Constraint-Typen
### Universal (immer aktiv, nicht abschaltbar)
| Constraint | Bedeutung |
|------------|-----------|
| `class_conflict` | Eine Klasse hat nur eine Lesson pro Timeslot |
| `teacher_conflict` | Ein Lehrer haelt nur eine Lesson pro Timeslot |
| `room_conflict` | Ein Raum hostet nur eine Lesson pro Timeslot |
### DB-Driven (vom Rektor konfigurierbar)
Jeder Typ existiert als `_hard` und `_soft` Constraint im Provider:
| Typ | Tabelle | Beispiel |
|-----|---------|----------|
| Lehrer Tag nicht verfuegbar | `tt_constraint_teacher_unavailable_day` | „Anna nie Montags" |
| Lehrer Zeitfenster nicht verfuegbar | `tt_constraint_teacher_unavailable_window` | „Bob Dienstag 1317 Uhr nicht" |
| Lehrer Max h/Tag | `tt_constraint_teacher_max_hours_day` | Anti-Burnout |
| Lehrer Max h/Woche | `tt_constraint_teacher_max_hours_week` | Teilzeit-Cap |
| Lehrer Fach ausgeschlossen | `tt_constraint_teacher_excluded_subject` | Qualifikationsluecke |
| Lehrer Raum ausgeschlossen | `tt_constraint_teacher_excluded_room` | Rollstuhl, kein Fahrstuhl |
| Fach Mindest-Tagesabstand | `tt_constraint_subject_min_day_gap` | Mathe nicht 2 Tage hintereinander |
| Fach Max Stunden am Stueck | `tt_constraint_subject_max_consecutive` | Keine Dreifachstunde |
| Fach Mehrfach=zusammen | `tt_constraint_subject_contiguous_when_repeated` | Wenn 2× am Tag, dann benachbart |
| Fach Bevorzugte Stunden | `tt_constraint_subject_preferred_period` | Hauptfaecher morgens |
| Fach Doppelstunde bevorzugt | `tt_constraint_subject_double_lesson` | Sport als 90-min-Block |
| Klasse Max h/Tag | `tt_constraint_class_max_hours_day` | Jugendgerecht |
| Klasse Keine Freistunden | `tt_constraint_class_no_gaps` | Soft, minimiert Loecher |
| Raumtyp erforderlich | `tt_constraint_room_requires_type` | Sport → Sporthalle |
| Raum nicht verfuegbar | `tt_constraint_room_unavailable` | Wartung, Renovierung |
## Hard vs. Soft — Faustregel
- **Hard** wenn die Schule den Plan rechtlich oder physisch nicht
ausfuehren kann (Lehrervertrag, Behinderung, Raum existiert nicht).
- **Soft** wenn es nur eine Praeferenz ist („Mathe lieber morgens",
„keine Freistunden").
Score-Bewertung im UI:
- `hard_score = 0` → Plan ist gueltig
- `hard_score < 0` → mindestens eine harte Regel ist verletzt (Solver
meldet das als `infeasible`)
- `soft_score` → wird in den UI angezeigt; je naeher an 0, desto besser
## Erweitern um einen 16. Constraint-Typ
1. Neue Tabelle in `school-service/internal/database/timetable_constraints_migrations.go`
2. Model + DTO in `models/timetable_constraints.go`
3. Service + Handler im gleichen Paket-Pattern wie die existierenden 15
4. Route in `cmd/server/main.go`
5. Rule-Dataclass in `timetable-solver-service/app/rules.py`
6. ProblemFactCollection in `domain.py`
7. ConstraintProvider-Funktion in `constraints.py` (Hard + Soft Variante)
8. Frontend: Editor-Komponente in `_components/regeln/`, dann in
`RegelnHub.tsx` registrieren
+72
View File
@@ -0,0 +1,72 @@
# Export
Drei Export-Formate fuer fertige Solutions, alle als GET-Endpoints im
school-service.
## CSV
```
GET /api/v1/school/timetable/solutions/{id}/export.csv
Content-Type: text/csv; charset=utf-8
```
Spalten: `day_of_week,period_index,start_time,end_time,class,subject,
subject_code,teacher,room,pinned`.
Komma in Feldwerten (z.B. „Schmidt, Anna") wird automatisch escaped.
Sortierung: by `(day_of_week, period_index, class_name)`.
Anwendungsfaelle:
- Import in Excel oder Google Sheets fuer Reports
- Datenuebergabe an externes Schulverwaltungs-System
- Datenarchivierung pro Schuljahr
## ICS (iCalendar, RFC 5545)
```
GET /api/v1/school/timetable/solutions/{id}/export.ics
GET /api/v1/school/timetable/solutions/{id}/export.ics?start=2026-08-24
Content-Type: text/calendar; charset=utf-8
```
Emittiert ein VEVENT pro Lesson, anchored auf die naechste Montag-Woche
(oder via `?start=YYYY-MM-DD` ueberschreibbar). Lehrer kann die Datei
direkt im Apple Calendar, Google Calendar oder Outlook importieren.
Strukturbeispiel:
```
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//BreakPilot//Timetable//DE
BEGIN:VEVENT
UID:lesson-0-d1p1-20260824@breakpilot
DTSTAMP:20260522T144800Z
DTSTART:20260824T080000
DTEND:20260824T084500
SUMMARY:Mathe (5a)
LOCATION:A101
DESCRIPTION:Lehrer: Schmidt, Anna\nSchuljahr 26/27
END:VEVENT
...
END:VCALENDAR
```
**Aktuell nur eine Kalender-Woche** — fuer ganzes Schuljahr braeuchte es
RRULE + Ferien-Exceptions, ist als Phase 9 vorgemerkt.
## Drucken (HTML print view)
Im PlanView gibt es einen „Drucken"-Button. Der Druck-Dialog des Browsers
oeffnet sich; eigene `@media print`-Stylesheet in `globals.css` blendet
Sidebar, Tabs, Help-Panel und Token-Banner aus und zwingt das Wochengrid auf
weisses A4-Format.
Vorteil ueber serverseitiges PDF: kein zusaetzliches Backend-Tool, keine
Headless-Browser-Container, der User waehlt selbst Drucker/PDF/Format.
## Aufruf vom Frontend
`lib/stundenplan/api.ts:downloadSolutionExport(solutionId, 'csv' | 'ics')`
laedt das Blob ueber den Next.js-Proxy, sodass der JWT (falls gesetzt) im
Authorization-Header weitergegeben wird. Im Dev-Mode ohne Token funktioniert
es ebenfalls.
+47
View File
@@ -0,0 +1,47 @@
# Stundenplaner
Schulweiter Stundenplan-Generator fuer den Rektor. Erfasst Klassen, Lehrer,
Faecher, Raeume + Constraints und ruft einen Timefold-basierten Solver auf,
um einen konfliktfreien Wochenplan zu produzieren.
## Architektur auf einen Blick
```
studio-v2 /stundenplan (Next.js)
│ HTTP über Next.js Proxy /api/school/*
school-service (Go/Gin, :8084)
│ ─ CRUD Stammdaten + Constraints + Solutions in PostgreSQL
│ ─ Fire-and-forget Trigger an Solver
timetable-solver-service (Python/FastAPI + Timefold, :8095)
│ ─ Liest Problem aus PG, rechnet im Worker-Thread
│ ─ Schreibt Lessons direkt nach tt_lesson
PostgreSQL (Schema `public` in `breakpilot_db`)
24 Tabellen: 7 Stammdaten + 15 Constraints + tt_solution + tt_lesson
```
## Module
| Bereich | Doku |
|---------|------|
| [Architektur + Datenmodell](architecture.md) | DB-Schema, Ownership-Modell |
| [Constraints](constraints.md) | 15 Constraint-Typen, hard/soft Semantik |
| [Solver-Tuning](solver-tuning.md) | Timefold-Konfiguration, Zeit-Budgets |
| [Export](export.md) | CSV, ICS, Drucken |
## Status
**Phasen 1-3 + 5-8 fertig** (Stand 2026-05-22, Phase 4 Untis übersprungen).
- 24 DB-Tabellen, alle 22 CRUD-Endpoints + Solve + Export-Endpoints live
- Frontend: 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren,
Wochengrid mit Pin-Funktion, 3 Perspektiven (Klasse/Lehrer/Raum)
- Tests: 73 Go + 36 Playwright + 4 Export-Unit-Tests
## Offene Punkte
- Phase 4 (Untis-Import) — verschoben, kein Kunde fordert es aktuell
- Seed-Daten fuer Demo-Schule
- Echte Auth-Integration ablöst Dev-Bypass
@@ -0,0 +1,70 @@
# Solver-Tuning
## Timefold-Konfiguration
Der `SolverFactory` wird in `runner.py` pro Solve gebaut so dass jedes
Job-Spent-Limit aus `tt_solution.seconds_limit` einzeln zur Geltung kommt.
```python
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints,
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=seconds),
),
)
```
Default Timeout: 60 s. Per Solve ueberschreibbar im UI (5600 s).
## Score-Modell
`HardSoftScore` — Hard-Komponente ist die wichtige:
- `hard_score < 0` → Solution ist `infeasible`, UI markiert in Amber.
- `hard_score == 0` → Solution gueltig, `soft_score` minimiert Praeferenz-
Verletzungen.
## Pinning fuer iterative Verbesserung
Workflow:
1. Initial-Solve laeuft → Plan A.
2. Rektor pinnt 510 Cells im UI, die ihm gefallen.
3. Neuer Solve mit `parent_solution_id = Plan A`. Der Solver nimmt die
gepinnten Cells als Fixpunkte (`@PlanningPin`) und rechnet die restlichen
Lessons neu.
4. Optional Sekunden-Limit erhoehen (z.B. 180 s) wenn die Solution-Qualitaet
wichtiger ist als die Wartezeit.
Implementierung in `repository._inherit_pinned_from_parent()`:
- Greedy First-Fit-Matching by `(class_id, subject_id)`
- Surplus pinned Rows aus dem Parent (z.B. weil curriculum-Stunden gekuerzt)
werden silently uebersprungen
- Mismatch wird in Logs ausgegeben, fuehrt aber nicht zu failed Status
## Was tun wenn der Solver `infeasible` meldet
Reihenfolge der Diagnose:
1. **Lessons-Count vs. Slots-Count**: Wenn die Summe der Wochenstunden ueber
alle Klassen > Anzahl Slots pro Woche × Anzahl Raeume ist, kann es
physisch keine Loesung geben. Stundentafel kuerzen oder mehr Raeume.
2. **Lehrer-Auslastung**: Wenn ein Lehrer mit 28 h Cap in der Stundentafel
30 h zugewiesen bekommt, ist es unloesbar. Lehrauftraege anpassen.
3. **Harte Constraints widerspruechlich**: Mathe muss morgens UND ist
`excluded_room` fuer alle Vormittags-Raeume → Konflikt. Constraints von
Hard auf Soft umstellen wo moeglich.
4. **Sekunden-Limit zu kurz**: Bei sehr restriktiven Modellen braucht der
Solver laenger zum ersten Fit-finden. 300 s probieren.
## Performance-Charakteristik
- Kleine Schule (3 Klassen, 8 Lehrer, 6 Faecher, ~80 Lessons): meist <5 s
- Mittlere Schule (15 Klassen, 30 Lehrer, ~400 Lessons): 3060 s fuer
hard_score=0, weitere Minuten fuer soft-Optimierung
- Sehr grosse Schule (>800 Lessons): Solver kommt mit 60 s Default nicht
konvergent, hoeheres Limit oder Multi-Threading evaluieren (Timefold
Enterprise)
+12
View File
@@ -82,6 +82,18 @@ nav:
- Uebersicht: services/voice-service/index.md
- Agent-Core:
- Uebersicht: services/agent-core/index.md
- Stundenplaner:
- Uebersicht: services/stundenplan/index.md
- Architektur: services/stundenplan/architecture.md
- 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
+34
View File
@@ -0,0 +1,34 @@
# Stundenplan — SBOM
Software Bill of Materials fuer den Stundenplaner-Stack.
Erzeugt via `scripts/stundenplan-sbom.sh`.
## Inhalt
- `school-service-licenses.csv` — Go-Module von `school-service`
- `timetable-solver-licenses.json` — Python-Pakete (incl. Timefold + JPype + asyncpg)
- `studio-v2-licenses.json` — npm-Pakete im Production-Build von studio-v2
## Lizenz-Whitelist
Per `.claude/rules/open-source-policy.md`:
- ✅ MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, LGPL, CC0
- ❌ GPL-2/3, AGPL, SSPL, BSL, „Non-Commercial"
Bei Updates: SBOM neu generieren, gegen Whitelist pruefen.
## Bekannt-relevante Dependencies (manuell verifiziert 2026-05-22)
| Package | Version | Lizenz | OK? |
|---------|---------|--------|-----|
| timefold (Python) | 1.24.0b0 | Apache-2.0 | ✅ |
| JPype1 | 1.5.1 | Apache-2.0 | ✅ |
| FastAPI | 0.115.0 | MIT | ✅ |
| asyncpg | 0.30.0 | Apache-2.0 | ✅ |
| pydantic | 2.9.2 | MIT | ✅ |
| gin-gonic/gin (Go) | latest | MIT | ✅ |
| jackc/pgx/v5 (Go) | latest | MIT | ✅ |
| golang-jwt/jwt/v5 (Go) | latest | MIT | ✅ |
| Next.js (studio-v2) | 15.x | MIT | ✅ |
| React | 19.x | MIT | ✅ |
+3
View File
@@ -40,6 +40,9 @@ COPY --from=builder /app/school-service .
# Copy templates directory
COPY --from=builder /app/templates ./templates
# Copy calendar seed snapshot (Phase 9a — OpenHolidaysAPI data)
COPY --from=builder /app/internal/seed ./internal/seed
# Use non-root user
USER appuser
+93 -2
View File
@@ -1,7 +1,10 @@
package main
import (
"context"
"log"
"os"
"time"
"github.com/breakpilot/school-service/internal/config"
"github.com/breakpilot/school-service/internal/database"
@@ -35,7 +38,41 @@ func main() {
}
// Create handler
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL)
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()
@@ -49,7 +86,7 @@ func main() {
// API routes (auth required)
api := router.Group("/api/v1/school")
api.Use(middleware.AuthMiddleware(cfg.JWTSecret))
api.Use(middleware.AuthMiddleware(cfg.JWTSecret, cfg.Environment != "production"))
{
// School Years
api.GET("/years", handler.GetSchoolYears)
@@ -218,6 +255,60 @@ func main() {
api.GET("/timetable/constraints/room/unavailable", handler.ListRoomUnavailable)
api.POST("/timetable/constraints/room/unavailable", handler.CreateRoomUnavailable)
api.DELETE("/timetable/constraints/room/unavailable/:id", handler.DeleteRoomUnavailable)
// Timetable Solver — Solutions
api.GET("/timetable/solutions", handler.ListTimetableSolutions)
api.POST("/timetable/solutions", handler.CreateTimetableSolution)
api.GET("/timetable/solutions/:id", handler.GetTimetableSolution)
api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution)
api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons)
// Phase 7: pin/unpin individual lessons for the next re-solve.
api.PUT("/timetable/lessons/:id/pin", handler.UpdateTimetableLessonPin)
// 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
+10
View File
@@ -28,6 +28,13 @@ type Config struct {
// LLM Gateway (for AI features)
LLMGatewayURL string
// 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
@@ -43,6 +50,9 @@ func Load() (*Config, error) {
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
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)`,
}
}
@@ -218,6 +218,18 @@ func Migrate(db *DB) error {
// Append timetable constraint migrations (see timetable_constraints_migrations.go)
migrations = append(migrations, TimetableConstraintMigrations()...)
// 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,54 @@
package database
// TimetableSolutionMigrations creates tt_solution + tt_lesson for the solver
// pipeline. One run of the solver produces exactly one tt_solution row plus
// many tt_lesson rows (one per scheduled class-subject hour).
//
// Status flow:
//
// pending → running → completed | failed | infeasible
//
// hard_score / soft_score come straight from Timefold's HardSoftScore. Lower
// (more negative) hard_score means more hard-constraint violations; the UI
// only ever offers solutions with hard_score == 0 as "valid".
func TimetableSolutionMigrations() []string {
return []string{
`CREATE TABLE IF NOT EXISTS tt_solution (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
name VARCHAR(120),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
hard_score INT,
soft_score INT,
error_message TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS tt_lesson (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
solution_id UUID NOT NULL REFERENCES tt_solution(id) ON DELETE CASCADE,
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
room_id UUID REFERENCES tt_room(id) ON DELETE SET NULL,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
period_index INT NOT NULL CHECK (period_index BETWEEN 1 AND 12),
pinned BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(solution_id, class_id, day_of_week, period_index),
UNIQUE(solution_id, teacher_id, day_of_week, period_index),
UNIQUE(solution_id, room_id, day_of_week, period_index)
)`,
`CREATE INDEX IF NOT EXISTS idx_tt_solution_user ON tt_solution(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_solution ON tt_lesson(solution_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_class ON tt_lesson(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_id)`,
// Phase 7: plan versioning + per-solve solver-timeout override.
`ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS parent_solution_id UUID REFERENCES tt_solution(id) ON DELETE SET NULL`,
`ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS seconds_limit INT`,
}
}
@@ -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)
}
+38 -8
View File
@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/notifications"
"github.com/breakpilot/school-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
@@ -17,10 +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 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)
@@ -28,18 +33,43 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler {
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,
examService: examService,
gradeService: gradeService,
gradebookService: gradebookService,
certificateService: certificateService,
aiService: aiService,
timetableService: timetableService,
classService: classService,
examService: examService,
gradeService: gradeService,
gradebookService: gradebookService,
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,74 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/school-service/internal/services"
"github.com/gin-gonic/gin"
)
// ExportTimetableSolutionCSV streams the lessons of a solution as CSV.
// The download filename includes the solution UUID so multiple plans don't
// clobber each other when saved to disk.
func (h *Handler) ExportTimetableSolutionCSV(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
solutionID := c.Param("id")
lessons, err := h.timetableService.LoadExportLessons(c.Request.Context(), solutionID, uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to load lessons: "+err.Error())
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="stundenplan-`+solutionID+`.csv"`)
c.Writer.WriteHeader(http.StatusOK)
if err := services.WriteCSV(c.Writer, lessons); err != nil {
// Best-effort; headers already flushed.
_ = c.Writer.Flush
}
}
// ExportTimetableSolutionICS emits a single-week iCalendar. The reference
// Monday defaults to the next Monday from "now"; callers can override via
// ?start=YYYY-MM-DD to align to a school year.
func (h *Handler) ExportTimetableSolutionICS(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
solutionID := c.Param("id")
weekStart := services.NextMonday(time.Now())
if param := c.Query("start"); param != "" {
parsed, err := time.Parse("2006-01-02", param)
if err != nil {
respondError(c, http.StatusBadRequest, "start must be YYYY-MM-DD")
return
}
weekStart = parsed
}
sol, err := h.timetableService.GetSolution(c.Request.Context(), solutionID, uid)
if err != nil {
respondError(c, http.StatusNotFound, "Solution not found")
return
}
lessons, err := h.timetableService.LoadExportLessons(c.Request.Context(), solutionID, uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to load lessons: "+err.Error())
return
}
c.Header("Content-Type", "text/calendar; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="stundenplan-`+solutionID+`.ics"`)
c.Writer.WriteHeader(http.StatusOK)
if err := services.WriteICS(c.Writer, lessons, weekStart, sol.Name); err != nil {
// Same best-effort situation as CSV.
_ = c.Writer.Flush
}
}
@@ -0,0 +1,111 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// ---------- Solutions ----------
func (h *Handler) CreateTimetableSolution(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableSolutionRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
sol, err := h.timetableService.CreateSolution(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create solution: "+err.Error())
return
}
// Fire-and-forget the solver invocation; the row is persisted regardless.
if err := h.timetableService.TriggerSolve(c.Request.Context(), h.solverServiceURL, sol.ID.String(), uid); err != nil {
// Don't fail the request — the solution row already shows status=failed.
// The client will see error_message via GET /solutions/:id.
respondCreated(c, sol)
return
}
respondCreated(c, sol)
}
func (h *Handler) ListTimetableSolutions(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSolutions(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list solutions: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) GetTimetableSolution(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
sol, err := h.timetableService.GetSolution(c.Request.Context(), c.Param("id"), uid)
if err != nil {
respondError(c, http.StatusNotFound, "Solution not found")
return
}
respondSuccess(c, sol)
}
func (h *Handler) ListTimetableLessons(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListLessons(c.Request.Context(), c.Param("id"), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list lessons: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableSolution(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSolution(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete solution: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Solution deleted"})
}
// UpdateTimetableLessonPin flips the pinned flag on a single lesson.
// The solver respects pinned cells via @PlanningPin when this user re-solves.
func (h *Handler) UpdateTimetableLessonPin(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.UpdateLessonPinRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := h.timetableService.UpdateLessonPin(c.Request.Context(), c.Param("id"), uid, req.Pinned); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to update lesson pin: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Lesson pin updated", "pinned": req.Pinned})
}
@@ -54,7 +54,7 @@ func RequestLogger() gin.HandlerFunc {
if status >= 400 {
gin.DefaultWriter.Write([]byte(
c.Request.Method + " " + path + " " +
http.StatusText(status) + " " + latency.String() + "\n",
http.StatusText(status) + " " + latency.String() + "\n",
))
}
}
@@ -104,11 +104,28 @@ func RateLimiter() gin.HandlerFunc {
}
}
// AuthMiddleware validates JWT tokens
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
// devUserID is the deterministic UUID injected when AuthMiddleware runs in
// development mode without a JWT. It's the all-zero UUID's first byte set so
// constraint-ownership filters can still match rows created by the dev user.
const devUserID = "00000000-0000-0000-0000-000000000001"
// AuthMiddleware validates JWT tokens.
//
// devMode=true relaxes the check: requests without an Authorization header
// fall back to a fixed dev user instead of being rejected. Useful for
// studio-v2 against a local school-service when no real login is wired up.
// In production (devMode=false) the original strict behaviour applies.
func AuthMiddleware(jwtSecret string, devMode bool) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if devMode {
c.Set("user_id", devUserID)
c.Set("email", "dev@breakpilot.local")
c.Set("role", "teacher")
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header required",
})
@@ -0,0 +1,42 @@
package middleware
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
)
// ParentResolver is the minimum the middleware needs from the parent
// service. Defined as interface so handlers can pass their own service
// without import cycles.
type ParentResolver interface {
ParentFromSession(ctx context.Context, token string) (parent interface{}, err error)
}
// ParentSessionCookieName is the name of the HttpOnly cookie that carries
// the parent's session token after redeem. Exported so handlers can set it.
const ParentSessionCookieName = "bp_parent_session"
// ParentSessionMiddleware reads the parent session cookie and resolves it
// to a parent_account. Stores parent_id (string) in the Gin context for
// downstream handlers. Aborts with 401 if the cookie is missing or the
// session expired.
func ParentSessionMiddleware(resolve func(ctx context.Context, token string) (string, string, string, error)) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(ParentSessionCookieName)
if err != nil || token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Parent session required"})
return
}
parentID, email, lang, err := resolve(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session"})
return
}
c.Set("parent_id", parentID)
c.Set("parent_email", email)
c.Set("parent_language", lang)
c.Next()
}
}
@@ -0,0 +1,97 @@
package models
import (
"time"
"github.com/google/uuid"
)
// PublicEvent is a holiday or school-vacation row imported from
// OpenHolidaysAPI. Global (no owner) — same for every school per region.
type PublicEvent struct {
ID uuid.UUID `json:"id" db:"id"`
Region string `json:"region" db:"region"` // e.g. "DE-NI"
EventType string `json:"event_type" db:"event_type"` // public_holiday | school_holiday
NameDe string `json:"name_de" db:"name_de"`
NameEn string `json:"name_en,omitempty" db:"name_en"`
StartDate string `json:"start_date" db:"start_date"` // YYYY-MM-DD
EndDate string `json:"end_date" db:"end_date"`
Source string `json:"source,omitempty" db:"source"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SchoolCalendarConfig stores the Bundesland selection for one school
// (= one Rektor account). One row per user.
type SchoolCalendarConfig struct {
UserID uuid.UUID `json:"user_id" db:"user_id"`
Bundesland string `json:"bundesland" db:"bundesland"` // DE-NI ...
SchoolYearStart *string `json:"school_year_start,omitempty" db:"school_year_start"`
SchoolYearEnd *string `json:"school_year_end,omitempty" db:"school_year_end"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SchoolEvent is a user-managed event (Fortbildung, Schulfeier, …).
type SchoolEvent struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
Title string `json:"title" db:"title"`
Description string `json:"description,omitempty" db:"description"`
EventType string `json:"event_type" db:"event_type"`
IsSchoolFree bool `json:"is_school_free" db:"is_school_free"`
StartDate string `json:"start_date" db:"start_date"`
EndDate string `json:"end_date" db:"end_date"`
StartTime *string `json:"start_time,omitempty" db:"start_time"`
EndTime *string `json:"end_time,omitempty" db:"end_time"`
AffectedClassIDs []uuid.UUID `json:"affected_class_ids" db:"affected_class_ids"`
VisibleToParents bool `json:"visible_to_parents" db:"visible_to_parents"`
NotifyParents bool `json:"notify_parents" db:"notify_parents"`
NotifyStudents bool `json:"notify_students" db:"notify_students"`
NotificationLeadDays []int `json:"notification_lead_days" db:"notification_lead_days"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Request DTOs
// UpsertSchoolCalendarConfigRequest sets or updates the Bundesland for the
// authenticated user. Both school-year dates are optional (defaults to the
// running year based on today's date).
type UpsertSchoolCalendarConfigRequest struct {
Bundesland string `json:"bundesland" binding:"required,len=5"`
SchoolYearStart *string `json:"school_year_start,omitempty"`
SchoolYearEnd *string `json:"school_year_end,omitempty"`
}
type CreateSchoolEventRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
EventType string `json:"event_type" binding:"required,oneof=fortbildung schulfeier klassenfahrt projekttag eltern_info andere"`
IsSchoolFree bool `json:"is_school_free"`
StartDate string `json:"start_date" binding:"required"`
EndDate string `json:"end_date" binding:"required"`
StartTime *string `json:"start_time,omitempty"`
EndTime *string `json:"end_time,omitempty"`
AffectedClassIDs []string `json:"affected_class_ids"`
VisibleToParents bool `json:"visible_to_parents"`
NotifyParents bool `json:"notify_parents"`
NotifyStudents bool `json:"notify_students"`
NotificationLeadDays []int `json:"notification_lead_days"`
}
// SchoolYearRolloverRequest moves all classes up by one grade and updates
// the config's school-year dates. Optional date pair, otherwise defaults
// to next Aug 01 → following Jul 31.
type SchoolYearRolloverRequest struct {
NewYearStart *string `json:"new_year_start,omitempty"` // YYYY-MM-DD
NewYearEnd *string `json:"new_year_end,omitempty"`
}
// SchoolYearRolloverResult is what the endpoint returns so the UI can show
// "promoted 8 classes, removed 2 graduating ones".
type SchoolYearRolloverResult struct {
ClassesPromoted int `json:"classes_promoted"`
ClassesGraduated int `json:"classes_graduated"`
NewYearStart string `json:"new_year_start"`
NewYearEnd string `json:"new_year_end"`
}
+76
View File
@@ -0,0 +1,76 @@
package models
import (
"time"
"github.com/google/uuid"
)
type ParentAccount struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
Email string `json:"email" db:"email"`
PreferredLanguage string `json:"preferred_language" db:"preferred_language"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type ParentChild struct {
ID uuid.UUID `json:"id" db:"id"`
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
TTClassID uuid.UUID `json:"tt_class_id" db:"tt_class_id"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined for display
ClassName string `json:"class_name,omitempty"`
}
// Request DTOs
// InviteParentRequest is what the teacher posts to invite one parent for one
// child. The endpoint creates the account if it doesn't exist, the child
// row, and a fresh magic_link. Same parent can be invited for several
// children (re-using the account).
type InviteParentRequest struct {
Email string `json:"email" binding:"required,email"`
PreferredLanguage string `json:"preferred_language"`
ChildFirstName string `json:"child_first_name" binding:"required"`
ChildLastName string `json:"child_last_name" binding:"required"`
TTClassID string `json:"tt_class_id" binding:"required,uuid"`
}
// InviteParentResponse carries the freshly-minted magic-link path so the
// teacher can copy it into Matrix/Email manually (mass-send comes from the
// notification worker in Phase 9d).
type InviteParentResponse struct {
Parent ParentAccount `json:"parent"`
Child ParentChild `json:"child"`
MagicToken string `json:"magic_token"`
MagicURL string `json:"magic_url"`
ExpiresAt time.Time `json:"expires_at"`
}
// ParentInviteListItem is the teacher-facing list row — one entry per
// (parent, child) pair, with the joined class name.
type ParentInviteListItem struct {
ParentID uuid.UUID `json:"parent_id"`
Email string `json:"email"`
PreferredLanguage string `json:"preferred_language"`
ChildID uuid.UUID `json:"child_id"`
ChildFirstName string `json:"child_first_name"`
ChildLastName string `json:"child_last_name"`
ClassID uuid.UUID `json:"class_id"`
ClassName string `json:"class_name"`
CreatedAt time.Time `json:"created_at"`
}
// RedeemMagicLinkRequest is what /parent/auth/redeem expects.
type RedeemMagicLinkRequest struct {
Token string `json:"token" binding:"required"`
}
// ParentMe is what /parent/me returns: the account + every linked child.
type ParentMe struct {
Parent ParentAccount `json:"parent"`
Children []ParentChild `json:"children"`
}
@@ -0,0 +1,63 @@
package models
import (
"time"
"github.com/google/uuid"
)
// TimetableSolution is one run of the solver — exactly one row per solve.
// Lessons attached via tt_lesson.solution_id.
type TimetableSolution struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
Name string `json:"name,omitempty" db:"name"`
Status string `json:"status" db:"status"`
HardScore *int `json:"hard_score,omitempty" db:"hard_score"`
SoftScore *int `json:"soft_score,omitempty" db:"soft_score"`
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ParentSolutionID *uuid.UUID `json:"parent_solution_id,omitempty" db:"parent_solution_id"`
SecondsLimit *int `json:"seconds_limit,omitempty" db:"seconds_limit"`
}
// TimetableLesson is one scheduled class-period in a solution.
type TimetableLesson struct {
ID uuid.UUID `json:"id" db:"id"`
SolutionID uuid.UUID `json:"solution_id" db:"solution_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
RoomID *uuid.UUID `json:"room_id,omitempty" db:"room_id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"`
PeriodIndex int `json:"period_index" db:"period_index"`
Pinned bool `json:"pinned" db:"pinned"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined fields for display
ClassName string `json:"class_name,omitempty"`
SubjectName string `json:"subject_name,omitempty"`
TeacherName string `json:"teacher_name,omitempty"`
RoomName string `json:"room_name,omitempty"`
}
// CreateTimetableSolutionRequest kicks off a solve. The solver-service is
// invoked async — this endpoint only registers the solution row and queues
// the job.
//
// ParentSolutionID, if set, instructs the solver to seed the new problem
// with the parent solution's pinned lessons (Phase 7 plan versioning).
// SecondsLimit overrides the default 60s solver budget.
type CreateTimetableSolutionRequest struct {
Name string `json:"name"`
ParentSolutionID *string `json:"parent_solution_id,omitempty" binding:"omitempty,uuid"`
SecondsLimit *int `json:"seconds_limit,omitempty" binding:"omitempty,min=5,max=600"`
}
// UpdateLessonPinRequest toggles tt_lesson.pinned. Used by the Plan view's
// pin/unpin button.
type UpdateLessonPinRequest struct {
Pinned bool `json:"pinned"`
}
@@ -0,0 +1,151 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// dispatchOne builds the payload for one (event, audience, channel) tuple,
// posts it to the upstream Matrix/Email service, and writes a
// notification_log row. Returns one of "sent", "failed", "skipped",
// "already" so the caller can tally counters.
//
// "skipped" means the upstream URL is empty (dev/test mode) — we still log
// so the UI can render "will-send-when-configured". "already" means the
// (event, lead, audience, channel) combo is already logged from an earlier
// run today; we don't re-send.
func (s *Service) dispatchOne(ctx context.Context, e dueEvent, audience, channel string, runDate time.Time) (status string, err error) {
// Idempotency check.
var existing int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM notification_log
WHERE event_id = $1 AND lead_days = $2 AND audience = $3 AND channel = $4
`, e.ID, e.LeadDays, audience, channel).Scan(&existing); err != nil {
return "failed", err
}
if existing > 0 {
return "already", nil
}
recipients, lang, err := s.recipientsFor(ctx, e, audience)
if err != nil {
return "failed", err
}
url := s.urlFor(channel)
if url == "" {
// Stub mode: write a 'skipped' log row but report success so the cron
// counter isn't alarming when running locally without the upstream.
_ = s.writeLog(ctx, e, audience, channel, "skipped", "no upstream URL configured", runDate)
return "skipped", nil
}
subject, body := Render(e.EventType, audience, e.LeadDays, lang, Vars{
Title: e.Title, Date: e.StartDate.Format("2006-01-02"),
DatePretty: e.StartDate.Format("02.01.2006"), ClassName: e.ClassName,
})
for _, recipient := range recipients {
payload := DispatchPayload{
Channel: channel, Recipient: recipient, Language: lang,
Subject: subject, Body: body, EventID: e.ID, LeadDays: e.LeadDays,
}
if err := s.postUpstream(ctx, url, payload); err != nil {
_ = s.writeLog(ctx, e, audience, channel, "failed", err.Error(), runDate)
return "failed", err
}
}
if err := s.writeLog(ctx, e, audience, channel, "sent", "", runDate); err != nil {
log.Printf("notification_log insert failed (already counted as sent): %v", err)
}
return "sent", nil
}
// recipientsFor returns the list of email addresses (parents) or Matrix
// handles (students — derived from … unimplemented for now; we just return
// the parent emails and let the bridge fan out).
//
// Per memory the Matrix/Email upstream services are owned by the colleague;
// our job here is to hand them a recipient identifier they can resolve.
// For parents that's the email; for students we have no contact identifier
// yet, so we fall back to the parent emails too (broadcast).
func (s *Service) recipientsFor(ctx context.Context, e dueEvent, audience string) ([]string, string, error) {
// Find the class IDs from the event row. If empty → all classes owned by
// the teacher.
rows, err := s.db.Query(ctx, `
SELECT DISTINCT pa.email, pa.preferred_language
FROM parent_account pa
JOIN parent_child pc ON pc.parent_id = pa.id
WHERE pa.created_by_user_id = $1
AND (
(SELECT array_length(affected_class_ids, 1) FROM cal_school_event WHERE id = $2) IS NULL
OR pc.tt_class_id = ANY(
(SELECT affected_class_ids FROM cal_school_event WHERE id = $2)
)
)
`, e.OwnerUserID, e.ID)
if err != nil {
return nil, "de", err
}
defer rows.Close()
var emails []string
primaryLang := "de"
first := true
for rows.Next() {
var email, lang string
if err := rows.Scan(&email, &lang); err != nil {
return nil, "de", err
}
emails = append(emails, email)
if first {
primaryLang = lang
first = false
}
}
return emails, primaryLang, nil
}
func (s *Service) urlFor(channel string) string {
switch channel {
case "matrix":
return s.matrixURL
case "email":
return s.emailURL
}
return ""
}
func (s *Service) postUpstream(ctx context.Context, url string, payload DispatchPayload) error {
body, _ := json.Marshal(payload)
cctx, cancel := context.WithTimeout(ctx, s.httpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("upstream returned HTTP %d", resp.StatusCode)
}
return nil
}
func (s *Service) writeLog(ctx context.Context, e dueEvent, audience, channel, status, errorMessage string, runDate time.Time) error {
_, err := s.db.Exec(ctx, `
INSERT INTO notification_log (event_id, lead_days, audience, channel, status, error_message, run_date)
VALUES ($1::uuid, $2, $3, $4, $5, NULLIF($6, ''), $7::date)
ON CONFLICT (event_id, lead_days, audience, channel) DO NOTHING
`, e.ID, e.LeadDays, audience, channel, status, errorMessage, runDate.Format("2006-01-02"))
return err
}
@@ -0,0 +1,204 @@
package notifications
import (
"context"
"fmt"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the entry point for the daily notification scan. It reads
// cal_school_event + parent_account + tt_class, decides which (event,
// lead_days, audience) pairs are due today, dispatches via the configured
// upstream URLs, and writes a notification_log row for idempotency.
type Service struct {
db *pgxpool.Pool
matrixURL string // empty → status=skipped
emailURL string // empty → status=skipped
httpTimeout time.Duration
}
// Dispatch is the contract our upstream services (Matrix bridge + Email
// gateway, owned by the colleague) must implement. We POST a body with
// these fields; they figure out delivery. Stub mode (URL == "") logs
// instead, useful for dev + tests.
type DispatchPayload struct {
Channel string `json:"channel"` // "matrix" | "email"
Recipient string `json:"recipient"` // email address; for Matrix the bridge maps it
Language string `json:"language"`
Subject string `json:"subject"`
Body string `json:"body"`
EventID string `json:"event_id"`
LeadDays int `json:"lead_days"`
}
func NewService(db *pgxpool.Pool, matrixURL, emailURL string) *Service {
return &Service{
db: db,
matrixURL: matrixURL,
emailURL: emailURL,
httpTimeout: 10 * time.Second,
}
}
// LogRow is the read shape for the notification_log table.
type LogRow struct {
LeadDays int `json:"lead_days"`
Audience string `json:"audience"`
Channel string `json:"channel"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
RunDate string `json:"run_date"`
CreatedAt time.Time `json:"created_at"`
}
// ListLog returns the notification_log rows for one event, scoped to a
// teacher so the UI can't query someone else's log.
func (s *Service) ListLog(ctx context.Context, eventID, ownerUserID string) ([]LogRow, error) {
rows, err := s.db.Query(ctx, `
SELECT nl.lead_days, nl.audience, nl.channel, nl.status,
COALESCE(nl.error_message, ''), nl.run_date::text, nl.created_at
FROM notification_log nl
JOIN cal_school_event ev ON ev.id = nl.event_id
WHERE ev.id = $1 AND ev.created_by_user_id = $2
ORDER BY nl.lead_days DESC, nl.audience, nl.channel
`, eventID, ownerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []LogRow{}
for rows.Next() {
var r LogRow
if err := rows.Scan(&r.LeadDays, &r.Audience, &r.Channel, &r.Status, &r.Error, &r.RunDate, &r.CreatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// RunForDate scans every active cal_school_event with a lead_day equal to
// (event_start - runDate). For each due (audience, channel) pair it
// renders + dispatches + logs. Idempotent via the UNIQUE constraint on
// notification_log.
type RunResult struct {
Date string `json:"date"`
Sent int `json:"sent"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
AlreadyLogged int `json:"already_logged"`
}
func (s *Service) RunForDate(ctx context.Context, runDate time.Time) (*RunResult, error) {
res := &RunResult{Date: runDate.Format("2006-01-02")}
events, err := s.dueEvents(ctx, runDate)
if err != nil {
return nil, fmt.Errorf("query due events: %w", err)
}
for _, e := range events {
// For each event we may notify parents and/or students depending on
// the row's flags. Channels are derived from the audience:
// parents → email + matrix
// students → matrix only (students don't always have email)
audiences := []string{}
if e.NotifyParents {
audiences = append(audiences, "parents")
}
if e.NotifyStudents {
audiences = append(audiences, "students")
}
for _, audience := range audiences {
channels := []string{"matrix"}
if audience == "parents" {
channels = append(channels, "email")
}
for _, channel := range channels {
status, err := s.dispatchOne(ctx, e, audience, channel, runDate)
switch status {
case "sent":
res.Sent++
case "failed":
res.Failed++
if err != nil {
log.Printf("notification dispatch failed: %v", err)
}
case "skipped":
res.Skipped++
case "already":
res.AlreadyLogged++
}
}
}
}
return res, nil
}
// dueEvent holds the small slice of cal_school_event row we need plus the
// matched lead_day for this run.
type dueEvent struct {
ID string
Title string
EventType string
StartDate time.Time
ClassName string // optional, may be empty for "alle Klassen"
OwnerUserID string
NotifyParents bool
NotifyStudents bool
LeadDays int
}
func (s *Service) dueEvents(ctx context.Context, runDate time.Time) ([]dueEvent, error) {
// A row is due when (start_date - runDate) appears in
// notification_lead_days. Lead=0 means "today the event starts".
rows, err := s.db.Query(ctx, `
SELECT ev.id::text, ev.title, ev.event_type, ev.start_date, ev.created_by_user_id::text,
ev.notify_parents, ev.notify_students, ev.notification_lead_days,
COALESCE(
(SELECT string_agg(cl.name, ', ' ORDER BY cl.name)
FROM tt_class cl
WHERE cl.id = ANY(ev.affected_class_ids)),
''
) AS class_names
FROM cal_school_event ev
WHERE ev.start_date >= $1::date
AND (ev.notify_parents OR ev.notify_students)
`, runDate.Format("2006-01-02"))
if err != nil {
return nil, err
}
defer rows.Close()
var out []dueEvent
for rows.Next() {
var (
id, title, eventType, ownerUserID, classNames string
notifyParents, notifyStudents bool
startDate time.Time
leadDays []int32
)
if err := rows.Scan(&id, &title, &eventType, &startDate, &ownerUserID,
&notifyParents, &notifyStudents, &leadDays, &classNames); err != nil {
return nil, err
}
distanceDays := int(startDate.Sub(runDate).Hours() / 24)
for _, lead := range leadDays {
if int(lead) == distanceDays {
out = append(out, dueEvent{
ID: id, Title: title, EventType: eventType,
StartDate: startDate, ClassName: classNames,
OwnerUserID: ownerUserID,
NotifyParents: notifyParents,
NotifyStudents: notifyStudents,
LeadDays: int(lead),
})
break
}
}
}
return out, nil
}
@@ -0,0 +1,255 @@
package notifications
import (
"fmt"
"strings"
)
// Vars are the placeholder values rendered into a template string.
// The fields here must match the {{…}} markers in templates below.
type Vars struct {
Title string
Date string // YYYY-MM-DD
DatePretty string // e.g. "Donnerstag, 15. Oktober"
ClassName string // empty = whole school
TeacherName string
}
// Render picks the right template based on event type, audience, lead-day
// bucket and language, then substitutes the {{var}} placeholders.
//
// lead is grouped into three buckets:
//
// 0 → "today"
// 1 → "tomorrow"
// >=2 → "in X days" with X = lead value
//
// Falls back through (lang → de) and (event_type → "andere") so we never
// fail to render even with custom rule combos.
func Render(eventType, audience string, lead int, lang string, v Vars) (subject, body string) {
bucket := bucketFor(lead)
lc := strings.ToLower(lang)
if _, ok := templates[lc]; !ok {
lc = "de"
}
t := lookup(lc, eventType, audience, bucket)
subject = substitute(t.Subject, lead, v)
body = substitute(t.Body, lead, v)
return subject, body
}
func bucketFor(lead int) string {
switch {
case lead <= 0:
return "today"
case lead == 1:
return "tomorrow"
default:
return "days"
}
}
// lookup walks the templates map applying the (lang → de) and
// (event_type → andere) fallbacks. The bucket is guaranteed to exist for
// every audience because we always define today/tomorrow/days in the de
// baseline.
func lookup(lang, eventType, audience, bucket string) tmpl {
byEvent, ok := templates[lang][eventType]
if !ok {
byEvent = templates[lang]["andere"]
}
byAudience, ok := byEvent[audience]
if !ok {
byAudience = byEvent["parents"]
}
t, ok := byAudience[bucket]
if !ok {
t = byAudience["days"]
}
return t
}
func substitute(s string, lead int, v Vars) string {
classSuffix := ""
if v.ClassName != "" {
classSuffix = " (" + v.ClassName + ")"
}
repl := strings.NewReplacer(
"{{title}}", v.Title,
"{{date}}", v.Date,
"{{date_pretty}}", v.DatePretty,
"{{class_name}}", v.ClassName,
"{{class_suffix}}", classSuffix,
"{{teacher_name}}", v.TeacherName,
"{{lead}}", fmt.Sprintf("%d", lead),
)
return repl.Replace(s)
}
type tmpl struct {
Subject string
Body string
}
// templates[lang][eventType][audience][bucket] → tmpl
//
// Only the eight parent languages we ship subject-i18n.ts for are covered.
// Custom event_types fall back to the "andere" branch. Custom languages
// fall back to "de". This keeps the file under 500 LOC; if more locales
// are needed, split into one file per language.
var templates = map[string]map[string]map[string]map[string]tmpl{
"de": deTemplates(),
"en": enTemplates(),
"tr": trTemplates(),
"ar": arTemplates(),
"uk": ukTemplates(),
"ru": ruTemplates(),
"pl": plTemplates(),
"fr": frTemplates(),
}
func deTemplates() map[string]map[string]map[string]tmpl {
// All event types share the same template family; we only vary by audience
// and bucket. Specialise where wording really differs (e.g. fortbildung
// is school-free, schulfeier invites attendance).
parentToday := tmpl{
Subject: "Heute: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, heute findet {{title}} statt{{class_suffix}}. Datum: {{date_pretty}}.",
}
parentTomorrow := tmpl{
Subject: "Morgen: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, morgen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
}
parentDays := tmpl{
Subject: "In {{lead}} Tagen: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, in {{lead}} Tagen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
}
studentToday := tmpl{
Subject: "Heute: {{title}}",
Body: "Heute ist {{title}}{{class_suffix}}.",
}
studentTomorrow := tmpl{
Subject: "Morgen: {{title}}",
Body: "Morgen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
}
studentDays := tmpl{
Subject: "In {{lead}} Tagen: {{title}}",
Body: "In {{lead}} Tagen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
}
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
}
universal := map[string]map[string]tmpl{
"parents": bucket(parentToday, parentTomorrow, parentDays),
"students": bucket(studentToday, studentTomorrow, studentDays),
}
return map[string]map[string]map[string]tmpl{
"fortbildung": universal,
"schulfeier": universal,
"klassenfahrt": universal,
"projekttag": universal,
"eltern_info": universal,
"andere": universal,
}
}
// The non-DE templates use the same structure; only the strings change.
// Defined in a single helper that takes the translation map and reuses the
// DE family glue.
func makeFamily(
pT, pTm, pD, sT, sTm, sD tmpl,
) map[string]map[string]map[string]tmpl {
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
}
universal := map[string]map[string]tmpl{
"parents": bucket(pT, pTm, pD),
"students": bucket(sT, sTm, sD),
}
return map[string]map[string]map[string]tmpl{
"fortbildung": universal,
"schulfeier": universal,
"klassenfahrt": universal,
"projekttag": universal,
"eltern_info": universal,
"andere": universal,
}
}
func enTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Today: {{title}}{{class_suffix}}", "Dear parents, today {{title}} takes place{{class_suffix}}. Date: {{date_pretty}}."},
tmpl{"Tomorrow: {{title}}{{class_suffix}}", "Dear parents, tomorrow ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
tmpl{"In {{lead}} days: {{title}}{{class_suffix}}", "Dear parents, in {{lead}} days ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
tmpl{"Today: {{title}}", "Today is {{title}}{{class_suffix}}."},
tmpl{"Tomorrow: {{title}}", "Tomorrow ({{date_pretty}}) is {{title}}{{class_suffix}}."},
tmpl{"In {{lead}} days: {{title}}", "In {{lead}} days ({{date_pretty}}) is {{title}}{{class_suffix}}."},
)
}
func trTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Bugün: {{title}}{{class_suffix}}", "Sayın veliler, bugün {{title}} gerçekleşiyor{{class_suffix}}. Tarih: {{date_pretty}}."},
tmpl{"Yarın: {{title}}{{class_suffix}}", "Sayın veliler, yarın ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
tmpl{"{{lead}} gün sonra: {{title}}{{class_suffix}}", "Sayın veliler, {{lead}} gün sonra ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
tmpl{"Bugün: {{title}}", "Bugün {{title}}{{class_suffix}}."},
tmpl{"Yarın: {{title}}", "Yarın ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"{{lead}} gün sonra: {{title}}", "{{lead}} gün sonra ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func arTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"اليوم: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، اليوم يقام {{title}}{{class_suffix}}. التاريخ: {{date_pretty}}."},
tmpl{"غدًا: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، غدًا ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
tmpl{"بعد {{lead}} أيام: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، بعد {{lead}} أيام ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
tmpl{"اليوم: {{title}}", "اليوم {{title}}{{class_suffix}}."},
tmpl{"غدًا: {{title}}", "غدًا ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"بعد {{lead}} أيام: {{title}}", "بعد {{lead}} أيام ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func ukTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Сьогодні: {{title}}{{class_suffix}}", "Шановні батьки, сьогодні відбудеться {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
tmpl{"Завтра: {{title}}{{class_suffix}}", "Шановні батьки, завтра ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} днів: {{title}}{{class_suffix}}", "Шановні батьки, через {{lead}} днів ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
tmpl{"Сьогодні: {{title}}", "Сьогодні {{title}}{{class_suffix}}."},
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} днів: {{title}}", "Через {{lead}} днів ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func ruTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Сегодня: {{title}}{{class_suffix}}", "Уважаемые родители, сегодня состоится {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
tmpl{"Завтра: {{title}}{{class_suffix}}", "Уважаемые родители, завтра ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} дней: {{title}}{{class_suffix}}", "Уважаемые родители, через {{lead}} дней ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
tmpl{"Сегодня: {{title}}", "Сегодня {{title}}{{class_suffix}}."},
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} дней: {{title}}", "Через {{lead}} дней ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func plTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Dzisiaj: {{title}}{{class_suffix}}", "Drodzy rodzice, dzisiaj odbywa się {{title}}{{class_suffix}}. Data: {{date_pretty}}."},
tmpl{"Jutro: {{title}}{{class_suffix}}", "Drodzy rodzice, jutro ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
tmpl{"Za {{lead}} dni: {{title}}{{class_suffix}}", "Drodzy rodzice, za {{lead}} dni ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
tmpl{"Dzisiaj: {{title}}", "Dzisiaj {{title}}{{class_suffix}}."},
tmpl{"Jutro: {{title}}", "Jutro ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Za {{lead}} dni: {{title}}", "Za {{lead}} dni ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func frTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Aujourd'hui : {{title}}{{class_suffix}}", "Chers parents, aujourd'hui a lieu {{title}}{{class_suffix}}. Date : {{date_pretty}}."},
tmpl{"Demain : {{title}}{{class_suffix}}", "Chers parents, demain ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
tmpl{"Dans {{lead}} jours : {{title}}{{class_suffix}}", "Chers parents, dans {{lead}} jours ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
tmpl{"Aujourd'hui : {{title}}", "Aujourd'hui c'est {{title}}{{class_suffix}}."},
tmpl{"Demain : {{title}}", "Demain ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
tmpl{"Dans {{lead}} jours : {{title}}", "Dans {{lead}} jours ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
)
}
@@ -0,0 +1,75 @@
package notifications
import (
"strings"
"testing"
)
func TestBucketFor(t *testing.T) {
cases := []struct {
lead int
want string
}{
{-1, "today"}, {0, "today"}, {1, "tomorrow"}, {2, "days"}, {7, "days"},
}
for _, c := range cases {
if got := bucketFor(c.lead); got != c.want {
t.Errorf("bucketFor(%d) = %q, want %q", c.lead, got, c.want)
}
}
}
func TestRender_GermanParentsToday(t *testing.T) {
subject, body := Render("fortbildung", "parents", 0, "de", Vars{
Title: "SCHILF", DatePretty: "10.10.2026", ClassName: "5a",
})
if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "SCHILF") {
t.Errorf("expected today + title in subject, got %q", subject)
}
if !strings.Contains(body, "Liebe Eltern") {
t.Errorf("expected greeting in body, got %q", body)
}
if !strings.Contains(body, "(5a)") {
t.Errorf("expected class suffix, got %q", body)
}
}
func TestRender_TurkishStudentTomorrow(t *testing.T) {
subject, _ := Render("schulfeier", "students", 1, "tr", Vars{
Title: "Yaz şenliği", DatePretty: "12.06.2026",
})
if !strings.Contains(subject, "Yarın") || !strings.Contains(subject, "Yaz şenliği") {
t.Errorf("expected Turkish 'tomorrow' subject, got %q", subject)
}
}
func TestRender_FallbackLanguage(t *testing.T) {
// 'xx' isn't supported → falls back to de.
subject, _ := Render("klassenfahrt", "parents", 7, "xx", Vars{
Title: "Wattenmeer", DatePretty: "15.05.2026", ClassName: "6b",
})
if !strings.Contains(subject, "In 7 Tagen") {
t.Errorf("expected German fallback, got %q", subject)
}
}
func TestRender_FallbackEventType(t *testing.T) {
// 'unknown_type' falls back to 'andere'.
subject, _ := Render("unknown_type", "parents", 0, "de", Vars{
Title: "Sondertermin",
})
if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "Sondertermin") {
t.Errorf("expected today subject after fallback, got %q", subject)
}
}
func TestSubstitute_DropsClassSuffixWhenEmpty(t *testing.T) {
out := substitute("X{{class_suffix}}Y", 0, Vars{ClassName: ""})
if out != "XY" {
t.Errorf("expected XY (no suffix), got %q", out)
}
out = substitute("X{{class_suffix}}Y", 0, Vars{ClassName: "5a"})
if out != "X (5a)Y" {
t.Errorf("expected ' (5a)' suffix, got %q", out)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
package services
import (
"context"
"fmt"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
)
// School-event CRUD. Ownership is per-user via created_by_user_id. Public
// holiday/Ferien data lives in cal_public_event and is handled by
// calendar_service.go.
func (s *CalendarService) CreateEvent(ctx context.Context, userID string, req *models.CreateSchoolEventRequest) (*models.SchoolEvent, error) {
classIDs, err := parseClassIDs(req.AffectedClassIDs)
if err != nil {
return nil, err
}
var e models.SchoolEvent
leadDays := req.NotificationLeadDays
if leadDays == nil {
leadDays = []int{7, 1}
}
row := s.db.QueryRow(ctx, `
INSERT INTO cal_school_event
(created_by_user_id, title, description, event_type, is_school_free,
start_date, end_date, start_time, end_time, affected_class_ids,
visible_to_parents, notify_parents, notify_students, notification_lead_days)
VALUES ($1, $2, $3, $4, $5,
$6::date, $7::date, NULLIF($8, '')::time, NULLIF($9, '')::time,
$10, $11, $12, $13, $14)
RETURNING id, created_by_user_id, title, COALESCE(description,''), event_type,
is_school_free, start_date::text, end_date::text,
start_time::text, end_time::text, affected_class_ids,
visible_to_parents, notify_parents, notify_students,
notification_lead_days, created_at, updated_at
`, userID, req.Title, req.Description, req.EventType, req.IsSchoolFree,
req.StartDate, req.EndDate, strOrEmpty(req.StartTime), strOrEmpty(req.EndTime),
classIDs, req.VisibleToParents, req.NotifyParents, req.NotifyStudents, leadDays)
if err := scanEvent(row, &e); err != nil {
return nil, err
}
return &e, nil
}
func (s *CalendarService) ListEvents(ctx context.Context, userID, from, to string) ([]models.SchoolEvent, error) {
if from == "" {
from = "1900-01-01"
}
if to == "" {
to = "2100-12-31"
}
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, title, COALESCE(description,''), event_type,
is_school_free, start_date::text, end_date::text,
start_time::text, end_time::text, affected_class_ids,
visible_to_parents, notify_parents, notify_students,
notification_lead_days, created_at, updated_at
FROM cal_school_event
WHERE created_by_user_id = $1
AND end_date >= $2::date
AND start_date <= $3::date
ORDER BY start_date, start_time NULLS FIRST, title
`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SchoolEvent
for rows.Next() {
var e models.SchoolEvent
if err := scanEvent(rows, &e); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
func (s *CalendarService) DeleteEvent(ctx context.Context, id, userID string) error {
res, err := s.db.Exec(ctx, `
DELETE FROM cal_school_event WHERE id = $1 AND created_by_user_id = $2
`, id, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("event not found or not owned")
}
return nil
}
// parseClassIDs validates the array of UUID strings the request sent.
// Returns a typed []uuid.UUID so asyncpg/pgx encodes it correctly into the
// UUID[] column.
func parseClassIDs(in []string) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
u, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid class_id %q: %w", s, err)
}
out = append(out, u)
}
return out, nil
}
// row interface so the same scan logic works for both QueryRow and Rows.
type rowScanner interface {
Scan(dest ...any) error
}
func scanEvent(r rowScanner, e *models.SchoolEvent) error {
var startTime, endTime *string
if err := r.Scan(
&e.ID, &e.CreatedByUserID, &e.Title, &e.Description, &e.EventType,
&e.IsSchoolFree, &e.StartDate, &e.EndDate,
&startTime, &endTime, &e.AffectedClassIDs,
&e.VisibleToParents, &e.NotifyParents, &e.NotifyStudents,
&e.NotificationLeadDays, &e.CreatedAt, &e.UpdatedAt,
); err != nil {
return err
}
e.StartTime = startTime
e.EndTime = endTime
return nil
}
@@ -0,0 +1,85 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
)
// RolloverSchoolYear advances every tt_class for this user by one grade
// level, removes graduating classes (grade > 13), and updates the
// cal_school_config school-year dates. Operates as a single transaction.
//
// Stammdaten (teachers, subjects, rooms, periods) bleiben unveraendert —
// es aendern sich nur Klassen-Stufen.
func (s *CalendarService) RolloverSchoolYear(ctx context.Context, userID string, req *models.SchoolYearRolloverRequest) (*models.SchoolYearRolloverResult, error) {
newStart, newEnd := defaultSchoolYearDates(req)
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// 1. Remove the graduating cohort first so they don't get bumped to 14.
gradRes, err := tx.Exec(ctx, `
DELETE FROM tt_class WHERE created_by_user_id = $1 AND grade_level >= 13
`, userID)
if err != nil {
return nil, fmt.Errorf("delete graduating: %w", err)
}
// 2. Promote everyone else.
promRes, err := tx.Exec(ctx, `
UPDATE tt_class SET grade_level = grade_level + 1
WHERE created_by_user_id = $1
`, userID)
if err != nil {
return nil, fmt.Errorf("promote classes: %w", err)
}
// 3. Update the school-year dates in the config (creates a row if the
// user never picked a Bundesland — but that's an edge case; in normal
// flow the wizard has run before rollover).
_, err = tx.Exec(ctx, `
UPDATE cal_school_config
SET school_year_start = $1::date,
school_year_end = $2::date,
updated_at = NOW()
WHERE user_id = $3
`, newStart, newEnd, userID)
if err != nil {
return nil, fmt.Errorf("update config: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &models.SchoolYearRolloverResult{
ClassesPromoted: int(promRes.RowsAffected()),
ClassesGraduated: int(gradRes.RowsAffected()),
NewYearStart: newStart,
NewYearEnd: newEnd,
}, nil
}
// defaultSchoolYearDates returns the dates from the request if both set,
// otherwise the next school year starting Aug 1 of "this year or next"
// and ending Jul 31 the year after.
func defaultSchoolYearDates(req *models.SchoolYearRolloverRequest) (string, string) {
if req != nil && req.NewYearStart != nil && req.NewYearEnd != nil {
return *req.NewYearStart, *req.NewYearEnd
}
now := time.Now()
startYear := now.Year()
// If we're past August, the "new" year refers to the next calendar year.
if int(now.Month()) >= 8 {
startYear++
}
start := time.Date(startYear, 8, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(startYear+1, 7, 31, 0, 0, 0, 0, time.UTC)
return start.Format("2006-01-02"), end.Format("2006-01-02")
}
@@ -0,0 +1,134 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// CalendarService owns the cal_* tables and the read of the seed snapshot
// on first boot. Holidays are global (no owner) — same data for every
// school in a given Bundesland.
type CalendarService struct {
db *pgxpool.Pool
}
func NewCalendarService(db *pgxpool.Pool) *CalendarService {
return &CalendarService{db: db}
}
// SeedFromSnapshot reads internal/seed/calendar_holidays.json and bulk-inserts
// every row that doesn't already exist (idempotent via the unique constraint
// on region+event_type+name_de+start_date). Called once at server start.
func (s *CalendarService) SeedFromSnapshot(ctx context.Context, path string) error {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
log.Printf("calendar seed file not found at %s — skipping", path)
return nil
}
return fmt.Errorf("read snapshot: %w", err)
}
var events []models.PublicEvent
if err := json.Unmarshal(raw, &events); err != nil {
return fmt.Errorf("parse snapshot: %w", err)
}
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
inserted := 0
for _, e := range events {
ct, err := tx.Exec(ctx, `
INSERT INTO cal_public_event (region, event_type, name_de, name_en, start_date, end_date, source)
VALUES ($1, $2, $3, NULLIF($4, ''), $5::date, $6::date, 'OpenHolidaysAPI')
ON CONFLICT (region, event_type, name_de, start_date) DO NOTHING
`, e.Region, e.EventType, e.NameDe, e.NameEn, e.StartDate, e.EndDate)
if err != nil {
return fmt.Errorf("insert event: %w", err)
}
inserted += int(ct.RowsAffected())
}
if err := tx.Commit(ctx); err != nil {
return err
}
log.Printf("calendar seed: %d new events inserted (of %d in snapshot)", inserted, len(events))
return nil
}
// ListHolidays returns all public + school holidays for the given region
// between from..to (YYYY-MM-DD inclusive).
func (s *CalendarService) ListHolidays(ctx context.Context, region, from, to string) ([]models.PublicEvent, error) {
rows, err := s.db.Query(ctx, `
SELECT id, region, event_type, name_de, COALESCE(name_en, ''),
start_date::text, end_date::text, COALESCE(source, ''), created_at
FROM cal_public_event
WHERE region = $1
AND end_date >= $2::date
AND start_date <= $3::date
ORDER BY start_date, event_type, name_de
`, region, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PublicEvent
for rows.Next() {
var e models.PublicEvent
if err := rows.Scan(&e.ID, &e.Region, &e.EventType, &e.NameDe, &e.NameEn,
&e.StartDate, &e.EndDate, &e.Source, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
// GetConfig returns the per-user calendar config (Bundesland etc.) or nil
// if the user has not configured one yet.
func (s *CalendarService) GetConfig(ctx context.Context, userID string) (*models.SchoolCalendarConfig, error) {
var c models.SchoolCalendarConfig
err := s.db.QueryRow(ctx, `
SELECT user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at
FROM cal_school_config WHERE user_id = $1
`, userID).Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
// pgx returns no-rows error; caller maps to 404.
return nil, err
}
return &c, nil
}
// UpsertConfig inserts or updates the Bundesland selection.
func (s *CalendarService) UpsertConfig(ctx context.Context, userID string, req *models.UpsertSchoolCalendarConfigRequest) (*models.SchoolCalendarConfig, error) {
var c models.SchoolCalendarConfig
err := s.db.QueryRow(ctx, `
INSERT INTO cal_school_config (user_id, bundesland, school_year_start, school_year_end)
VALUES ($1, $2, NULLIF($3, '')::date, NULLIF($4, '')::date)
ON CONFLICT (user_id) DO UPDATE
SET bundesland = EXCLUDED.bundesland,
school_year_start = EXCLUDED.school_year_start,
school_year_end = EXCLUDED.school_year_end,
updated_at = NOW()
RETURNING user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at
`, userID, req.Bundesland, strOrEmpty(req.SchoolYearStart), strOrEmpty(req.SchoolYearEnd)).
Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt)
return &c, err
}
func strOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
@@ -0,0 +1,102 @@
package services
import (
"testing"
"github.com/breakpilot/school-service/internal/models"
)
func TestUpsertSchoolCalendarConfigRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.UpsertSchoolCalendarConfigRequest
wantErr bool
}{
{"valid NI", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE-NI"}, false},
{"empty bundesland", models.UpsertSchoolCalendarConfigRequest{Bundesland: ""}, true},
{"too long", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE-NIE"}, true},
{"too short", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
func TestCreateSchoolEventRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateSchoolEventRequest
wantErr bool
}{
{"valid fortbildung", models.CreateSchoolEventRequest{
Title: "SCHILF", EventType: "fortbildung",
StartDate: "2026-10-01", EndDate: "2026-10-01",
}, false},
{"missing title", models.CreateSchoolEventRequest{
EventType: "fortbildung", StartDate: "2026-10-01", EndDate: "2026-10-01",
}, true},
{"invalid event type", models.CreateSchoolEventRequest{
Title: "X", EventType: "wedding",
StartDate: "2026-10-01", EndDate: "2026-10-01",
}, true},
{"missing dates", models.CreateSchoolEventRequest{
Title: "X", EventType: "schulfeier",
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
func TestNewCalendarService_Constructs(t *testing.T) {
s := NewCalendarService(nil)
if s == nil {
t.Fatal("expected non-nil service")
}
}
func TestDefaultSchoolYearDates_FallbackFormat(t *testing.T) {
// No override → deterministic YYYY-MM-DD strings with end > start.
start, end := defaultSchoolYearDates(nil)
if len(start) != 10 || len(end) != 10 {
t.Fatalf("expected YYYY-MM-DD strings, got %q %q", start, end)
}
if end <= start {
t.Errorf("end %q must be after start %q", end, start)
}
}
func TestDefaultSchoolYearDates_ExplicitOverride(t *testing.T) {
s, e := "2030-09-01", "2031-06-30"
req := &models.SchoolYearRolloverRequest{NewYearStart: &s, NewYearEnd: &e}
gotS, gotE := defaultSchoolYearDates(req)
if gotS != s || gotE != e {
t.Errorf("override ignored: got %q/%q want %q/%q", gotS, gotE, s, e)
}
}
func TestParseClassIDs_AcceptsValidAndRejectsGarbage(t *testing.T) {
good := []string{"00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"}
out, err := parseClassIDs(good)
if err != nil || len(out) != 2 {
t.Fatalf("expected 2 parsed UUIDs, got %v err=%v", out, err)
}
if _, err := parseClassIDs([]string{"not-a-uuid"}); err == nil {
t.Errorf("expected error for invalid uuid")
}
// Empty strings are silently dropped (curl convenience).
out, err = parseClassIDs([]string{"", "00000000-0000-0000-0000-000000000003", ""})
if err != nil || len(out) != 1 {
t.Errorf("expected 1 parsed UUID, got %v err=%v", out, err)
}
}
@@ -0,0 +1,209 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
)
// RedeemMagicLink validates a one-shot link, marks it used, mints a session
// token. Returns the raw session token; caller (HTTP handler) sets it as
// HttpOnly cookie.
func (s *ParentService) RedeemMagicLink(ctx context.Context, token string) (sessionToken string, parent *models.ParentAccount, err error) {
hash := hashToken(token)
tx, err := s.db.Begin(ctx)
if err != nil {
return "", nil, err
}
defer tx.Rollback(ctx)
var (
linkID uuid.UUID
parentID uuid.UUID
expiresAt time.Time
usedAt *time.Time
)
if err := tx.QueryRow(ctx, `
SELECT id, parent_id, expires_at, used_at
FROM parent_magic_link
WHERE token_hash = $1
`, hash).Scan(&linkID, &parentID, &expiresAt, &usedAt); err != nil {
return "", nil, fmt.Errorf("invalid token")
}
if usedAt != nil {
return "", nil, fmt.Errorf("token already used")
}
if time.Now().After(expiresAt) {
return "", nil, fmt.Errorf("token expired")
}
// Mark used.
if _, err := tx.Exec(ctx, `UPDATE parent_magic_link SET used_at = NOW() WHERE id = $1`, linkID); err != nil {
return "", nil, err
}
// Mint session token.
raw, h, err := randomToken()
if err != nil {
return "", nil, err
}
sessionExpires := time.Now().Add(parentSessionTTL)
if _, err := tx.Exec(ctx, `
INSERT INTO parent_session (parent_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, parentID, h, sessionExpires); err != nil {
return "", nil, err
}
// Fetch the account so callers (UI) get the email + language back.
var p models.ParentAccount
if err := tx.QueryRow(ctx, `
SELECT id, created_by_user_id, email, preferred_language, created_at
FROM parent_account WHERE id = $1
`, parentID).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt); err != nil {
return "", nil, err
}
if err := tx.Commit(ctx); err != nil {
return "", nil, err
}
return raw, &p, nil
}
// ParentFromSession resolves a session token back to the parent account.
// Returns error on missing/expired session. Called by ParentSession
// middleware.
func (s *ParentService) ParentFromSession(ctx context.Context, sessionToken string) (*models.ParentAccount, error) {
hash := hashToken(sessionToken)
var p models.ParentAccount
var expiresAt time.Time
if err := s.db.QueryRow(ctx, `
SELECT pa.id, pa.created_by_user_id, pa.email, pa.preferred_language, pa.created_at, ps.expires_at
FROM parent_session ps
JOIN parent_account pa ON pa.id = ps.parent_id
WHERE ps.token_hash = $1
`, hash).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt, &expiresAt); err != nil {
return nil, fmt.Errorf("invalid session")
}
if time.Now().After(expiresAt) {
return nil, fmt.Errorf("session expired")
}
return &p, nil
}
// ListChildren returns all parent_child rows for a parent, joined with the
// class name from tt_class.
func (s *ParentService) ListChildren(ctx context.Context, parentID string) ([]models.ParentChild, error) {
rows, err := s.db.Query(ctx, `
SELECT pc.id, pc.parent_id, pc.tt_class_id, pc.first_name, pc.last_name, pc.created_at, cl.name
FROM parent_child pc
JOIN tt_class cl ON cl.id = pc.tt_class_id
WHERE pc.parent_id = $1
ORDER BY pc.last_name, pc.first_name
`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ParentChild
for rows.Next() {
var c models.ParentChild
if err := rows.Scan(&c.ID, &c.ParentID, &c.TTClassID, &c.FirstName, &c.LastName, &c.CreatedAt, &c.ClassName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
// TeacherOfParent returns the created_by_user_id of the teacher who invited
// this parent. Used to scope timetable + calendar queries.
func (s *ParentService) TeacherOfParent(ctx context.Context, parentID string) (string, error) {
var uid string
err := s.db.QueryRow(ctx,
`SELECT created_by_user_id::text FROM parent_account WHERE id = $1`, parentID,
).Scan(&uid)
return uid, err
}
// ChildBelongsToParent checks whether a tt_class is one this parent has a
// child in. Used by the timetable + calendar handlers as authorization.
func (s *ParentService) ChildBelongsToParent(ctx context.Context, parentID, classID string) (bool, error) {
var ok bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM parent_child
WHERE parent_id = $1 AND tt_class_id = $2)
`, parentID, classID).Scan(&ok)
return ok, err
}
// LatestCompletedSolutionLessonsForClass returns the lessons of the most
// recent COMPLETED tt_solution where the given class has rows, owned by
// the teacher that originally invited the parent. Joined with subject + room
// + teacher names so the parent UI can render directly.
func (s *ParentService) LatestCompletedSolutionLessonsForClass(ctx context.Context, classID, teacherUserID string) ([]LessonExport, error) {
// Find latest completed solution by the teacher that has at least one
// lesson in this class.
var solutionID string
if err := s.db.QueryRow(ctx, `
SELECT s.id::text
FROM tt_solution s
JOIN tt_lesson l ON l.solution_id = s.id
WHERE s.created_by_user_id = $1
AND s.status = 'completed'
AND l.class_id = $2::uuid
ORDER BY s.created_at DESC
LIMIT 1
`, teacherUserID, classID).Scan(&solutionID); err != nil {
return nil, nil // no plan yet — parent UI shows empty grid
}
// Re-use the existing export shape with a stricter filter (class only).
rows, err := s.db.Query(ctx, `
SELECT l.day_of_week, l.period_index,
to_char(p.start_time, 'HH24:MI') AS st,
to_char(p.end_time, 'HH24:MI') AS et,
cl.name, sub.name, sub.short_code,
t.last_name || ', ' || t.first_name,
COALESCE(r.name, ''),
l.pinned
FROM tt_lesson l
JOIN tt_solution s ON l.solution_id = s.id
JOIN tt_class cl ON l.class_id = cl.id
JOIN tt_subject sub ON l.subject_id = sub.id
JOIN tt_teacher t ON l.teacher_id = t.id
LEFT JOIN tt_room r ON l.room_id = r.id
LEFT JOIN tt_period p
ON p.day_of_week = l.day_of_week
AND p.period_index = l.period_index
AND p.created_by_user_id = s.created_by_user_id
WHERE s.id = $1::uuid AND l.class_id = $2::uuid
ORDER BY l.day_of_week, l.period_index
`, solutionID, classID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LessonExport
for rows.Next() {
var le LessonExport
var st, et *string
if err := rows.Scan(&le.DayOfWeek, &le.PeriodIndex, &st, &et,
&le.ClassName, &le.SubjectName, &le.SubjectCode,
&le.TeacherName, &le.RoomName, &le.Pinned); err != nil {
return nil, err
}
if st != nil {
le.StartTime = *st
}
if et != nil {
le.EndTime = *et
}
out = append(out, le)
}
return out, nil
}
@@ -0,0 +1,175 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// ParentService owns the parent_* tables. Magic-link tokens are random
// 32-byte values; only the SHA-256 hash is stored in the DB. The raw token
// goes back to the teacher exactly once (when they invite a parent) so
// they can paste it into a Matrix message or email. After redeem, a
// browser session (own table, separate token) carries the parent through
// the API.
type ParentService struct {
db *pgxpool.Pool
}
func NewParentService(db *pgxpool.Pool) *ParentService {
return &ParentService{db: db}
}
const (
magicLinkTTL = 7 * 24 * time.Hour
parentSessionTTL = 30 * 24 * time.Hour
parentCookieName = "bp_parent_session"
tokenLen = 32 // raw bytes; URL-safe base64 encoded
)
func randomToken() (raw string, hash string, err error) {
buf := make([]byte, tokenLen)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
h := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(h[:])
return raw, hash, nil
}
func hashToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
// InviteParent upserts the parent account, creates a fresh child row, and
// issues a magic-link. Caller (teacher) is the owner; child must belong to
// one of their tt_class rows.
func (s *ParentService) InviteParent(ctx context.Context, userID string, req *models.InviteParentRequest) (*models.InviteParentResponse, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// 1. Verify class ownership.
var owned bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tt_class WHERE id = $1 AND created_by_user_id = $2)`,
req.TTClassID, userID,
).Scan(&owned); err != nil {
return nil, err
}
if !owned {
return nil, fmt.Errorf("tt_class_id not found or not owned by user")
}
lang := req.PreferredLanguage
if lang == "" {
lang = "de"
}
// 2. Upsert parent_account.
var parent models.ParentAccount
if err := tx.QueryRow(ctx, `
INSERT INTO parent_account (created_by_user_id, email, preferred_language)
VALUES ($1, $2, $3)
ON CONFLICT (created_by_user_id, email) DO UPDATE
SET preferred_language = EXCLUDED.preferred_language
RETURNING id, created_by_user_id, email, preferred_language, created_at
`, userID, req.Email, lang).Scan(
&parent.ID, &parent.CreatedByUserID, &parent.Email, &parent.PreferredLanguage, &parent.CreatedAt,
); err != nil {
return nil, fmt.Errorf("upsert parent: %w", err)
}
// 3. Insert child.
var child models.ParentChild
if err := tx.QueryRow(ctx, `
INSERT INTO parent_child (parent_id, tt_class_id, first_name, last_name)
VALUES ($1, $2::uuid, $3, $4)
RETURNING id, parent_id, tt_class_id, first_name, last_name, created_at
`, parent.ID, req.TTClassID, req.ChildFirstName, req.ChildLastName).Scan(
&child.ID, &child.ParentID, &child.TTClassID, &child.FirstName, &child.LastName, &child.CreatedAt,
); err != nil {
return nil, fmt.Errorf("insert child: %w", err)
}
// 4. Mint a magic-link token (raw goes back, hash goes to DB).
raw, hash, err := randomToken()
if err != nil {
return nil, fmt.Errorf("token gen: %w", err)
}
expiresAt := time.Now().Add(magicLinkTTL)
if _, err := tx.Exec(ctx, `
INSERT INTO parent_magic_link (parent_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, parent.ID, hash, expiresAt); err != nil {
return nil, fmt.Errorf("insert magic link: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &models.InviteParentResponse{
Parent: parent,
Child: child,
MagicToken: raw,
MagicURL: "/eltern/login?token=" + raw,
ExpiresAt: expiresAt,
}, nil
}
func (s *ParentService) ListInvites(ctx context.Context, userID string) ([]models.ParentInviteListItem, error) {
rows, err := s.db.Query(ctx, `
SELECT pa.id, pa.email, pa.preferred_language,
pc.id, pc.first_name, pc.last_name,
cl.id, cl.name, pc.created_at
FROM parent_account pa
JOIN parent_child pc ON pc.parent_id = pa.id
JOIN tt_class cl ON cl.id = pc.tt_class_id
WHERE pa.created_by_user_id = $1
ORDER BY pa.email, pc.last_name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ParentInviteListItem
for rows.Next() {
var it models.ParentInviteListItem
if err := rows.Scan(&it.ParentID, &it.Email, &it.PreferredLanguage,
&it.ChildID, &it.ChildFirstName, &it.ChildLastName,
&it.ClassID, &it.ClassName, &it.CreatedAt); err != nil {
return nil, err
}
out = append(out, it)
}
return out, nil
}
// DeleteInvite removes one child row (parent stays if other children still
// exist for the same teacher).
func (s *ParentService) DeleteInvite(ctx context.Context, childID, userID string) error {
res, err := s.db.Exec(ctx, `
DELETE FROM parent_child pc
USING parent_account pa
WHERE pc.id = $1 AND pc.parent_id = pa.id AND pa.created_by_user_id = $2
`, childID, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("child not found or not owned")
}
return nil
}
@@ -0,0 +1,80 @@
package services
import (
"strings"
"testing"
"github.com/breakpilot/school-service/internal/models"
)
func TestRandomToken_Hashable(t *testing.T) {
raw, hash, err := randomToken()
if err != nil {
t.Fatalf("randomToken error: %v", err)
}
if len(raw) < 30 {
t.Errorf("raw token suspiciously short: %d", len(raw))
}
if len(hash) != 64 {
t.Errorf("sha256 hex hash must be 64 chars, got %d", len(hash))
}
if hashToken(raw) != hash {
t.Errorf("hashToken(raw) must equal the hash randomToken returned")
}
}
func TestRandomToken_NonRepeating(t *testing.T) {
// 16 iterations, all raw tokens must differ.
seen := map[string]struct{}{}
for i := 0; i < 16; i++ {
raw, _, err := randomToken()
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
if _, dup := seen[raw]; dup {
t.Fatalf("duplicate raw token at iter %d", i)
}
seen[raw] = struct{}{}
}
}
func TestHashToken_StableHexLowercase(t *testing.T) {
h := hashToken("hello world")
if strings.ToLower(h) != h {
t.Errorf("hash should be lowercase hex")
}
if len(h) != 64 {
t.Errorf("expected 64-char hash, got %d", len(h))
}
}
func TestInviteParentRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.InviteParentRequest
wantErr bool
}{
{"valid", models.InviteParentRequest{
Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller",
TTClassID: "00000000-0000-0000-0000-000000000001",
}, false},
{"bad email", models.InviteParentRequest{
Email: "not-an-email", ChildFirstName: "Max", ChildLastName: "Mueller",
TTClassID: "00000000-0000-0000-0000-000000000001",
}, true},
{"missing child", models.InviteParentRequest{
Email: "a@b.de", TTClassID: "00000000-0000-0000-0000-000000000001",
}, true},
{"bad class uuid", models.InviteParentRequest{
Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller",
TTClassID: "not-a-uuid",
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
@@ -0,0 +1,185 @@
package services
import (
"context"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
)
// LessonExport is the flat row shape used by both CSV and ICS exports.
// We materialise it once via SQL so the two encoders share zero logic.
type LessonExport struct {
DayOfWeek int
PeriodIndex int
StartTime string // HH:MM
EndTime string // HH:MM
ClassName string
SubjectName string
SubjectCode string
TeacherName string
RoomName string
Pinned bool
}
// LoadExportLessons joins tt_lesson against the period schedule so each row
// already carries the wall-clock time. Ownership enforced via the parent
// solution.created_by_user_id.
func (s *TimetableService) LoadExportLessons(ctx context.Context, solutionID, userID string) ([]LessonExport, error) {
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 AND s.created_by_user_id = $2
ORDER BY l.day_of_week, l.period_index, cl.name
`, solutionID, userID)
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
}
// WriteCSV streams the lesson list as comma-separated UTF-8.
func WriteCSV(w io.Writer, lessons []LessonExport) error {
csvw := csv.NewWriter(w)
defer csvw.Flush()
if err := csvw.Write([]string{
"day_of_week", "period_index", "start_time", "end_time",
"class", "subject", "subject_code", "teacher", "room", "pinned",
}); err != nil {
return err
}
for _, l := range lessons {
pinned := "false"
if l.Pinned {
pinned = "true"
}
if err := csvw.Write([]string{
fmt.Sprintf("%d", l.DayOfWeek),
fmt.Sprintf("%d", l.PeriodIndex),
l.StartTime, l.EndTime,
l.ClassName, l.SubjectName, l.SubjectCode,
l.TeacherName, l.RoomName, pinned,
}); err != nil {
return err
}
}
return nil
}
// WriteICS emits one VEVENT per lesson, anchored to weekStart (a Monday).
// RFC 5545 line endings are CRLF; we let strings.Builder handle that.
//
// The icsTimestamp helper drops the ":" + seconds so the emitted string
// matches Apple Calendar's and Google Calendar's expectations exactly.
func WriteICS(w io.Writer, lessons []LessonExport, weekStart time.Time, solutionName string) error {
if solutionName == "" {
solutionName = "BreakPilot Stundenplan"
}
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//BreakPilot//Timetable//DE\r\n")
b.WriteString("CALSCALE:GREGORIAN\r\n")
b.WriteString("METHOD:PUBLISH\r\n")
now := time.Now().UTC()
for i, l := range lessons {
if l.StartTime == "" || l.EndTime == "" {
continue // skip rows where no matching period row found
}
// day_of_week 1=Mo..7=So → offset 0..6 from weekStart (Mon).
date := weekStart.AddDate(0, 0, l.DayOfWeek-1)
dtStart, err := combineDateTime(date, l.StartTime)
if err != nil {
return err
}
dtEnd, err := combineDateTime(date, l.EndTime)
if err != nil {
return err
}
b.WriteString("BEGIN:VEVENT\r\n")
fmt.Fprintf(&b, "UID:lesson-%d-d%dp%d-%s@breakpilot\r\n", i, l.DayOfWeek, l.PeriodIndex, dtStart.Format("20060102"))
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", now.Format("20060102T150405Z"))
fmt.Fprintf(&b, "DTSTART:%s\r\n", dtStart.Format("20060102T150405"))
fmt.Fprintf(&b, "DTEND:%s\r\n", dtEnd.Format("20060102T150405"))
fmt.Fprintf(&b, "SUMMARY:%s (%s)\r\n", icsEscape(l.SubjectName), icsEscape(l.ClassName))
if l.RoomName != "" {
fmt.Fprintf(&b, "LOCATION:%s\r\n", icsEscape(l.RoomName))
}
fmt.Fprintf(&b, "DESCRIPTION:Lehrer: %s\\n%s\r\n", icsEscape(l.TeacherName), icsEscape(solutionName))
b.WriteString("END:VEVENT\r\n")
}
b.WriteString("END:VCALENDAR\r\n")
_, err := io.WriteString(w, b.String())
return err
}
func icsEscape(s string) string {
r := strings.NewReplacer(",", "\\,", ";", "\\;", "\n", "\\n")
return r.Replace(s)
}
// combineDateTime fuses a date (yyyy-mm-dd) and an HH:MM string into a
// timezone-naive local timestamp.
func combineDateTime(date time.Time, hhmm string) (time.Time, error) {
parts := strings.SplitN(hhmm, ":", 2)
if len(parts) != 2 {
return time.Time{}, fmt.Errorf("invalid HH:MM %q", hhmm)
}
var hour, minute int
if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil {
return time.Time{}, err
}
if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil {
return time.Time{}, err
}
return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, time.Local), nil
}
// NextMonday returns the next Monday on or after the given reference time.
func NextMonday(ref time.Time) time.Time {
weekday := int(ref.Weekday()) // 0=Sun..6=Sat
if weekday == 0 {
weekday = 7 // shift Sun to 7 so Mon=1..Sun=7 mapping works
}
offset := (8 - weekday) % 7 // distance to next Mon (0 if today is Mon)
return time.Date(ref.Year(), ref.Month(), ref.Day()+offset, 0, 0, 0, 0, ref.Location())
}
@@ -0,0 +1,105 @@
package services
import (
"bytes"
"strings"
"testing"
"time"
)
func sampleLessons() []LessonExport {
return []LessonExport{
{DayOfWeek: 1, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45",
ClassName: "5a", SubjectName: "Mathe", SubjectCode: "M",
TeacherName: "Schmidt, Anna", RoomName: "A101", Pinned: false},
{DayOfWeek: 2, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45",
ClassName: "5a", SubjectName: "Deutsch, Klasse 5", SubjectCode: "D",
TeacherName: "Mueller, Bob", RoomName: "", Pinned: true},
}
}
func TestWriteCSV_HeaderAndRows(t *testing.T) {
var buf bytes.Buffer
if err := WriteCSV(&buf, sampleLessons()); err != nil {
t.Fatalf("WriteCSV failed: %v", err)
}
out := buf.String()
wantHeader := "day_of_week,period_index,start_time,end_time,class,subject,subject_code,teacher,room,pinned"
if !strings.Contains(out, wantHeader) {
t.Errorf("CSV missing header line; got:\n%s", out)
}
if !strings.Contains(out, "1,1,08:00,08:45,5a,Mathe,M,\"Schmidt, Anna\",A101,false") {
t.Errorf("CSV missing first row; got:\n%s", out)
}
// Commas inside subject name must be quoted.
if !strings.Contains(out, "\"Deutsch, Klasse 5\"") {
t.Errorf("CSV should quote comma in subject name; got:\n%s", out)
}
if !strings.Contains(out, ",true") {
t.Errorf("Pinned flag should serialise as 'true'; got:\n%s", out)
}
}
func TestWriteICS_StructureAndDates(t *testing.T) {
weekStart := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) // a Monday
var buf bytes.Buffer
if err := WriteICS(&buf, sampleLessons(), weekStart, "Schuljahr 26/27"); err != nil {
t.Fatalf("WriteICS failed: %v", err)
}
out := buf.String()
for _, want := range []string{
"BEGIN:VCALENDAR\r\n",
"VERSION:2.0\r\n",
"PRODID:-//BreakPilot//Timetable//DE\r\n",
"BEGIN:VEVENT\r\n",
"END:VEVENT\r\n",
"END:VCALENDAR\r\n",
"DTSTART:20260824T080000",
"DTSTART:20260825T080000",
"SUMMARY:Mathe (5a)",
"LOCATION:A101",
"Schuljahr 26/27",
} {
if !strings.Contains(out, want) {
t.Errorf("ICS missing %q in output", want)
}
}
}
func TestICSEscape_SpecialChars(t *testing.T) {
tests := []struct {
in, want string
}{
{"plain", "plain"},
{"a,b", "a\\,b"},
{"x;y", "x\\;y"},
{"line1\nline2", "line1\\nline2"},
}
for _, tt := range tests {
got := icsEscape(tt.in)
if got != tt.want {
t.Errorf("icsEscape(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestNextMonday(t *testing.T) {
// Verify the offset arithmetic for every weekday — 2026-08-22 is a Saturday.
cases := []struct {
ref time.Time
wantMo string
}{
{time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC), "2026-08-24"}, // Mon → same day
{time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC), "2026-08-31"}, // Tue → next Mon
{time.Date(2026, 8, 23, 0, 0, 0, 0, time.UTC), "2026-08-24"}, // Sun → next Mon
{time.Date(2026, 8, 22, 0, 0, 0, 0, time.UTC), "2026-08-24"}, // Sat → next Mon
}
for _, tt := range cases {
got := NextMonday(tt.ref).Format("2006-01-02")
if got != tt.wantMo {
t.Errorf("NextMonday(%s) = %s, want %s", tt.ref.Format("2006-01-02"), got, tt.wantMo)
}
}
}
@@ -0,0 +1,191 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/breakpilot/school-service/internal/models"
)
// TimetableSolutionService persists solver runs and forwards solve requests
// to the timetable-solver-service. The solver writes lesson rows back to the
// same DB once it finishes, so listing solutions = simple SELECTs here.
func (s *TimetableService) CreateSolution(ctx context.Context, userID string, req *models.CreateTimetableSolutionRequest) (*models.TimetableSolution, error) {
// Resolve optional parent — guard against cross-user references.
var parentID *string
if req.ParentSolutionID != nil && *req.ParentSolutionID != "" {
var owned bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM tt_solution
WHERE id = $1 AND created_by_user_id = $2)
`, *req.ParentSolutionID, userID).Scan(&owned)
if err != nil {
return nil, err
}
if !owned {
return nil, fmt.Errorf("parent_solution_id not found or not owned by user")
}
parentID = req.ParentSolutionID
}
var sol models.TimetableSolution
err := s.db.QueryRow(ctx, `
INSERT INTO tt_solution (created_by_user_id, name, status, parent_solution_id, seconds_limit)
VALUES ($1, $2, 'pending', $3::uuid, $4)
RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
`, userID, req.Name, parentID, req.SecondsLimit).Scan(
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit,
)
return &sol, err
}
// UpdateLessonPin flips tt_lesson.pinned. Ownership is enforced via the
// lesson's solution.created_by_user_id — users can only pin their own
// solutions' lessons.
func (s *TimetableService) UpdateLessonPin(ctx context.Context, lessonID, userID string, pinned bool) error {
res, err := s.db.Exec(ctx, `
UPDATE tt_lesson l
SET pinned = $1
FROM tt_solution s
WHERE l.solution_id = s.id AND l.id = $2 AND s.created_by_user_id = $3
`, pinned, lessonID, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("lesson not found or not owned")
}
return nil
}
func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableSolution
for rows.Next() {
var sol models.TimetableSolution
if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit); err != nil {
return nil, err
}
out = append(out, sol)
}
return out, nil
}
func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) (*models.TimetableSolution, error) {
var sol models.TimetableSolution
err := s.db.QueryRow(ctx, `
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
FROM tt_solution WHERE id = $1 AND created_by_user_id = $2
`, id, userID).Scan(
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit,
)
if err != nil {
return nil, err
}
return &sol, nil
}
func (s *TimetableService) ListLessons(ctx context.Context, solutionID, userID string) ([]models.TimetableLesson, error) {
rows, err := s.db.Query(ctx, `
SELECT l.id, l.solution_id, l.class_id, l.subject_id, l.teacher_id, l.room_id,
l.day_of_week, l.period_index, l.pinned, l.created_at,
cl.name, sub.name, t.last_name || ', ' || t.first_name,
COALESCE(r.name, '')
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
WHERE s.id = $1 AND s.created_by_user_id = $2
ORDER BY l.day_of_week, l.period_index
`, solutionID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableLesson
for rows.Next() {
var l models.TimetableLesson
if err := rows.Scan(&l.ID, &l.SolutionID, &l.ClassID, &l.SubjectID, &l.TeacherID, &l.RoomID,
&l.DayOfWeek, &l.PeriodIndex, &l.Pinned, &l.CreatedAt,
&l.ClassName, &l.SubjectName, &l.TeacherName, &l.RoomName); err != nil {
return nil, err
}
out = append(out, l)
}
return out, nil
}
func (s *TimetableService) DeleteSolution(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_solution WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// TriggerSolve hands the freshly-created solution off to the solver-service.
// The solver writes back to tt_solution/tt_lesson directly once finished, so
// from this side we just need to fire-and-forget and let the client poll.
func (s *TimetableService) TriggerSolve(ctx context.Context, solverURL, solutionID, userID string) error {
payload := map[string]string{
"solution_id": solutionID,
"created_by_user_id": userID,
}
body, _ := json.Marshal(payload)
// 5s timeout — solver should accept the job in milliseconds and run async.
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, "POST", solverURL+"/api/v1/solve", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Mark solution as failed so the user sees something went wrong.
_, _ = s.db.Exec(ctx, `
UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW()
WHERE id = $2
`, "solver-service unreachable: "+err.Error(), solutionID)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
_, _ = s.db.Exec(ctx, `
UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW()
WHERE id = $2
`, fmt.Sprintf("solver returned HTTP %d", resp.StatusCode), solutionID)
return fmt.Errorf("solver returned HTTP %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,53 @@
package services
import (
"testing"
"github.com/breakpilot/school-service/internal/models"
)
func TestCreateTimetableSolutionRequest_NoBindingTags(t *testing.T) {
// CreateSolution accepts an empty name; the binding tag is intentionally
// absent. Both states (with + without name) must pass validation.
tests := []struct {
name string
req models.CreateTimetableSolutionRequest
wantErr bool
}{
{"empty", models.CreateTimetableSolutionRequest{}, false},
{"with name", models.CreateTimetableSolutionRequest{Name: "Schuljahr 26/27 Test"}, false},
}
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")
}
})
}
}
func TestCreateTimetableSolutionRequest_ParentAndLimit_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000001"
secs := func(n int) *int { return &n }
parent := func(s string) *string { return &s }
tests := []struct {
name string
req models.CreateTimetableSolutionRequest
wantErr bool
}{
{"valid parent + limit", models.CreateTimetableSolutionRequest{ParentSolutionID: parent(uid), SecondsLimit: secs(60)}, false},
{"bad parent uuid", models.CreateTimetableSolutionRequest{ParentSolutionID: parent("not-a-uuid")}, true},
{"limit too low", models.CreateTimetableSolutionRequest{SecondsLimit: secs(1)}, true},
{"limit too high", models.CreateTimetableSolutionRequest{SecondsLimit: secs(9999)}, true},
{"limit at boundary (5)", models.CreateTimetableSolutionRequest{SecondsLimit: secs(5)}, false},
{"limit at boundary (600)", models.CreateTimetableSolutionRequest{SecondsLimit: secs(600)}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if (validate.Struct(tt.req) != nil) != tt.wantErr {
t.Errorf("unexpected validation outcome for %s", tt.name)
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Snapshot Public Holidays + School Holidays for all 16 German Bundeslaender
# from openholidaysapi.org. The result is committed to the repo and imported
# at first DB boot by school-service. Re-run yearly (or whenever the next
# school year's data needs to be added).
#
# Usage: bash scripts/calendar-snapshot.sh [FIRST_YEAR] [LAST_YEAR]
# defaults: current year .. current year + 2
#
# Output: school-service/internal/seed/calendar_holidays.json
# shape: [{ region, event_type, name_de, name_en, start_date, end_date }, ...]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/school-service/internal/seed/calendar_holidays.json"
mkdir -p "$(dirname "$OUT")"
START_YEAR="${1:-$(date +%Y)}"
END_YEAR="${2:-$((START_YEAR + 2))}"
API="https://openholidaysapi.org"
# DE-XX codes for all 16 Bundeslaender (alphabetical).
REGIONS=(
"DE-BW" "DE-BY" "DE-BE" "DE-BB" "DE-HB" "DE-HH" "DE-HE" "DE-MV"
"DE-NI" "DE-NW" "DE-RP" "DE-SL" "DE-SN" "DE-ST" "DE-SH" "DE-TH"
)
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required (brew install jq)" >&2
exit 1
fi
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
echo '[]' > "$TMP"
fetch() {
local endpoint="$1" region="$2" year="$3"
curl -sf -G "$API/$endpoint" \
--data-urlencode "countryIsoCode=DE" \
--data-urlencode "languageIsoCode=DE" \
--data-urlencode "validFrom=${year}-01-01" \
--data-urlencode "validTo=${year}-12-31" \
--data-urlencode "subdivisionCode=$region" \
|| echo '[]'
}
# Map OpenHolidaysAPI shape → our DB schema. The API returns an array of:
# { id, startDate, endDate, type, name: [{ language, text }], ... }
# We keep DE name as canonical, EN name if present, plus dates and a typed
# event_type discriminator. PublicHolidays and SchoolHolidays come from two
# separate endpoints.
normalise_jq='
map({
region: $region,
event_type: $event_type,
name_de: ((.name // []) | map(select(.language == "DE")) | .[0].text // ""),
name_en: ((.name // []) | map(select(.language == "EN")) | .[0].text // null),
start_date: .startDate,
end_date: .endDate
}) | map(select(.name_de != ""))
'
for region in "${REGIONS[@]}"; do
for year in $(seq "$START_YEAR" "$END_YEAR"); do
echo " $region $year — public" >&2
fetch "PublicHolidays" "$region" "$year" \
| jq --arg region "$region" --arg event_type "public_holiday" "$normalise_jq" \
| jq -s --slurpfile existing "$TMP" '$existing[0] + .[0]' > "$TMP.new"
mv "$TMP.new" "$TMP"
echo " $region $year — school" >&2
fetch "SchoolHolidays" "$region" "$year" \
| jq --arg region "$region" --arg event_type "school_holiday" "$normalise_jq" \
| jq -s --slurpfile existing "$TMP" '$existing[0] + .[0]' > "$TMP.new"
mv "$TMP.new" "$TMP"
done
done
# Deduplicate (the API sometimes returns overlapping rows for events that
# straddle a year boundary) and sort for a stable diff.
jq 'unique_by({region, event_type, name_de, start_date}) | sort_by([.region, .start_date])' \
"$TMP" > "$OUT"
echo
echo "Wrote $(jq length "$OUT") events to $OUT"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Generate a Software Bill of Materials for the Stundenplan stack.
#
# Per Open-Source-Policy (.claude/rules/open-source-policy.md) we need a
# license inventory for every shipped artifact. This script collects all
# three flavours into sbom/stundenplan/ as JSON/CSV/Markdown.
#
# Usage: bash scripts/stundenplan-sbom.sh
#
# Tools required (skipped with warning if missing):
# - go-licenses (Go) go install github.com/google/go-licenses@latest
# - pip-licenses (Python) pip install pip-licenses
# - license-checker (Node) already in studio-v2/node_modules
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/sbom/stundenplan"
mkdir -p "$OUT"
# --------- Go: school-service ---------
echo "==> school-service (Go)"
if command -v go-licenses >/dev/null 2>&1; then
( cd "$ROOT/school-service" \
&& go-licenses csv ./... > "$OUT/school-service-licenses.csv" 2> "$OUT/school-service-warnings.log" \
|| echo " go-licenses returned non-zero (see warnings.log)" )
else
echo " skipped — install with: go install github.com/google/go-licenses@latest"
fi
# --------- Python: timetable-solver-service ---------
echo "==> timetable-solver-service (Python)"
if [[ -d "$ROOT/timetable-solver-service" ]]; then
if command -v pip-licenses >/dev/null 2>&1; then
# Resolve against the requirements.txt the Dockerfile installs.
pip install --quiet --no-deps -r "$ROOT/timetable-solver-service/requirements.txt" \
--target "$OUT/.python-tmp" 2>/dev/null || true
PYTHONPATH="$OUT/.python-tmp" pip-licenses --format=json \
> "$OUT/timetable-solver-licenses.json" 2> "$OUT/timetable-solver-warnings.log" \
|| echo " pip-licenses returned non-zero"
rm -rf "$OUT/.python-tmp"
else
echo " skipped — install with: pip install pip-licenses"
fi
fi
# --------- Node: studio-v2 (subset that ships in the bundle) ---------
echo "==> studio-v2 (Node)"
if [[ -d "$ROOT/studio-v2/node_modules" ]]; then
( cd "$ROOT/studio-v2" \
&& ./node_modules/.bin/license-checker --json --production \
> "$OUT/studio-v2-licenses.json" 2> "$OUT/studio-v2-warnings.log" \
|| echo " license-checker returned non-zero" )
else
echo " studio-v2/node_modules missing — run npm install first"
fi
# --------- Summary ---------
echo
echo "SBOM written to $OUT"
ls -la "$OUT"
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy for the parent-side school-service endpoints. Mirrors the school
* proxy but forwards the parent-session cookie via Set-Cookie/Cookie
* headers so HttpOnly survives the round-trip.
*/
const BACKEND_URL = process.env.SCHOOL_SERVICE_URL || 'http://school-service:8084'
async function proxy(request: NextRequest, params: { path: string[] }): Promise<NextResponse> {
const path = params.path.join('/')
const url = `${BACKEND_URL}/api/v1/parent/${path}${request.nextUrl.search}`
const headers: HeadersInit = { 'Content-Type': 'application/json' }
const cookie = request.headers.get('cookie')
if (cookie) headers['Cookie'] = cookie
const init: RequestInit = { method: request.method, headers }
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
init.body = await request.text()
}
try {
const upstream = await fetch(url, init)
const body = await upstream.text()
const res = new NextResponse(body, {
status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' },
})
// Mirror Set-Cookie back so the browser stores the parent session.
const setCookie = upstream.headers.get('set-cookie')
if (setCookie) res.headers.set('Set-Cookie', setCookie)
return res
} catch (error) {
return NextResponse.json(
{ error: 'school-service nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 },
)
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) }
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { elternApi } from '@/lib/eltern/api'
function LoginInner() {
const router = useRouter()
const search = useSearchParams()
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
useEffect(() => {
const token = search.get('token')
if (!token) {
setError('Kein Token in der URL. Bitte den Link aus der Einladung verwenden.')
return
}
elternApi.redeem(token)
.then(() => { setDone(true); setTimeout(() => router.replace('/eltern'), 800) })
.catch(e => setError(e instanceof Error ? e.message : 'Login fehlgeschlagen'))
}, [router, search])
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
<div className="max-w-md w-full mx-4 p-6 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl" data-testid="eltern-login">
<h1 className="text-2xl font-semibold mb-3">Eltern-Login</h1>
{!error && !done && <p className="opacity-80">Pruefe Token </p>}
{done && <p className="text-emerald-200">Erfolgreich angemeldet. Weiterleitung </p>}
{error && (
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200 text-sm">{error}</div>
)}
</div>
</div>
)
}
export default function ElternLoginPage() {
return (
<Suspense fallback={null}>
<LoginInner />
</Suspense>
)
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { elternApi, type ParentMeResponse, type ParentLesson } from '@/lib/eltern/api'
import { translateSubject } from '@/lib/calendar/subject-i18n'
const DAY_LABELS: Record<string, string[]> = {
de: ['Mo', 'Di', 'Mi', 'Do', 'Fr'],
en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
tr: ['Pzt', 'Sal', 'Çar', 'Per', 'Cum'],
ar: ['الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة'],
uk: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
pl: ['Pon', 'Wt', 'Śr', 'Czw', 'Pt'],
fr: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'],
}
const HEADINGS: Record<string, { greeting: string; selectChild: string; period: string; logout: string; noPlan: string }> = {
de: { greeting: 'Willkommen', selectChild: 'Kind auswählen', period: 'Stunde', logout: 'Abmelden', noPlan: 'Noch kein Stundenplan veröffentlicht.' },
en: { greeting: 'Welcome', selectChild: 'Select child', period: 'Period', logout: 'Sign out', noPlan: 'No timetable published yet.' },
tr: { greeting: 'Hoş geldiniz', selectChild: 'Çocuk seç', period: 'Ders', logout: 'Çıkış', noPlan: 'Henüz ders programı yayımlanmadı.' },
ar: { greeting: 'مرحبًا', selectChild: 'اختر الطفل', period: 'حصة', logout: 'خروج', noPlan: 'لم يتم نشر جدول حصص بعد.' },
uk: { greeting: 'Ласкаво просимо', selectChild: 'Виберіть дитину', period: 'Урок', logout: 'Вийти', noPlan: 'Розклад ще не опубліковано.' },
ru: { greeting: 'Добро пожаловать', selectChild: 'Выберите ребёнка', period: 'Урок', logout: 'Выйти', noPlan: 'Расписание ещё не опубликовано.' },
pl: { greeting: 'Witamy', selectChild: 'Wybierz dziecko', period: 'Lekcja', logout: 'Wyloguj', noPlan: 'Plan lekcji nie jest jeszcze opublikowany.' },
fr: { greeting: 'Bienvenue', selectChild: 'Choisir un enfant', period: 'Cours', logout: 'Déconnexion', noPlan: 'Aucun emploi du temps publié.' },
}
function t(lang: string, key: keyof typeof HEADINGS['de']): string {
const code = (lang || 'de').slice(0, 2)
return HEADINGS[code]?.[key] ?? HEADINGS.de[key]
}
export default function ElternPage() {
const router = useRouter()
const [me, setMe] = useState<ParentMeResponse | null>(null)
const [selected, setSelected] = useState<string>('')
const [lessons, setLessons] = useState<ParentLesson[]>([])
const [error, setError] = useState<string | null>(null)
const lang = me?.parent.preferred_language || 'de'
const dayLabels = DAY_LABELS[lang.slice(0, 2)] || DAY_LABELS.de
const loadMe = useCallback(async () => {
try {
const data = await elternApi.me()
setMe(data)
if (data.children.length > 0) setSelected(data.children[0].tt_class_id)
} catch (e) {
// Not logged in → redirect to login.
if (e instanceof Error && /session/i.test(e.message)) {
router.replace('/eltern/login')
return
}
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
}
}, [router])
useEffect(() => { loadMe() }, [loadMe])
const loadTimetable = useCallback(async () => {
if (!selected) return
try {
const data = await elternApi.timetable(selected)
setLessons(data || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Stundenplan laden fehlgeschlagen')
}
}, [selected])
useEffect(() => { loadTimetable() }, [loadTimetable])
const periodIndices = useMemo(() => {
const set = new Set<number>()
for (const l of lessons) set.add(l.PeriodIndex)
return Array.from(set).sort((a, b) => a - b)
}, [lessons])
const cell = (day: number, idx: number) =>
lessons.find(l => l.DayOfWeek === day && l.PeriodIndex === idx)
const handleLogout = async () => {
try { await elternApi.logout() } catch { /* ignore */ }
router.replace('/eltern/login')
}
if (!me) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
{error ? <span className="text-red-200">{error}</span> : <span className="opacity-70">Laedt </span>}
</div>
)
}
const activeChild = me.children.find(c => c.tt_class_id === selected)
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white p-6" data-testid="eltern-page">
<div className="max-w-5xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">{t(lang, 'greeting')}, {me.parent.email}</h1>
<p className="text-sm text-white/60 mt-1">
{activeChild ? `${activeChild.first_name} ${activeChild.last_name} · ${activeChild.class_name}` : ''}
</p>
</div>
<button onClick={handleLogout} data-testid="eltern-logout" className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-sm">
{t(lang, 'logout')}
</button>
</header>
{me.children.length > 1 && (
<div className="mb-4">
<label className="block text-sm mb-1 opacity-70">{t(lang, 'selectChild')}</label>
<select
value={selected}
onChange={e => setSelected(e.target.value)}
data-testid="child-selector"
className="px-3 py-2 rounded-lg border bg-white/10 border-white/20 text-white"
>
{me.children.map(c => (
<option key={c.id} value={c.tt_class_id} className="text-slate-900">
{c.first_name} {c.last_name} ({c.class_name})
</option>
))}
</select>
</div>
)}
{error && <div className="mb-3 p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200">{error}</div>}
{periodIndices.length === 0 ? (
<div className="rounded-2xl bg-white/10 border border-white/20 p-8 text-center opacity-70">
{t(lang, 'noPlan')}
</div>
) : (
<div className="rounded-2xl bg-white/10 border border-white/20 overflow-hidden">
<table className="w-full">
<thead className="bg-white/5">
<tr>
<th className="text-left px-3 py-3 text-sm font-medium opacity-70 w-16">{t(lang, 'period')}</th>
{dayLabels.map(d => <th key={d} className="text-left px-3 py-3 text-sm font-medium opacity-70">{d}</th>)}
</tr>
</thead>
<tbody>
{periodIndices.map(idx => (
<tr key={idx} className="border-t border-white/10">
<td className="px-3 py-2 font-medium text-sm">{idx}.</td>
{[1, 2, 3, 4, 5].map(d => {
const l = cell(d, idx)
if (!l) return <td key={d} className="px-3 py-2 opacity-20 text-xs"></td>
return (
<td key={d} className="px-2 py-1" data-testid={`eltern-cell-${d}-${idx}`}>
<div className="rounded-md p-2 text-xs space-y-0.5 bg-indigo-500/30 border-l-2 border-indigo-300">
<div className="font-semibold">{translateSubject(l.SubjectName, lang)}</div>
<div className="opacity-80">{l.TeacherName.split(',')[0]}</div>
{l.RoomName && <div className="opacity-60">{l.RoomName}</div>}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
+25
View File
@@ -44,3 +44,28 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Stundenplan print view — hide chrome, force the Wochengrid full-width
on white background so window.print() yields a clean A4 page. */
@media print {
.no-print,
aside,
header button,
details {
display: none !important;
}
body,
main {
background: white !important;
color: black !important;
}
[data-testid="plan-view"] table {
width: 100%;
border-collapse: collapse;
}
[data-testid="plan-view"] td,
[data-testid="plan-view"] th {
border: 1px solid #ccc;
color: black !important;
}
}
@@ -0,0 +1,68 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { BUNDESLAENDER } from '@/app/schulkalender/types'
interface BundeslandWizardProps {
onSave: (bundesland: string) => Promise<void>
}
export function BundeslandWizard({ onSave }: BundeslandWizardProps) {
const { isDark } = useTheme()
const [selected, setSelected] = useState('DE-NI')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await onSave(selected)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className={`max-w-xl mx-auto rounded-2xl border backdrop-blur-xl p-6 ${cardClass}`} data-testid="bundesland-wizard">
<h2 className="text-xl font-semibold mb-2">Willkommen im Schulkalender</h2>
<p className={`text-sm mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Waehle das Bundesland deiner Schule. Damit laden wir Ferien und
Feiertage aus dem offiziellen Datensatz fuer die naechsten drei
Schuljahre.
</p>
<div className="mb-4">
<label className="block text-sm mb-1 opacity-70">Bundesland</label>
<select
value={selected}
onChange={e => setSelected(e.target.value)}
data-testid="bundesland-select"
className={`w-full px-3 py-2 rounded-lg border ${selectClass}`}
>
{BUNDESLAENDER.map(b => (
<option key={b.code} value={b.code}>{b.name}</option>
))}
</select>
</div>
{error && (
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">
{error}
</div>
)}
<button
onClick={handleSave}
disabled={saving}
data-testid="bundesland-save"
className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Bundesland uebernehmen'}
</button>
</div>
)
}
@@ -0,0 +1,106 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
import { NotificationStatus } from './NotificationStatus'
interface DayDetailProps {
iso: string
holidays: PublicEvent[]
events: SchoolEvent[]
onClose: () => void
onDeleted: () => void
}
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
const { isDark } = useTheme()
const handleDelete = async (id: string) => {
if (!confirm('Termin wirklich loeschen?')) return
try {
await calendarApi.deleteEvent(id)
onDeleted()
} catch {
// best-effort
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{formattedDate}</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{dayHolidays.length === 0 && dayEvents.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Eintraege fuer diesen Tag.
</p>
)}
{dayHolidays.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
{dayHolidays.map(h => (
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
<div className="font-medium">{h.name_de}</div>
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` ${h.end_date}` : ''}</div>
</div>
))}
</section>
)}
{dayEvents.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
{dayEvents.map(e => (
<div
key={e.id}
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium">{e.title}</div>
<div className="text-xs opacity-70 mt-0.5">
{EVENT_TYPE_LABEL[e.event_type]}
{e.start_time && ` · ${e.start_time}${e.end_time ? `${e.end_time}` : ''}`}
{e.is_school_free && ' · unterrichtsfrei'}
</div>
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
<div className="text-xs opacity-60 mt-1.5">
{e.visible_to_parents && '👨‍👩‍👧 sichtbar fuer Eltern'}
{e.notify_parents && ' · 📧 Eltern erinnern'}
{e.notify_students && ' · 💬 Schueler erinnern'}
</div>
{(e.notify_parents || e.notify_students) && (
<NotificationStatus eventId={e.id} />
)}
</div>
<button
onClick={() => handleDelete(e.id)}
className="text-xs text-red-400 hover:text-red-300"
>
Loeschen
</button>
</div>
</div>
))}
</section>
)}
</div>
</div>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types'
import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
interface EventModalProps {
defaultDate: string // YYYY-MM-DD
onClose: () => void
onCreated: () => void
}
const initial = (date: string): CreateSchoolEvent => ({
title: '',
event_type: 'fortbildung',
is_school_free: false,
start_date: date,
end_date: date,
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
})
export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) {
const { isDark } = useTheme()
const [form, setForm] = useState<CreateSchoolEvent>(initial(defaultDate))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await calendarApi.createEvent(form)
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="event-modal">
<form
onSubmit={handleSubmit}
className={`w-full max-w-2xl rounded-2xl border p-6 space-y-3 max-h-[90vh] overflow-y-auto ${cardClass}`}
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Neuer Termin</h2>
<button type="button" onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Titel</label>
<input
required
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="z.B. SCHILF: Digitale Tafeln"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-title"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Typ</label>
<select
value={form.event_type}
onChange={e => setForm({ ...form, event_type: e.target.value as SchoolEventType })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-type"
>
{(Object.keys(EVENT_TYPE_LABEL) as SchoolEventType[]).map(k => (
<option key={k} value={k}>{EVENT_TYPE_LABEL[k]}</option>
))}
</select>
</div>
<div className="flex items-end gap-2">
<input
type="checkbox"
id="is-school-free"
checked={form.is_school_free || false}
onChange={e => setForm({ ...form, is_school_free: e.target.checked })}
className="w-5 h-5"
/>
<label htmlFor="is-school-free" className="text-sm">Unterrichtsfrei</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Von</label>
<input type="date" required value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis</label>
<input type="date" required value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Startzeit (optional)</label>
<input type="time" value={form.start_time || ''} onChange={e => setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Endzeit (optional)</label>
<input type="time" value={form.end_time || ''} onChange={e => setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Beschreibung (optional)</label>
<textarea
value={form.description || ''}
onChange={e => setForm({ ...form, description: e.target.value })}
rows={2}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div className="space-y-2 pt-2 border-t border-white/10">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.visible_to_parents ?? true}
onChange={e => setForm({ ...form, visible_to_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern sehen diesen Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_parents ?? false}
onChange={e => setForm({ ...form, notify_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern per Mail/Chat erinnern
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_students ?? false}
onChange={e => setForm({ ...form, notify_students: e.target.checked })}
className="w-5 h-5"
/>
Schueler per Chat erinnern
</label>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={saving}
data-testid="event-save"
className="flex-1 px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Anlegen'}
</button>
<button type="button" onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'}`}>
Abbrechen
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,201 @@
'use client'
import { useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
interface MonthViewProps {
year: number
month: number // 1-12
holidays: PublicEvent[]
schoolEvents?: SchoolEvent[]
onPrev: () => void
onNext: () => void
onToday: () => void
onDayClick?: (iso: string) => void
onAddEvent?: () => void
onRollover?: () => void
}
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_DE = [
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
interface Cell {
date: Date
inMonth: boolean
events: PublicEvent[]
}
function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): Cell[] {
// First Monday on or before the 1st of the month.
const first = new Date(Date.UTC(year, month - 1, 1))
const firstWeekday = (first.getUTCDay() + 6) % 7 // Monday = 0
const start = new Date(first)
start.setUTCDate(first.getUTCDate() - firstWeekday)
const cells: Cell[] = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setUTCDate(start.getUTCDate() + i)
const iso = d.toISOString().slice(0, 10)
const events = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
cells.push({
date: d,
inMonth: d.getUTCMonth() === month - 1,
events,
})
if (i >= 27 && d.getUTCMonth() !== month - 1) {
// Stop a row early if the rest is fully outside the month.
const restAllOutside = cells.slice(i + 1 - ((i + 1) % 7), i + 1).every(c => !c.inMonth)
if (restAllOutside) break
}
}
// Pad to multiple of 7 if we cut early.
while (cells.length % 7 !== 0) {
const last = cells[cells.length - 1].date
const d = new Date(last)
d.setUTCDate(last.getUTCDate() + 1)
cells.push({ date: d, inMonth: false, events: [] })
}
return cells
}
export function MonthView({ year, month, holidays, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
const { isDark } = useTheme()
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
// School events per ISO date — quick lookup during cell render.
const schoolEventsByDate = useMemo(() => {
const map = new Map<string, SchoolEvent[]>()
for (const ev of schoolEvents) {
const start = new Date(ev.start_date)
const end = new Date(ev.end_date)
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const iso = d.toISOString().slice(0, 10)
const arr = map.get(iso) || []
arr.push(ev)
map.set(iso, arr)
}
}
return map
}, [schoolEvents])
const headerClass = isDark ? 'text-white' : 'text-slate-900'
const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
const buttonClass = isDark
? 'bg-white/10 text-white/80 hover:bg-white/20'
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
const todayIso = new Date().toISOString().slice(0, 10)
return (
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="month-view">
<div className="flex items-center justify-between mb-4">
<h2 className={`text-2xl font-semibold ${headerClass}`}>
{MONTHS_DE[month - 1]} {year}
</h2>
<div className="flex gap-2">
{onAddEvent && (
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
)}
{onRollover && (
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
)}
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS_DE.map(w => (
<div key={w} className={`text-xs font-medium text-center ${subtleText}`}>{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((c, i) => {
const iso = c.date.toISOString().slice(0, 10)
const isToday = iso === todayIso
const publicHoliday = c.events.find(e => e.event_type === 'public_holiday')
const schoolHoliday = c.events.find(e => e.event_type === 'school_holiday')
let bg = isDark ? 'bg-white/5' : 'bg-slate-50'
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
const dayEvents = schoolEventsByDate.get(iso) || []
return (
<div
key={i}
data-testid={`day-${iso}`}
onClick={() => c.inMonth && onDayClick?.(iso)}
className={`relative aspect-square rounded-lg p-2 text-sm border ${
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
} ${
isDark ? 'border-white/10' : 'border-black/5'
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
}`}
>
<div className={`font-medium ${
c.inMonth ? (isDark ? 'text-white' : 'text-slate-900') : subtleText
}`}>
{c.date.getUTCDate()}
</div>
{c.events.length > 0 && (
<div className="mt-1 space-y-0.5 overflow-hidden">
{c.events.slice(0, 2).map(e => (
<div
key={e.id}
title={e.name_de}
className={`text-[10px] leading-tight truncate ${
e.event_type === 'public_holiday'
? (isDark ? 'text-rose-200' : 'text-rose-800')
: (isDark ? 'text-amber-200' : 'text-amber-800')
}`}
>
{e.name_de}
</div>
))}
{c.events.length > 2 && (
<div className={`text-[10px] ${subtleText}`}>+{c.events.length - 2}</div>
)}
</div>
)}
{dayEvents.length > 0 && (
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
{dayEvents.slice(0, 4).map(ev => (
<span
key={ev.id}
title={ev.title}
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
/>
))}
</div>
)}
</div>
)
})}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
<span className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-rose-500/40' : 'bg-rose-200'}`}></span>
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Feiertag</span>
</span>
<span className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-amber-500/40' : 'bg-amber-200'}`}></span>
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Schulferien</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { NotificationLogRow } from '@/app/schulkalender/types'
interface NotificationStatusProps {
eventId: string
}
const STATUS_ICON: Record<string, string> = {
sent: '✓',
failed: '✗',
skipped: '⏱',
}
export function NotificationStatus({ eventId }: NotificationStatusProps) {
const { isDark } = useTheme()
const [rows, setRows] = useState<NotificationLogRow[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
calendarApi.listEventNotifications(eventId)
.then(r => { setRows(r || []); setLoading(false) })
.catch(() => setLoading(false))
}, [eventId])
if (loading || rows.length === 0) return null
return (
<div className={`mt-2 pt-2 border-t text-xs ${isDark ? 'border-white/10' : 'border-black/10'}`} data-testid={`notif-status-${eventId}`}>
<div className={`font-medium mb-1 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>Erinnerungen</div>
<div className="flex flex-wrap gap-2">
{rows.map((r, i) => (
<span
key={i}
title={r.error_message || `${r.status} ${r.run_date}`}
className={`px-2 py-0.5 rounded ${
r.status === 'sent' ? (isDark ? 'bg-emerald-500/30 text-emerald-100' : 'bg-emerald-100 text-emerald-900') :
r.status === 'failed' ? (isDark ? 'bg-red-500/30 text-red-100' : 'bg-red-100 text-red-900') :
(isDark ? 'bg-amber-500/30 text-amber-100' : 'bg-amber-100 text-amber-900')
}`}
>
{STATUS_ICON[r.status]} {r.lead_days === 0 ? 'Heute' : r.lead_days === 1 ? '1 Tag' : `${r.lead_days} Tage`}
{' · '}{r.audience === 'parents' ? 'Eltern' : 'Schueler'}
{' · '}{r.channel}
</span>
))}
</div>
</div>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import { classesApi } from '@/lib/stundenplan/api'
import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types'
import type { TimetableClass } from '@/app/stundenplan/types'
const LANGS: { code: string; name: string }[] = [
{ code: 'de', name: 'Deutsch' },
{ code: 'en', name: 'English' },
{ code: 'tr', name: 'Tuerkce' },
{ code: 'ar', name: 'العربية' },
{ code: 'uk', name: 'Українська' },
{ code: 'ru', name: 'Русский' },
{ code: 'pl', name: 'Polski' },
{ code: 'fr', name: 'Francais' },
]
export function ParentManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<ParentInviteListItem[]>([])
const [classes, setClasses] = useState<TimetableClass[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [lastInvite, setLastInvite] = useState<InviteParentResponse | null>(null)
const [form, setForm] = useState({
email: '',
preferred_language: 'de',
child_first_name: '',
child_last_name: '',
tt_class_id: '',
})
const load = useCallback(async () => {
setLoading(true)
try {
const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()])
setItems(list || [])
setClasses(cls || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await calendarApi.inviteParent(form)
setLastInvite(res)
setForm({ ...form, child_first_name: '', child_last_name: '' })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (childId: string) => {
if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return
try { await calendarApi.deleteParentChild(childId); await load() }
catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') }
}
const fullLink = (path: string) =>
typeof window === 'undefined' ? path : `${window.location.origin}${path}`
const copyLink = (path: string) => {
navigator.clipboard?.writeText(fullLink(path))
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="parent-manager">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Eltern verwalten ({items.length})</h3>
<button
onClick={() => setShowForm(s => !s)}
disabled={classes.length === 0}
data-testid="parent-invite-toggle"
className={`px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Eltern einladen'}
</button>
</div>
{classes.length === 0 && (
<p className={`text-sm mb-2 ${isDark ? 'text-amber-200' : 'text-amber-900'}`}>
Zuerst Klassen im Stundenplan-Modul anlegen.
</p>
)}
{error && (
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
{showForm && (
<form onSubmit={handleSubmit} className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-2">
<input required type="email" placeholder="Eltern-E-Mail" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<input required placeholder="Vorname Kind" value={form.child_first_name} onChange={e => setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<input required placeholder="Nachname Kind" value={form.child_last_name} onChange={e => setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<select required value={form.tt_class_id} onChange={e => setForm({ ...form, tt_class_id: e.target.value })} data-testid="parent-class" className={`px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> Klasse waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select value={form.preferred_language} onChange={e => setForm({ ...form, preferred_language: e.target.value })} className={`px-3 py-2 rounded-lg border ${inputClass}`}>
{LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
</select>
<button type="submit" disabled={submitting} data-testid="parent-invite-submit" className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Erstellt…' : 'Einladen'}
</button>
</form>
)}
{lastInvite && (
<div className={`mb-3 p-3 rounded-lg ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`} data-testid="parent-invite-link">
<div className="text-sm font-medium mb-1">Einladungs-Link fuer {lastInvite.parent.email}</div>
<div className="flex gap-2 items-center">
<code className={`flex-1 text-xs px-2 py-1 rounded overflow-x-auto ${isDark ? 'bg-white/10' : 'bg-white'}`}>
{fullLink(lastInvite.magic_url)}
</code>
<button onClick={() => copyLink(lastInvite.magic_url)} className="text-xs px-2 py-1 rounded bg-indigo-500 hover:bg-indigo-600 text-white">Kopieren</button>
</div>
<p className="text-xs opacity-70 mt-1">Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}</p>
</div>
)}
{loading ? (
<div className="opacity-60 py-4 text-center text-sm">Laedt</div>
) : items.length === 0 ? (
<div className="opacity-60 py-4 text-center text-sm">Keine eingeladenen Eltern.</div>
) : (
<table className="w-full text-sm">
<thead className={isDark ? 'opacity-70' : 'opacity-70'}>
<tr>
<th className="text-left py-2">E-Mail</th>
<th className="text-left py-2">Kind</th>
<th className="text-left py-2">Klasse</th>
<th className="text-left py-2">Sprache</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(it => (
<tr key={it.child_id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="py-2">{it.email}</td>
<td className="py-2">{it.child_first_name} {it.child_last_name}</td>
<td className="py-2">{it.class_name}</td>
<td className="py-2">{it.preferred_language}</td>
<td className="py-2 text-right">
<button onClick={() => handleDelete(it.child_id)} className="text-xs text-red-400 hover:text-red-300">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { SchoolYearRolloverResult } from '@/app/schulkalender/types'
interface RolloverWizardProps {
onClose: () => void
onDone: () => void
}
function nextSchoolYearISO(): { start: string; end: string } {
const now = new Date()
let y = now.getFullYear()
if (now.getMonth() + 1 >= 8) y++ // Aug → bumped to next year
return { start: `${y}-08-01`, end: `${y + 1}-07-31` }
}
export function RolloverWizard({ onClose, onDone }: RolloverWizardProps) {
const { isDark } = useTheme()
const defaults = nextSchoolYearISO()
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SchoolYearRolloverResult | null>(null)
const expected = 'SCHULJAHR WECHSELN'
const handleSubmit = async () => {
setSaving(true)
setError(null)
try {
const r = await calendarApi.rolloverSchoolYear(start, end)
setResult(r)
} catch (e) {
setError(e instanceof Error ? e.message : 'Rollover fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="rollover-wizard">
<div className={`w-full max-w-xl rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Schuljahres-Wechsel</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{result ? (
<div className="space-y-3" data-testid="rollover-result">
<div className={`p-4 rounded-xl ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`}>
<div className="font-medium mb-2">Rollover erfolgreich</div>
<ul className="text-sm space-y-1 opacity-90">
<li>{result.classes_promoted} Klassen um eine Stufe aufgerueckt</li>
<li>{result.classes_graduated} Abschlussklassen entfernt</li>
<li>Neues Schuljahr: {result.new_year_start} {result.new_year_end}</li>
</ul>
</div>
<button onClick={onDone} className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium">
Schliessen
</button>
</div>
) : (
<>
<div className={`p-3 rounded-lg text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-100' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
<p className="font-medium mb-1">Was passiert?</p>
<ul className="list-disc list-inside space-y-1 opacity-90">
<li>Alle Klassen ruecken eine Stufe hoeher (5a 6, 6a 7, )</li>
<li>Abschlussklassen (Stufe 13) werden entfernt</li>
<li>Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert</li>
<li>Vorhandene Stundenplaene bleiben als Historie erhalten</li>
</ul>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Beginn</label>
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Ende</label>
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">
Bestaetigung tippe <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>{expected}</code> zur Bestaetigung
</label>
<input
value={confirm}
onChange={e => setConfirm(e.target.value)}
data-testid="rollover-confirm"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2">
<button
onClick={handleSubmit}
disabled={saving || confirm !== expected}
data-testid="rollover-submit"
className="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium disabled:opacity-30"
>
{saving ? 'Wechselt…' : 'Schuljahr wechseln'}
</button>
<button onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-100 hover:bg-slate-200'}`}>
Abbrechen
</button>
</div>
</>
)}
</div>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolCalendarConfig, SchoolEvent } from './types'
import { BUNDESLAENDER } from './types'
import { MonthView } from './_components/MonthView'
import { BundeslandWizard } from './_components/BundeslandWizard'
import { EventModal } from './_components/EventModal'
import { DayDetail } from './_components/DayDetail'
import { RolloverWizard } from './_components/RolloverWizard'
import { ParentManager } from './_components/ParentManager'
function monthRange(year: number, month: number): { from: string; to: string } {
// Render the visible 6-week grid worth of holidays (covers prev/next month edges).
const from = new Date(Date.UTC(year, month - 1, 1))
from.setUTCDate(from.getUTCDate() - 7)
const to = new Date(Date.UTC(year, month, 0))
to.setUTCDate(to.getUTCDate() + 14)
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) }
}
export default function SchulkalenderPage() {
const { isDark } = useTheme()
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [config, setConfig] = useState<SchoolCalendarConfig | null>(null)
const [holidays, setHolidays] = useState<PublicEvent[]>([])
const [schoolEvents, setSchoolEvents] = useState<SchoolEvent[]>([])
const [configLoading, setConfigLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openDay, setOpenDay] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showRollover, setShowRollover] = useState(false)
const loadConfig = useCallback(async () => {
setConfigLoading(true)
try {
const cfg = await calendarApi.getConfig()
setConfig(cfg)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Config laden fehlgeschlagen')
} finally {
setConfigLoading(false)
}
}, [])
useEffect(() => { loadConfig() }, [loadConfig])
const loadHolidays = useCallback(async () => {
if (!config?.bundesland) return
const { from, to } = monthRange(year, month)
try {
const [hd, ev] = await Promise.all([
calendarApi.listHolidays(config.bundesland, from, to),
calendarApi.listEvents(from, to),
])
setHolidays(hd || [])
setSchoolEvents(ev || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ferien/Events laden fehlgeschlagen')
}
}, [config, year, month])
useEffect(() => { loadHolidays() }, [loadHolidays])
const handleSaveBundesland = async (bundesland: string) => {
const cfg = await calendarApi.upsertConfig({ bundesland })
setConfig(cfg)
}
const goPrev = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else setMonth(m => m - 1)
}
const goNext = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else setMonth(m => m + 1)
}
const goToday = () => {
const t = new Date()
setYear(t.getFullYear())
setMonth(t.getMonth() + 1)
}
const bundeslandName = config
? BUNDESLAENDER.find(b => b.code === config.bundesland)?.name || config.bundesland
: ''
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
<div className="relative z-10 p-4"><Sidebar selectedTab="schulkalender" /></div>
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
<div className="max-w-6xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Schulkalender
</h1>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{config ? `Ferien und Feiertage fuer ${bundeslandName}` : 'Ferien, Feiertage und Schultermine'}
</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</header>
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>
)}
{configLoading ? (
<div className={`text-center py-12 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt</div>
) : !config ? (
<BundeslandWizard onSave={handleSaveBundesland} />
) : (
<>
<MonthView
year={year}
month={month}
holidays={holidays}
schoolEvents={schoolEvents}
onPrev={goPrev}
onNext={goNext}
onToday={goToday}
onDayClick={(iso) => setOpenDay(iso)}
onAddEvent={() => setShowAddModal(true)}
onRollover={() => setShowRollover(true)}
/>
{openDay && (
<DayDetail
iso={openDay}
holidays={holidays}
events={schoolEvents}
onClose={() => setOpenDay(null)}
onDeleted={() => { loadHolidays(); setOpenDay(null) }}
/>
)}
{showAddModal && (
<EventModal
defaultDate={openDay || new Date().toISOString().slice(0, 10)}
onClose={() => setShowAddModal(false)}
onCreated={() => { setShowAddModal(false); loadHolidays() }}
/>
)}
{showRollover && (
<RolloverWizard
onClose={() => setShowRollover(false)}
onDone={() => { setShowRollover(false); loadHolidays() }}
/>
)}
<div className="mt-6">
<ParentManager />
</div>
</>
)}
</div>
</main>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
export type PublicEventType = 'public_holiday' | 'school_holiday'
export interface PublicEvent {
id: string
region: string
event_type: PublicEventType
name_de: string
name_en?: string
start_date: string // YYYY-MM-DD
end_date: string
source?: string
created_at?: string
}
export interface SchoolCalendarConfig {
user_id: string
bundesland: string
school_year_start?: string | null
school_year_end?: string | null
created_at?: string
updated_at?: string
}
export interface UpsertSchoolCalendarConfig {
bundesland: string
school_year_start?: string | null
school_year_end?: string | null
}
export type SchoolEventType =
| 'fortbildung'
| 'schulfeier'
| 'klassenfahrt'
| 'projekttag'
| 'eltern_info'
| 'andere'
export interface SchoolEvent {
id: string
created_by_user_id: string
title: string
description?: string
event_type: SchoolEventType
is_school_free: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids: string[]
visible_to_parents: boolean
notify_parents: boolean
notify_students: boolean
notification_lead_days: number[]
created_at?: string
updated_at?: string
}
export interface CreateSchoolEvent {
title: string
description?: string
event_type: SchoolEventType
is_school_free?: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids?: string[]
visible_to_parents?: boolean
notify_parents?: boolean
notify_students?: boolean
notification_lead_days?: number[]
}
export interface SchoolYearRolloverResult {
classes_promoted: number
classes_graduated: number
new_year_start: string
new_year_end: string
}
export const EVENT_TYPE_LABEL: Record<SchoolEventType, string> = {
fortbildung: 'Fortbildung',
schulfeier: 'Schulfeier',
klassenfahrt: 'Klassenfahrt',
projekttag: 'Projekttag',
eltern_info: 'Eltern-Info',
andere: 'Andere',
}
export const EVENT_TYPE_COLOR: Record<SchoolEventType, string> = {
fortbildung: '#0ea5e9',
schulfeier: '#a855f7',
klassenfahrt: '#22c55e',
projekttag: '#f59e0b',
eltern_info: '#ec4899',
andere: '#64748b',
}
export const BUNDESLAENDER: { code: string; name: string }[] = [
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' },
{ code: 'DE-BY', name: 'Bayern' },
{ code: 'DE-BE', name: 'Berlin' },
{ code: 'DE-BB', name: 'Brandenburg' },
{ code: 'DE-HB', name: 'Bremen' },
{ code: 'DE-HH', name: 'Hamburg' },
{ code: 'DE-HE', name: 'Hessen' },
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'DE-NI', name: 'Niedersachsen' },
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
{ code: 'DE-SL', name: 'Saarland' },
{ code: 'DE-SN', name: 'Sachsen' },
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
{ code: 'DE-TH', name: 'Thueringen' },
]
// ---------- Parent invitations (Phase 9c) ----------
export interface ParentAccount {
id: string
email: string
preferred_language: string
}
export interface ParentChild {
id: string
parent_id: string
tt_class_id: string
first_name: string
last_name: string
class_name?: string
}
export interface ParentInviteListItem {
parent_id: string
email: string
preferred_language: string
child_id: string
child_first_name: string
child_last_name: string
class_id: string
class_name: string
created_at: string
}
export interface InviteParentRequest {
email: string
preferred_language?: string
child_first_name: string
child_last_name: string
tt_class_id: string
}
export interface InviteParentResponse {
parent: ParentAccount
child: ParentChild
magic_token: string
magic_url: string
expires_at: string
}
// ---------- Notifications (Phase 9d) ----------
export type NotificationStatus = 'sent' | 'failed' | 'skipped'
export interface NotificationLogRow {
lead_days: number
audience: 'parents' | 'students'
channel: 'matrix' | 'email'
status: NotificationStatus
error_message?: string
run_date: string
created_at: string
}
export interface NotificationRunResult {
date: string
sent: number
failed: number
skipped: number
already_logged: number
}
@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
const STEPS: { title: string; body: string }[] = [
{
title: '1. Klassen, Lehrer, Faecher, Raeume anlegen',
body: 'Stammdaten zuerst — der Solver kann nur scheduln was er kennt. Ohne mindestens 1 Klasse, 1 Fach, 1 Raum und 1 Lehrer wird der Plan leer.',
},
{
title: '2. Zeitraster definieren',
body: 'Wochentag + Stundennummer + Start/Ende fuer jeden Slot. Pausen anhaken; der Solver belegt sie nicht.',
},
{
title: '3. Stundentafel + Lehrauftraege',
body: 'Stundentafel: pro Klasse, wie viele Wochenstunden welches Fach. Lehrauftraege: welcher Lehrer unterrichtet welches Fach in welcher Klasse. Ohne Lehrauftrag wird die Lesson uebersprungen.',
},
{
title: '4. Regeln (Constraints) — optional',
body: 'Lehrer-Abwesenheiten, Fach-Bevorzugungen, Raum-Sperren. Hart-Regeln muss der Solver einhalten, Soft-Regeln werden gewichtet.',
},
{
title: '5. Plan generieren',
body: 'Zurueck auf den Plan-Tab → "Neuen Plan generieren". Der Solver laeuft im Hintergrund (bis zu 60 s) und schreibt das Ergebnis direkt in die Datenbank. Status erscheint live in der Liste.',
},
{
title: '6. Cells anpinnen + Re-Solve',
body: 'Im Wochengrid einzelne Stunden anpinnen (Schloss-Icon). Beim naechsten Solve mit dem Plan als "Basieren auf"-Quelle bleiben die gepinnten Cells stehen, alles andere wird neu gerechnet.',
},
]
export function HelpPanel() {
const { isDark } = useTheme()
const [open, setOpen] = useState(false)
return (
<div
className={`mb-4 rounded-2xl border backdrop-blur-xl ${
isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
}`}
data-testid="help-panel"
>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 text-left"
>
<span className="flex items-center gap-2 font-medium">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Bedienungsanleitung
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
(6 Schritte vom Setup bis zum fertigen Stundenplan)
</span>
</span>
<span className={`text-sm transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className={`px-4 pb-4 space-y-3 border-t ${isDark ? 'border-white/10' : 'border-black/10'}`}>
{STEPS.map((s, i) => (
<div key={i} className="pt-3">
<h4 className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{s.title}</h4>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{s.body}</p>
</div>
))}
<p className={`pt-3 text-xs italic ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
Tipp: Solver-Probleme im Status-Feld der Plan-Liste &quot;Keine Lessons&quot; heisst meistens fehlende
Lehrauftraege; &quot;Nicht loesbar&quot; heisst harte Constraints widersprechen sich.
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { SolutionList } from './SolutionList'
import { PlanView } from './PlanView'
export function PlanHub() {
const { isDark } = useTheme()
const [selected, setSelected] = useState<string | null>(null)
return (
<div className="space-y-6" data-testid="plan-hub">
<SolutionList onView={setSelected} selectedId={selected} />
{selected ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Plan-Ansicht
</h3>
<button
onClick={() => setSelected(null)}
className={`text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}
>
Schliessen
</button>
</div>
<PlanView solutionId={selected} />
</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl p-6 text-center text-sm ${
isDark ? 'bg-white/5 border-white/10 text-white/60' : 'bg-white/80 border-black/10 text-slate-500'
}`}>
Waehle einen abgeschlossenen Plan oben, um die Wochenansicht zu sehen.
</div>
)}
</div>
)
}
@@ -0,0 +1,263 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { solutionsApi, subjectsApi, lessonsApi, downloadSolutionExport } from '@/lib/stundenplan/api'
import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types'
interface PlanViewProps {
solutionId: string
}
const DAYS = [
{ v: 1, label: 'Mo' },
{ v: 2, label: 'Di' },
{ v: 3, label: 'Mi' },
{ v: 4, label: 'Do' },
{ v: 5, label: 'Fr' },
]
type Perspective = 'class' | 'teacher' | 'room'
const PERSPECTIVE_LABEL: Record<Perspective, string> = {
class: 'Klasse',
teacher: 'Lehrer',
room: 'Raum',
}
interface Resource {
id: string
label: string
}
export function PlanView({ solutionId }: PlanViewProps) {
const { isDark } = useTheme()
const [lessons, setLessons] = useState<TimetableLesson[]>([])
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [perspective, setPerspective] = useState<Perspective>('class')
const [selectedResource, setSelectedResource] = useState<string>('')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [ls, sub] = await Promise.all([
solutionsApi.lessons(solutionId),
subjectsApi.list(),
])
setLessons(ls || [])
setSubjects(sub || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [solutionId])
useEffect(() => { load() }, [load])
// Unique resources for the chosen perspective.
const resources: Resource[] = useMemo(() => {
const seen = new Map<string, Resource>()
for (const l of lessons) {
let id = ''
let label = ''
if (perspective === 'class') {
id = l.class_id
label = l.class_name || id.slice(0, 8)
} else if (perspective === 'teacher') {
id = l.teacher_id
label = l.teacher_name || id.slice(0, 8)
} else if (perspective === 'room') {
id = l.room_id || 'kein-raum'
label = l.room_name || (l.room_id ? l.room_id.slice(0, 8) : '— kein Raum —')
}
if (!seen.has(id)) seen.set(id, { id, label })
}
return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label))
}, [lessons, perspective])
// Reset selected resource when perspective changes or list refreshes.
useEffect(() => {
if (resources.length > 0 && !resources.some(r => r.id === selectedResource)) {
setSelectedResource(resources[0].id)
}
}, [resources, selectedResource])
const visibleLessons = useMemo(() => {
if (!selectedResource) return []
return lessons.filter(l => {
if (perspective === 'class') return l.class_id === selectedResource
if (perspective === 'teacher') return l.teacher_id === selectedResource
return (l.room_id || 'kein-raum') === selectedResource
})
}, [lessons, perspective, selectedResource])
const subjectColor = useCallback((id: string): string => {
const s = subjects.find(x => x.id === id)
return s?.color || (isDark ? '#475569' : '#cbd5e1')
}, [subjects, isDark])
const periodIndices = useMemo(() => {
const set = new Set<number>()
for (const l of lessons) set.add(l.period_index)
return Array.from(set).sort((a, b) => a - b)
}, [lessons])
const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined =>
visibleLessons.find(l => l.day_of_week === day && l.period_index === periodIdx)
const togglePin = useCallback(async (lesson: TimetableLesson) => {
// Optimistic update so the lock icon flips immediately even if the
// server is slow.
setLessons(prev => prev.map(l => l.id === lesson.id ? { ...l, pinned: !l.pinned } : l))
try {
await lessonsApi.pin(lesson.id, !lesson.pinned)
} catch (e) {
// Revert on failure and surface the error.
setLessons(prev => prev.map(l => l.id === lesson.id ? { ...l, pinned: lesson.pinned } : l))
setError(e instanceof Error ? e.message : 'Pin fehlgeschlagen')
}
}, [])
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'
const handleExport = (fmt: 'csv' | 'ics') => {
downloadSolutionExport(solutionId, fmt).catch(e =>
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen'),
)
}
return (
<div className="space-y-4" data-testid="plan-view">
<div className={`p-4 rounded-2xl border backdrop-blur-xl no-print ${cardClass}`}>
<div className="flex flex-wrap items-center gap-3">
<div>
<label className="block text-xs mb-1 opacity-70">Perspektive</label>
<div className="flex gap-1">
{(['class', 'teacher', 'room'] as Perspective[]).map(p => (
<button
key={p}
onClick={() => setPerspective(p)}
data-testid={`perspective-${p}`}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
perspective === p
? isDark ? 'bg-indigo-500 text-white' : 'bg-indigo-600 text-white'
: isDark ? 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{PERSPECTIVE_LABEL[p]}
</button>
))}
</div>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs mb-1 opacity-70">{PERSPECTIVE_LABEL[perspective]}</label>
<select value={selectedResource} onChange={e => setSelectedResource(e.target.value)} className={`w-full px-3 py-1.5 rounded-lg border ${selectClass}`}>
{resources.length === 0 && <option value=""> keine Daten </option>}
{resources.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs mb-1 opacity-70">Export</label>
<div className="flex gap-1">
<button
onClick={() => handleExport('csv')}
data-testid="export-csv"
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${isDark ? 'bg-white/10 text-white/80 hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}
>
CSV
</button>
<button
onClick={() => handleExport('ics')}
data-testid="export-ics"
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${isDark ? 'bg-white/10 text-white/80 hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}
>
ICS
</button>
<button
onClick={() => window.print()}
data-testid="export-print"
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${isDark ? 'bg-white/10 text-white/80 hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}
>
Drucken
</button>
</div>
</div>
</div>
</div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt</div>
) : lessons.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>
Keine Lessons in diesem Plan.
</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-3 py-3 text-sm font-medium opacity-70 w-16">Stunde</th>
{DAYS.map(d => (
<th key={d.v} className="text-left px-3 py-3 text-sm font-medium opacity-70">{d.label}</th>
))}
</tr>
</thead>
<tbody>
{periodIndices.map(idx => (
<tr key={idx} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-3 py-2 font-medium text-sm">{idx}.</td>
{DAYS.map(d => {
const lesson = cellLesson(d.v, idx)
if (!lesson) {
return <td key={d.v} className="px-3 py-2 opacity-20 text-xs"></td>
}
const color = subjectColor(lesson.subject_id)
return (
<td key={d.v} className="px-2 py-1">
<div
className={`rounded-md p-2 text-xs space-y-0.5 relative ${lesson.pinned ? 'ring-2 ring-amber-400/70' : ''}`}
style={{ backgroundColor: color + (isDark ? '40' : '30'), borderLeft: `3px solid ${color}` }}
data-testid={`cell-${d.v}-${idx}`}
>
<button
onClick={() => togglePin(lesson)}
data-testid={`pin-${lesson.id}`}
title={lesson.pinned ? 'Lesson loesen' : 'Lesson anpinnen'}
className={`absolute top-1 right-1 text-xs leading-none px-1 py-0.5 rounded ${
lesson.pinned
? 'text-amber-300 hover:text-amber-200'
: 'opacity-30 hover:opacity-100'
}`}
>
{lesson.pinned ? '🔒' : '📌'}
</button>
<div className="font-semibold pr-5">{lesson.subject_name || '?'}</div>
{perspective !== 'class' && lesson.class_name && (
<div className="opacity-80">{lesson.class_name}</div>
)}
{perspective !== 'teacher' && lesson.teacher_name && (
<div className="opacity-70">{lesson.teacher_name.split(',')[0]}</div>
)}
{perspective !== 'room' && lesson.room_name && (
<div className="opacity-60">{lesson.room_name}</div>
)}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,209 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { solutionsApi } from '@/lib/stundenplan/api'
import type { TimetableSolution, SolutionStatus } from '@/app/stundenplan/types'
interface SolutionListProps {
onView: (solutionId: string) => void
selectedId?: string | null
}
const STATUS_LABEL: Record<SolutionStatus, string> = {
pending: 'Wartet',
running: 'Laeuft',
completed: 'Fertig',
failed: 'Fehler',
infeasible: 'Nicht loesbar',
}
const STATUS_BADGE: Record<SolutionStatus, string> = {
pending: 'bg-slate-500/30 text-slate-200',
running: 'bg-blue-500/30 text-blue-200',
completed: 'bg-emerald-500/30 text-emerald-200',
failed: 'bg-red-500/30 text-red-200',
infeasible: 'bg-amber-500/30 text-amber-200',
}
export function SolutionList({ onView, selectedId }: SolutionListProps) {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetableSolution[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [name, setName] = useState('')
const [parentId, setParentId] = useState<string>('')
const [secondsLimit, setSecondsLimit] = useState<number | ''>('')
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
try {
const data = await solutionsApi.list()
setItems(data || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// Poll every 4 s while at least one solution is pending/running.
useEffect(() => {
const inFlight = items.some(s => s.status === 'pending' || s.status === 'running')
if (inFlight && pollingRef.current === null) {
pollingRef.current = setInterval(load, 4000)
} else if (!inFlight && pollingRef.current !== null) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
}, [items, load])
const handleSolve = async () => {
setSubmitting(true)
setError(null)
try {
await solutionsApi.create({
name: name || `Plan ${new Date().toLocaleString('de-DE')}`,
parent_solution_id: parentId || null,
seconds_limit: secondsLimit === '' ? null : Number(secondsLimit),
})
setName('')
setParentId('')
setSecondsLimit('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Solve fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Plan wirklich loeschen? Alle Lessons gehen verloren.')) return
try {
await solutionsApi.remove(id)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
}
}
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 placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400'
const completedParents = items.filter(s => s.status === 'completed')
return (
<div className="space-y-4" data-testid="solution-list">
<div className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="md:col-span-2">
<label className="block text-sm mb-1 opacity-70">Name (optional)</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Basieren auf (optional)</label>
<select
value={parentId}
onChange={e => setParentId(e.target.value)}
disabled={completedParents.length === 0}
data-testid="parent-selector"
className={`w-full px-3 py-2 rounded-lg border disabled:opacity-50 ${inputClass}`}
>
<option value=""> ohne Vorlage </option>
{completedParents.map(p => (
<option key={p.id} value={p.id}>{p.name || p.id.slice(0, 8)}</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Sekunden-Limit</label>
<input
type="number"
min={5}
max={600}
value={secondsLimit}
onChange={e => setSecondsLimit(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="60"
data-testid="seconds-limit"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
Mit Vorlage uebernimmt der Solver alle gepinnten Cells aus dem Quellplan.
</p>
<button
onClick={handleSolve}
disabled={submitting}
data-testid="solve-trigger"
className="px-5 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{submitting ? 'Startet…' : 'Neuen Plan generieren'}
</button>
</div>
</div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt</div>
) : items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Noch keine Plaene generiert.</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Name</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Status</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Score</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Erstellt</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(sol => {
const isSelected = sol.id === selectedId
return (
<tr key={sol.id} className={`${isDark ? 'border-t border-white/10' : 'border-t border-slate-200'} ${isSelected ? (isDark ? 'bg-indigo-500/10' : 'bg-indigo-50') : ''}`}>
<td className="px-4 py-3 font-medium">{sol.name || sol.id.slice(0, 8) + '…'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-md ${STATUS_BADGE[sol.status]}`}>{STATUS_LABEL[sol.status]}</span>
{sol.error_message && <span className="ml-2 text-xs opacity-70" title={sol.error_message}></span>}
</td>
<td className="px-4 py-3 text-sm">
{sol.hard_score !== null && sol.hard_score !== undefined
? `${sol.hard_score}H / ${sol.soft_score ?? 0}S`
: '—'}
</td>
<td className="px-4 py-3 text-sm opacity-70">{new Date(sol.created_at).toLocaleString('de-DE')}</td>
<td className="px-4 py-3 text-right space-x-3 whitespace-nowrap">
{sol.status === 'completed' && (
<button onClick={() => onView(sol.id)} className="text-indigo-300 hover:text-indigo-200 text-sm font-medium">
{isSelected ? 'Ausgewaehlt' : 'Anzeigen'}
</button>
)}
<button onClick={() => handleDelete(sol.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
+113 -77
View File
@@ -13,11 +13,14 @@ import { PeriodsManager } from './_components/PeriodsManager'
import { CurriculumManager } from './_components/CurriculumManager'
import { AssignmentsManager } from './_components/AssignmentsManager'
import { RegelnHub } from './_components/regeln/RegelnHub'
import { PlanHub } from './_components/plan/PlanHub'
import { HelpPanel } from './_components/HelpPanel'
import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api'
type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
const TABS: { id: Tab; label: string }[] = [
{ id: 'plan', label: 'Plan' },
{ id: 'klassen', label: 'Klassen' },
{ id: 'lehrer', label: 'Lehrer' },
{ id: 'faecher', label: 'Faecher' },
@@ -30,93 +33,126 @@ const TABS: { id: Tab; label: string }[] = [
export default function StundenplanPage() {
const { isDark } = useTheme()
const [tab, setTab] = useState<Tab>('klassen')
const [tab, setTab] = useState<Tab>('plan')
const [token, setToken] = useState(getStundenplanToken())
const [tokenSaved, setTokenSaved] = useState(false)
const handleSaveToken = () => {
setStundenplanToken(token)
alert('Token gespeichert. Seite neu laden um die Aenderung zu uebernehmen.')
setTokenSaved(true)
setTimeout(() => setTokenSaved(false), 2500)
}
return (
<div className={`flex min-h-screen p-4 gap-4 ${isDark ? 'bg-slate-900' : 'bg-slate-50'}`}>
<Sidebar selectedTab="stundenplan" />
<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'
}`}>
{/* Background blobs — same effect as /korrektur to keep the visual
language consistent across studio-v2 pages. */}
<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'}`} />
<main className="flex-1 max-w-7xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Stundenplan
</h1>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Stammdaten und Regeln fuer den Solver
</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</header>
<div className="relative z-10 p-4"><Sidebar selectedTab="stundenplan" /></div>
<div
className={`mb-4 p-3 rounded-xl border backdrop-blur-xl ${
isDark ? 'bg-amber-500/10 border-amber-500/30 text-amber-200' : 'bg-amber-50 border-amber-200 text-amber-900'
}`}
>
<details>
<summary className="cursor-pointer text-sm font-medium">
Dev: JWT-Token setzen (Anmeldung noch nicht integriert)
</summary>
<div className="mt-2 flex gap-2">
<input
type="password"
value={token}
onChange={e => setToken(e.target.value)}
placeholder="Bearer-Token"
className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${
isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900'
}`}
/>
<button
onClick={handleSaveToken}
className="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium"
>
Speichern
</button>
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
<div className="max-w-7xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Stundenplan
</h1>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan
</p>
</div>
</details>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</header>
<HelpPanel />
<div
className={`mb-4 p-3 rounded-xl border backdrop-blur-xl text-sm ${
isDark ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-200' : 'bg-emerald-50 border-emerald-200 text-emerald-900'
}`}
>
<details>
<summary className="cursor-pointer font-medium">
Testumgebung Anmeldung deaktiviert
</summary>
<div className="mt-2 space-y-2">
<p>
Der school-service laeuft im Development-Mode und akzeptiert Requests
ohne JWT. Alle Aktionen werden einem festen Dev-User
zugeordnet (<code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-emerald-100'}`}>00000000-0000-0000-0000-000000000001</code>).
</p>
<p>
Fuer Production muss <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-emerald-100'}`}>ENVIRONMENT=production</code> gesetzt werden dann ist ein gueltiger
JWT in jedem Request Pflicht.
</p>
<details className="opacity-70">
<summary className="cursor-pointer text-xs">Manueller Token (falls noetig)</summary>
<div className="mt-2 flex gap-2">
<input
type="password"
value={token}
onChange={e => setToken(e.target.value)}
placeholder="Bearer-Token (optional)"
className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${
isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900'
}`}
/>
<button
onClick={handleSaveToken}
className="px-3 py-1.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium"
>
Speichern
</button>
</div>
{tokenSaved && (
<p className="mt-1 text-xs opacity-90">Token gespeichert. Seite neu laden.</p>
)}
</details>
</div>
</details>
</div>
<nav className="flex flex-wrap gap-2 mb-6">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
tab === t.id
? isDark
? 'bg-white/20 text-white shadow-lg'
: 'bg-indigo-600 text-white shadow-lg'
: isDark
? 'bg-white/5 text-white/70 hover:bg-white/15'
: 'bg-white/70 text-slate-700 hover:bg-white border border-slate-200'
}`}
>
{t.label}
</button>
))}
</nav>
<section>
{tab === 'plan' && <PlanHub />}
{tab === 'klassen' && <KlassenManager />}
{tab === 'lehrer' && <LehrerManager />}
{tab === 'faecher' && <FaecherManager />}
{tab === 'raeume' && <RaeumeManager />}
{tab === 'periods' && <PeriodsManager />}
{tab === 'curriculum' && <CurriculumManager />}
{tab === 'assignments' && <AssignmentsManager />}
{tab === 'regeln' && <RegelnHub />}
</section>
</div>
<nav className="flex flex-wrap gap-2 mb-6">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
tab === t.id
? isDark
? 'bg-white/20 text-white'
: 'bg-indigo-100 text-indigo-900'
: isDark
? 'bg-white/5 text-white/70 hover:bg-white/10'
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
}`}
>
{t.label}
</button>
))}
</nav>
<section>
{tab === 'klassen' && <KlassenManager />}
{tab === 'lehrer' && <LehrerManager />}
{tab === 'faecher' && <FaecherManager />}
{tab === 'raeume' && <RaeumeManager />}
{tab === 'periods' && <PeriodsManager />}
{tab === 'curriculum' && <CurriculumManager />}
{tab === 'assignments' && <AssignmentsManager />}
{tab === 'regeln' && <RegelnHub />}
</section>
</main>
</div>
)
+42
View File
@@ -224,3 +224,45 @@ export interface RoomUnavailable extends ConstraintBase {
day_of_week: number
period_index: number
}
// ---------- Solutions (Phase 5+) ----------
export type SolutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'infeasible'
export interface TimetableSolution {
id: string
created_by_user_id: string
name?: string
status: SolutionStatus
hard_score?: number | null
soft_score?: number | null
error_message?: string
started_at?: string | null
finished_at?: string | null
created_at: string
parent_solution_id?: string | null
seconds_limit?: number | null
}
export interface TimetableLesson {
id: string
solution_id: string
class_id: string
subject_id: string
teacher_id: string
room_id?: string | null
day_of_week: number
period_index: number
pinned: boolean
created_at: string
class_name?: string
subject_name?: string
teacher_name?: string
room_name?: string
}
export interface CreateTimetableSolution {
name?: string
parent_solution_id?: string | null
seconds_limit?: number | null
}
+9
View File
@@ -18,6 +18,7 @@ const NAV_LABELS: Record<string, Record<string, string>> = {
nav_woerterbuch: { de: 'Woerterbuch', en: 'Dictionary', tr: 'Sozluk', ar: '\u0627\u0644\u0642\u0627\u0645\u0648\u0633', uk: '\u0421\u043b\u043e\u0432\u043d\u0438\u043a', ru: '\u0421\u043b\u043e\u0432\u0430\u0440\u044c', pl: 'Slownik', fr: 'Dictionnaire', es: 'Diccionario', it: 'Dizionario', pt: 'Dicionario', nl: 'Woordenboek', ro: 'Dictionar', el: '\u039b\u03b5\u03be\u03b9\u03ba\u03cc', bg: '\u0420\u0435\u0447\u043d\u0438\u043a', hr: 'Rjecnik', cs: 'Slovnik', hu: 'Szotar', sv: 'Ordbok', da: 'Ordbog', fi: 'Sanakirja', sk: 'Slovnik', sl: 'Slovar', lt: 'Zodynas', lv: 'Vardnica', et: 'Sonaraamat' },
nav_meet: { de: 'Videokonferenz', en: 'Video Call', tr: 'Gorusme', ar: '\u0645\u0643\u0627\u0644\u0645\u0629', uk: '\u0412\u0456\u0434\u0435\u043e\u0434\u0437\u0432\u0456\u043d\u043e\u043a', ru: '\u0412\u0438\u0434\u0435\u043e\u0437\u0432\u043e\u043d\u043e\u043a', pl: 'Wideorozmowa', fr: 'Visioconference', es: 'Videollamada', it: 'Videochiamata', pt: 'Videochamada', nl: 'Videogesprek', ro: 'Videoconferinta', el: '\u0392\u03b9\u03bd\u03c4\u03b5\u03bf\u03ba\u03bb\u03ae\u03c3\u03b7', bg: '\u0412\u0438\u0434\u0435\u043e\u0440\u0430\u0437\u0433\u043e\u0432\u043e\u0440', hr: 'Videopoziv', cs: 'Videohovor', hu: 'Videohivas', sv: 'Videosamtal', da: 'Videoopkald', fi: 'Videopuhelu', sk: 'Videohovor', sl: 'Videoklic', lt: 'Vaizdo skambutis', lv: 'Videozvans', et: 'Videokoone' },
nav_stundenplan: { de: 'Stundenplan', en: 'Timetable', tr: 'Ders Programi', ar: 'جدول حصص', uk: 'Розклад', ru: 'Расписание', pl: 'Plan lekcji', fr: 'Emploi du temps', es: 'Horario', it: 'Orario', pt: 'Horario', nl: 'Rooster', ro: 'Orar', el: 'Πρόγραμμα', bg: 'Разписание', hr: 'Raspored', cs: 'Rozvrh', hu: 'Orarend', sv: 'Schema', da: 'Skema', fi: 'Lukujarjestys', sk: 'Rozvrh', sl: 'Urnik', lt: 'Tvarkarastis', lv: 'Stundu saraksts', et: 'Tunniplaan' },
nav_schulkalender: { de: 'Schulkalender', en: 'School Calendar', tr: 'Okul Takvimi', ar: 'تقويم المدرسة', uk: 'Шкільний календар', ru: 'Школьный календарь', pl: 'Kalendarz szkolny', fr: 'Calendrier scolaire', es: 'Calendario escolar', it: 'Calendario scolastico', pt: 'Calendario escolar', nl: 'Schoolkalender', ro: 'Calendar scolar', el: 'Σχολικό ημερολόγιο', bg: 'Училищен календар', hr: 'Skolski kalendar', cs: 'Skolni kalendar', hu: 'Iskolai naptar', sv: 'Skolkalender', da: 'Skolekalender', fi: 'Koulukalenteri', sk: 'Skolsky kalendar', sl: 'Solski koledar', lt: 'Mokyklos kalendorius', lv: 'Skolas kalendars', et: 'Koolikalender' },
nav_companion: { de: 'KI-Assistent', en: 'AI Assistant', tr: 'Yapay Zeka', ar: '\u0645\u0633\u0627\u0639\u062f \u0630\u0643\u064a', uk: '\u0428\u0406-\u0430\u0441\u0438\u0441\u0442\u0435\u043d\u0442', ru: '\u0418\u0418-\u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442', pl: 'Asystent AI', fr: 'Assistant IA', es: 'Asistente IA', it: 'Assistente IA', pt: 'Assistente IA', nl: 'AI-assistent', ro: 'Asistent AI', el: 'AI \u0392\u03bf\u03b7\u03b8\u03cc\u03c2', bg: 'AI \u0430\u0441\u0438\u0441\u0442\u0435\u043d\u0442', hr: 'AI pomoenik', cs: 'AI asistent', hu: 'AI asszisztens', sv: 'AI-assistent', da: 'AI-assistent', fi: 'Tekoalyavustaja', sk: 'AI asistent', sl: 'AI pomoenik', lt: 'DI asistentas', lv: 'MI paligs', et: 'Tehisabiabi' },
}
@@ -111,6 +112,13 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)},
{ id: 'schulkalender', labelKey: 'nav_schulkalender', href: '/schulkalender', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3M3 11h18M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<circle cx="8" cy="15" r="1.5" fill="currentColor" />
<circle cx="16" cy="15" r="1.5" fill="currentColor" />
</svg>
)},
{ id: 'companion', labelKey: 'nav_companion', href: '/companion', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
@@ -158,6 +166,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
if (pathname === '/messages') return 'messages'
if (pathname?.startsWith('/korrektur')) return 'korrektur'
if (pathname?.startsWith('/stundenplan')) return 'stundenplan'
if (pathname?.startsWith('/schulkalender')) return 'schulkalender'
return selectedTab
}
+156
View File
@@ -0,0 +1,156 @@
import { Page } from '@playwright/test'
/**
* Shared mock helper for the /stundenplan suite. Intercepts every endpoint
* the school-service proxy serves so tests stay hermetic.
*/
export const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111'
export const MOCK_SUBJECT_ID = '22222222-2222-2222-2222-222222222222'
export const MOCK_CLASS_ID = '33333333-3333-3333-3333-333333333333'
export interface MockClass {
id: string
name: string
grade_level: number
student_count: number
notes?: string
created_by_user_id: string
created_at: string
}
export interface MockOpts {
classes?: MockClass[]
teachers?: unknown[]
subjects?: unknown[]
rooms?: unknown[]
periods?: unknown[]
curriculum?: unknown[]
assignments?: unknown[]
solutions?: unknown[]
lessons?: unknown[]
}
export async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
const classes = opts.classes ?? []
const teachers = opts.teachers ?? []
const subjects = opts.subjects ?? []
const rooms = opts.rooms ?? []
const periods = opts.periods ?? []
const curriculum = opts.curriculum ?? []
const assignments = opts.assignments ?? []
const solutions = opts.solutions ?? []
const lessons = opts.lessons ?? []
await page.route('**/api/school/timetable/classes', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(classes) })
}
if (route.request().method() === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
const created: MockClass = {
id: 'new-class-id',
name: body.name,
grade_level: body.grade_level,
student_count: body.student_count ?? 0,
notes: body.notes,
created_by_user_id: 'test-user',
created_at: new Date().toISOString(),
}
classes.push(created)
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) })
}
return route.fulfill({ status: 405 })
})
const staticList = (path: string, data: unknown) =>
page.route(`**/api/school/timetable/${path}`, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }))
await staticList('teachers', teachers)
await staticList('subjects', subjects)
await staticList('rooms', rooms)
await staticList('periods', periods)
await staticList('curriculum', curriculum)
await staticList('assignments', assignments)
for (const path of [
'constraints/teacher/unavailable-day',
'constraints/teacher/unavailable-window',
'constraints/teacher/max-hours-day',
'constraints/teacher/max-hours-week',
'constraints/teacher/excluded-subject',
'constraints/teacher/excluded-room',
'constraints/subject/max-consecutive',
'constraints/subject/preferred-period',
'constraints/subject/min-day-gap',
'constraints/subject/contiguous-when-repeated',
'constraints/subject/double-lesson',
'constraints/class/max-hours-day',
'constraints/class/no-gaps',
'constraints/room/requires-type',
'constraints/room/unavailable',
]) {
await staticList(path, [])
}
await page.route('**/api/school/timetable/solutions', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(solutions) })
}
if (route.request().method() === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
const created = {
id: 'new-solution-id',
created_by_user_id: 'test-user',
name: body.name || 'Plan',
status: 'pending',
hard_score: null,
soft_score: null,
created_at: new Date().toISOString(),
}
;(solutions as unknown[]).push(created)
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) })
})
// Phase 7: lesson-level pin toggle.
await page.route(/\/api\/school\/timetable\/lessons\/[^/]+\/pin$/, async (route) => {
if (route.request().method() !== 'PUT') return route.fulfill({ status: 405 })
const body = JSON.parse(route.request().postData() || '{}')
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ message: 'ok', pinned: body.pinned ?? false }),
})
})
// Phase 8: CSV + ICS exports. Routed BEFORE the generic /solutions/:id
// catch-all so the .csv / .ics suffix path is matched first.
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/export\.csv$/, async (route) => {
return route.fulfill({
status: 200, contentType: 'text/csv',
body: 'day_of_week,period_index,start_time,end_time,class,subject,subject_code,teacher,room,pinned\n1,1,08:00,08:45,5a,Mathe,M,"Schmidt, Anna",A101,false\n',
})
})
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/export\.ics(\?.*)?$/, async (route) => {
return route.fulfill({
status: 200, contentType: 'text/calendar',
body: 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n',
})
})
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' })
}
const url = route.request().url()
const id = url.split('/').pop() ?? ''
const sol = (solutions as Array<{ id: string }>).find(s => s.id === id)
if (!sol) return route.fulfill({ status: 404 })
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(sol) })
})
}
+134
View File
@@ -0,0 +1,134 @@
import { test, expect, Page } from '@playwright/test'
/**
* E2E for the Phase 9c parent-side: ParentManager on /schulkalender (teacher
* UI) and the /eltern login + timetable view. Backend calls are intercepted
* so the suite doesn't need a real teacher parent invitation cycle.
*/
async function mockTeacherCalendar(page: Page, opts: { classes?: unknown[]; parents?: unknown[]; invite?: unknown } = {}) {
// Existing schulkalender mocks the page already needs.
await page.route('**/api/school/calendar/config', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user_id: 'dev', bundesland: 'DE-NI' }) })
})
await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
// ParentManager loads classes via the stundenplan API.
await page.route('**/api/school/timetable/classes', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.classes ?? []) }))
await page.route('**/api/school/calendar/parents', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.parents ?? []) }))
await page.route('**/api/school/calendar/parents/invite', async (route) => {
if (route.request().method() !== 'POST') return route.fulfill({ status: 405 })
return route.fulfill({
status: 201, contentType: 'application/json',
body: JSON.stringify(opts.invite ?? {
parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' },
child: { id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller' },
magic_token: 'abc123',
magic_url: '/eltern/login?token=abc123',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
}),
})
})
}
test.describe('Schulkalender — ParentManager', () => {
test('renders empty state when no parents invited', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
const manager = page.getByTestId('parent-manager')
await expect(manager).toBeVisible()
await expect(manager.getByText('Keine eingeladenen Eltern.')).toBeVisible()
})
test('+ Eltern einladen opens the form when classes exist', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('parent-invite-toggle').click()
await expect(page.getByTestId('parent-email')).toBeVisible()
})
test('submitting invite shows the magic link to copy', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('parent-invite-toggle').click()
await page.getByTestId('parent-email').fill('mama@example.de')
await page.getByTestId('parent-child-first').fill('Max')
await page.getByTestId('parent-child-last').fill('Mueller')
await page.getByTestId('parent-class').selectOption('class-1')
await page.getByTestId('parent-invite-submit').click()
await expect(page.getByTestId('parent-invite-link')).toBeVisible()
await expect(page.getByText('Einladungs-Link fuer mama@example.de')).toBeVisible()
})
})
async function mockParentApi(page: Page, opts: { redeemOk?: boolean; me?: unknown; lessons?: unknown[] } = {}) {
const redeemOk = opts.redeemOk ?? true
await page.route('**/api/parent/auth/redeem', async (route) => {
if (!redeemOk) return route.fulfill({ status: 401, contentType: 'application/json', body: '{"error":"invalid"}' })
return route.fulfill({
status: 200, contentType: 'application/json',
headers: { 'set-cookie': 'bp_parent_session=test; Path=/; HttpOnly' },
body: JSON.stringify({ id: 'p1', email: 'mama@example.de', preferred_language: 'tr' }),
})
})
await page.route('**/api/parent/me', async (route) => {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(opts.me ?? {
parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' },
children: [{ id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller', class_name: '5a' }],
}),
})
})
await page.route(/\/api\/parent\/me\/timetable(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.lessons ?? []) }))
await page.route('**/api/parent/auth/logout', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' }))
}
test.describe('Eltern — Login + Wochengrid', () => {
test('login page shows error when no token in URL', async ({ page }) => {
await mockParentApi(page)
await page.goto('/eltern/login')
await expect(page.getByTestId('eltern-login')).toBeVisible()
await expect(page.getByText('Kein Token in der URL')).toBeVisible()
})
test('valid token redirects to the parent overview', async ({ page }) => {
await mockParentApi(page, {})
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern', { timeout: 3000 })
await expect(page.getByTestId('eltern-page')).toBeVisible()
})
test('shows greeting and child class on /eltern', async ({ page }) => {
await mockParentApi(page)
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern')
// Turkish greeting because preferred_language=tr.
await expect(page.getByText('Hoş geldiniz, mama@example.de')).toBeVisible()
await expect(page.getByText('Max Mueller · 5a')).toBeVisible()
})
test('translates subject names into the parent language', async ({ page }) => {
await mockParentApi(page, {
lessons: [
{ DayOfWeek: 1, PeriodIndex: 1, StartTime: '08:00', EndTime: '08:45', ClassName: '5a', SubjectName: 'Mathematik', SubjectCode: 'M', TeacherName: 'Schmidt, Anna', RoomName: 'A101', Pinned: false },
],
})
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern')
// Turkish target = Matematik.
await expect(page.getByTestId('eltern-cell-1-1').getByText('Matematik')).toBeVisible()
})
})
+317
View File
@@ -0,0 +1,317 @@
import { test, expect, Page } from '@playwright/test'
/**
* E2E tests for /schulkalender. Mocks the /api/school/calendar/* routes
* so the wizard, save flow and month grid render deterministically without
* the live backend or seed data.
*/
interface MockOpts {
config?: { user_id: string; bundesland: string } | null
holidays?: unknown[]
events?: unknown[]
notificationLog?: unknown[]
}
async function mockCalendarApi(page: Page, opts: MockOpts = {}) {
let config = opts.config ?? null
const events = (opts.events ?? []) as Array<Record<string, unknown>>
await page.route('**/api/school/calendar/config', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(config),
})
}
if (route.request().method() === 'PUT') {
const body = JSON.parse(route.request().postData() || '{}')
config = { user_id: 'dev', bundesland: body.bundesland }
return route.fulfill({
status: 201, contentType: 'application/json',
body: JSON.stringify(config),
})
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) => {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(opts.holidays ?? []),
})
})
// Phase 9b: school events + rollover.
await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) => {
const method = route.request().method()
if (method === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(events) })
}
if (method === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
const created = {
id: `new-${events.length}`,
created_by_user_id: 'dev',
affected_class_ids: [],
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
is_school_free: false,
...body,
}
events.push(created)
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/events\/[^/]+$/, async (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/school-year-rollover$/, async (route) => {
if (route.request().method() !== 'POST') return route.fulfill({ status: 405 })
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({
classes_promoted: 8, classes_graduated: 2,
new_year_start: '2026-08-01', new_year_end: '2027-07-31',
}),
})
})
// Phase 9d: per-event notification_log + manual trigger. NotificationStatus
// component fetches the log when an event has notify_parents/students.
await page.route(/\/api\/school\/calendar\/events\/[^/]+\/notifications$/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.notificationLog ?? []) })
})
await page.route(/\/api\/school\/calendar\/notifications\/run-now.*/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json',
body: '{"date":"2026-05-22","sent":0,"failed":0,"skipped":0,"already_logged":0}' })
})
}
test.describe('Schulkalender — Bundesland Wizard', () => {
test('wizard renders when no config exists', async ({ page }) => {
await mockCalendarApi(page, { config: null })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('bundesland-wizard')).toBeVisible()
await expect(page.getByText('Willkommen im Schulkalender')).toBeVisible()
})
test('saving a Bundesland switches to MonthView', async ({ page }) => {
await mockCalendarApi(page, { config: null })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('bundesland-select').selectOption('DE-NI')
await page.getByTestId('bundesland-save').click()
await expect(page.getByTestId('month-view')).toBeVisible()
await expect(page.getByText('Niedersachsen')).toBeVisible()
})
})
test.describe('Schulkalender — Month View', () => {
test('shows MonthView when config is set', async ({ page }) => {
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
holidays: [],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('month-view')).toBeVisible()
// Weekday header line.
for (const w of ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']) {
await expect(page.getByText(w, { exact: true }).first()).toBeVisible()
}
})
test('colours holidays in the grid', async ({ page }) => {
// Fix today by mocking config with a deterministic month/year via prev/next.
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
holidays: [
{ id: 'h1', region: 'DE-NI', event_type: 'public_holiday', name_de: 'Test-Feiertag', start_date: '2099-06-15', end_date: '2099-06-15' },
{ id: 'h2', region: 'DE-NI', event_type: 'school_holiday', name_de: 'Test-Ferien', start_date: '2099-06-20', end_date: '2099-06-21' },
],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
// Assert the legend rows — using exact text to avoid colliding with
// tooltips like "Tag der deutschen Einheit" that also contain 'tag'.
await expect(page.getByText('Feiertag', { exact: true })).toBeVisible()
await expect(page.getByText('Schulferien', { exact: true })).toBeVisible()
})
test('Heute button resets to current month', async ({ page }) => {
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
holidays: [],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('month-prev').click()
await page.getByTestId('month-prev').click()
await page.getByTestId('month-today').click()
// After clicking Heute, the current month name must appear in the heading.
const months = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const currentMonth = months[new Date().getMonth()]
await expect(page.getByRole('heading', { name: new RegExp(currentMonth) })).toBeVisible()
})
})
test.describe('Schulkalender — Sidebar entry', () => {
test('sidebar contains Schulkalender link', async ({ page }) => {
await mockCalendarApi(page)
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
const sidebar = page.locator('aside').first()
await expect(sidebar.getByText(/Schulkalender|School Calendar/).first()).toBeVisible()
})
})
// ==========================================================================
// Phase 9b — Schul-Events + Schuljahres-Rollover
// ==========================================================================
test.describe('Schulkalender — School Event CRUD', () => {
test('+ Termin button opens the event modal', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('add-event').click()
await expect(page.getByTestId('event-modal')).toBeVisible()
await expect(page.getByText('Neuer Termin')).toBeVisible()
})
test('submitting the form creates an event and closes the modal', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('add-event').click()
await page.getByTestId('event-title').fill('SCHILF: Digitale Tafeln')
await page.getByTestId('event-type').selectOption('fortbildung')
await page.getByTestId('event-save').click()
await expect(page.getByTestId('event-modal')).toHaveCount(0)
})
test('clicking a day opens the DayDetail with its events', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e1', created_by_user_id: 'dev',
title: 'Pruefe Test-Event', event_type: 'projekttag',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: false, notify_students: false,
notification_lead_days: [7, 1],
}],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('day-detail')).toBeVisible()
await expect(page.getByText('Pruefe Test-Event')).toBeVisible()
})
})
test.describe('Schulkalender — Schuljahres-Rollover', () => {
test('Schuljahr-wechseln button opens the wizard', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
await expect(page.getByTestId('rollover-wizard')).toBeVisible()
})
test('confirm-typing protects against accidental submit', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
const submit = page.getByTestId('rollover-submit')
await expect(submit).toBeDisabled()
await page.getByTestId('rollover-confirm').fill('falsch')
await expect(submit).toBeDisabled()
await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN')
await expect(submit).toBeEnabled()
})
test('successful rollover shows summary numbers', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN')
await page.getByTestId('rollover-submit').click()
await expect(page.getByTestId('rollover-result')).toBeVisible()
await expect(page.getByText('8 Klassen um eine Stufe aufgerueckt')).toBeVisible()
await expect(page.getByText('2 Abschlussklassen entfernt')).toBeVisible()
})
})
// ==========================================================================
// Phase 9d — Notification-Status im DayDetail
// ==========================================================================
test.describe('Schulkalender — Notification-Status', () => {
test('shows sent badge for delivered reminders', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e1', created_by_user_id: 'dev',
title: 'Pruefe Test-Event', event_type: 'projekttag',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: true, notify_students: false,
notification_lead_days: [7, 1],
}],
notificationLog: [
{ lead_days: 7, audience: 'parents', channel: 'email', status: 'sent', run_date: '2026-05-15', created_at: '2026-05-15T06:00:00Z' },
{ lead_days: 1, audience: 'parents', channel: 'email', status: 'skipped', run_date: '2026-05-21', created_at: '2026-05-21T06:00:00Z' },
],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('day-detail')).toBeVisible()
const status = page.getByTestId('notif-status-e1')
await expect(status).toBeVisible()
await expect(status.getByText(/7 Tage.*Eltern.*email/)).toBeVisible()
await expect(status.getByText(/1 Tag.*Eltern.*email/)).toBeVisible()
})
test('hides notification status when notifications are off', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e2', created_by_user_id: 'dev',
title: 'Stilles Event', event_type: 'andere',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: false, notify_students: false,
notification_lead_days: [],
}],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('notif-status-e2')).toHaveCount(0)
})
})
+51
View File
@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test'
import { mockSchoolApi } from './_helpers'
/**
* E2E tests for the Phase 8 export functionality on /stundenplan.
* Split into its own file so stundenplan.spec.ts stays under the 500 LOC
* budget enforced by the pre-commit hook.
*/
const exportOpts = () => ({
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
})
test.describe('Stundenplan — Export buttons', () => {
test('export buttons are rendered on the PlanView', async ({ page }) => {
await mockSchoolApi(page, exportOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('export-csv')).toBeVisible()
await expect(page.getByTestId('export-ics')).toBeVisible()
await expect(page.getByTestId('export-print')).toBeVisible()
})
test('CSV download triggers a fetch to export.csv', async ({ page }) => {
await mockSchoolApi(page, exportOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
const downloadPromise = page.waitForEvent('download')
await page.getByTestId('export-csv').click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('.csv')
})
test('ICS download triggers a fetch to export.ics', async ({ page }) => {
await mockSchoolApi(page, exportOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
const downloadPromise = page.waitForEvent('download')
await page.getByTestId('export-ics').click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('.ics')
})
})
+219 -108
View File
@@ -1,101 +1,14 @@
import { test, expect, Page } from '@playwright/test'
import { mockSchoolApi, MOCK_TEACHER_ID, MOCK_SUBJECT_ID, MOCK_CLASS_ID } from './_helpers'
/**
* E2E tests for /stundenplan
*
* Backend calls go through /api/school/* (Next.js proxy school-service).
* For most tests we intercept those routes so the suite is hermetic and does
* not depend on a populated database or a valid JWT.
* Tests intercept those routes via mockSchoolApi() from _helpers.ts so the
* suite stays hermetic.
*/
const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111'
const MOCK_SUBJECT_ID = '22222222-2222-2222-2222-222222222222'
const MOCK_CLASS_ID = '33333333-3333-3333-3333-333333333333'
interface MockClass {
id: string
name: string
grade_level: number
student_count: number
notes?: string
created_by_user_id: string
created_at: string
}
interface MockOpts {
classes?: MockClass[]
teachers?: unknown[]
subjects?: unknown[]
rooms?: unknown[]
periods?: unknown[]
curriculum?: unknown[]
assignments?: unknown[]
}
async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
const classes = opts.classes ?? []
const teachers = opts.teachers ?? []
const subjects = opts.subjects ?? []
const rooms = opts.rooms ?? []
const periods = opts.periods ?? []
const curriculum = opts.curriculum ?? []
const assignments = opts.assignments ?? []
await page.route('**/api/school/timetable/classes', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(classes) })
}
if (route.request().method() === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
const created: MockClass = {
id: 'new-class-id',
name: body.name,
grade_level: body.grade_level,
student_count: body.student_count ?? 0,
notes: body.notes,
created_by_user_id: 'test-user',
created_at: new Date().toISOString(),
}
classes.push(created)
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) })
}
return route.fulfill({ status: 405 })
})
// Helper to mount a read-only endpoint with a static list.
const staticList = (path: string, data: unknown) =>
page.route(`**/api/school/timetable/${path}`, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }))
await staticList('teachers', teachers)
await staticList('subjects', subjects)
await staticList('rooms', rooms)
await staticList('periods', periods)
await staticList('curriculum', curriculum)
await staticList('assignments', assignments)
// Constraint endpoints — all empty by default.
for (const path of [
'constraints/teacher/unavailable-day',
'constraints/teacher/unavailable-window',
'constraints/teacher/max-hours-day',
'constraints/teacher/max-hours-week',
'constraints/teacher/excluded-subject',
'constraints/teacher/excluded-room',
'constraints/subject/max-consecutive',
'constraints/subject/preferred-period',
'constraints/subject/min-day-gap',
'constraints/subject/contiguous-when-repeated',
'constraints/subject/double-lesson',
'constraints/class/max-hours-day',
'constraints/class/no-gaps',
'constraints/room/requires-type',
'constraints/room/unavailable',
]) {
await staticList(path, [])
}
}
test.describe('Stundenplan — Page Shell', () => {
test.beforeEach(async ({ page }) => {
await mockSchoolApi(page)
@@ -105,25 +18,33 @@ test.describe('Stundenplan — Page Shell', () => {
test('page loads with title and subtitle', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible()
await expect(page.getByText('Stammdaten und Regeln fuer den Solver')).toBeVisible()
await expect(page.getByText('Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan')).toBeVisible()
})
test('shows all 8 tabs', async ({ page }) => {
test('help panel toggles open', async ({ page }) => {
const panel = page.getByTestId('help-panel')
await expect(panel).toBeVisible()
await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toHaveCount(0)
await panel.getByRole('button', { name: /Bedienungsanleitung/ }).click()
await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toBeVisible()
})
test('shows all 9 tabs', async ({ page }) => {
// Sidebar entries collide with tab labels for 'Lehrer' — scope to <main nav>.
const tabs = page.locator('main nav')
for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) {
for (const label of ['Plan', 'Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) {
await expect(tabs.getByRole('button', { name: label, exact: true })).toBeVisible()
}
})
test('Klassen tab is active by default', async ({ page }) => {
await expect(page.getByTestId('klassen-manager')).toBeVisible()
test('Plan tab is active by default', async ({ page }) => {
await expect(page.getByTestId('plan-hub')).toBeVisible()
})
test('JWT dev field exists and persists into localStorage', async ({ page }) => {
await page.getByText('Dev: JWT-Token setzen').click()
page.on('dialog', d => d.accept())
await page.getByPlaceholder('Bearer-Token').fill('test-jwt-abc')
test('Dev mode banner is collapsed by default; manual token still available', async ({ page }) => {
await page.getByText('Testumgebung — Anmeldung deaktiviert').click()
await page.getByText('Manueller Token').click()
await page.getByPlaceholder(/Bearer-Token/).fill('test-jwt-abc')
await page.getByRole('button', { name: 'Speichern' }).click()
const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt'))
expect(stored).toBe('test-jwt-abc')
@@ -137,9 +58,10 @@ test.describe('Stundenplan — Tab navigation', () => {
await page.waitForLoadState('networkidle')
})
test('all 8 tabs render their manager', async ({ page }) => {
test('all 9 tabs render their manager', async ({ page }) => {
const tabs = page.locator('main nav')
const cases: { label: string; testId: string }[] = [
{ label: 'Klassen', testId: 'klassen-manager' },
{ label: 'Lehrer', testId: 'lehrer-manager' },
{ label: 'Faecher', testId: 'faecher-manager' },
{ label: 'Raeume', testId: 'raeume-manager' },
@@ -147,6 +69,7 @@ test.describe('Stundenplan — Tab navigation', () => {
{ label: 'Stundentafel', testId: 'curriculum-manager' },
{ label: 'Lehrauftraege', testId: 'assignments-manager' },
{ label: 'Regeln (Constraints)', testId: 'regeln-hub' },
{ label: 'Plan', testId: 'plan-hub' },
]
for (const c of cases) {
await tabs.getByRole('button', { name: c.label, exact: true }).click()
@@ -156,10 +79,16 @@ test.describe('Stundenplan — Tab navigation', () => {
})
test.describe('Stundenplan — Klassen CRUD', () => {
test('empty state shows when no classes', async ({ page }) => {
await mockSchoolApi(page, { classes: [] })
const gotoKlassen = async (page: Page) => {
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
// Plan is now the default tab; switch to Klassen first.
await page.locator('main nav').getByRole('button', { name: 'Klassen', exact: true }).click()
}
test('empty state shows when no classes', async ({ page }) => {
await mockSchoolApi(page, { classes: [] })
await gotoKlassen(page)
await expect(page.getByText('Noch keine Klassen angelegt.')).toBeVisible()
})
@@ -170,8 +99,7 @@ test.describe('Stundenplan — Klassen CRUD', () => {
{ id: 'c2', name: '5b', grade_level: 5, student_count: 23, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await gotoKlassen(page)
await expect(page.getByText('Klassen (2)')).toBeVisible()
await expect(page.getByRole('cell', { name: '5a' })).toBeVisible()
await expect(page.getByRole('cell', { name: '5b' })).toBeVisible()
@@ -179,8 +107,7 @@ test.describe('Stundenplan — Klassen CRUD', () => {
test('+ Neue Klasse toggles the form', async ({ page }) => {
await mockSchoolApi(page)
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await gotoKlassen(page)
await expect(page.getByPlaceholder('z.B. 5a')).toHaveCount(0)
await page.getByRole('button', { name: '+ Neue Klasse' }).click()
@@ -191,8 +118,7 @@ test.describe('Stundenplan — Klassen CRUD', () => {
test('form submission appends a new class to the list', async ({ page }) => {
await mockSchoolApi(page, { classes: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await gotoKlassen(page)
await page.getByRole('button', { name: '+ Neue Klasse' }).click()
await page.getByPlaceholder('z.B. 5a').fill('7c')
@@ -368,3 +294,188 @@ test.describe('Stundenplan — Sidebar entry', () => {
await expect(sidebar.getByText(/Stundenplan|Timetable/).first()).toBeVisible()
})
})
// ==========================================================================
// Phase 6 — Plan-Ansicht
// ==========================================================================
test.describe('Stundenplan — Plan tab + SolutionList', () => {
test('empty state when no solutions exist', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Noch keine Plaene generiert.')).toBeVisible()
await expect(page.getByTestId('solve-trigger')).toBeEnabled()
})
test('renders solutions returned by the backend', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: -42, created_at: '2026-05-22T10:00:00Z' },
{ id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', error_message: 'no lessons', created_at: '2026-05-22T11:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByRole('cell', { name: 'Plan A' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'Plan B' })).toBeVisible()
await expect(page.getByText('Fertig').first()).toBeVisible()
await expect(page.getByText('Fehler').first()).toBeVisible()
await expect(page.getByText('0H / -42S')).toBeVisible()
})
test('completed solutions expose an Anzeigen button; failed ones do not', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
{ id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', created_at: '2026-05-22T11:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const planA = page.getByRole('row', { name: /Plan A/ })
const planB = page.getByRole('row', { name: /Plan B/ })
await expect(planA.getByRole('button', { name: 'Anzeigen' })).toBeVisible()
await expect(planB.getByRole('button', { name: 'Anzeigen' })).toHaveCount(0)
})
test('triggering Solve calls POST /solutions and reloads the list', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByTestId('solve-trigger').click()
await expect(page.getByText('Plan').first()).toBeVisible()
})
})
test.describe('Stundenplan — PlanView grid', () => {
test('shows placeholder hint until a solution is selected', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Waehle einen abgeschlossenen Plan oben')).toBeVisible()
await expect(page.getByTestId('plan-view')).toHaveCount(0)
})
test('clicking Anzeigen mounts the PlanView with mocked lessons', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
{ id: 'l2', solution_id: 's1', class_id: 'c1', subject_id: 'sub2', teacher_id: 't1', room_id: 'r1', day_of_week: 2, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Deutsch', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
subjects: [
{ id: 'sub1', name: 'Mathematik', short_code: 'M', color: '#3b82f6', is_main_subject: true, created_by_user_id: 'u', created_at: '' },
{ id: 'sub2', name: 'Deutsch', short_code: 'D', color: '#ef4444', is_main_subject: true, created_by_user_id: 'u', created_at: '' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('plan-view')).toBeVisible()
await expect(page.getByTestId('cell-1-1')).toBeVisible()
await expect(page.getByTestId('cell-2-1')).toBeVisible()
})
test('switching perspective updates the resource selector', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('plan-view')).toBeVisible()
// Default perspective is 'class' → selector shows '5a'.
await expect(page.locator('select').last()).toHaveValue('c1')
await page.getByTestId('perspective-teacher').click()
await expect(page.locator('select').last()).toHaveValue('t1')
await page.getByTestId('perspective-room').click()
await expect(page.locator('select').last()).toHaveValue('r1')
})
})
// ==========================================================================
// Phase 7 — Pinning + Plan-Versionen
// ==========================================================================
test.describe('Stundenplan — Solve options (parent + seconds limit)', () => {
test('parent-selector disabled when no completed solutions exist', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('parent-selector')).toBeDisabled()
})
test('parent-selector lists only completed solutions', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
{ id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', created_at: '2026-05-22T11:00:00Z' },
{ id: 's3', created_by_user_id: 'u', name: 'Plan C', status: 'completed', hard_score: 0, soft_score: -5, created_at: '2026-05-22T12:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const sel = page.getByTestId('parent-selector')
await expect(sel).toBeEnabled()
// Only Plan A and Plan C are options (Plan B failed).
await expect(sel.locator('option', { hasText: 'Plan A' })).toHaveCount(1)
await expect(sel.locator('option', { hasText: 'Plan C' })).toHaveCount(1)
await expect(sel.locator('option', { hasText: 'Plan B' })).toHaveCount(0)
})
test('seconds-limit field accepts numeric input', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const field = page.getByTestId('seconds-limit')
await field.fill('120')
await expect(field).toHaveValue('120')
})
})
test.describe('Stundenplan — Pin lesson', () => {
const baseOpts = () => ({
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
})
test('pin button is rendered on each lesson cell', async ({ page }) => {
await mockSchoolApi(page, baseOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('pin-l1')).toBeVisible()
})
test('clicking pin flips the icon via optimistic update', async ({ page }) => {
await mockSchoolApi(page, baseOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
const pinBtn = page.getByTestId('pin-l1')
await expect(pinBtn).toContainText('📌')
await pinBtn.click()
await expect(pinBtn).toContainText('🔒')
})
})
+64
View File
@@ -0,0 +1,64 @@
/**
* Subject-name translations for the parent-facing weekly grid.
*
* The teacher enters German subject names in tt_subject.name. For parents
* whose preferred_language differs, we look up the German name in this
* table and substitute the localised version. If no match (custom AG,
* Wahlfach, ...), the German original is shown.
*
* Keys are normalised lowercase German subject names. Languages cover the
* 8 most-common parent locales in DE schools; everything else falls back
* to German.
*/
type SupportedLanguage = 'de' | 'en' | 'tr' | 'ar' | 'uk' | 'ru' | 'pl' | 'fr'
interface SubjectTranslation {
de: string
en: string
tr: string
ar: string
uk: string
ru: string
pl: string
fr: string
}
const SUBJECTS: Record<string, SubjectTranslation> = {
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Mathématiques' },
mathe: { de: 'Mathe', en: 'Maths', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Maths' },
deutsch: { de: 'Deutsch', en: 'German', tr: 'Almanca', ar: 'الألمانية', uk: 'Німецька мова', ru: 'Немецкий язык', pl: 'Język niemiecki', fr: 'Allemand' },
englisch: { de: 'Englisch', en: 'English', tr: 'İngilizce', ar: 'الإنجليزية', uk: 'Англійська мова', ru: 'Английский язык', pl: 'Język angielski', fr: 'Anglais' },
franzoesisch: { de: 'Franzoesisch', en: 'French', tr: 'Fransızca', ar: 'الفرنسية', uk: 'Французька мова', ru: 'Французский язык', pl: 'Język francuski', fr: 'Français' },
spanisch: { de: 'Spanisch', en: 'Spanish', tr: 'İspanyolca', ar: 'الإسبانية', uk: 'Іспанська мова', ru: 'Испанский язык', pl: 'Język hiszpański', fr: 'Espagnol' },
latein: { de: 'Latein', en: 'Latin', tr: 'Latince', ar: 'اللاتينية', uk: 'Латинська мова', ru: 'Латинский язык', pl: 'Łacina', fr: 'Latin' },
sachkunde: { de: 'Sachkunde', en: 'General Studies', tr: 'Hayat Bilgisi', ar: 'الدراسات العامة', uk: 'Природознавство', ru: 'Окружающий мир', pl: 'Wiedza o przyrodzie', fr: 'Découverte du monde' },
sport: { de: 'Sport', en: 'PE', tr: 'Beden Eğitimi', ar: 'التربية البدنية', uk: 'Фізкультура', ru: 'Физкультура', pl: 'WF', fr: 'EPS' },
musik: { de: 'Musik', en: 'Music', tr: 'Müzik', ar: 'الموسيقى', uk: 'Музика', ru: 'Музыка', pl: 'Muzyka', fr: 'Musique' },
kunst: { de: 'Kunst', en: 'Art', tr: 'Sanat', ar: 'الفن', uk: 'Мистецтво', ru: 'Искусство', pl: 'Plastyka', fr: 'Arts plastiques' },
religion: { de: 'Religion', en: 'Religion', tr: 'Din Bilgisi', ar: 'الدين', uk: 'Релігія', ru: 'Религия', pl: 'Religia', fr: 'Religion' },
ethik: { de: 'Ethik', en: 'Ethics', tr: 'Etik', ar: 'الأخلاق', uk: 'Етика', ru: 'Этика', pl: 'Etyka', fr: 'Éthique' },
biologie: { de: 'Biologie', en: 'Biology', tr: 'Biyoloji', ar: 'الأحياء', uk: 'Біологія', ru: 'Биология', pl: 'Biologia', fr: 'Biologie' },
chemie: { de: 'Chemie', en: 'Chemistry', tr: 'Kimya', ar: 'الكيمياء', uk: 'Хімія', ru: 'Химия', pl: 'Chemia', fr: 'Chimie' },
physik: { de: 'Physik', en: 'Physics', tr: 'Fizik', ar: 'الفيزياء', uk: 'Фізика', ru: 'Физика', pl: 'Fizyka', fr: 'Physique' },
geschichte: { de: 'Geschichte', en: 'History', tr: 'Tarih', ar: 'التاريخ', uk: 'Історія', ru: 'История', pl: 'Historia', fr: 'Histoire' },
geografie: { de: 'Geografie', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' },
erdkunde: { de: 'Erdkunde', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' },
politik: { de: 'Politik', en: 'Civics', tr: 'Vatandaşlık', ar: 'التربية الوطنية', uk: 'Громадянознавство', ru: 'Обществознание', pl: 'Wiedza o społeczeństwie', fr: 'Éducation civique' },
informatik: { de: 'Informatik', en: 'Computer Science', tr: 'Bilişim', ar: 'علوم الحاسوب', uk: 'Інформатика', ru: 'Информатика', pl: 'Informatyka', fr: 'Informatique' },
wirtschaft: { de: 'Wirtschaft', en: 'Economics', tr: 'Ekonomi', ar: 'الاقتصاد', uk: 'Економіка', ru: 'Экономика', pl: 'Ekonomia', fr: 'Économie' },
}
/**
* Translate a German subject name into the requested language.
* Falls back to the original input if no match in the table or no
* translation for the target language.
*/
export function translateSubject(germanName: string, lang: string): string {
if (!germanName) return germanName
const key = germanName.toLowerCase().trim()
const row = SUBJECTS[key]
if (!row) return germanName
const code = (lang || 'de').slice(0, 2) as SupportedLanguage
return row[code] || row.de || germanName
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Parent API client. Cookies (HttpOnly bp_parent_session) carry auth
* we never store the session token in JS-readable storage. credentials:
* 'include' is mandatory so the cookie ships with each request.
*/
const PROXY_PREFIX = '/api/parent'
interface FetchOptions extends RequestInit {
expectJson?: boolean
}
async function parentFetch<T>(endpoint: string, opts: FetchOptions = {}): Promise<T> {
const res = await fetch(`${PROXY_PREFIX}${endpoint}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string> | undefined),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
export interface ParentMeResponse {
parent: { id: string; email: string; preferred_language: string }
children: Array<{
id: string
parent_id: string
tt_class_id: string
first_name: string
last_name: string
class_name?: string
}>
}
export interface ParentLesson {
DayOfWeek: number
PeriodIndex: number
StartTime: string
EndTime: string
ClassName: string
SubjectName: string
SubjectCode: string
TeacherName: string
RoomName: string
Pinned: boolean
}
export const elternApi = {
redeem: (token: string) =>
parentFetch<{ id: string; email: string; preferred_language: string }>('/auth/redeem', {
method: 'POST', body: JSON.stringify({ token }),
}),
me: () => parentFetch<ParentMeResponse>('/me'),
timetable: (classId: string) =>
parentFetch<ParentLesson[]>(`/me/timetable?class_id=${encodeURIComponent(classId)}`),
logout: () => parentFetch<void>('/auth/logout', { method: 'POST' }),
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Schulkalender API client. Re-uses the same /api/school/* proxy + the JWT
* helper from stundenplan so we don't fork the auth flow.
*/
import { getStundenplanToken } from '@/lib/stundenplan/api'
import type {
PublicEvent, SchoolCalendarConfig, UpsertSchoolCalendarConfig,
SchoolEvent, CreateSchoolEvent, SchoolYearRolloverResult,
ParentInviteListItem, InviteParentRequest, InviteParentResponse,
NotificationLogRow, NotificationRunResult,
} from '@/app/schulkalender/types'
async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
}
const token = getStundenplanToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`/api/school${endpoint}`, { ...options, headers })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || err.detail || `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
export const calendarApi = {
listHolidays: (region: string, from: string, to: string) =>
apiFetch<PublicEvent[]>(`/calendar/holidays?region=${encodeURIComponent(region)}&from=${from}&to=${to}`),
getConfig: () => apiFetch<SchoolCalendarConfig | null>('/calendar/config'),
upsertConfig: (data: UpsertSchoolCalendarConfig) =>
apiFetch<SchoolCalendarConfig>('/calendar/config', { method: 'PUT', body: JSON.stringify(data) }),
// School events
listEvents: (from: string, to: string) =>
apiFetch<SchoolEvent[]>(`/calendar/events?from=${from}&to=${to}`),
createEvent: (data: CreateSchoolEvent) =>
apiFetch<SchoolEvent>('/calendar/events', { method: 'POST', body: JSON.stringify(data) }),
deleteEvent: (id: string) =>
apiFetch<void>(`/calendar/events/${id}`, { method: 'DELETE' }),
rolloverSchoolYear: (newYearStart?: string, newYearEnd?: string) =>
apiFetch<SchoolYearRolloverResult>('/calendar/school-year-rollover', {
method: 'POST',
body: JSON.stringify({
new_year_start: newYearStart,
new_year_end: newYearEnd,
}),
}),
// Phase 9c: parent invitations
listParents: () => apiFetch<ParentInviteListItem[]>('/calendar/parents'),
inviteParent: (data: InviteParentRequest) =>
apiFetch<InviteParentResponse>('/calendar/parents/invite', { method: 'POST', body: JSON.stringify(data) }),
deleteParentChild: (childId: string) =>
apiFetch<void>(`/calendar/parents/children/${childId}`, { method: 'DELETE' }),
// Phase 9d: notifications.
runNotifications: (date?: string) =>
apiFetch<NotificationRunResult>('/calendar/notifications/run-now' + (date ? `?date=${date}` : ''), { method: 'POST' }),
listEventNotifications: (eventId: string) =>
apiFetch<NotificationLogRow[]>(`/calendar/events/${eventId}/notifications`),
}
+49
View File
@@ -16,6 +16,7 @@ import type {
SubjectPreferredPeriod, SubjectDoubleLesson,
ClassMaxHoursDay, ClassNoGaps,
RoomRequiresType, RoomUnavailable,
TimetableSolution, TimetableLesson, CreateTimetableSolution,
} from '@/app/stundenplan/types'
const TOKEN_KEY = 'bp_stundenplan_jwt'
@@ -138,3 +139,51 @@ export const classNoGapsApi = constraintApi<ClassNoGaps, Omit<ClassNoGaps, 'id'
export const roomRequiresTypeApi = constraintApi<RoomRequiresType, Omit<RoomRequiresType, 'id' | 'created_by_user_id' | 'created_at'>>('room/requires-type')
export const roomUnavailableApi = constraintApi<RoomUnavailable, Omit<RoomUnavailable, 'id' | 'created_by_user_id' | 'created_at'>>('room/unavailable')
// ---------- Solutions ----------
export const solutionsApi = {
list: () => apiFetch<TimetableSolution[]>('/timetable/solutions'),
get: (id: string) => apiFetch<TimetableSolution>(`/timetable/solutions/${id}`),
create: (data: CreateTimetableSolution) =>
apiFetch<TimetableSolution>('/timetable/solutions', { method: 'POST', body: JSON.stringify(data) }),
remove: (id: string) =>
apiFetch<void>(`/timetable/solutions/${id}`, { method: 'DELETE' }),
lessons: (id: string) =>
apiFetch<TimetableLesson[]>(`/timetable/solutions/${id}/lessons`),
}
export const lessonsApi = {
pin: (id: string, pinned: boolean) =>
apiFetch<{ message: string; pinned: boolean }>(`/timetable/lessons/${id}/pin`, {
method: 'PUT',
body: JSON.stringify({ pinned }),
}),
}
// Phase 8: exports. Fetched as blobs through the proxy so the JWT (when
// set) is forwarded; download is triggered by creating an object URL.
export async function downloadSolutionExport(
solutionId: string,
format: 'csv' | 'ics',
options: { startDate?: string } = {},
): Promise<void> {
const token = getStundenplanToken()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const qs = format === 'ics' && options.startDate ? `?start=${options.startDate}` : ''
const res = await fetch(`/api/school/timetable/solutions/${solutionId}/export.${format}${qs}`, { headers })
if (!res.ok) {
throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`)
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `stundenplan-${solutionId.slice(0, 8)}.${format}`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
+26
View File
@@ -0,0 +1,26 @@
# Timetable solver — Timefold needs a JVM at runtime (it's a JPype wrapper
# around the Java engine). We install OpenJDK 17 alongside Python 3.11 on
# the slim Debian base so the same image works on ARM64 and x86_64.
FROM python:3.11-slim
ENV JAVA_HOME=/usr/lib/jvm/default-java
ENV PATH="${JAVA_HOME}/bin:${PATH}"
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
default-jdk-headless \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8095/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8095"]
+16
View File
@@ -0,0 +1,16 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Solver-service configuration. Values come from env vars."""
database_url: str = ""
solver_seconds_limit: int = 60
log_level: str = "INFO"
class Config:
env_file = ".env"
env_prefix = ""
settings = Settings()
+279
View File
@@ -0,0 +1,279 @@
"""Timefold constraint provider for the school timetable.
Categories:
* universal hard no double-booking class/teacher/room. Mandatory.
* DB-driven each tt_constraint_* table maps to two constraints here:
a `_hard` variant filtering rules with is_hard=True (penalised as hard),
and a `_soft` variant for is_hard=False (penalised as soft, weighted).
Splitting is required because Timefold's penalize() can only emit one
score axis per constraint.
* Quality soft preferred periods etc. Always soft.
Hard violations use HardSoftScore.ONE_HARD (one per match). Soft violations
multiply ONE_SOFT by the rule's weight (0-100).
"""
from timefold.solver.score import (
constraint_provider,
HardSoftScore,
ConstraintFactory,
Constraint,
Joiners,
)
from .domain import Lesson
from .rules import (
TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule,
RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule,
)
@constraint_provider
def define_constraints(factory: ConstraintFactory) -> list[Constraint]:
return [
# ---------- Universal hard ----------
_class_conflict(factory),
_teacher_conflict(factory),
_room_conflict(factory),
# ---------- DB-driven (each split hard/soft) ----------
_teacher_unavailable_day_hard(factory),
_teacher_unavailable_day_soft(factory),
_teacher_unavailable_window_hard(factory),
_teacher_unavailable_window_soft(factory),
_teacher_excluded_room_hard(factory),
_teacher_excluded_room_soft(factory),
_room_unavailable_hard(factory),
_room_unavailable_soft(factory),
_room_requires_type_hard(factory),
_room_requires_type_soft(factory),
# ---------- Quality soft ----------
_subject_preferred_period(factory),
]
# ==========================================================================
# Universal hard constraints
# ==========================================================================
def _class_conflict(factory: ConstraintFactory) -> Constraint:
"""A class can't sit in two lessons at once."""
return (
factory.for_each_unique_pair(
Lesson,
Joiners.equal(lambda l: l.school_class.id),
Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None),
)
.filter(lambda l1, l2: l1.timeslot is not None and l2.timeslot is not None)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("class_conflict")
)
def _teacher_conflict(factory: ConstraintFactory) -> Constraint:
"""A teacher can't run two lessons at once."""
return (
factory.for_each_unique_pair(
Lesson,
Joiners.equal(lambda l: l.teacher.id),
Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None),
)
.filter(lambda l1, l2: l1.timeslot is not None and l2.timeslot is not None)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("teacher_conflict")
)
def _room_conflict(factory: ConstraintFactory) -> Constraint:
"""A room can't host two lessons at once."""
return (
factory.for_each_unique_pair(
Lesson,
Joiners.equal(lambda l: l.room.id if l.room else None),
Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None),
)
.filter(lambda l1, l2: l1.room is not None and l2.room is not None
and l1.timeslot is not None and l2.timeslot is not None)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("room_conflict")
)
# ==========================================================================
# DB-driven constraints — each split into hard + soft variants
# ==========================================================================
def _teacher_unavailable_day_hard(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.timeslot is not None)
.join(
TeacherUnavailableDayRule,
Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id),
Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week),
)
.filter(lambda l, r: r.is_hard)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("teacher_unavailable_day_hard")
)
def _teacher_unavailable_day_soft(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.timeslot is not None)
.join(
TeacherUnavailableDayRule,
Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id),
Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week),
)
.filter(lambda l, r: not r.is_hard)
.penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1))
.as_constraint("teacher_unavailable_day_soft")
)
def _overlaps_window(l, r) -> bool:
if l.timeslot is None:
return False
return l.timeslot.start_time < r.end_time and l.timeslot.end_time > r.start_time
def _teacher_unavailable_window_hard(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.timeslot is not None)
.join(
TeacherUnavailableWindowRule,
Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id),
Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week),
)
.filter(lambda l, r: r.is_hard and _overlaps_window(l, r))
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("teacher_unavailable_window_hard")
)
def _teacher_unavailable_window_soft(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.timeslot is not None)
.join(
TeacherUnavailableWindowRule,
Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id),
Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week),
)
.filter(lambda l, r: not r.is_hard and _overlaps_window(l, r))
.penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1))
.as_constraint("teacher_unavailable_window_soft")
)
def _teacher_excluded_room_hard(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.room is not None)
.join(
TeacherExcludedRoomRule,
Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id),
Joiners.equal(lambda l: l.room.id, lambda r: r.room_id),
)
.filter(lambda l, r: r.is_hard)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("teacher_excluded_room_hard")
)
def _teacher_excluded_room_soft(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.room is not None)
.join(
TeacherExcludedRoomRule,
Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id),
Joiners.equal(lambda l: l.room.id, lambda r: r.room_id),
)
.filter(lambda l, r: not r.is_hard)
.penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1))
.as_constraint("teacher_excluded_room_soft")
)
def _room_unavailable_hard(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.room is not None and l.timeslot is not None)
.join(
RoomUnavailableRule,
Joiners.equal(lambda l: l.room.id, lambda r: r.room_id),
Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week),
Joiners.equal(lambda l: l.timeslot.period_index, lambda r: r.period_index),
)
.filter(lambda l, r: r.is_hard)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("room_unavailable_hard")
)
def _room_unavailable_soft(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.room is not None and l.timeslot is not None)
.join(
RoomUnavailableRule,
Joiners.equal(lambda l: l.room.id, lambda r: r.room_id),
Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week),
Joiners.equal(lambda l: l.timeslot.period_index, lambda r: r.period_index),
)
.filter(lambda l, r: not r.is_hard)
.penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1))
.as_constraint("room_unavailable_soft")
)
def _room_requires_type_hard(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.room is not None)
.join(
RoomRequiresTypeRule,
Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id),
)
.filter(lambda l, r: r.is_hard and l.room.room_type != r.room_type)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("room_requires_type_hard")
)
def _room_requires_type_soft(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Lesson)
.filter(lambda l: l.room is not None)
.join(
RoomRequiresTypeRule,
Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id),
)
.filter(lambda l, r: not r.is_hard and l.room.room_type != r.room_type)
.penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1))
.as_constraint("room_requires_type_soft")
)
# ==========================================================================
# Quality soft
# ==========================================================================
def _subject_preferred_period(factory: ConstraintFactory) -> Constraint:
"""Soft penalty when a lesson lands outside the subject's preferred period range."""
return (
factory.for_each(Lesson)
.filter(lambda l: l.timeslot is not None)
.join(
SubjectPreferredPeriodRule,
Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id),
)
.filter(lambda l, r: not (r.period_from <= l.timeslot.period_index <= r.period_to))
.penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1))
.as_constraint("subject_preferred_period")
)
+28
View File
@@ -0,0 +1,28 @@
import asyncpg
from typing import Optional
from .config import settings
_pool: Optional[asyncpg.Pool] = None
async def get_pool() -> asyncpg.Pool:
"""Lazy-init the asyncpg pool. Reused across requests + background jobs."""
global _pool
if _pool is None:
if not settings.database_url:
raise RuntimeError("DATABASE_URL not configured")
_pool = await asyncpg.create_pool(
dsn=settings.database_url,
min_size=2,
max_size=10,
command_timeout=30,
)
return _pool
async def close_pool() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
+140
View File
@@ -0,0 +1,140 @@
"""Timefold planning domain for school timetables.
Lessons are the planning entities; their `timeslot` and `room` are the
variables the solver picks. Class/subject/teacher come from the assignment
(`tt_assignment`) and stay fixed for a given Lesson instance.
Note on equality: Timefold compares facts by identity by default, so we
use frozen dataclasses with id-based equality where needed.
"""
from dataclasses import dataclass, field
from typing import Annotated, Optional
from timefold.solver.domain import (
planning_entity,
planning_solution,
PlanningVariable,
PlanningPin,
PlanningId,
PlanningEntityCollectionProperty,
ProblemFactCollectionProperty,
ValueRangeProvider,
PlanningScore,
)
from timefold.solver.score import HardSoftScore
@dataclass(frozen=True)
class Timeslot:
"""A single weekday + lesson period (e.g. Monday 1st hour, 08:0008:45)."""
id: Annotated[str, PlanningId]
day_of_week: int # 1..7
period_index: int # 1..N
start_time: str # HH:MM
end_time: str # HH:MM
def __str__(self) -> str:
return f"D{self.day_of_week}P{self.period_index}"
@dataclass(frozen=True)
class Room:
id: Annotated[str, PlanningId]
name: str
room_type: str = ""
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Teacher:
id: Annotated[str, PlanningId]
last_name: str
first_name: str
short_code: str
def __str__(self) -> str:
return f"{self.last_name}, {self.first_name}"
@dataclass(frozen=True)
class SchoolClass:
id: Annotated[str, PlanningId]
name: str
grade_level: int
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Subject:
id: Annotated[str, PlanningId]
name: str
short_code: str
required_room_type: str = ""
def __str__(self) -> str:
return self.short_code
@planning_entity
@dataclass
class Lesson:
"""One scheduled class-subject pairing.
Curriculum says "5a needs 4 hours of Mathe per week" 4 Lesson instances
with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment).
The solver assigns timeslot + room.
pinned (Phase 7): when True, this Lesson's timeslot + room have been
pre-assigned and the solver must not move it. Used for plan versioning
so re-solves keep the parent solution's locked cells in place.
"""
id: Annotated[str, PlanningId]
school_class: SchoolClass
subject: Subject
teacher: Teacher
timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None)
room: Annotated[Optional[Room], PlanningVariable] = field(default=None)
pinned: Annotated[bool, PlanningPin] = field(default=False)
def __str__(self) -> str:
return f"{self.school_class}-{self.subject}#{self.id[:8]}"
from .rules import (
TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule,
RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule,
)
@planning_solution
@dataclass
class Timetable:
"""The solver works on one Timetable instance: shuffles `lessons[*].timeslot`
and `lessons[*].room` to satisfy the constraints.
Constraint-rule facts are pulled from the DB at solve time and passed
here so the constraint provider can join against them.
"""
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
teachers: Annotated[list[Teacher], ProblemFactCollectionProperty]
classes: Annotated[list[SchoolClass], ProblemFactCollectionProperty]
subjects: Annotated[list[Subject], ProblemFactCollectionProperty]
teacher_unavailable_days: Annotated[list[TeacherUnavailableDayRule], ProblemFactCollectionProperty]
teacher_unavailable_windows: Annotated[list[TeacherUnavailableWindowRule], ProblemFactCollectionProperty]
teacher_excluded_rooms: Annotated[list[TeacherExcludedRoomRule], ProblemFactCollectionProperty]
room_unavailables: Annotated[list[RoomUnavailableRule], ProblemFactCollectionProperty]
subject_preferred_periods: Annotated[list[SubjectPreferredPeriodRule], ProblemFactCollectionProperty]
room_requires_types: Annotated[list[RoomRequiresTypeRule], ProblemFactCollectionProperty]
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
score: Annotated[Optional[HardSoftScore], PlanningScore] = field(default=None)
+96
View File
@@ -0,0 +1,96 @@
"""Timetable solver service — FastAPI entrypoint.
POST /api/v1/solve schedules a solve job (BackgroundTasks). Returns 202.
GET /api/v1/jobs/{solution_id} reads back tt_solution status from DB.
GET /health liveness probe for Docker.
The actual solver call lives in runner.py and runs in a worker thread, so
this process can accept multiple concurrent solves without blocking.
"""
import logging
import os
from fastapi import BackgroundTasks, FastAPI, HTTPException, status
from pydantic import BaseModel
from .config import settings
from .db import close_pool, get_pool
from .runner import run_solve
logging.basicConfig(level=os.getenv("LOG_LEVEL", settings.log_level))
logger = logging.getLogger(__name__)
app = FastAPI(title="BreakPilot Timetable Solver", version="0.1.0")
class SolveRequest(BaseModel):
solution_id: str
created_by_user_id: str
class SolveResponse(BaseModel):
solution_id: str
status: str
message: str
class JobStatus(BaseModel):
solution_id: str
status: str
hard_score: int | None = None
soft_score: int | None = None
error_message: str | None = None
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "healthy", "service": "timetable-solver"}
@app.post("/api/v1/solve", response_model=SolveResponse, status_code=status.HTTP_202_ACCEPTED)
async def solve(req: SolveRequest, bg: BackgroundTasks) -> SolveResponse:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT status FROM tt_solution WHERE id = $1 AND created_by_user_id = $2",
req.solution_id, req.created_by_user_id,
)
if row is None:
raise HTTPException(status_code=404, detail="Solution not found")
if row["status"] in ("running", "completed"):
return SolveResponse(
solution_id=req.solution_id, status=row["status"],
message="already in progress or finished",
)
bg.add_task(run_solve, req.solution_id, req.created_by_user_id)
logger.info("Solve queued for %s (user %s)", req.solution_id, req.created_by_user_id)
return SolveResponse(
solution_id=req.solution_id, status="queued",
message="job accepted, poll tt_solution for progress",
)
@app.get("/api/v1/jobs/{solution_id}", response_model=JobStatus)
async def job_status(solution_id: str) -> JobStatus:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT id::text, status, hard_score, soft_score, COALESCE(error_message, '') AS err
FROM tt_solution WHERE id = $1
""", solution_id)
if row is None:
raise HTTPException(status_code=404, detail="Solution not found")
return JobStatus(
solution_id=row["id"],
status=row["status"],
hard_score=row["hard_score"],
soft_score=row["soft_score"],
error_message=row["err"] or None,
)
@app.on_event("shutdown")
async def _on_shutdown() -> None:
await close_pool()
+320
View File
@@ -0,0 +1,320 @@
"""Read stammdaten + constraints from PostgreSQL and turn them into Timefold
domain objects. Used by runner.py to build a Timetable problem instance.
Ownership is enforced via created_by_user_id everywhere the solver only
sees data belonging to the Rektor who triggered the solve.
"""
from typing import Any
import asyncpg
from .domain import Lesson, Room, SchoolClass, Subject, Teacher, Timeslot, Timetable
from .rules import (
RoomRequiresTypeRule, RoomUnavailableRule,
SubjectPreferredPeriodRule, TeacherExcludedRoomRule,
TeacherUnavailableDayRule, TeacherUnavailableWindowRule,
)
async def build_problem(
pool: asyncpg.Pool,
user_id: str,
parent_solution_id: str | None = None,
) -> Timetable:
async with pool.acquire() as conn:
timeslots = await _load_timeslots(conn, user_id)
rooms = await _load_rooms(conn, user_id)
teachers = await _load_teachers(conn, user_id)
classes = await _load_classes(conn, user_id)
subjects = await _load_subjects(conn, user_id)
lessons = await _build_lessons(conn, user_id, classes, subjects, teachers)
rules = await _load_rules(conn, user_id)
if parent_solution_id:
await _inherit_pinned_from_parent(
conn, parent_solution_id, lessons, timeslots, rooms,
)
return Timetable(
timeslots=timeslots,
rooms=rooms,
teachers=teachers,
classes=classes,
subjects=subjects,
lessons=lessons,
score=None,
**rules,
)
async def _inherit_pinned_from_parent(
conn: asyncpg.Connection,
parent_solution_id: str,
lessons: list[Lesson],
timeslots: list[Timeslot],
rooms: list[Room],
) -> None:
"""Apply pinned lessons from a parent solution onto the new problem.
Matching rule: parent pinned lesson maps to a new Lesson with the same
(class_id, subject_id) that hasn't already received a pinned assignment.
Greedy first-fit so the count matches the parent's pinned count up to
the curriculum's weekly_hours per (class, subject). If curriculum
changed between solves, surplus pinned rows are silently dropped.
"""
rows = await conn.fetch("""
SELECT class_id::text, subject_id::text, room_id::text,
day_of_week, period_index
FROM tt_lesson
WHERE solution_id = $1 AND pinned = true
""", parent_solution_id)
if not rows:
return
ts_by_dp = {(t.day_of_week, t.period_index): t for t in timeslots}
room_by_id = {r.id: r for r in rooms}
used: set[str] = set()
for r in rows:
ts = ts_by_dp.get((r["day_of_week"], r["period_index"]))
if ts is None:
continue # period was deleted in the meantime
# Find a not-yet-pinned lesson with matching class+subject.
candidate: Lesson | None = None
for lesson in lessons:
if lesson.id in used:
continue
if (lesson.school_class.id == r["class_id"] and
lesson.subject.id == r["subject_id"]):
candidate = lesson
break
if candidate is None:
continue
candidate.timeslot = ts
room_id = r["room_id"]
if room_id:
candidate.room = room_by_id.get(room_id)
candidate.pinned = True
used.add(candidate.id)
async def _load_timeslots(conn: asyncpg.Connection, user_id: str) -> list[Timeslot]:
rows = await conn.fetch("""
SELECT id::text, day_of_week, period_index,
to_char(start_time, 'HH24:MI') AS st,
to_char(end_time, 'HH24:MI') AS et,
is_break
FROM tt_period
WHERE created_by_user_id = $1 AND is_break = false
ORDER BY day_of_week, period_index
""", user_id)
return [
Timeslot(id=r["id"], day_of_week=r["day_of_week"], period_index=r["period_index"],
start_time=r["st"], end_time=r["et"])
for r in rows
]
async def _load_rooms(conn: asyncpg.Connection, user_id: str) -> list[Room]:
rows = await conn.fetch("""
SELECT id::text, name, COALESCE(room_type, '') AS rt
FROM tt_room WHERE created_by_user_id = $1 ORDER BY name
""", user_id)
return [Room(id=r["id"], name=r["name"], room_type=r["rt"]) for r in rows]
async def _load_teachers(conn: asyncpg.Connection, user_id: str) -> list[Teacher]:
rows = await conn.fetch("""
SELECT id::text, first_name, last_name, short_code
FROM tt_teacher WHERE created_by_user_id = $1 ORDER BY last_name, first_name
""", user_id)
return [Teacher(id=r["id"], first_name=r["first_name"], last_name=r["last_name"], short_code=r["short_code"]) for r in rows]
async def _load_classes(conn: asyncpg.Connection, user_id: str) -> list[SchoolClass]:
rows = await conn.fetch("""
SELECT id::text, name, grade_level
FROM tt_class WHERE created_by_user_id = $1 ORDER BY grade_level, name
""", user_id)
return [SchoolClass(id=r["id"], name=r["name"], grade_level=r["grade_level"]) for r in rows]
async def _load_subjects(conn: asyncpg.Connection, user_id: str) -> list[Subject]:
rows = await conn.fetch("""
SELECT id::text, name, short_code, COALESCE(required_room_type, '') AS rt
FROM tt_subject WHERE created_by_user_id = $1 ORDER BY name
""", user_id)
return [Subject(id=r["id"], name=r["name"], short_code=r["short_code"], required_room_type=r["rt"]) for r in rows]
async def _build_lessons(
conn: asyncpg.Connection,
user_id: str,
classes: list[SchoolClass],
subjects: list[Subject],
teachers: list[Teacher],
) -> list[Lesson]:
"""Materialise curriculum × assignment into Lesson instances.
For each (class, subject) row in tt_curriculum with weekly_hours=N, we
create N Lesson rows. The teacher is the one assigned in tt_assignment
for the same (class, subject) there must be exactly one, else the
lesson can't be scheduled and is skipped (the UI surfaces this gap).
"""
rows = await conn.fetch("""
SELECT cu.class_id::text, cu.subject_id::text, cu.weekly_hours,
a.teacher_id::text
FROM tt_curriculum cu
JOIN tt_class cl ON cu.class_id = cl.id
LEFT JOIN tt_assignment a
ON a.class_id = cu.class_id AND a.subject_id = cu.subject_id
WHERE cl.created_by_user_id = $1
""", user_id)
class_by_id = {c.id: c for c in classes}
subject_by_id = {s.id: s for s in subjects}
teacher_by_id = {t.id: t for t in teachers}
lessons: list[Lesson] = []
counter = 0
for r in rows:
cls = class_by_id.get(r["class_id"])
sub = subject_by_id.get(r["subject_id"])
tch = teacher_by_id.get(r["teacher_id"]) if r["teacher_id"] else None
if cls is None or sub is None or tch is None:
# Missing assignment — solver can't schedule without a teacher.
continue
for _ in range(int(r["weekly_hours"])):
lessons.append(Lesson(
id=f"L{counter}-{cls.id[:6]}-{sub.id[:6]}",
school_class=cls,
subject=sub,
teacher=tch,
))
counter += 1
return lessons
async def _load_rules(conn: asyncpg.Connection, user_id: str) -> dict[str, list[Any]]:
"""Pull the subset of constraint tables the constraint provider uses."""
rules: dict[str, list[Any]] = {}
rows = await conn.fetch("""
SELECT teacher_id::text, day_of_week, is_hard, weight
FROM tt_constraint_teacher_unavailable_day
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_unavailable_days"] = [TeacherUnavailableDayRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT teacher_id::text, day_of_week,
to_char(start_time, 'HH24:MI') AS start_time,
to_char(end_time, 'HH24:MI') AS end_time,
is_hard, weight
FROM tt_constraint_teacher_unavailable_window
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_unavailable_windows"] = [TeacherUnavailableWindowRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT teacher_id::text, room_id::text, is_hard, weight
FROM tt_constraint_teacher_excluded_room
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_excluded_rooms"] = [TeacherExcludedRoomRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT room_id::text, day_of_week, period_index, is_hard, weight
FROM tt_constraint_room_unavailable
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["room_unavailables"] = [RoomUnavailableRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT subject_id::text, period_from, period_to, is_hard, weight
FROM tt_constraint_subject_preferred_period
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["subject_preferred_periods"] = [SubjectPreferredPeriodRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT subject_id::text, room_type, is_hard, weight
FROM tt_constraint_room_requires_type
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["room_requires_types"] = [RoomRequiresTypeRule(**dict(r)) for r in rows]
return rules
async def persist_solution(
pool: asyncpg.Pool,
solution_id: str,
timetable: Timetable,
hard_score: int,
soft_score: int,
) -> None:
"""Write the solver result back to tt_solution + tt_lesson."""
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("""
UPDATE tt_solution
SET status = 'completed',
hard_score = $2,
soft_score = $3,
finished_at = NOW()
WHERE id = $1
""", solution_id, hard_score, soft_score)
# Clear any prior lesson rows (re-solves overwrite).
await conn.execute("DELETE FROM tt_lesson WHERE solution_id = $1", solution_id)
for lesson in timetable.lessons:
if lesson.timeslot is None:
continue
await conn.execute("""
INSERT INTO tt_lesson
(solution_id, class_id, subject_id, teacher_id, room_id,
day_of_week, period_index, pinned)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8)
""",
solution_id,
lesson.school_class.id,
lesson.subject.id,
lesson.teacher.id,
lesson.room.id if lesson.room else None,
lesson.timeslot.day_of_week,
lesson.timeslot.period_index,
lesson.pinned,
)
async def mark_failed(pool: asyncpg.Pool, solution_id: str, error_message: str) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution
SET status = 'failed', error_message = $2, finished_at = NOW()
WHERE id = $1
""", solution_id, error_message)
async def mark_running(pool: asyncpg.Pool, solution_id: str) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution SET status = 'running', started_at = NOW()
WHERE id = $1
""", solution_id)
async def mark_infeasible(pool: asyncpg.Pool, solution_id: str, hard_score: int, soft_score: int) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution
SET status = 'infeasible',
hard_score = $2,
soft_score = $3,
finished_at = NOW()
WHERE id = $1
""", solution_id, hard_score, soft_score)
+64
View File
@@ -0,0 +1,64 @@
"""DB-driven constraint rules as Timefold problem facts.
Each tt_constraint_* table from school-service maps to one dataclass here.
Rows loaded at solve time are passed in via Timetable.* fact collections
(see domain.py for wiring) and queried by the constraint provider.
Only the rule types actually wired into constraints.py are defined for now.
Adding a new one is two steps: define the dataclass, add it to Timetable's
problem-fact properties, then implement a constraint that joins it.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class TeacherUnavailableDayRule:
teacher_id: str
day_of_week: int
is_hard: bool
weight: int
@dataclass(frozen=True)
class TeacherUnavailableWindowRule:
teacher_id: str
day_of_week: int
start_time: str # HH:MM
end_time: str # HH:MM
is_hard: bool
weight: int
@dataclass(frozen=True)
class TeacherExcludedRoomRule:
teacher_id: str
room_id: str
is_hard: bool
weight: int
@dataclass(frozen=True)
class RoomUnavailableRule:
room_id: str
day_of_week: int
period_index: int
is_hard: bool
weight: int
@dataclass(frozen=True)
class SubjectPreferredPeriodRule:
subject_id: str
period_from: int
period_to: int
is_hard: bool
weight: int
@dataclass(frozen=True)
class RoomRequiresTypeRule:
subject_id: str
room_type: str
is_hard: bool
weight: int
+113
View File
@@ -0,0 +1,113 @@
"""Solver job runner. One async entry point per solve.
Lifecycle:
1. mark_running -> tt_solution.status = 'running'
2. build_problem -> Timetable from DB
3. SolverFactory.buildSolver() -> Timefold solver
4. solver.solve(problem) -> completed Timetable
5. persist_solution or mark_infeasible based on hard_score
Errors at any step mark_failed.
Long solves are CPU-bound. We run the solver in an executor so the FastAPI
event loop stays responsive for other requests.
"""
import asyncio
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor
from timefold.solver import SolverFactory
from timefold.solver.config import (
SolverConfig,
ScoreDirectorFactoryConfig,
TerminationConfig,
Duration,
)
from .config import settings
from .constraints import define_constraints
from .db import get_pool
from .domain import Lesson, Timetable
from .repository import build_problem, mark_failed, mark_infeasible, mark_running, persist_solution
logger = logging.getLogger(__name__)
_executor = ThreadPoolExecutor(max_workers=2)
def _build_factory(seconds: int) -> SolverFactory:
"""One factory per solve so we can honour per-job timeout overrides.
Cheap to construct the Java side caches what it can across builds."""
return SolverFactory.create(
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints,
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=seconds),
),
)
)
def _solve_sync(problem: Timetable, seconds: int) -> Timetable:
"""Blocking solver call; runs in a worker thread."""
solver = _build_factory(seconds).build_solver()
return solver.solve(problem)
async def _fetch_solve_options(pool, solution_id: str) -> tuple[str | None, int]:
"""Read parent_solution_id + seconds_limit from tt_solution."""
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT parent_solution_id::text, seconds_limit
FROM tt_solution WHERE id = $1
""", solution_id)
parent = row["parent_solution_id"] if row else None
limit = row["seconds_limit"] if row and row["seconds_limit"] else settings.solver_seconds_limit
return parent, int(limit)
async def run_solve(solution_id: str, user_id: str) -> None:
"""Top-level async entry. Caller fires-and-forgets via BackgroundTasks."""
pool = await get_pool()
try:
parent_id, seconds = await _fetch_solve_options(pool, solution_id)
await mark_running(pool, solution_id)
problem = await build_problem(pool, user_id, parent_solution_id=parent_id)
if not problem.lessons:
await mark_failed(pool, solution_id,
"Keine Lessons — pruefe Stundentafel + Lehrauftraege.")
return
if not problem.timeslots:
await mark_failed(pool, solution_id,
"Kein Zeitraster definiert.")
return
if not problem.rooms:
await mark_failed(pool, solution_id,
"Keine Raeume definiert.")
return
loop = asyncio.get_running_loop()
solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem, seconds)
score = solved.score
hard = score.hard_score() if score else 0
soft = score.soft_score() if score else 0
if hard < 0:
await mark_infeasible(pool, solution_id, hard, soft)
logger.info("Solution %s infeasible: hard=%d soft=%d", solution_id, hard, soft)
else:
await persist_solution(pool, solution_id, solved, hard, soft)
logger.info("Solution %s completed: hard=%d soft=%d", solution_id, hard, soft)
except Exception as exc:
logger.exception("Solver failed for %s", solution_id)
try:
await mark_failed(pool, solution_id, f"{exc.__class__.__name__}: {exc}\n{traceback.format_exc()[:1000]}")
except Exception:
logger.exception("Failed to even mark solution as failed")
+13
View File
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "timetable-solver-service"
version = "0.1.0"
description = "BreakPilot timetable solver (Timefold + FastAPI)"
requires-python = ">=3.10"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
@@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
asyncpg==0.30.0
pydantic==2.9.2
pydantic-settings==2.6.0
timefold==1.24.0b0
httpx==0.27.2
python-multipart==0.0.12
@@ -0,0 +1,51 @@
"""Unit tests for the planning domain dataclasses.
These tests deliberately avoid spinning up the JVM-backed solver they
only verify that the domain objects construct, serialise, and compare as
expected. The full solver lifecycle is exercised by integration tests run
against a populated DB (Phase 8).
"""
from app.domain import Lesson, Room, SchoolClass, Subject, Teacher, Timeslot
def _ts() -> Timeslot:
return Timeslot(id="ts1", day_of_week=1, period_index=1, start_time="08:00", end_time="08:45")
def _room() -> Room:
return Room(id="r1", name="A101", room_type="standard")
def _teacher() -> Teacher:
return Teacher(id="t1", last_name="Schmidt", first_name="Anna", short_code="SCH")
def _class() -> SchoolClass:
return SchoolClass(id="c1", name="5a", grade_level=5)
def _subject() -> Subject:
return Subject(id="s1", name="Mathematik", short_code="M")
def test_timeslot_str() -> None:
assert str(_ts()) == "D1P1"
def test_teacher_str() -> None:
assert str(_teacher()) == "Schmidt, Anna"
def test_lesson_starts_unassigned() -> None:
lesson = Lesson(id="L1", school_class=_class(), subject=_subject(), teacher=_teacher())
assert lesson.timeslot is None
assert lesson.room is None
def test_lesson_accepts_assignment() -> None:
lesson = Lesson(id="L1", school_class=_class(), subject=_subject(), teacher=_teacher())
lesson.timeslot = _ts()
lesson.room = _room()
assert lesson.timeslot.day_of_week == 1
assert lesson.room.name == "A101"
@@ -0,0 +1,27 @@
"""Smoke tests for the constraint-rule dataclasses."""
from app.rules import (
RoomRequiresTypeRule, RoomUnavailableRule,
SubjectPreferredPeriodRule, TeacherExcludedRoomRule,
TeacherUnavailableDayRule, TeacherUnavailableWindowRule,
)
def test_rules_construct_with_expected_fields() -> None:
rules = [
TeacherUnavailableDayRule(teacher_id="t1", day_of_week=1, is_hard=True, weight=100),
TeacherUnavailableWindowRule(teacher_id="t1", day_of_week=2, start_time="13:00", end_time="17:00", is_hard=True, weight=100),
TeacherExcludedRoomRule(teacher_id="t1", room_id="r1", is_hard=True, weight=100),
RoomUnavailableRule(room_id="r1", day_of_week=3, period_index=4, is_hard=True, weight=100),
SubjectPreferredPeriodRule(subject_id="s1", period_from=1, period_to=4, is_hard=False, weight=40),
RoomRequiresTypeRule(subject_id="s1", room_type="Sporthalle", is_hard=True, weight=100),
]
assert len(rules) == 6
def test_rules_are_hashable_frozen() -> None:
# Frozen dataclasses are hashable, important when Timefold inserts them
# into hash-based caches inside the solver.
rule = TeacherUnavailableDayRule(teacher_id="t1", day_of_week=1, is_hard=True, weight=100)
s = {rule, rule}
assert len(s) == 1