Compare commits

...

39 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
Benjamin Admin 082a5bb68c Strip orphan straight-quote pairings in JSX descriptions
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 30s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 21s
The German „X" markers in the description prop combined a curly „
(U+201E) with a straight " (U+0022). The straight quote prematurely
terminated the JavaScript string inside the JSX expression. Removing
both markers around the example text keeps the description readable
and unambiguously valid JSX.

Test selector for the UnavailableWindow description updated to match
the new wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:52:09 +02:00
Benjamin Admin a315db0388 Fix JSX attribute syntax in constraint editor descriptions
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 33s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 24s
German curly quotes („…") combined with a closing straight " inside
JSX attribute values were terminating the attribute prematurely, e.g.
`description="Beispiel: „X" (jugendgerecht)."` lost everything after
the inner straight quote. Switch all such descriptions to the JSX
expression form `description={"…"}` so the inner quotes are part of
a JavaScript string literal and parsed correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:41:24 +02:00
Benjamin Admin 7c96d89927 Stundenplan Phase 3d: all 15 constraint editors via shared shell
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 30s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 20s
Backend was already complete in Phase 2; this finishes the UI.

  - regeln/_shell.tsx introduces useConstraintCrud (handles list/create/
    delete state + reload), ConstraintShell (header, prereq banner,
    form toggle, error display, empty/loading/table render), and
    useShellStyles for the recurring theme tokens. Each editor now
    only carries its schema-specific bits.
  - Existing 4 editors (TeacherUnavailableDay/Window, SubjectMax
    Consecutive/PreferredPeriod) refactored onto the shell — every
    Playwright selector preserved.
  - 11 new editors covering the remaining constraint tables:
      TeacherMaxHours{Day,Week}, TeacherExcluded{Subject,Room},
      Subject{MinDayGap,ContiguousWhenRepeated,DoubleLesson},
      Class{MaxHoursDay,NoGaps},
      Room{RequiresType,Unavailable}.
  - RegelnHub now references all 15 editors directly — no more 'soon'
    placeholders. The two duplicate 'Max. Stunden / Tag' entries
    (teacher + class) are intentional and disambiguated by group.

Tests:
  - e2e/stundenplan.spec.ts: mock routes added for all 11 new constraint
    endpoints. RegelnHub suite gains a single test that switches
    through 13 uniquely-labelled editors, plus a dedicated test for
    the two duplicate 'Max. Stunden / Tag' labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:27:34 +02:00
Benjamin Admin c2c09e1cd9 Stundenplan Phase 3c: complete Stammdaten + RegelnHub with 4 editors
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 3m31s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
Frontend additions in studio-v2:
  - PeriodsManager renders the weekly grid as a Mo–So table with one
    row per period_index. New entries auto-increment period_index so
    the user can hit Anlegen repeatedly for a full day's slots.
  - CurriculumManager joins classes + subjects; new entries refuse to
    open when either prerequisite list is empty (banner instead).
  - AssignmentsManager joins teacher × class × subject with the same
    prerequisite-banner pattern.
  - regeln/RegelnHub: vertical sidebar grouping all 15 constraint
    types by parent entity (Lehrer/Fach/Klasse/Raum). Implemented
    editors are clickable, the other 11 are visibly disabled with
    a 'soon' tag.
  - Three new editors:
      TeacherUnavailableWindowEditor (time-window pattern),
      SubjectMaxConsecutiveEditor (number-input pattern),
      SubjectPreferredPeriodEditor (number range pattern).
  - page.tsx wires every tab to its manager; the not-implemented
    placeholder is gone (no more empty tabs).

Test coverage:
  - e2e/stundenplan.spec.ts rewritten: 23 tests across 7 suites,
    covering all 8 tabs, the new managers' prerequisite banners,
    sub-tab switching in the RegelnHub, and the disabled state of
    not-yet-implemented constraint rules. Each test mocks the
    backend via page.route() so the suite stays hermetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:08:15 +02:00
Benjamin Admin 4657589b89 Fix Playwright selector ambiguities in stundenplan.spec.ts
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 31s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 22s
- 'Lehrer' label collided with a Sidebar entry; scope to <main nav>.
- 'Schmidt, Anna' lived in a closed <option>; assert on count via
  select.locator() instead of toBeVisible().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:53:10 +02:00
Benjamin Admin 73636f76a2 Stundenplan Phase 3b: 3 more Stammdaten managers, first constraint editor, full test coverage
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 30s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m6s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 21s
Frontend additions in studio-v2:
  - LehrerManager / FaecherManager / RaeumeManager — same CRUD pattern as
    Klassen, with entity-specific form fields and table columns.
  - regeln/TeacherUnavailableDayEditor — first constraint editor, joins
    against teachersApi to render a readable name in the dropdown and
    list. Falls back to a guidance banner when no teachers exist yet.
  - page.tsx wires up the new tabs; data-testid attributes added across
    managers so the Playwright suite can target them deterministically.

Tests:
  - school-service: timetable_constraints_more_test.go fills the
    remaining 9 constraint DTOs (TeacherMaxHoursDay/Week,
    TeacherExcludedSubject/Room, SubjectMinDayGap,
    SubjectContiguousWhenRepeated, SubjectDoubleLesson, ClassNoGaps,
    RoomRequiresType). 66 subtests total, all green.
  - studio-v2: e2e/stundenplan.spec.ts covers the page shell, tab
    navigation, Klassen CRUD with mocked backend, constraint editor's
    empty-teacher fallback, sidebar entry. All school-service calls
    intercepted via page.route() so the suite is hermetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:44:39 +02:00
Benjamin Admin f21ecf293b Add Stundenplan frontend scaffolding in studio-v2
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 29s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 22s
Phase 3 — initial UI for the timetable scheduler:
  - app/stundenplan/page.tsx with tab navigation (Klassen / Lehrer /
    Faecher / Raeume / Zeitraster / Stundentafel / Lehrauftraege /
    Regeln) and a dev-mode JWT entry to authenticate against
    school-service until full auth is wired up.
  - app/stundenplan/_components/KlassenManager.tsx as the working
    prototype for one entity (list / create / delete). Pattern can be
    copied for the other 6 stammdaten + 15 constraint editors.
  - lib/stundenplan/api.ts exposing typed clients for all 22 endpoints
    (7 stammdaten + 15 constraint tables). Constraints use a factory
    to keep the file tight.
  - app/api/school/[...path]/route.ts proxies the browser through
    Next.js to school-service so HTTPS studio-v2 can reach the plain
    HTTP backend.
  - Sidebar.tsx gains a Stundenplan entry with 26-language labels.
  - docker-compose.yml exposes SCHOOL_SERVICE_URL to studio-v2 and
    declares the school-service dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:25:18 +02:00
Benjamin Admin 64e7176267 Apply gofmt to timetable.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 39s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 21s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:13:17 +02:00
Benjamin Admin e958f88a2d Add timetable scheduler Phases 1 + 2 to school-service
Phase 1 — Stammdaten (7 tables):
  tt_class, tt_period, tt_room, tt_subject, tt_teacher,
  tt_curriculum, tt_assignment with CRUD endpoints.

Phase 2 — Constraints (15 typed tables):
  Teacher (6): unavailable_day, unavailable_window, max_hours_day,
    max_hours_week, excluded_subject, excluded_room
  Subject (5): min_day_gap, max_consecutive, contiguous_when_repeated,
    preferred_period, double_lesson
  Class (2): max_hours_day, no_gaps
  Room (2): requires_type, unavailable

Each constraint row carries is_hard / weight / active / note /
created_by_user_id; ownership enforced via WHERE EXISTS against the
parent tt_teacher/tt_class/tt_subject/tt_room row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:12:23 +02:00
Benjamin Admin a1488b2fec Fix: enrich non-EN Kaikki search results with translations from EN hub
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 33s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m33s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 24s
When searching for DE/FR/ES/etc. words, the Kaikki entries have empty
translations. Now does a reverse lookup to find the EN entry and copies
its 24-language translations. This ensures wordInNative() works for
all languages, not just the original 7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:40:29 +02:00
Benjamin Admin 8d53b1f6b9 Translate Sidebar nav labels into 26 languages
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 30s
CI / test-python-klausur (push) Failing after 2m22s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 20s
- Lernmodule, Eltern, Woerterbuch, Meet, KI-Assistent now translated
- Uses NAV_LABELS dict with fallback chain (lang → en → de)
- Fixed "nav_eltern" → shows "Eltern"/"Parents"/"Ebeveyn" etc.
- Fixed "Lernmodule" hardcoded → uses nav_lernmodule key
- Woerterbuch link now points to /vocabulary (not /vocab-worksheet)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:11 +02:00
Benjamin Admin 399ab88f5f Translate language dropdown tabs + add info box in 26 languages
CI / test-go-edu-search (push) Waiting to run
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-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 28s
- Tab labels (Muttersprache/Schulsprache) now translate with selected language
  e.g. Turkish: "Ana dilim" / "Okul dili"
- Info box below tabs explains each setting in the user's native language
  e.g. "Evde konustunuz dil. Tum menuler bu dilde gorunecektir."
- Wider dropdown (w-72) to fit translated text
- All 26 European languages covered

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:23:05 +02:00
Benjamin Admin d52eb43a32 Fix: auto-fill target translation with best match (not only single result)
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 30s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m18s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 22s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:00:19 +02:00
Benjamin Admin bde0d57b5a Add Schulsprache (school language) as second language setting
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 28s
CI / test-python-klausur (push) Failing after 2m21s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
- LanguageContext: new schoolLanguage + setSchoolLanguage
- LanguageDropdown: two tabs (Muttersprache / Schulsprache) with flag selection
- UnitBuilder: defaults target language to schoolLanguage
- Stored in bp_school_language localStorage (default: de)
- Shows school flag badge next to main language when different

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 17:49:58 +02:00
Benjamin Admin fc49d87928 Add exercise translations for all 26 European languages
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 32s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m26s
CI / test-python-agent-core (push) Successful in 34s
CI / test-nodejs-website (push) Successful in 23s
Buttons (Richtig/Falsch/Weiter), instructions, labels — now translated
for FR, ES, IT, PT, NL, RO, EL, BG, HR, CS, HU, SV, DA, FI, SK, SL, LT, LV, ET.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 17:30:35 +02:00
Benjamin Admin 0018076ed5 Unify language system: one setting for all modules
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 41s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m29s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 26s
- Merge two separate language systems (bp_language + bp_native_language) into one
- NativeLanguageContext now reads from LanguageContext (same localStorage key)
- Extend i18n.ts to 26 languages with flags (UI falls back to EN/DE)
- Replace LanguageSwitcher with LanguageDropdown (flags) in learn + parent layouts
- Migration: old bp_native_language value auto-migrates to bp_language
- Onboarding page writes to bp_language (unified key)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 16:54:27 +02:00
Benjamin Admin a30f10a467 Widen AudioButton lang prop to string for multi-language support
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 43s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 16:19:45 +02:00
Benjamin Admin a44d360cbc Fix useRef initial value for React 19 compatibility
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 39s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m33s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 16:04:43 +02:00
Benjamin Admin 52a15b24fe Add custom word entry + language pair support for learning units
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 31s
CI / test-python-klausur (push) Failing after 2m29s
CI / test-python-agent-core (push) Successful in 24s
CI / test-nodejs-website (push) Successful in 22s
- New UnitBuilder component with language pair selector (DE⇄EN, ES, FR, etc.)
- Manual word entry form with auto-suggest from Kaikki dictionary (6M words)
- "No results" prompt to add multi-word terms (e.g. "schottisches Hochland")
- New backend endpoint GET /vocabulary/lookup-translation (any→any via EN hub)
- Updated POST /vocabulary/units: accepts custom_words + source_lang/target_lang
- Split unit endpoints into vocabulary/unit_api.py (500 LOC budget)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 15:24:13 +02:00
145 changed files with 23662 additions and 453 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 |
+4
View File
@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
from vocabulary.api import router as vocabulary_router
app.include_router(vocabulary_router, prefix="/api")
# --- 4c2. Vocabulary Unit Creation + Translation ---
from vocabulary.unit_api import router as vocab_unit_router
app.include_router(vocab_unit_router, prefix="/api")
# --- 4d. User Language Preferences ---
from api.user_language import router as user_language_router
app.include_router(user_language_router, prefix="/api")
+29 -132
View File
@@ -22,11 +22,6 @@ from .db import (
get_all_pos,
VocabularyWord,
)
from units.learning import (
LearningUnitCreate,
create_learning_unit,
get_learning_unit,
)
logger = logging.getLogger(__name__)
@@ -87,13 +82,38 @@ async def _search_kaikki(q: str, lang: str, limit: int, offset: int):
if isinstance(tr, str):
import json as _json
tr = _json.loads(tr)
en_word = ""
en_ipa = ""
if r["lang"] == "en":
en_word = r["word"]
en_ipa = r["ipa"] or ""
else:
# Non-EN entries have empty translations — enrich from EN via reverse lookup
if not tr or len(tr) < 3:
async with pool.acquire() as conn2:
en_row = await conn2.fetchrow(
"""SELECT word, ipa, translations FROM vocabulary_kaikki
WHERE lang = 'en' AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word) LIMIT 1""" % lang,
r["word"],
)
if en_row:
en_word = en_row["word"]
en_ipa = en_row["ipa"] or ""
en_tr = en_row["translations"]
if isinstance(en_tr, str):
en_tr = _json.loads(en_tr)
tr = en_tr
words.append({
"id": str(r["id"]),
"english": r["word"] if r["lang"] == "en" else "",
"german": tr.get("de", {}).get("text", "") if r["lang"] == "en" else r["word"] if r["lang"] == "de" else "",
"english": en_word if r["lang"] != "en" else r["word"],
"german": tr.get("de", {}).get("text", "") if r["lang"] != "de" else r["word"],
"word": r["word"],
"lang": r["lang"],
"ipa_en": r["ipa"] if r["lang"] == "en" else "",
"ipa_en": en_ipa if r["lang"] != "en" else (r["ipa"] or ""),
"ipa_de": r["ipa"] if r["lang"] == "de" else "",
"part_of_speech": r["pos"],
"syllables_en": [],
@@ -239,130 +259,7 @@ async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
# ---------------------------------------------------------------------------
class CreateUnitFromWordsPayload(BaseModel):
title: str
word_ids: List[str]
grade: Optional[str] = None
language: Optional[str] = "de"
@router.post("/units")
async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
"""Create a learning unit from selected vocabulary word IDs.
Fetches full word details, creates a LearningUnit in the
learning_units system, and stores the vocabulary data.
"""
if not payload.word_ids:
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
# Fetch all selected words
words = []
for wid in payload.word_ids:
word = await get_word(wid)
if word:
words.append(word)
if not words:
raise HTTPException(status_code=404, detail="Keine der Woerter gefunden")
# Create learning unit
lu = create_learning_unit(LearningUnitCreate(
title=payload.title,
topic="Vocabulary",
grade_level=payload.grade or "5-8",
language=payload.language or "de",
status="raw",
))
# Save vocabulary data as analysis JSON for generators
import os
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
vocab_data = [w.to_dict() for w in words]
analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json")
with open(analysis_path, "w", encoding="utf-8") as f:
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
# Also save as QA items for flashcards/type trainer
qa_items = []
for i, w in enumerate(words):
qa_items.append({
"id": f"qa_{i+1}",
"question": w.english,
"answer": w.german,
"question_type": "knowledge",
"key_terms": [w.english],
"difficulty": w.difficulty,
"source_hint": w.part_of_speech,
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
# Extra fields for enhanced flashcards
"ipa_en": w.ipa_en,
"ipa_de": w.ipa_de,
"syllables_en": w.syllables_en,
"syllables_de": w.syllables_de,
"example_en": w.example_en,
"example_de": w.example_de,
"image_url": w.image_url,
"audio_url_en": w.audio_url_en,
"audio_url_de": w.audio_url_de,
"part_of_speech": w.part_of_speech,
"translations": w.translations,
})
qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json")
with open(qa_path, "w", encoding="utf-8") as f:
json.dump({
"qa_items": qa_items,
"metadata": {
"subject": "English Vocabulary",
"grade_level": payload.grade or "5-8",
"source_title": payload.title,
"total_questions": len(qa_items),
},
}, f, ensure_ascii=False, indent=2)
# Auto-enrich words with images (Wikipedia + emoji fallback)
try:
from services.image_service import enrich_words_with_images
await enrich_words_with_images(payload.word_ids)
except Exception as e:
logger.warning(f"Image enrichment failed (non-critical): {e}")
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
return {
"unit_id": lu.id,
"title": payload.title,
"word_count": len(words),
"status": "created",
}
@router.get("/units/{unit_id}")
async def api_get_unit_words(unit_id: str):
"""Get all words for a learning unit."""
import os
vocab_path = os.path.join(
os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"),
f"{unit_id}_vocab.json",
)
if not os.path.exists(vocab_path):
raise HTTPException(status_code=404, detail="Unit nicht gefunden")
with open(vocab_path, "r", encoding="utf-8") as f:
data = json.load(f)
return {
"unit_id": unit_id,
"title": data.get("title", ""),
"words": data.get("words", []),
}
# Unit creation and translation lookup moved to vocabulary/unit_api.py
# ---------------------------------------------------------------------------
+356
View File
@@ -0,0 +1,356 @@
"""
Vocabulary Unit API — Create learning units, translate words, manage language pairs.
Endpoints for teachers to build vocabulary learning units with custom words,
auto-translation via Kaikki dictionary, and flexible language pair support.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from .db import get_word, VocabularyWord, get_pool
from units.learning import LearningUnitCreate, create_learning_unit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
# All supported language codes
SUPPORTED_LANGS = {
"en", "de", "fr", "es", "it", "pt", "nl", "tr", "ru", "ar",
"uk", "pl", "sv", "fi", "da", "ro", "el", "hu", "cs", "bg",
"lv", "lt", "sk", "et", "sl", "hr",
}
# ---------------------------------------------------------------------------
# Translation Lookup (auto-suggest)
# ---------------------------------------------------------------------------
@router.get("/lookup-translation")
async def api_lookup_translation(
word: str = Query("", min_length=1, description="Word to translate"),
source: str = Query("en", description="Source language code"),
target: str = Query("de", description="Target language code"),
limit: int = Query(5, ge=1, le=20),
):
"""Look up translations between any two languages via Kaikki dictionary.
Uses EN entries as a hub: all EN words have translations to 24 languages.
- EN → X: direct lookup (word in EN, translation from JSONB)
- X → EN: reverse lookup (search EN entries where translations.X matches)
- X → Y: bridge via EN (find EN word via X, then get Y translation)
"""
if source not in SUPPORTED_LANGS or target not in SUPPORTED_LANGS:
raise HTTPException(status_code=400, detail="Sprache nicht unterstuetzt")
if source == target:
return {"results": [], "word": word, "source": source, "target": target}
pool = await get_pool()
q = word.strip()
results = []
async with pool.acquire() as conn:
if source == "en":
# Direct: search EN word, return target translation
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations
FROM vocabulary_kaikki
WHERE lang = 'en' AND lower(word) LIKE $1
ORDER BY length(word), lower(word)
LIMIT $2""",
f"{q.lower()}%", limit,
)
for r in rows:
tr = _parse_translations(r["translations"])
target_text = tr.get(target, {}).get("text", "")
if target_text:
results.append({
"source_text": r["word"],
"target_text": target_text,
"pos": r["pos"],
"ipa": r["ipa"] or "",
})
elif target == "en":
# Reverse: search EN entries where translations.source matches
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations->'%s'->>'text' as src_text
FROM vocabulary_kaikki
WHERE lang = 'en'
AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word)
LIMIT $2""" % (source, source),
f"{q}%", limit,
)
for r in rows:
results.append({
"source_text": r["src_text"],
"target_text": r["word"],
"pos": r["pos"],
"ipa": r["ipa"] or "",
})
else:
# Bridge via EN: find EN word via source, then get target translation
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations
FROM vocabulary_kaikki
WHERE lang = 'en'
AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word)
LIMIT $2""" % source,
f"{q}%", limit,
)
for r in rows:
tr = _parse_translations(r["translations"])
src_text = tr.get(source, {}).get("text", "")
target_text = tr.get(target, {}).get("text", "")
if src_text and target_text:
results.append({
"source_text": src_text,
"target_text": target_text,
"pos": r["pos"],
"ipa": "",
})
return {"results": results, "word": q, "source": source, "target": target}
def _parse_translations(tr) -> dict:
"""Parse translations field (may be JSONB dict or JSON string)."""
if isinstance(tr, str):
return json.loads(tr)
return tr or {}
# ---------------------------------------------------------------------------
# Unit Creation (with custom words + language pair)
# ---------------------------------------------------------------------------
class CustomWord(BaseModel):
source_text: str
target_text: str
class CreateUnitPayload(BaseModel):
title: str
word_ids: List[str] = []
custom_words: List[CustomWord] = []
source_lang: str = "en"
target_lang: str = "de"
grade: Optional[str] = None
@router.post("/units")
async def api_create_unit_from_words(payload: CreateUnitPayload):
"""Create a learning unit from dictionary words and/or custom word pairs.
Supports any language pair. Words can come from:
1. word_ids — looked up in Kaikki dictionary
2. custom_words — manually entered source/target pairs
"""
if not payload.word_ids and not payload.custom_words:
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
qa_items = []
vocab_data = []
idx = 0
# 1. Process dictionary words
for wid in payload.word_ids:
word = await get_word(wid)
if not word:
# Try Kaikki lookup
kaikki_word = await _get_kaikki_word(wid, payload.source_lang, payload.target_lang)
if kaikki_word:
qa_items.append(_make_qa_item(idx, kaikki_word, payload.source_lang, payload.target_lang))
vocab_data.append(kaikki_word)
idx += 1
continue
# Manual vocabulary_words entry
source_text, target_text = _get_word_pair(word, payload.source_lang, payload.target_lang)
qa_items.append({
"id": f"qa_{idx+1}",
"question": source_text,
"answer": target_text,
"question_type": "knowledge",
"key_terms": [source_text],
"difficulty": word.difficulty,
"source_hint": word.part_of_speech,
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"ipa_en": word.ipa_en,
"ipa_de": word.ipa_de,
"syllables_en": word.syllables_en,
"syllables_de": word.syllables_de,
"example_en": word.example_en,
"example_de": word.example_de,
"image_url": word.image_url,
"audio_url_en": word.audio_url_en,
"audio_url_de": word.audio_url_de,
"part_of_speech": word.part_of_speech,
"translations": word.translations,
})
vocab_data.append(word.to_dict())
idx += 1
# 2. Process custom words (manually entered by teacher)
for cw in payload.custom_words:
qa_items.append({
"id": f"qa_{idx+1}",
"question": cw.source_text,
"answer": cw.target_text,
"question_type": "knowledge",
"key_terms": [cw.source_text],
"difficulty": 1,
"source_hint": "",
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"part_of_speech": "",
"translations": {},
})
vocab_data.append({
"english": cw.source_text if payload.source_lang == "en" else cw.target_text if payload.target_lang == "en" else "",
"german": cw.source_text if payload.source_lang == "de" else cw.target_text if payload.target_lang == "de" else "",
"word": cw.source_text,
"translation": cw.target_text,
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
})
idx += 1
if not qa_items:
raise HTTPException(status_code=400, detail="Keine gültigen Woerter")
# Create learning unit
lang_label = f"{payload.source_lang.upper()}{payload.target_lang.upper()}"
lu = create_learning_unit(LearningUnitCreate(
title=payload.title,
topic="Vocabulary",
grade_level=payload.grade or "5-8",
language=payload.target_lang,
status="raw",
))
# Save files
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
with open(os.path.join(analysis_dir, f"{lu.id}_vocab.json"), "w", encoding="utf-8") as f:
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
with open(os.path.join(analysis_dir, f"{lu.id}_qa.json"), "w", encoding="utf-8") as f:
json.dump({
"qa_items": qa_items,
"metadata": {
"subject": f"Vocabulary {lang_label}",
"grade_level": payload.grade or "5-8",
"source_title": payload.title,
"total_questions": len(qa_items),
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
},
}, f, ensure_ascii=False, indent=2)
# Auto-enrich images for dictionary words
dict_ids = [wid for wid in payload.word_ids]
if dict_ids:
try:
from services.image_service import enrich_words_with_images
await enrich_words_with_images(dict_ids)
except Exception as e:
logger.warning(f"Image enrichment failed (non-critical): {e}")
logger.info(f"Created vocab unit {lu.id} ({lang_label}) with {len(qa_items)} words")
return {
"unit_id": lu.id,
"title": payload.title,
"word_count": len(qa_items),
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
"status": "created",
}
def _get_word_pair(word: VocabularyWord, source_lang: str, target_lang: str):
"""Extract source/target text from a VocabularyWord for the given language pair."""
lang_map = {"en": word.english, "de": word.german}
# Check translations for other languages
if source_lang not in lang_map:
tr = word.translations or {}
lang_map[source_lang] = tr.get(source_lang, {}).get("text", word.english)
if target_lang not in lang_map:
tr = word.translations or {}
lang_map[target_lang] = tr.get(target_lang, {}).get("text", word.german)
return lang_map.get(source_lang, word.english), lang_map.get(target_lang, word.german)
async def _get_kaikki_word(word_id: str, source_lang: str, target_lang: str) -> Optional[dict]:
"""Look up a word by ID in the Kaikki table and return a vocab dict."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, word, lang, pos, ipa, translations, example FROM vocabulary_kaikki WHERE id = $1",
_to_uuid(word_id),
)
if not row:
return None
tr = _parse_translations(row["translations"])
src = row["word"] if row["lang"] == source_lang else tr.get(source_lang, {}).get("text", "")
tgt = tr.get(target_lang, {}).get("text", "") if row["lang"] != target_lang else row["word"]
return {
"id": str(row["id"]),
"word": row["word"],
"lang": row["lang"],
"source_text": src or row["word"],
"target_text": tgt,
"pos": row["pos"],
"ipa": row["ipa"] or "",
"example": row["example"] or "",
"translations": tr,
}
def _make_qa_item(idx: int, kw: dict, source_lang: str, target_lang: str) -> dict:
"""Create a QA item from a Kaikki word dict."""
return {
"id": f"qa_{idx+1}",
"question": kw.get("source_text", kw.get("word", "")),
"answer": kw.get("target_text", ""),
"question_type": "knowledge",
"key_terms": [kw.get("source_text", kw.get("word", ""))],
"difficulty": 0,
"source_hint": kw.get("pos", ""),
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"ipa_en": kw.get("ipa", "") if source_lang == "en" else "",
"ipa_de": kw.get("ipa", "") if source_lang == "de" else "",
"part_of_speech": kw.get("pos", ""),
"translations": kw.get("translations", {}),
}
def _to_uuid(s: str):
"""Convert string to UUID, return as-is if already valid."""
import uuid
try:
return uuid.UUID(s)
except (ValueError, AttributeError):
return s
+22
View File
@@ -109,8 +109,10 @@ services:
environment:
NODE_ENV: production
BACKEND_URL: http://backend-lehrer:8001
SCHOOL_SERVICE_URL: http://school-service:8084
depends_on:
- backend-lehrer
- school-service
restart: unless-stopped
networks:
- breakpilot-network
@@ -287,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
+188 -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)
@@ -123,6 +160,155 @@ func main() {
api.PUT("/certificates/detail/:id/finalize", handler.FinalizeCertificate)
api.GET("/certificates/detail/:id/pdf", handler.GetCertificatePDF)
api.DELETE("/certificates/detail/:id", handler.DeleteCertificate)
// Timetable Scheduler — Stammdaten
api.GET("/timetable/classes", handler.ListTimetableClasses)
api.POST("/timetable/classes", handler.CreateTimetableClass)
api.DELETE("/timetable/classes/:id", handler.DeleteTimetableClass)
api.GET("/timetable/periods", handler.ListTimetablePeriods)
api.POST("/timetable/periods", handler.CreateTimetablePeriod)
api.DELETE("/timetable/periods/:id", handler.DeleteTimetablePeriod)
api.GET("/timetable/rooms", handler.ListTimetableRooms)
api.POST("/timetable/rooms", handler.CreateTimetableRoom)
api.DELETE("/timetable/rooms/:id", handler.DeleteTimetableRoom)
api.GET("/timetable/subjects", handler.ListTimetableSubjects)
api.POST("/timetable/subjects", handler.CreateTimetableSubject)
api.DELETE("/timetable/subjects/:id", handler.DeleteTimetableSubject)
api.GET("/timetable/teachers", handler.ListTimetableTeachers)
api.POST("/timetable/teachers", handler.CreateTimetableTeacher)
api.DELETE("/timetable/teachers/:id", handler.DeleteTimetableTeacher)
// Timetable Scheduler — Relations
api.GET("/timetable/curriculum", handler.ListTimetableCurriculum)
api.POST("/timetable/curriculum", handler.CreateTimetableCurriculum)
api.DELETE("/timetable/curriculum/:id", handler.DeleteTimetableCurriculum)
api.GET("/timetable/assignments", handler.ListTimetableAssignments)
api.POST("/timetable/assignments", handler.CreateTimetableAssignment)
api.DELETE("/timetable/assignments/:id", handler.DeleteTimetableAssignment)
// Timetable Scheduler — Constraints (15 typed tables)
// Teacher
api.GET("/timetable/constraints/teacher/unavailable-day", handler.ListTeacherUnavailableDays)
api.POST("/timetable/constraints/teacher/unavailable-day", handler.CreateTeacherUnavailableDay)
api.DELETE("/timetable/constraints/teacher/unavailable-day/:id", handler.DeleteTeacherUnavailableDay)
api.GET("/timetable/constraints/teacher/unavailable-window", handler.ListTeacherUnavailableWindows)
api.POST("/timetable/constraints/teacher/unavailable-window", handler.CreateTeacherUnavailableWindow)
api.DELETE("/timetable/constraints/teacher/unavailable-window/:id", handler.DeleteTeacherUnavailableWindow)
api.GET("/timetable/constraints/teacher/max-hours-day", handler.ListTeacherMaxHoursDay)
api.POST("/timetable/constraints/teacher/max-hours-day", handler.CreateTeacherMaxHoursDay)
api.DELETE("/timetable/constraints/teacher/max-hours-day/:id", handler.DeleteTeacherMaxHoursDay)
api.GET("/timetable/constraints/teacher/max-hours-week", handler.ListTeacherMaxHoursWeek)
api.POST("/timetable/constraints/teacher/max-hours-week", handler.CreateTeacherMaxHoursWeek)
api.DELETE("/timetable/constraints/teacher/max-hours-week/:id", handler.DeleteTeacherMaxHoursWeek)
api.GET("/timetable/constraints/teacher/excluded-subject", handler.ListTeacherExcludedSubjects)
api.POST("/timetable/constraints/teacher/excluded-subject", handler.CreateTeacherExcludedSubject)
api.DELETE("/timetable/constraints/teacher/excluded-subject/:id", handler.DeleteTeacherExcludedSubject)
api.GET("/timetable/constraints/teacher/excluded-room", handler.ListTeacherExcludedRooms)
api.POST("/timetable/constraints/teacher/excluded-room", handler.CreateTeacherExcludedRoom)
api.DELETE("/timetable/constraints/teacher/excluded-room/:id", handler.DeleteTeacherExcludedRoom)
// Subject
api.GET("/timetable/constraints/subject/min-day-gap", handler.ListSubjectMinDayGaps)
api.POST("/timetable/constraints/subject/min-day-gap", handler.CreateSubjectMinDayGap)
api.DELETE("/timetable/constraints/subject/min-day-gap/:id", handler.DeleteSubjectMinDayGap)
api.GET("/timetable/constraints/subject/max-consecutive", handler.ListSubjectMaxConsecutives)
api.POST("/timetable/constraints/subject/max-consecutive", handler.CreateSubjectMaxConsecutive)
api.DELETE("/timetable/constraints/subject/max-consecutive/:id", handler.DeleteSubjectMaxConsecutive)
api.GET("/timetable/constraints/subject/contiguous-when-repeated", handler.ListSubjectContiguousWhenRepeated)
api.POST("/timetable/constraints/subject/contiguous-when-repeated", handler.CreateSubjectContiguousWhenRepeated)
api.DELETE("/timetable/constraints/subject/contiguous-when-repeated/:id", handler.DeleteSubjectContiguousWhenRepeated)
api.GET("/timetable/constraints/subject/preferred-period", handler.ListSubjectPreferredPeriods)
api.POST("/timetable/constraints/subject/preferred-period", handler.CreateSubjectPreferredPeriod)
api.DELETE("/timetable/constraints/subject/preferred-period/:id", handler.DeleteSubjectPreferredPeriod)
api.GET("/timetable/constraints/subject/double-lesson", handler.ListSubjectDoubleLessons)
api.POST("/timetable/constraints/subject/double-lesson", handler.CreateSubjectDoubleLesson)
api.DELETE("/timetable/constraints/subject/double-lesson/:id", handler.DeleteSubjectDoubleLesson)
// Class
api.GET("/timetable/constraints/class/max-hours-day", handler.ListClassMaxHoursDay)
api.POST("/timetable/constraints/class/max-hours-day", handler.CreateClassMaxHoursDay)
api.DELETE("/timetable/constraints/class/max-hours-day/:id", handler.DeleteClassMaxHoursDay)
api.GET("/timetable/constraints/class/no-gaps", handler.ListClassNoGaps)
api.POST("/timetable/constraints/class/no-gaps", handler.CreateClassNoGaps)
api.DELETE("/timetable/constraints/class/no-gaps/:id", handler.DeleteClassNoGaps)
// Room
api.GET("/timetable/constraints/room/requires-type", handler.ListRoomRequiresTypes)
api.POST("/timetable/constraints/room/requires-type", handler.CreateRoomRequiresType)
api.DELETE("/timetable/constraints/room/requires-type/:id", handler.DeleteRoomRequiresType)
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)`,
}
}
@@ -212,6 +212,24 @@ func Migrate(db *DB) error {
`CREATE INDEX IF NOT EXISTS idx_gradebook_class ON gradebook_entries(class_id)`,
}
// Append timetable scheduler migrations (see timetable_migrations.go)
migrations = append(migrations, TimetableMigrations()...)
// 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,224 @@
package database
// TimetableConstraintMigrations returns the DDL for all 15 constraint tables.
// Each table follows the same shape:
// - id / created_by_user_id / is_hard / weight / active / note / created_at
// - one or more FKs to tt_teacher / tt_class / tt_subject / tt_room
//
// FK ON DELETE CASCADE removes constraints when their parent (teacher/room/etc.)
// is deleted — the rules become meaningless without the referenced resource.
func TimetableConstraintMigrations() []string {
return []string{
// ---------- Teacher constraints (6) ----------
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_unavailable_day (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, day_of_week)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_unavailable_window (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
CHECK (end_time > start_time)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_max_hours_day (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 12),
is_hard BOOLEAN NOT NULL DEFAULT false,
weight INT NOT NULL DEFAULT 50 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_max_hours_week (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 40),
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_excluded_subject (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, subject_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_excluded_room (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
room_id UUID NOT NULL REFERENCES tt_room(id) ON DELETE CASCADE,
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, room_id)
)`,
// ---------- Subject constraints (5) ----------
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_min_day_gap (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
min_gap_days INT NOT NULL CHECK (min_gap_days BETWEEN 1 AND 4),
is_hard BOOLEAN NOT NULL DEFAULT false,
weight INT NOT NULL DEFAULT 70 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(subject_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_max_consecutive (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
max_consecutive INT NOT NULL CHECK (max_consecutive BETWEEN 1 AND 5),
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(subject_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_contiguous_when_repeated (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(subject_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_preferred_period (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
period_from INT NOT NULL CHECK (period_from BETWEEN 1 AND 12),
period_to INT NOT NULL CHECK (period_to BETWEEN 1 AND 12),
is_hard BOOLEAN NOT NULL DEFAULT false,
weight INT NOT NULL DEFAULT 40 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
CHECK (period_to >= period_from)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_double_lesson (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
is_hard BOOLEAN NOT NULL DEFAULT false,
weight INT NOT NULL DEFAULT 60 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(subject_id)
)`,
// ---------- Class constraints (2) ----------
`CREATE TABLE IF NOT EXISTS tt_constraint_class_max_hours_day (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 12),
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(class_id)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_class_no_gaps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
is_hard BOOLEAN NOT NULL DEFAULT false,
weight INT NOT NULL DEFAULT 80 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(class_id)
)`,
// ---------- Room constraints (2) ----------
`CREATE TABLE IF NOT EXISTS tt_constraint_room_requires_type (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
room_type VARCHAR(30) NOT NULL,
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(subject_id, room_type)
)`,
`CREATE TABLE IF NOT EXISTS tt_constraint_room_unavailable (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
room_id UUID NOT NULL REFERENCES tt_room(id) ON DELETE CASCADE,
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),
is_hard BOOLEAN NOT NULL DEFAULT true,
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
active BOOLEAN NOT NULL DEFAULT true,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(room_id, day_of_week, period_index)
)`,
// ---------- Indexes ----------
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_unav_day_teacher ON tt_constraint_teacher_unavailable_day(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_unav_win_teacher ON tt_constraint_teacher_unavailable_window(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_excl_subj_teacher ON tt_constraint_teacher_excluded_subject(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_excl_room_teacher ON tt_constraint_teacher_excluded_room(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_c_room_unav_room ON tt_constraint_room_unavailable(room_id)`,
}
}
@@ -0,0 +1,104 @@
package database
// TimetableMigrations returns the SQL statements that create all timetable-related
// tables. They are applied idempotently via CREATE TABLE IF NOT EXISTS.
func TimetableMigrations() []string {
return []string{
// Classes (school-wide, distinct from per-teacher `classes` table)
`CREATE TABLE IF NOT EXISTS tt_class (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
name VARCHAR(50) NOT NULL,
grade_level INT NOT NULL,
student_count INT DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(created_by_user_id, name)
)`,
// Time periods (Mon=1..Sun=7, period_index = 1..N)
`CREATE TABLE IF NOT EXISTS tt_period (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
period_index INT NOT NULL CHECK (period_index >= 1),
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_break BOOLEAN DEFAULT false,
label VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(created_by_user_id, day_of_week, period_index)
)`,
// Rooms
`CREATE TABLE IF NOT EXISTS tt_room (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
name VARCHAR(50) NOT NULL,
room_type VARCHAR(30),
capacity INT DEFAULT 30,
floor_level INT DEFAULT 0,
has_elevator BOOLEAN DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(created_by_user_id, name)
)`,
// Subjects (school-wide, distinct from per-teacher `subjects`)
`CREATE TABLE IF NOT EXISTS tt_subject (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
short_code VARCHAR(10) NOT NULL,
color VARCHAR(7),
is_main_subject BOOLEAN DEFAULT false,
required_room_type VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(created_by_user_id, short_code)
)`,
// Teachers (planning resource, NOT a BreakPilot user)
`CREATE TABLE IF NOT EXISTS tt_teacher (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by_user_id UUID NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
short_code VARCHAR(10) NOT NULL,
employment_percentage INT DEFAULT 100,
max_hours_week INT DEFAULT 28,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(created_by_user_id, short_code)
)`,
// Curriculum: weekly hour count per class+subject
`CREATE TABLE IF NOT EXISTS tt_curriculum (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
weekly_hours INT NOT NULL CHECK (weekly_hours >= 1),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(class_id, subject_id)
)`,
// Assignment: which teacher teaches which subject in which class
`CREATE TABLE IF NOT EXISTS tt_assignment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL REFERENCES tt_teacher(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,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, class_id, subject_id)
)`,
// Indexes
`CREATE INDEX IF NOT EXISTS idx_tt_class_user ON tt_class(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_period_user ON tt_period(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_room_user ON tt_room(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_subject_user ON tt_subject(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_teacher_user ON tt_teacher(created_by_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_curriculum_class ON tt_curriculum(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_assignment_teacher ON tt_assignment(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_tt_assignment_class ON tt_assignment(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)
}
+40 -7
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"
@@ -16,27 +17,59 @@ type Handler struct {
gradebookService *services.GradebookService
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)
gradebookService := services.NewGradebookService(db)
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,
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,202 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// Class- and Room-constraint HTTP handlers.
// ---------- Class Max Hours / Day ----------
func (h *Handler) CreateClassMaxHoursDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateClassMaxHoursDayRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateClassMaxHoursDay(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListClassMaxHoursDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListClassMaxHoursDay(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteClassMaxHoursDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteClassMaxHoursDay(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Class No Gaps ----------
func (h *Handler) CreateClassNoGaps(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateClassNoGapsRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateClassNoGaps(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListClassNoGaps(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListClassNoGaps(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteClassNoGaps(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteClassNoGaps(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Room Requires Type ----------
func (h *Handler) CreateRoomRequiresType(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateRoomRequiresTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateRoomRequiresType(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListRoomRequiresTypes(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListRoomRequiresTypes(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteRoomRequiresType(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteRoomRequiresType(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Room Unavailable ----------
func (h *Handler) CreateRoomUnavailable(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateRoomUnavailableRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateRoomUnavailable(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListRoomUnavailable(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListRoomUnavailable(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteRoomUnavailable(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteRoomUnavailable(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
@@ -0,0 +1,250 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// Subject-constraint HTTP handlers.
// ---------- Subject Min Day Gap ----------
func (h *Handler) CreateSubjectMinDayGap(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSubjectMinDayGapRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateSubjectMinDayGap(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListSubjectMinDayGaps(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSubjectMinDayGaps(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteSubjectMinDayGap(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSubjectMinDayGap(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Subject Max Consecutive ----------
func (h *Handler) CreateSubjectMaxConsecutive(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSubjectMaxConsecutiveRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateSubjectMaxConsecutive(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListSubjectMaxConsecutives(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSubjectMaxConsecutives(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteSubjectMaxConsecutive(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSubjectMaxConsecutive(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Subject Contiguous When Repeated ----------
func (h *Handler) CreateSubjectContiguousWhenRepeated(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSubjectContiguousWhenRepeatedRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateSubjectContiguousWhenRepeated(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListSubjectContiguousWhenRepeated(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSubjectContiguousWhenRepeated(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteSubjectContiguousWhenRepeated(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSubjectContiguousWhenRepeated(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Subject Preferred Period ----------
func (h *Handler) CreateSubjectPreferredPeriod(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSubjectPreferredPeriodRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateSubjectPreferredPeriod(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListSubjectPreferredPeriods(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSubjectPreferredPeriods(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteSubjectPreferredPeriod(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSubjectPreferredPeriod(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Subject Double Lesson ----------
func (h *Handler) CreateSubjectDoubleLesson(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSubjectDoubleLessonRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateSubjectDoubleLesson(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListSubjectDoubleLessons(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSubjectDoubleLessons(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteSubjectDoubleLesson(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSubjectDoubleLesson(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
@@ -0,0 +1,300 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// Teacher-constraint HTTP handlers. They share the same auth + JSON-bind
// shape as the existing timetable handlers; per-table the only thing that
// differs is the request DTO type and the service method invoked.
// ---------- Teacher Unavailable Day ----------
func (h *Handler) CreateTeacherUnavailableDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTeacherUnavailableDayRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacherUnavailableDay(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTeacherUnavailableDays(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeacherUnavailableDays(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTeacherUnavailableDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacherUnavailableDay(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Teacher Unavailable Window ----------
func (h *Handler) CreateTeacherUnavailableWindow(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTeacherUnavailableWindowRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacherUnavailableWindow(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTeacherUnavailableWindows(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeacherUnavailableWindows(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTeacherUnavailableWindow(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacherUnavailableWindow(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Teacher Max Hours / Day ----------
func (h *Handler) CreateTeacherMaxHoursDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTeacherMaxHoursDayRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacherMaxHoursDay(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTeacherMaxHoursDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeacherMaxHoursDay(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTeacherMaxHoursDay(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacherMaxHoursDay(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Teacher Max Hours / Week ----------
func (h *Handler) CreateTeacherMaxHoursWeek(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTeacherMaxHoursWeekRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacherMaxHoursWeek(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTeacherMaxHoursWeek(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeacherMaxHoursWeek(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTeacherMaxHoursWeek(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacherMaxHoursWeek(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Teacher Excluded Subject ----------
func (h *Handler) CreateTeacherExcludedSubject(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTeacherExcludedSubjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacherExcludedSubject(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTeacherExcludedSubjects(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeacherExcludedSubjects(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTeacherExcludedSubject(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacherExcludedSubject(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
// ---------- Teacher Excluded Room ----------
func (h *Handler) CreateTeacherExcludedRoom(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTeacherExcludedRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacherExcludedRoom(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTeacherExcludedRooms(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeacherExcludedRooms(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTeacherExcludedRoom(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacherExcludedRoom(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
}
@@ -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,248 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// ---------- Classes ----------
func (h *Handler) CreateTimetableClass(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableClassRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateClass(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create class: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetableClasses(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListClasses(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list classes: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableClass(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteClass(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete class: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Class deleted"})
}
// ---------- Periods ----------
func (h *Handler) CreateTimetablePeriod(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetablePeriodRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreatePeriod(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create period: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetablePeriods(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListPeriods(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list periods: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetablePeriod(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeletePeriod(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete period: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Period deleted"})
}
// ---------- Rooms ----------
func (h *Handler) CreateTimetableRoom(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateRoom(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create room: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetableRooms(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListRooms(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list rooms: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableRoom(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteRoom(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete room: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Room deleted"})
}
// ---------- Subjects ----------
func (h *Handler) CreateTimetableSubject(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableSubjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateSubject(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create subject: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetableSubjects(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListSubjects(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list subjects: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableSubject(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteSubject(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete subject: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Subject deleted"})
}
// ---------- Teachers ----------
func (h *Handler) CreateTimetableTeacher(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableTeacherRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateTeacher(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create teacher: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetableTeachers(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListTeachers(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list teachers: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableTeacher(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteTeacher(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete teacher: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Teacher deleted"})
}
@@ -0,0 +1,104 @@
package handlers
import (
"net/http"
"github.com/breakpilot/school-service/internal/models"
"github.com/gin-gonic/gin"
)
// ---------- Curriculum ----------
func (h *Handler) CreateTimetableCurriculum(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableCurriculumRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateCurriculum(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create curriculum: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetableCurriculum(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListCurriculum(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list curriculum: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableCurriculum(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteCurriculum(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete curriculum: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Curriculum entry deleted"})
}
// ---------- Assignment ----------
func (h *Handler) CreateTimetableAssignment(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateTimetableAssignmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
out, err := h.timetableService.CreateAssignment(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create assignment: "+err.Error())
return
}
respondCreated(c, out)
}
func (h *Handler) ListTimetableAssignments(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
out, err := h.timetableService.ListAssignments(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list assignments: "+err.Error())
return
}
respondSuccess(c, out)
}
func (h *Handler) DeleteTimetableAssignment(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.timetableService.DeleteAssignment(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete assignment: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
}
@@ -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"`
}
+155
View File
@@ -0,0 +1,155 @@
package models
import (
"time"
"github.com/google/uuid"
)
// TimetableClass is a school class managed for timetabling.
// Separate from the per-teacher `classes` table because timetabling is school-wide.
type TimetableClass 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" db:"name"`
GradeLevel int `json:"grade_level" db:"grade_level"`
StudentCount int `json:"student_count" db:"student_count"`
Notes string `json:"notes,omitempty" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TimetablePeriod is one time slot in the weekly grid.
// DayOfWeek: 1=Mon..7=Sun. PeriodIndex: 1=first lesson of the day.
type TimetablePeriod struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"`
PeriodIndex int `json:"period_index" db:"period_index"`
StartTime string `json:"start_time" db:"start_time"`
EndTime string `json:"end_time" db:"end_time"`
IsBreak bool `json:"is_break" db:"is_break"`
Label string `json:"label,omitempty" db:"label"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TimetableRoom is a physical room that can host lessons.
type TimetableRoom 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" db:"name"`
RoomType string `json:"room_type,omitempty" db:"room_type"`
Capacity int `json:"capacity" db:"capacity"`
FloorLevel int `json:"floor_level" db:"floor_level"`
HasElevator bool `json:"has_elevator" db:"has_elevator"`
Notes string `json:"notes,omitempty" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TimetableSubject is a school-wide subject for timetabling.
type TimetableSubject 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" db:"name"`
ShortCode string `json:"short_code" db:"short_code"`
Color string `json:"color,omitempty" db:"color"`
IsMainSubject bool `json:"is_main_subject" db:"is_main_subject"`
RequiredRoomType string `json:"required_room_type,omitempty" db:"required_room_type"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TimetableTeacher is a teacher as a schedulable resource.
// Independent from BreakPilot users — the Rektor enters all teachers manually.
type TimetableTeacher struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
ShortCode string `json:"short_code" db:"short_code"`
EmploymentPercentage int `json:"employment_percentage" db:"employment_percentage"`
MaxHoursWeek int `json:"max_hours_week" db:"max_hours_week"`
Notes string `json:"notes,omitempty" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TimetableCurriculum links a class to a subject with the weekly hour count.
type TimetableCurriculum struct {
ID uuid.UUID `json:"id" db:"id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
WeeklyHours int `json:"weekly_hours" db:"weekly_hours"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined fields
SubjectName string `json:"subject_name,omitempty"`
ClassName string `json:"class_name,omitempty"`
}
// TimetableAssignment is the teaching contract: who teaches what subject in which class.
type TimetableAssignment struct {
ID uuid.UUID `json:"id" db:"id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined fields
TeacherName string `json:"teacher_name,omitempty"`
ClassName string `json:"class_name,omitempty"`
SubjectName string `json:"subject_name,omitempty"`
}
// Request DTOs
type CreateTimetableClassRequest struct {
Name string `json:"name" binding:"required"`
GradeLevel int `json:"grade_level" binding:"required,min=1,max=13"`
StudentCount int `json:"student_count" binding:"min=0"`
Notes string `json:"notes"`
}
type CreateTimetablePeriodRequest struct {
DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"`
PeriodIndex int `json:"period_index" binding:"required,min=1"`
StartTime string `json:"start_time" binding:"required"`
EndTime string `json:"end_time" binding:"required"`
IsBreak bool `json:"is_break"`
Label string `json:"label"`
}
type CreateTimetableRoomRequest struct {
Name string `json:"name" binding:"required"`
RoomType string `json:"room_type"`
Capacity int `json:"capacity"`
FloorLevel int `json:"floor_level"`
HasElevator bool `json:"has_elevator"`
Notes string `json:"notes"`
}
type CreateTimetableSubjectRequest struct {
Name string `json:"name" binding:"required"`
ShortCode string `json:"short_code" binding:"required"`
Color string `json:"color"`
IsMainSubject bool `json:"is_main_subject"`
RequiredRoomType string `json:"required_room_type"`
}
type CreateTimetableTeacherRequest struct {
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
ShortCode string `json:"short_code" binding:"required"`
EmploymentPercentage int `json:"employment_percentage" binding:"min=0,max=100"`
MaxHoursWeek int `json:"max_hours_week" binding:"min=0"`
Notes string `json:"notes"`
}
type CreateTimetableCurriculumRequest struct {
ClassID string `json:"class_id" binding:"required,uuid"`
SubjectID string `json:"subject_id" binding:"required,uuid"`
WeeklyHours int `json:"weekly_hours" binding:"required,min=1,max=10"`
}
type CreateTimetableAssignmentRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
ClassID string `json:"class_id" binding:"required,uuid"`
SubjectID string `json:"subject_id" binding:"required,uuid"`
}
@@ -0,0 +1,360 @@
package models
import (
"time"
"github.com/google/uuid"
)
// Each constraint table carries the same audit/policy columns:
// - is_hard: true = solver must satisfy, false = soft (weighted)
// - weight: higher = stronger penalty when violated (used for soft constraints)
// - active: allows toggling a rule off without deletion
// - note: free-text rationale ("Lehrer X im Rollstuhl")
// - created_by_user_id: the Rektor account that owns this rule
// ---------- Teacher constraints (6) ----------
// TeacherUnavailableDay: Lehrer kann an Wochentag NIE.
type TeacherUnavailableDay struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TeacherUnavailableWindow: Lehrer kann an Tag X von HH:MM bis HH:MM nicht.
type TeacherUnavailableWindow struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"`
StartTime string `json:"start_time" db:"start_time"`
EndTime string `json:"end_time" db:"end_time"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TeacherMaxHoursDay: Lehrer darf max. N Stunden pro Tag haben.
type TeacherMaxHoursDay struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
MaxHours int `json:"max_hours" db:"max_hours"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TeacherMaxHoursWeek: Teilzeit-Cap.
type TeacherMaxHoursWeek struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
MaxHours int `json:"max_hours" db:"max_hours"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TeacherExcludedSubject: Lehrer darf bestimmtes Fach nicht unterrichten.
type TeacherExcludedSubject struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TeacherExcludedRoom: Lehrer kann Raum nicht nutzen (z.B. kein Aufzug).
type TeacherExcludedRoom struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
RoomID uuid.UUID `json:"room_id" db:"room_id"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ---------- Subject constraints (5) ----------
// SubjectMinDayGap: Mindestens N Tage Abstand zwischen zwei Lessons desselben Fachs.
type SubjectMinDayGap struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
MinGapDays int `json:"min_gap_days" db:"min_gap_days"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SubjectMaxConsecutive: Max. N aufeinander folgende Stunden des Fachs (keine Tripel-Stunde).
type SubjectMaxConsecutive struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
MaxConsecutive int `json:"max_consecutive" db:"max_consecutive"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SubjectContiguousWhenRepeated: Wenn das Fach mehrfach am gleichen Tag stattfindet, dann nur als Block.
type SubjectContiguousWhenRepeated struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SubjectPreferredPeriod: Fach lieber in einem bestimmten Period-Bereich (z.B. Hauptfächer morgens).
type SubjectPreferredPeriod struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
PeriodFrom int `json:"period_from" db:"period_from"`
PeriodTo int `json:"period_to" db:"period_to"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SubjectDoubleLesson: Fach bevorzugt als Doppelstunde.
type SubjectDoubleLesson struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ---------- Class constraints (2) ----------
// ClassMaxHoursDay: Klasse darf max. N Stunden pro Tag haben.
type ClassMaxHoursDay struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
MaxHours int `json:"max_hours" db:"max_hours"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ClassNoGaps: Keine Freistunden für die Klasse zwischen Lessons (Soft-Standard).
type ClassNoGaps struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ---------- Room constraints (2) ----------
// RoomRequiresType: Fach benötigt einen bestimmten Raumtyp (Sport → Sporthalle).
type RoomRequiresType struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
RoomType string `json:"room_type" db:"room_type"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// RoomUnavailable: Raum an Tag X, Stunde Y blockiert (Wartung, Renovierung).
type RoomUnavailable struct {
ID uuid.UUID `json:"id" db:"id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
RoomID uuid.UUID `json:"room_id" db:"room_id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"`
PeriodIndex int `json:"period_index" db:"period_index"`
IsHard bool `json:"is_hard" db:"is_hard"`
Weight int `json:"weight" db:"weight"`
Active bool `json:"active" db:"active"`
Note string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ---------- Request DTOs ----------
// Teacher
type CreateTeacherUnavailableDayRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateTeacherUnavailableWindowRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"`
StartTime string `json:"start_time" binding:"required"`
EndTime string `json:"end_time" binding:"required"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateTeacherMaxHoursDayRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
MaxHours int `json:"max_hours" binding:"required,min=1,max=12"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateTeacherMaxHoursWeekRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
MaxHours int `json:"max_hours" binding:"required,min=1,max=40"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateTeacherExcludedSubjectRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
SubjectID string `json:"subject_id" binding:"required,uuid"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateTeacherExcludedRoomRequest struct {
TeacherID string `json:"teacher_id" binding:"required,uuid"`
RoomID string `json:"room_id" binding:"required,uuid"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
// Subject
type CreateSubjectMinDayGapRequest struct {
SubjectID string `json:"subject_id" binding:"required,uuid"`
MinGapDays int `json:"min_gap_days" binding:"required,min=1,max=4"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateSubjectMaxConsecutiveRequest struct {
SubjectID string `json:"subject_id" binding:"required,uuid"`
MaxConsecutive int `json:"max_consecutive" binding:"required,min=1,max=5"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateSubjectContiguousWhenRepeatedRequest struct {
SubjectID string `json:"subject_id" binding:"required,uuid"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateSubjectPreferredPeriodRequest struct {
SubjectID string `json:"subject_id" binding:"required,uuid"`
PeriodFrom int `json:"period_from" binding:"required,min=1,max=12"`
PeriodTo int `json:"period_to" binding:"required,min=1,max=12"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateSubjectDoubleLessonRequest struct {
SubjectID string `json:"subject_id" binding:"required,uuid"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
// Class
type CreateClassMaxHoursDayRequest struct {
ClassID string `json:"class_id" binding:"required,uuid"`
MaxHours int `json:"max_hours" binding:"required,min=1,max=12"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateClassNoGapsRequest struct {
ClassID string `json:"class_id" binding:"required,uuid"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
// Room
type CreateRoomRequiresTypeRequest struct {
SubjectID string `json:"subject_id" binding:"required,uuid"`
RoomType string `json:"room_type" binding:"required"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
type CreateRoomUnavailableRequest struct {
RoomID string `json:"room_id" binding:"required,uuid"`
DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"`
PeriodIndex int `json:"period_index" binding:"required,min=1,max=12"`
IsHard bool `json:"is_hard"`
Weight int `json:"weight" binding:"min=0,max=100"`
Active bool `json:"active"`
Note string `json:"note"`
}
@@ -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,174 @@
package services
import (
"context"
"github.com/breakpilot/school-service/internal/models"
)
// Class- and room-scoped constraint CRUD. Ownership is via tt_class /
// tt_subject / tt_room.created_by_user_id.
// ---------- Class Max Hours / Day ----------
func (s *TimetableService) CreateClassMaxHoursDay(ctx context.Context, userID string, req *models.CreateClassMaxHoursDayRequest) (*models.ClassMaxHoursDay, error) {
var c models.ClassMaxHoursDay
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_class_max_hours_day
(created_by_user_id, class_id, max_hours, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_class WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, class_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.ClassID, req.MaxHours, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.ClassID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListClassMaxHoursDay(ctx context.Context, userID string) ([]models.ClassMaxHoursDay, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, class_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_class_max_hours_day WHERE created_by_user_id = $1 ORDER BY class_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ClassMaxHoursDay
for rows.Next() {
var c models.ClassMaxHoursDay
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.ClassID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteClassMaxHoursDay(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_class_max_hours_day WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Class No Gaps ----------
func (s *TimetableService) CreateClassNoGaps(ctx context.Context, userID string, req *models.CreateClassNoGapsRequest) (*models.ClassNoGaps, error) {
var c models.ClassNoGaps
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_class_no_gaps
(created_by_user_id, class_id, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6
WHERE EXISTS (SELECT 1 FROM tt_class WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, class_id, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.ClassID, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.ClassID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListClassNoGaps(ctx context.Context, userID string) ([]models.ClassNoGaps, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, class_id, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_class_no_gaps WHERE created_by_user_id = $1 ORDER BY class_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ClassNoGaps
for rows.Next() {
var c models.ClassNoGaps
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.ClassID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteClassNoGaps(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_class_no_gaps WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Room Requires Type ----------
func (s *TimetableService) CreateRoomRequiresType(ctx context.Context, userID string, req *models.CreateRoomRequiresTypeRequest) (*models.RoomRequiresType, error) {
var c models.RoomRequiresType
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_room_requires_type
(created_by_user_id, subject_id, room_type, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, subject_id, room_type, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.SubjectID, req.RoomType, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.RoomType, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListRoomRequiresTypes(ctx context.Context, userID string) ([]models.RoomRequiresType, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, subject_id, room_type, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_room_requires_type WHERE created_by_user_id = $1 ORDER BY subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.RoomRequiresType
for rows.Next() {
var c models.RoomRequiresType
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.RoomType, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteRoomRequiresType(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_room_requires_type WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Room Unavailable ----------
func (s *TimetableService) CreateRoomUnavailable(ctx context.Context, userID string, req *models.CreateRoomUnavailableRequest) (*models.RoomUnavailable, error) {
var c models.RoomUnavailable
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_room_unavailable
(created_by_user_id, room_id, day_of_week, period_index, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7, $8
WHERE EXISTS (SELECT 1 FROM tt_room WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, room_id, day_of_week, period_index, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.RoomID, req.DayOfWeek, req.PeriodIndex, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.RoomID, &c.DayOfWeek, &c.PeriodIndex, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListRoomUnavailable(ctx context.Context, userID string) ([]models.RoomUnavailable, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, room_id, day_of_week, period_index, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_room_unavailable WHERE created_by_user_id = $1 ORDER BY room_id, day_of_week, period_index
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.RoomUnavailable
for rows.Next() {
var c models.RoomUnavailable
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.RoomID, &c.DayOfWeek, &c.PeriodIndex, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteRoomUnavailable(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_room_unavailable WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
@@ -0,0 +1,188 @@
package services
import (
"testing"
"github.com/breakpilot/school-service/internal/models"
)
// Additional validator tests covering the 9 constraint DTOs not exercised in
// timetable_constraints_test.go. Each entry probes both the happy path and
// the boundary that the binding tags are supposed to reject.
const (
uidTeacher = "00000000-0000-0000-0000-0000000000a1"
uidSubject = "00000000-0000-0000-0000-0000000000a2"
uidClass = "00000000-0000-0000-0000-0000000000a3"
uidRoom = "00000000-0000-0000-0000-0000000000a4"
)
func TestCreateTeacherMaxHoursDayRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTeacherMaxHoursDayRequest
wantErr bool
}{
{"valid", models.CreateTeacherMaxHoursDayRequest{TeacherID: uidTeacher, MaxHours: 6, IsHard: false, Weight: 50}, false},
{"missing teacher", models.CreateTeacherMaxHoursDayRequest{MaxHours: 6}, true},
{"hours below 1", models.CreateTeacherMaxHoursDayRequest{TeacherID: uidTeacher, MaxHours: 0}, true},
{"hours above 12", models.CreateTeacherMaxHoursDayRequest{TeacherID: uidTeacher, MaxHours: 13}, 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")
}
})
}
}
func TestCreateTeacherMaxHoursWeekRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTeacherMaxHoursWeekRequest
wantErr bool
}{
{"valid", models.CreateTeacherMaxHoursWeekRequest{TeacherID: uidTeacher, MaxHours: 28, IsHard: true, Weight: 100}, false},
{"hours below 1", models.CreateTeacherMaxHoursWeekRequest{TeacherID: uidTeacher, MaxHours: 0}, true},
{"hours above 40", models.CreateTeacherMaxHoursWeekRequest{TeacherID: uidTeacher, MaxHours: 41}, 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")
}
})
}
}
func TestCreateTeacherExcludedSubjectRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTeacherExcludedSubjectRequest
wantErr bool
}{
{"valid", models.CreateTeacherExcludedSubjectRequest{TeacherID: uidTeacher, SubjectID: uidSubject, IsHard: true, Weight: 100}, false},
{"missing subject", models.CreateTeacherExcludedSubjectRequest{TeacherID: uidTeacher}, true},
{"non-uuid subject", models.CreateTeacherExcludedSubjectRequest{TeacherID: uidTeacher, SubjectID: "nope"}, 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")
}
})
}
}
func TestCreateTeacherExcludedRoomRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTeacherExcludedRoomRequest
wantErr bool
}{
{"valid", models.CreateTeacherExcludedRoomRequest{TeacherID: uidTeacher, RoomID: uidRoom, IsHard: true, Weight: 100}, false},
{"missing room", models.CreateTeacherExcludedRoomRequest{TeacherID: uidTeacher}, true},
{"non-uuid room", models.CreateTeacherExcludedRoomRequest{TeacherID: uidTeacher, RoomID: "nope"}, 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")
}
})
}
}
func TestCreateSubjectMinDayGapRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateSubjectMinDayGapRequest
wantErr bool
}{
{"valid", models.CreateSubjectMinDayGapRequest{SubjectID: uidSubject, MinGapDays: 1, IsHard: false, Weight: 70}, false},
{"below 1", models.CreateSubjectMinDayGapRequest{SubjectID: uidSubject, MinGapDays: 0}, true},
{"above 4", models.CreateSubjectMinDayGapRequest{SubjectID: uidSubject, MinGapDays: 5}, 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")
}
})
}
}
func TestCreateSubjectContiguousWhenRepeatedRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateSubjectContiguousWhenRepeatedRequest
wantErr bool
}{
{"valid", models.CreateSubjectContiguousWhenRepeatedRequest{SubjectID: uidSubject, IsHard: true, Weight: 100}, false},
{"missing subject", models.CreateSubjectContiguousWhenRepeatedRequest{}, true},
{"weight above 100", models.CreateSubjectContiguousWhenRepeatedRequest{SubjectID: uidSubject, Weight: 200}, 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")
}
})
}
}
func TestCreateSubjectDoubleLessonRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateSubjectDoubleLessonRequest
wantErr bool
}{
{"valid", models.CreateSubjectDoubleLessonRequest{SubjectID: uidSubject, IsHard: false, Weight: 60}, false},
{"missing subject", models.CreateSubjectDoubleLessonRequest{}, 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")
}
})
}
}
func TestCreateClassNoGapsRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateClassNoGapsRequest
wantErr bool
}{
{"valid", models.CreateClassNoGapsRequest{ClassID: uidClass, IsHard: false, Weight: 80}, false},
{"missing class", models.CreateClassNoGapsRequest{}, 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")
}
})
}
}
func TestCreateRoomRequiresTypeRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateRoomRequiresTypeRequest
wantErr bool
}{
{"valid", models.CreateRoomRequiresTypeRequest{SubjectID: uidSubject, RoomType: "Sporthalle", IsHard: true, Weight: 100}, false},
{"missing room type", models.CreateRoomRequiresTypeRequest{SubjectID: uidSubject}, true},
{"non-uuid subject", models.CreateRoomRequiresTypeRequest{SubjectID: "x", RoomType: "y"}, 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")
}
})
}
}
@@ -0,0 +1,214 @@
package services
import (
"context"
"github.com/breakpilot/school-service/internal/models"
)
// Subject-scoped constraint CRUD. Ownership via tt_subject.created_by_user_id.
// ---------- Subject Min Day Gap ----------
func (s *TimetableService) CreateSubjectMinDayGap(ctx context.Context, userID string, req *models.CreateSubjectMinDayGapRequest) (*models.SubjectMinDayGap, error) {
var c models.SubjectMinDayGap
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_subject_min_day_gap
(created_by_user_id, subject_id, min_gap_days, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, subject_id, min_gap_days, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.SubjectID, req.MinGapDays, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MinGapDays, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListSubjectMinDayGaps(ctx context.Context, userID string) ([]models.SubjectMinDayGap, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, subject_id, min_gap_days, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_subject_min_day_gap WHERE created_by_user_id = $1 ORDER BY subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SubjectMinDayGap
for rows.Next() {
var c models.SubjectMinDayGap
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MinGapDays, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteSubjectMinDayGap(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_min_day_gap WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Subject Max Consecutive ----------
func (s *TimetableService) CreateSubjectMaxConsecutive(ctx context.Context, userID string, req *models.CreateSubjectMaxConsecutiveRequest) (*models.SubjectMaxConsecutive, error) {
var c models.SubjectMaxConsecutive
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_subject_max_consecutive
(created_by_user_id, subject_id, max_consecutive, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, subject_id, max_consecutive, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.SubjectID, req.MaxConsecutive, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MaxConsecutive, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListSubjectMaxConsecutives(ctx context.Context, userID string) ([]models.SubjectMaxConsecutive, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, subject_id, max_consecutive, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_subject_max_consecutive WHERE created_by_user_id = $1 ORDER BY subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SubjectMaxConsecutive
for rows.Next() {
var c models.SubjectMaxConsecutive
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MaxConsecutive, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteSubjectMaxConsecutive(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_max_consecutive WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Subject Contiguous When Repeated ----------
func (s *TimetableService) CreateSubjectContiguousWhenRepeated(ctx context.Context, userID string, req *models.CreateSubjectContiguousWhenRepeatedRequest) (*models.SubjectContiguousWhenRepeated, error) {
var c models.SubjectContiguousWhenRepeated
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_subject_contiguous_when_repeated
(created_by_user_id, subject_id, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6
WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.SubjectID, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListSubjectContiguousWhenRepeated(ctx context.Context, userID string) ([]models.SubjectContiguousWhenRepeated, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_subject_contiguous_when_repeated WHERE created_by_user_id = $1 ORDER BY subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SubjectContiguousWhenRepeated
for rows.Next() {
var c models.SubjectContiguousWhenRepeated
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteSubjectContiguousWhenRepeated(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_contiguous_when_repeated WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Subject Preferred Period ----------
func (s *TimetableService) CreateSubjectPreferredPeriod(ctx context.Context, userID string, req *models.CreateSubjectPreferredPeriodRequest) (*models.SubjectPreferredPeriod, error) {
var c models.SubjectPreferredPeriod
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_subject_preferred_period
(created_by_user_id, subject_id, period_from, period_to, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7, $8
WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, subject_id, period_from, period_to, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.SubjectID, req.PeriodFrom, req.PeriodTo, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.PeriodFrom, &c.PeriodTo, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListSubjectPreferredPeriods(ctx context.Context, userID string) ([]models.SubjectPreferredPeriod, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, subject_id, period_from, period_to, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_subject_preferred_period WHERE created_by_user_id = $1 ORDER BY subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SubjectPreferredPeriod
for rows.Next() {
var c models.SubjectPreferredPeriod
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.PeriodFrom, &c.PeriodTo, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteSubjectPreferredPeriod(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_preferred_period WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Subject Double Lesson ----------
func (s *TimetableService) CreateSubjectDoubleLesson(ctx context.Context, userID string, req *models.CreateSubjectDoubleLessonRequest) (*models.SubjectDoubleLesson, error) {
var c models.SubjectDoubleLesson
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_subject_double_lesson
(created_by_user_id, subject_id, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6
WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.SubjectID, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListSubjectDoubleLessons(ctx context.Context, userID string) ([]models.SubjectDoubleLesson, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_subject_double_lesson WHERE created_by_user_id = $1 ORDER BY subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SubjectDoubleLesson
for rows.Next() {
var c models.SubjectDoubleLesson
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteSubjectDoubleLesson(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_double_lesson WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
@@ -0,0 +1,263 @@
package services
import (
"context"
"github.com/breakpilot/school-service/internal/models"
)
// Teacher-scoped constraint CRUD. Ownership is enforced via the parent
// tt_teacher row's created_by_user_id (and tt_subject / tt_room for the
// composite excluded-* constraints).
// ---------- Teacher Unavailable Day ----------
func (s *TimetableService) CreateTeacherUnavailableDay(ctx context.Context, userID string, req *models.CreateTeacherUnavailableDayRequest) (*models.TeacherUnavailableDay, error) {
var c models.TeacherUnavailableDay
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_teacher_unavailable_day
(created_by_user_id, teacher_id, day_of_week, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, teacher_id, day_of_week, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.TeacherID, req.DayOfWeek, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListTeacherUnavailableDays(ctx context.Context, userID string) ([]models.TeacherUnavailableDay, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, teacher_id, day_of_week, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_teacher_unavailable_day
WHERE created_by_user_id = $1
ORDER BY teacher_id, day_of_week
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TeacherUnavailableDay
for rows.Next() {
var c models.TeacherUnavailableDay
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteTeacherUnavailableDay(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_unavailable_day WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Teacher Unavailable Window ----------
func (s *TimetableService) CreateTeacherUnavailableWindow(ctx context.Context, userID string, req *models.CreateTeacherUnavailableWindowRequest) (*models.TeacherUnavailableWindow, error) {
var c models.TeacherUnavailableWindow
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_teacher_unavailable_window
(created_by_user_id, teacher_id, day_of_week, start_time, end_time, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, teacher_id, day_of_week, start_time::text, end_time::text, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.TeacherID, req.DayOfWeek, req.StartTime, req.EndTime, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.StartTime, &c.EndTime, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListTeacherUnavailableWindows(ctx context.Context, userID string) ([]models.TeacherUnavailableWindow, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, teacher_id, day_of_week, start_time::text, end_time::text, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_teacher_unavailable_window
WHERE created_by_user_id = $1
ORDER BY teacher_id, day_of_week, start_time
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TeacherUnavailableWindow
for rows.Next() {
var c models.TeacherUnavailableWindow
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.StartTime, &c.EndTime, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteTeacherUnavailableWindow(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_unavailable_window WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Teacher Max Hours / Day ----------
func (s *TimetableService) CreateTeacherMaxHoursDay(ctx context.Context, userID string, req *models.CreateTeacherMaxHoursDayRequest) (*models.TeacherMaxHoursDay, error) {
var c models.TeacherMaxHoursDay
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_teacher_max_hours_day
(created_by_user_id, teacher_id, max_hours, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.TeacherID, req.MaxHours, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListTeacherMaxHoursDay(ctx context.Context, userID string) ([]models.TeacherMaxHoursDay, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_teacher_max_hours_day WHERE created_by_user_id = $1 ORDER BY teacher_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TeacherMaxHoursDay
for rows.Next() {
var c models.TeacherMaxHoursDay
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteTeacherMaxHoursDay(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_max_hours_day WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Teacher Max Hours / Week ----------
func (s *TimetableService) CreateTeacherMaxHoursWeek(ctx context.Context, userID string, req *models.CreateTeacherMaxHoursWeekRequest) (*models.TeacherMaxHoursWeek, error) {
var c models.TeacherMaxHoursWeek
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_teacher_max_hours_week
(created_by_user_id, teacher_id, max_hours, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.TeacherID, req.MaxHours, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListTeacherMaxHoursWeek(ctx context.Context, userID string) ([]models.TeacherMaxHoursWeek, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_teacher_max_hours_week WHERE created_by_user_id = $1 ORDER BY teacher_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TeacherMaxHoursWeek
for rows.Next() {
var c models.TeacherMaxHoursWeek
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteTeacherMaxHoursWeek(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_max_hours_week WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Teacher Excluded Subject ----------
func (s *TimetableService) CreateTeacherExcludedSubject(ctx context.Context, userID string, req *models.CreateTeacherExcludedSubjectRequest) (*models.TeacherExcludedSubject, error) {
var c models.TeacherExcludedSubject
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_teacher_excluded_subject
(created_by_user_id, teacher_id, subject_id, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1)
AND EXISTS (SELECT 1 FROM tt_subject WHERE id = $3 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, teacher_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.TeacherID, req.SubjectID, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListTeacherExcludedSubjects(ctx context.Context, userID string) ([]models.TeacherExcludedSubject, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, teacher_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_teacher_excluded_subject WHERE created_by_user_id = $1 ORDER BY teacher_id, subject_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TeacherExcludedSubject
for rows.Next() {
var c models.TeacherExcludedSubject
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteTeacherExcludedSubject(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_excluded_subject WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Teacher Excluded Room ----------
func (s *TimetableService) CreateTeacherExcludedRoom(ctx context.Context, userID string, req *models.CreateTeacherExcludedRoomRequest) (*models.TeacherExcludedRoom, error) {
var c models.TeacherExcludedRoom
err := s.db.QueryRow(ctx, `
INSERT INTO tt_constraint_teacher_excluded_room
(created_by_user_id, teacher_id, room_id, is_hard, weight, active, note)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1)
AND EXISTS (SELECT 1 FROM tt_room WHERE id = $3 AND created_by_user_id = $1)
RETURNING id, created_by_user_id, teacher_id, room_id, is_hard, weight, active, COALESCE(note,''), created_at
`, userID, req.TeacherID, req.RoomID, req.IsHard, req.Weight, req.Active, req.Note).Scan(
&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.RoomID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListTeacherExcludedRooms(ctx context.Context, userID string) ([]models.TeacherExcludedRoom, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, teacher_id, room_id, is_hard, weight, active, COALESCE(note,''), created_at
FROM tt_constraint_teacher_excluded_room WHERE created_by_user_id = $1 ORDER BY teacher_id, room_id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TeacherExcludedRoom
for rows.Next() {
var c models.TeacherExcludedRoom
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.RoomID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteTeacherExcludedRoom(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_excluded_room WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
@@ -0,0 +1,140 @@
package services
import (
"testing"
"github.com/breakpilot/school-service/internal/models"
)
// These tests exercise the request DTO binding tags (the same the Gin layer
// uses). They don't hit the database — DB-level checks live in integration
// tests against a real Postgres.
func TestCreateTeacherUnavailableDayRequest_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000001"
tests := []struct {
name string
req models.CreateTeacherUnavailableDayRequest
wantErr bool
}{
{"valid monday", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 1, IsHard: true, Weight: 100, Active: true}, false},
{"day too low", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 0}, true},
{"day too high", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 8}, true},
{"non-uuid teacher", models.CreateTeacherUnavailableDayRequest{TeacherID: "not-a-uuid", DayOfWeek: 1}, true},
{"weight above 100", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 1, Weight: 150}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateTeacherUnavailableWindowRequest_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000001"
tests := []struct {
name string
req models.CreateTeacherUnavailableWindowRequest
wantErr bool
}{
{"valid", models.CreateTeacherUnavailableWindowRequest{TeacherID: uid, DayOfWeek: 2, StartTime: "13:00", EndTime: "17:00"}, false},
{"missing times", models.CreateTeacherUnavailableWindowRequest{TeacherID: uid, DayOfWeek: 2}, true},
{"day too high", models.CreateTeacherUnavailableWindowRequest{TeacherID: uid, DayOfWeek: 8, StartTime: "13:00", EndTime: "17:00"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateSubjectMaxConsecutiveRequest_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000002"
tests := []struct {
name string
req models.CreateSubjectMaxConsecutiveRequest
wantErr bool
}{
{"valid 2 in a row", models.CreateSubjectMaxConsecutiveRequest{SubjectID: uid, MaxConsecutive: 2, IsHard: true, Weight: 100}, false},
{"below 1", models.CreateSubjectMaxConsecutiveRequest{SubjectID: uid, MaxConsecutive: 0}, true},
{"above 5", models.CreateSubjectMaxConsecutiveRequest{SubjectID: uid, MaxConsecutive: 6}, true},
{"non-uuid subject", models.CreateSubjectMaxConsecutiveRequest{SubjectID: "x", MaxConsecutive: 2}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateSubjectPreferredPeriodRequest_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000002"
tests := []struct {
name string
req models.CreateSubjectPreferredPeriodRequest
wantErr bool
}{
{"valid morning", models.CreateSubjectPreferredPeriodRequest{SubjectID: uid, PeriodFrom: 1, PeriodTo: 4, IsHard: false, Weight: 40}, false},
{"from missing", models.CreateSubjectPreferredPeriodRequest{SubjectID: uid, PeriodTo: 4}, true},
{"to too high", models.CreateSubjectPreferredPeriodRequest{SubjectID: uid, PeriodFrom: 1, PeriodTo: 13}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateClassMaxHoursDayRequest_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000003"
tests := []struct {
name string
req models.CreateClassMaxHoursDayRequest
wantErr bool
}{
{"valid", models.CreateClassMaxHoursDayRequest{ClassID: uid, MaxHours: 6, IsHard: true, Weight: 100}, false},
{"hours below 1", models.CreateClassMaxHoursDayRequest{ClassID: uid, MaxHours: 0}, true},
{"hours above 12", models.CreateClassMaxHoursDayRequest{ClassID: uid, MaxHours: 13}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateRoomUnavailableRequest_Validation(t *testing.T) {
uid := "00000000-0000-0000-0000-000000000004"
tests := []struct {
name string
req models.CreateRoomUnavailableRequest
wantErr bool
}{
{"valid", models.CreateRoomUnavailableRequest{RoomID: uid, DayOfWeek: 3, PeriodIndex: 4, IsHard: true, Weight: 100}, false},
{"missing day", models.CreateRoomUnavailableRequest{RoomID: uid, PeriodIndex: 4}, true},
{"period too high", models.CreateRoomUnavailableRequest{RoomID: uid, DayOfWeek: 3, PeriodIndex: 13}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
@@ -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,112 @@
package services
import (
"context"
"github.com/breakpilot/school-service/internal/models"
)
// Curriculum and Assignment operations.
// Ownership is enforced by joining against tt_class.created_by_user_id.
// ---------- Curriculum (class × subject → weekly hours) ----------
func (s *TimetableService) CreateCurriculum(ctx context.Context, userID string, req *models.CreateTimetableCurriculumRequest) (*models.TimetableCurriculum, error) {
var c models.TimetableCurriculum
err := s.db.QueryRow(ctx, `
INSERT INTO tt_curriculum (class_id, subject_id, weekly_hours)
SELECT $1, $2, $3
WHERE EXISTS (SELECT 1 FROM tt_class WHERE id = $1 AND created_by_user_id = $4)
AND EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $4)
RETURNING id, class_id, subject_id, weekly_hours, created_at
`, req.ClassID, req.SubjectID, req.WeeklyHours, userID).Scan(
&c.ID, &c.ClassID, &c.SubjectID, &c.WeeklyHours, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListCurriculum(ctx context.Context, userID string) ([]models.TimetableCurriculum, error) {
rows, err := s.db.Query(ctx, `
SELECT cu.id, cu.class_id, cu.subject_id, cu.weekly_hours, cu.created_at,
sub.name, cl.name
FROM tt_curriculum cu
JOIN tt_class cl ON cu.class_id = cl.id
JOIN tt_subject sub ON cu.subject_id = sub.id
WHERE cl.created_by_user_id = $1
ORDER BY cl.grade_level, cl.name, sub.name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableCurriculum
for rows.Next() {
var c models.TimetableCurriculum
if err := rows.Scan(&c.ID, &c.ClassID, &c.SubjectID, &c.WeeklyHours, &c.CreatedAt, &c.SubjectName, &c.ClassName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteCurriculum(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `
DELETE FROM tt_curriculum cu
USING tt_class cl
WHERE cu.id = $1 AND cu.class_id = cl.id AND cl.created_by_user_id = $2
`, id, userID)
return err
}
// ---------- Assignment (teacher × class × subject) ----------
func (s *TimetableService) CreateAssignment(ctx context.Context, userID string, req *models.CreateTimetableAssignmentRequest) (*models.TimetableAssignment, error) {
var a models.TimetableAssignment
err := s.db.QueryRow(ctx, `
INSERT INTO tt_assignment (teacher_id, class_id, subject_id)
SELECT $1, $2, $3
WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $1 AND created_by_user_id = $4)
AND EXISTS (SELECT 1 FROM tt_class WHERE id = $2 AND created_by_user_id = $4)
AND EXISTS (SELECT 1 FROM tt_subject WHERE id = $3 AND created_by_user_id = $4)
RETURNING id, teacher_id, class_id, subject_id, created_at
`, req.TeacherID, req.ClassID, req.SubjectID, userID).Scan(
&a.ID, &a.TeacherID, &a.ClassID, &a.SubjectID, &a.CreatedAt,
)
return &a, err
}
func (s *TimetableService) ListAssignments(ctx context.Context, userID string) ([]models.TimetableAssignment, error) {
rows, err := s.db.Query(ctx, `
SELECT a.id, a.teacher_id, a.class_id, a.subject_id, a.created_at,
t.last_name || ', ' || t.first_name, cl.name, sub.name
FROM tt_assignment a
JOIN tt_teacher t ON a.teacher_id = t.id
JOIN tt_class cl ON a.class_id = cl.id
JOIN tt_subject sub ON a.subject_id = sub.id
WHERE t.created_by_user_id = $1
ORDER BY cl.grade_level, cl.name, sub.name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableAssignment
for rows.Next() {
var a models.TimetableAssignment
if err := rows.Scan(&a.ID, &a.TeacherID, &a.ClassID, &a.SubjectID, &a.CreatedAt, &a.TeacherName, &a.ClassName, &a.SubjectName); err != nil {
return nil, err
}
out = append(out, a)
}
return out, nil
}
func (s *TimetableService) DeleteAssignment(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `
DELETE FROM tt_assignment a
USING tt_teacher t
WHERE a.id = $1 AND a.teacher_id = t.id AND t.created_by_user_id = $2
`, id, userID)
return err
}
@@ -0,0 +1,213 @@
package services
import (
"context"
"github.com/breakpilot/school-service/internal/models"
"github.com/jackc/pgx/v5/pgxpool"
)
// TimetableService handles all CRUD for the school-wide timetable scheduler:
// classes, periods, rooms, subjects, teachers, curriculum, assignments.
type TimetableService struct {
db *pgxpool.Pool
}
func NewTimetableService(db *pgxpool.Pool) *TimetableService {
return &TimetableService{db: db}
}
// ---------- Classes ----------
func (s *TimetableService) CreateClass(ctx context.Context, userID string, req *models.CreateTimetableClassRequest) (*models.TimetableClass, error) {
var c models.TimetableClass
err := s.db.QueryRow(ctx, `
INSERT INTO tt_class (created_by_user_id, name, grade_level, student_count, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_by_user_id, name, grade_level, student_count, notes, created_at
`, userID, req.Name, req.GradeLevel, req.StudentCount, req.Notes).Scan(
&c.ID, &c.CreatedByUserID, &c.Name, &c.GradeLevel, &c.StudentCount, &c.Notes, &c.CreatedAt,
)
return &c, err
}
func (s *TimetableService) ListClasses(ctx context.Context, userID string) ([]models.TimetableClass, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, name, grade_level, student_count, COALESCE(notes,''), created_at
FROM tt_class WHERE created_by_user_id = $1 ORDER BY grade_level, name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableClass
for rows.Next() {
var c models.TimetableClass
if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.Name, &c.GradeLevel, &c.StudentCount, &c.Notes, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (s *TimetableService) DeleteClass(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_class WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Periods ----------
func (s *TimetableService) CreatePeriod(ctx context.Context, userID string, req *models.CreateTimetablePeriodRequest) (*models.TimetablePeriod, error) {
var p models.TimetablePeriod
err := s.db.QueryRow(ctx, `
INSERT INTO tt_period (created_by_user_id, day_of_week, period_index, start_time, end_time, is_break, label)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_by_user_id, day_of_week, period_index, start_time::text, end_time::text, is_break, COALESCE(label,''), created_at
`, userID, req.DayOfWeek, req.PeriodIndex, req.StartTime, req.EndTime, req.IsBreak, req.Label).Scan(
&p.ID, &p.CreatedByUserID, &p.DayOfWeek, &p.PeriodIndex, &p.StartTime, &p.EndTime, &p.IsBreak, &p.Label, &p.CreatedAt,
)
return &p, err
}
func (s *TimetableService) ListPeriods(ctx context.Context, userID string) ([]models.TimetablePeriod, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, day_of_week, period_index, start_time::text, end_time::text, is_break, COALESCE(label,''), created_at
FROM tt_period WHERE created_by_user_id = $1 ORDER BY day_of_week, period_index
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetablePeriod
for rows.Next() {
var p models.TimetablePeriod
if err := rows.Scan(&p.ID, &p.CreatedByUserID, &p.DayOfWeek, &p.PeriodIndex, &p.StartTime, &p.EndTime, &p.IsBreak, &p.Label, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, nil
}
func (s *TimetableService) DeletePeriod(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_period WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Rooms ----------
func (s *TimetableService) CreateRoom(ctx context.Context, userID string, req *models.CreateTimetableRoomRequest) (*models.TimetableRoom, error) {
var r models.TimetableRoom
err := s.db.QueryRow(ctx, `
INSERT INTO tt_room (created_by_user_id, name, room_type, capacity, floor_level, has_elevator, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_by_user_id, name, COALESCE(room_type,''), capacity, floor_level, has_elevator, COALESCE(notes,''), created_at
`, userID, req.Name, req.RoomType, req.Capacity, req.FloorLevel, req.HasElevator, req.Notes).Scan(
&r.ID, &r.CreatedByUserID, &r.Name, &r.RoomType, &r.Capacity, &r.FloorLevel, &r.HasElevator, &r.Notes, &r.CreatedAt,
)
return &r, err
}
func (s *TimetableService) ListRooms(ctx context.Context, userID string) ([]models.TimetableRoom, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, name, COALESCE(room_type,''), capacity, floor_level, has_elevator, COALESCE(notes,''), created_at
FROM tt_room WHERE created_by_user_id = $1 ORDER BY name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableRoom
for rows.Next() {
var r models.TimetableRoom
if err := rows.Scan(&r.ID, &r.CreatedByUserID, &r.Name, &r.RoomType, &r.Capacity, &r.FloorLevel, &r.HasElevator, &r.Notes, &r.CreatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
func (s *TimetableService) DeleteRoom(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_room WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Subjects ----------
func (s *TimetableService) CreateSubject(ctx context.Context, userID string, req *models.CreateTimetableSubjectRequest) (*models.TimetableSubject, error) {
var sub models.TimetableSubject
err := s.db.QueryRow(ctx, `
INSERT INTO tt_subject (created_by_user_id, name, short_code, color, is_main_subject, required_room_type)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_by_user_id, name, short_code, COALESCE(color,''), is_main_subject, COALESCE(required_room_type,''), created_at
`, userID, req.Name, req.ShortCode, req.Color, req.IsMainSubject, req.RequiredRoomType).Scan(
&sub.ID, &sub.CreatedByUserID, &sub.Name, &sub.ShortCode, &sub.Color, &sub.IsMainSubject, &sub.RequiredRoomType, &sub.CreatedAt,
)
return &sub, err
}
func (s *TimetableService) ListSubjects(ctx context.Context, userID string) ([]models.TimetableSubject, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, name, short_code, COALESCE(color,''), is_main_subject, COALESCE(required_room_type,''), created_at
FROM tt_subject WHERE created_by_user_id = $1 ORDER BY name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableSubject
for rows.Next() {
var sub models.TimetableSubject
if err := rows.Scan(&sub.ID, &sub.CreatedByUserID, &sub.Name, &sub.ShortCode, &sub.Color, &sub.IsMainSubject, &sub.RequiredRoomType, &sub.CreatedAt); err != nil {
return nil, err
}
out = append(out, sub)
}
return out, nil
}
func (s *TimetableService) DeleteSubject(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_subject WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// ---------- Teachers ----------
func (s *TimetableService) CreateTeacher(ctx context.Context, userID string, req *models.CreateTimetableTeacherRequest) (*models.TimetableTeacher, error) {
var t models.TimetableTeacher
err := s.db.QueryRow(ctx, `
INSERT INTO tt_teacher (created_by_user_id, first_name, last_name, short_code, employment_percentage, max_hours_week, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_by_user_id, first_name, last_name, short_code, employment_percentage, max_hours_week, COALESCE(notes,''), created_at
`, userID, req.FirstName, req.LastName, req.ShortCode, req.EmploymentPercentage, req.MaxHoursWeek, req.Notes).Scan(
&t.ID, &t.CreatedByUserID, &t.FirstName, &t.LastName, &t.ShortCode, &t.EmploymentPercentage, &t.MaxHoursWeek, &t.Notes, &t.CreatedAt,
)
return &t, err
}
func (s *TimetableService) ListTeachers(ctx context.Context, userID string) ([]models.TimetableTeacher, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, first_name, last_name, short_code, employment_percentage, max_hours_week, COALESCE(notes,''), created_at
FROM tt_teacher WHERE created_by_user_id = $1 ORDER BY last_name, first_name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableTeacher
for rows.Next() {
var t models.TimetableTeacher
if err := rows.Scan(&t.ID, &t.CreatedByUserID, &t.FirstName, &t.LastName, &t.ShortCode, &t.EmploymentPercentage, &t.MaxHoursWeek, &t.Notes, &t.CreatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
func (s *TimetableService) DeleteTeacher(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_teacher WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
@@ -0,0 +1,109 @@
package services
import (
"testing"
"github.com/go-playground/validator/v10"
"github.com/breakpilot/school-service/internal/models"
)
// validate is a singleton used to exercise the same struct tags Gin uses for
// request validation. The DB tests live in integration tests against a real
// database; this test pins the contract for the request DTOs.
var validate = func() *validator.Validate {
v := validator.New()
v.SetTagName("binding")
return v
}()
func TestNewTimetableService_Constructs(t *testing.T) {
s := NewTimetableService(nil)
if s == nil {
t.Fatal("expected non-nil service")
}
}
func TestCreateTimetableClassRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTimetableClassRequest
wantErr bool
}{
{"valid", models.CreateTimetableClassRequest{Name: "5a", GradeLevel: 5, StudentCount: 24}, false},
{"missing name", models.CreateTimetableClassRequest{GradeLevel: 5}, true},
{"grade too low", models.CreateTimetableClassRequest{Name: "0a", GradeLevel: 0}, true},
{"grade too high", models.CreateTimetableClassRequest{Name: "14a", GradeLevel: 14}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateTimetablePeriodRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTimetablePeriodRequest
wantErr bool
}{
{"valid monday first", models.CreateTimetablePeriodRequest{DayOfWeek: 1, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45"}, false},
{"day too low", models.CreateTimetablePeriodRequest{DayOfWeek: 0, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45"}, true},
{"day too high", models.CreateTimetablePeriodRequest{DayOfWeek: 8, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45"}, true},
{"missing times", models.CreateTimetablePeriodRequest{DayOfWeek: 1, PeriodIndex: 1}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateTimetableTeacherRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTimetableTeacherRequest
wantErr bool
}{
{"valid full-time", models.CreateTimetableTeacherRequest{FirstName: "Anna", LastName: "Schmidt", ShortCode: "SCH", EmploymentPercentage: 100, MaxHoursWeek: 28}, false},
{"valid part-time", models.CreateTimetableTeacherRequest{FirstName: "Bea", LastName: "Mueller", ShortCode: "MUE", EmploymentPercentage: 50, MaxHoursWeek: 14}, false},
{"missing names", models.CreateTimetableTeacherRequest{ShortCode: "XX"}, true},
{"employment above 100", models.CreateTimetableTeacherRequest{FirstName: "X", LastName: "Y", ShortCode: "Z", EmploymentPercentage: 150}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestCreateTimetableCurriculumRequest_Validation(t *testing.T) {
tests := []struct {
name string
req models.CreateTimetableCurriculumRequest
wantErr bool
}{
{"valid", models.CreateTimetableCurriculumRequest{ClassID: "00000000-0000-0000-0000-000000000001", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 4}, false},
{"non-uuid class", models.CreateTimetableCurriculumRequest{ClassID: "not-a-uuid", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 4}, true},
{"hours below 1", models.CreateTimetableCurriculumRequest{ClassID: "00000000-0000-0000-0000-000000000001", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 0}, true},
{"hours above 10", models.CreateTimetableCurriculumRequest{ClassID: "00000000-0000-0000-0000-000000000001", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 11}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Struct(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
@@ -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) }
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy for the school-service (Go/Gin, port 8084). The browser cannot call
* the backend directly because studio-v2 is served over HTTPS and the backend
* is plain HTTP; this Next.js route bridges them server-side.
*/
const BACKEND_URL = process.env.SCHOOL_SERVICE_URL || 'http://school-service:8084'
async function proxyRequest(
request: NextRequest,
params: { path: string[] }
): Promise<NextResponse> {
const path = params.path.join('/')
const url = `${BACKEND_URL}/api/v1/school/${path}`
try {
const headers: HeadersInit = { 'Content-Type': 'application/json' }
const authHeader = request.headers.get('authorization')
if (authHeader) headers['Authorization'] = authHeader
const fetchOptions: RequestInit = {
method: request.method,
headers,
}
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
fetchOptions.body = await request.text()
}
const response = await fetch(url, fetchOptions)
const contentType = response.headers.get('content-type')
const data = contentType?.includes('application/json')
? await response.text()
: await response.arrayBuffer()
return new NextResponse(data, {
status: response.status,
headers: { 'Content-Type': contentType || 'application/json' },
})
} catch (error) {
console.error(`Failed to proxy ${request.method} ${url}:`, error)
return NextResponse.json(
{ error: 'school-service nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 },
)
}
}
export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
return proxyRequest(request, await context.params)
}
export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
return proxyRequest(request, await context.params)
}
export async function PUT(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
return proxyRequest(request, await context.params)
}
export async function DELETE(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
return proxyRequest(request, await context.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;
}
}
+5 -10
View File
@@ -2,16 +2,15 @@
import { Sidebar } from '@/components/Sidebar'
import { useTheme } from '@/lib/ThemeContext'
import { useNativeLanguage } from '@/lib/useNativeLanguage'
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
import { LanguageDropdown } from '@/components/LanguageDropdown'
/**
* Shared layout for ALL /learn/* pages.
* Provides: Sidebar + gradient background + language switcher.
* Provides: Sidebar + gradient background + language dropdown (flags).
* Uses the central LanguageContext (same as all other modules).
*/
export default function LearnLayout({ children }: { children: React.ReactNode }) {
const { isDark } = useTheme()
const { nativeLang, setNativeLang, isThirdLanguage } = useNativeLanguage()
return (
<div className={`min-h-screen flex relative overflow-hidden ${
@@ -23,13 +22,9 @@ export default function LearnLayout({ children }: { children: React.ReactNode })
<Sidebar />
</div>
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{/* Sticky language switcher at top-right */}
{/* Language dropdown at top-right (same as worksheet-editor etc.) */}
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
<LanguageSwitcher
currentLang={nativeLang}
onLangChange={setNativeLang}
isDark={isDark}
/>
<LanguageDropdown />
</div>
{children}
<div className="text-center py-4">
+1 -1
View File
@@ -15,7 +15,7 @@ interface LangOption {
rtl: boolean
}
const STORAGE_KEY = 'bp_native_language'
const STORAGE_KEY = 'bp_language'
export default function OnboardingPage() {
const router = useRouter()
+3 -10
View File
@@ -2,16 +2,14 @@
import { Sidebar } from '@/components/Sidebar'
import { useTheme } from '@/lib/ThemeContext'
import { useNativeLanguage } from '@/lib/useNativeLanguage'
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
import { LanguageDropdown } from '@/components/LanguageDropdown'
/**
* Shared layout for ALL /parent/* pages.
* Same design as learn layout — Sidebar + gradient + language switcher.
* Same design as learn layout — Sidebar + gradient + flag language dropdown.
*/
export default function ParentLayout({ children }: { children: React.ReactNode }) {
const { isDark } = useTheme()
const { nativeLang, setNativeLang } = useNativeLanguage()
return (
<div className={`min-h-screen flex relative overflow-hidden ${
@@ -23,13 +21,8 @@ export default function ParentLayout({ children }: { children: React.ReactNode }
<Sidebar />
</div>
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{/* Sticky language switcher at top-right */}
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
<LanguageSwitcher
currentLang={nativeLang}
onLangChange={setNativeLang}
isDark={isDark}
/>
<LanguageDropdown />
</div>
{children}
</div>
@@ -0,0 +1,68 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { BUNDESLAENDER } from '@/app/schulkalender/types'
interface BundeslandWizardProps {
onSave: (bundesland: string) => Promise<void>
}
export function BundeslandWizard({ onSave }: BundeslandWizardProps) {
const { isDark } = useTheme()
const [selected, setSelected] = useState('DE-NI')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await onSave(selected)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className={`max-w-xl mx-auto rounded-2xl border backdrop-blur-xl p-6 ${cardClass}`} data-testid="bundesland-wizard">
<h2 className="text-xl font-semibold mb-2">Willkommen im Schulkalender</h2>
<p className={`text-sm mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Waehle das Bundesland deiner Schule. Damit laden wir Ferien und
Feiertage aus dem offiziellen Datensatz fuer die naechsten drei
Schuljahre.
</p>
<div className="mb-4">
<label className="block text-sm mb-1 opacity-70">Bundesland</label>
<select
value={selected}
onChange={e => setSelected(e.target.value)}
data-testid="bundesland-select"
className={`w-full px-3 py-2 rounded-lg border ${selectClass}`}
>
{BUNDESLAENDER.map(b => (
<option key={b.code} value={b.code}>{b.name}</option>
))}
</select>
</div>
{error && (
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">
{error}
</div>
)}
<button
onClick={handleSave}
disabled={saving}
data-testid="bundesland-save"
className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Bundesland uebernehmen'}
</button>
</div>
)
}
@@ -0,0 +1,106 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
import { NotificationStatus } from './NotificationStatus'
interface DayDetailProps {
iso: string
holidays: PublicEvent[]
events: SchoolEvent[]
onClose: () => void
onDeleted: () => void
}
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
const { isDark } = useTheme()
const handleDelete = async (id: string) => {
if (!confirm('Termin wirklich loeschen?')) return
try {
await calendarApi.deleteEvent(id)
onDeleted()
} catch {
// best-effort
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{formattedDate}</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{dayHolidays.length === 0 && dayEvents.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Eintraege fuer diesen Tag.
</p>
)}
{dayHolidays.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
{dayHolidays.map(h => (
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
<div className="font-medium">{h.name_de}</div>
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` ${h.end_date}` : ''}</div>
</div>
))}
</section>
)}
{dayEvents.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
{dayEvents.map(e => (
<div
key={e.id}
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium">{e.title}</div>
<div className="text-xs opacity-70 mt-0.5">
{EVENT_TYPE_LABEL[e.event_type]}
{e.start_time && ` · ${e.start_time}${e.end_time ? `${e.end_time}` : ''}`}
{e.is_school_free && ' · unterrichtsfrei'}
</div>
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
<div className="text-xs opacity-60 mt-1.5">
{e.visible_to_parents && '👨‍👩‍👧 sichtbar fuer Eltern'}
{e.notify_parents && ' · 📧 Eltern erinnern'}
{e.notify_students && ' · 💬 Schueler erinnern'}
</div>
{(e.notify_parents || e.notify_students) && (
<NotificationStatus eventId={e.id} />
)}
</div>
<button
onClick={() => handleDelete(e.id)}
className="text-xs text-red-400 hover:text-red-300"
>
Loeschen
</button>
</div>
</div>
))}
</section>
)}
</div>
</div>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types'
import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
interface EventModalProps {
defaultDate: string // YYYY-MM-DD
onClose: () => void
onCreated: () => void
}
const initial = (date: string): CreateSchoolEvent => ({
title: '',
event_type: 'fortbildung',
is_school_free: false,
start_date: date,
end_date: date,
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
})
export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) {
const { isDark } = useTheme()
const [form, setForm] = useState<CreateSchoolEvent>(initial(defaultDate))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await calendarApi.createEvent(form)
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="event-modal">
<form
onSubmit={handleSubmit}
className={`w-full max-w-2xl rounded-2xl border p-6 space-y-3 max-h-[90vh] overflow-y-auto ${cardClass}`}
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Neuer Termin</h2>
<button type="button" onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Titel</label>
<input
required
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="z.B. SCHILF: Digitale Tafeln"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-title"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Typ</label>
<select
value={form.event_type}
onChange={e => setForm({ ...form, event_type: e.target.value as SchoolEventType })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-type"
>
{(Object.keys(EVENT_TYPE_LABEL) as SchoolEventType[]).map(k => (
<option key={k} value={k}>{EVENT_TYPE_LABEL[k]}</option>
))}
</select>
</div>
<div className="flex items-end gap-2">
<input
type="checkbox"
id="is-school-free"
checked={form.is_school_free || false}
onChange={e => setForm({ ...form, is_school_free: e.target.checked })}
className="w-5 h-5"
/>
<label htmlFor="is-school-free" className="text-sm">Unterrichtsfrei</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Von</label>
<input type="date" required value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis</label>
<input type="date" required value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Startzeit (optional)</label>
<input type="time" value={form.start_time || ''} onChange={e => setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Endzeit (optional)</label>
<input type="time" value={form.end_time || ''} onChange={e => setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Beschreibung (optional)</label>
<textarea
value={form.description || ''}
onChange={e => setForm({ ...form, description: e.target.value })}
rows={2}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div className="space-y-2 pt-2 border-t border-white/10">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.visible_to_parents ?? true}
onChange={e => setForm({ ...form, visible_to_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern sehen diesen Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_parents ?? false}
onChange={e => setForm({ ...form, notify_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern per Mail/Chat erinnern
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_students ?? false}
onChange={e => setForm({ ...form, notify_students: e.target.checked })}
className="w-5 h-5"
/>
Schueler per Chat erinnern
</label>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={saving}
data-testid="event-save"
className="flex-1 px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Anlegen'}
</button>
<button type="button" onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'}`}>
Abbrechen
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,201 @@
'use client'
import { useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
interface MonthViewProps {
year: number
month: number // 1-12
holidays: PublicEvent[]
schoolEvents?: SchoolEvent[]
onPrev: () => void
onNext: () => void
onToday: () => void
onDayClick?: (iso: string) => void
onAddEvent?: () => void
onRollover?: () => void
}
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_DE = [
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
interface Cell {
date: Date
inMonth: boolean
events: PublicEvent[]
}
function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): Cell[] {
// First Monday on or before the 1st of the month.
const first = new Date(Date.UTC(year, month - 1, 1))
const firstWeekday = (first.getUTCDay() + 6) % 7 // Monday = 0
const start = new Date(first)
start.setUTCDate(first.getUTCDate() - firstWeekday)
const cells: Cell[] = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setUTCDate(start.getUTCDate() + i)
const iso = d.toISOString().slice(0, 10)
const events = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
cells.push({
date: d,
inMonth: d.getUTCMonth() === month - 1,
events,
})
if (i >= 27 && d.getUTCMonth() !== month - 1) {
// Stop a row early if the rest is fully outside the month.
const restAllOutside = cells.slice(i + 1 - ((i + 1) % 7), i + 1).every(c => !c.inMonth)
if (restAllOutside) break
}
}
// Pad to multiple of 7 if we cut early.
while (cells.length % 7 !== 0) {
const last = cells[cells.length - 1].date
const d = new Date(last)
d.setUTCDate(last.getUTCDate() + 1)
cells.push({ date: d, inMonth: false, events: [] })
}
return cells
}
export function MonthView({ year, month, holidays, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
const { isDark } = useTheme()
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
// School events per ISO date — quick lookup during cell render.
const schoolEventsByDate = useMemo(() => {
const map = new Map<string, SchoolEvent[]>()
for (const ev of schoolEvents) {
const start = new Date(ev.start_date)
const end = new Date(ev.end_date)
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const iso = d.toISOString().slice(0, 10)
const arr = map.get(iso) || []
arr.push(ev)
map.set(iso, arr)
}
}
return map
}, [schoolEvents])
const headerClass = isDark ? 'text-white' : 'text-slate-900'
const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
const buttonClass = isDark
? 'bg-white/10 text-white/80 hover:bg-white/20'
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
const todayIso = new Date().toISOString().slice(0, 10)
return (
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="month-view">
<div className="flex items-center justify-between mb-4">
<h2 className={`text-2xl font-semibold ${headerClass}`}>
{MONTHS_DE[month - 1]} {year}
</h2>
<div className="flex gap-2">
{onAddEvent && (
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
)}
{onRollover && (
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
)}
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS_DE.map(w => (
<div key={w} className={`text-xs font-medium text-center ${subtleText}`}>{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((c, i) => {
const iso = c.date.toISOString().slice(0, 10)
const isToday = iso === todayIso
const publicHoliday = c.events.find(e => e.event_type === 'public_holiday')
const schoolHoliday = c.events.find(e => e.event_type === 'school_holiday')
let bg = isDark ? 'bg-white/5' : 'bg-slate-50'
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
const dayEvents = schoolEventsByDate.get(iso) || []
return (
<div
key={i}
data-testid={`day-${iso}`}
onClick={() => c.inMonth && onDayClick?.(iso)}
className={`relative aspect-square rounded-lg p-2 text-sm border ${
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
} ${
isDark ? 'border-white/10' : 'border-black/5'
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
}`}
>
<div className={`font-medium ${
c.inMonth ? (isDark ? 'text-white' : 'text-slate-900') : subtleText
}`}>
{c.date.getUTCDate()}
</div>
{c.events.length > 0 && (
<div className="mt-1 space-y-0.5 overflow-hidden">
{c.events.slice(0, 2).map(e => (
<div
key={e.id}
title={e.name_de}
className={`text-[10px] leading-tight truncate ${
e.event_type === 'public_holiday'
? (isDark ? 'text-rose-200' : 'text-rose-800')
: (isDark ? 'text-amber-200' : 'text-amber-800')
}`}
>
{e.name_de}
</div>
))}
{c.events.length > 2 && (
<div className={`text-[10px] ${subtleText}`}>+{c.events.length - 2}</div>
)}
</div>
)}
{dayEvents.length > 0 && (
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
{dayEvents.slice(0, 4).map(ev => (
<span
key={ev.id}
title={ev.title}
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
/>
))}
</div>
)}
</div>
)
})}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
<span className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-rose-500/40' : 'bg-rose-200'}`}></span>
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Feiertag</span>
</span>
<span className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-amber-500/40' : 'bg-amber-200'}`}></span>
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Schulferien</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { NotificationLogRow } from '@/app/schulkalender/types'
interface NotificationStatusProps {
eventId: string
}
const STATUS_ICON: Record<string, string> = {
sent: '✓',
failed: '✗',
skipped: '⏱',
}
export function NotificationStatus({ eventId }: NotificationStatusProps) {
const { isDark } = useTheme()
const [rows, setRows] = useState<NotificationLogRow[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
calendarApi.listEventNotifications(eventId)
.then(r => { setRows(r || []); setLoading(false) })
.catch(() => setLoading(false))
}, [eventId])
if (loading || rows.length === 0) return null
return (
<div className={`mt-2 pt-2 border-t text-xs ${isDark ? 'border-white/10' : 'border-black/10'}`} data-testid={`notif-status-${eventId}`}>
<div className={`font-medium mb-1 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>Erinnerungen</div>
<div className="flex flex-wrap gap-2">
{rows.map((r, i) => (
<span
key={i}
title={r.error_message || `${r.status} ${r.run_date}`}
className={`px-2 py-0.5 rounded ${
r.status === 'sent' ? (isDark ? 'bg-emerald-500/30 text-emerald-100' : 'bg-emerald-100 text-emerald-900') :
r.status === 'failed' ? (isDark ? 'bg-red-500/30 text-red-100' : 'bg-red-100 text-red-900') :
(isDark ? 'bg-amber-500/30 text-amber-100' : 'bg-amber-100 text-amber-900')
}`}
>
{STATUS_ICON[r.status]} {r.lead_days === 0 ? 'Heute' : r.lead_days === 1 ? '1 Tag' : `${r.lead_days} Tage`}
{' · '}{r.audience === 'parents' ? 'Eltern' : 'Schueler'}
{' · '}{r.channel}
</span>
))}
</div>
</div>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import { classesApi } from '@/lib/stundenplan/api'
import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types'
import type { TimetableClass } from '@/app/stundenplan/types'
const LANGS: { code: string; name: string }[] = [
{ code: 'de', name: 'Deutsch' },
{ code: 'en', name: 'English' },
{ code: 'tr', name: 'Tuerkce' },
{ code: 'ar', name: 'العربية' },
{ code: 'uk', name: 'Українська' },
{ code: 'ru', name: 'Русский' },
{ code: 'pl', name: 'Polski' },
{ code: 'fr', name: 'Francais' },
]
export function ParentManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<ParentInviteListItem[]>([])
const [classes, setClasses] = useState<TimetableClass[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [lastInvite, setLastInvite] = useState<InviteParentResponse | null>(null)
const [form, setForm] = useState({
email: '',
preferred_language: 'de',
child_first_name: '',
child_last_name: '',
tt_class_id: '',
})
const load = useCallback(async () => {
setLoading(true)
try {
const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()])
setItems(list || [])
setClasses(cls || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await calendarApi.inviteParent(form)
setLastInvite(res)
setForm({ ...form, child_first_name: '', child_last_name: '' })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (childId: string) => {
if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return
try { await calendarApi.deleteParentChild(childId); await load() }
catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') }
}
const fullLink = (path: string) =>
typeof window === 'undefined' ? path : `${window.location.origin}${path}`
const copyLink = (path: string) => {
navigator.clipboard?.writeText(fullLink(path))
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="parent-manager">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Eltern verwalten ({items.length})</h3>
<button
onClick={() => setShowForm(s => !s)}
disabled={classes.length === 0}
data-testid="parent-invite-toggle"
className={`px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Eltern einladen'}
</button>
</div>
{classes.length === 0 && (
<p className={`text-sm mb-2 ${isDark ? 'text-amber-200' : 'text-amber-900'}`}>
Zuerst Klassen im Stundenplan-Modul anlegen.
</p>
)}
{error && (
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
{showForm && (
<form onSubmit={handleSubmit} className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-2">
<input required type="email" placeholder="Eltern-E-Mail" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<input required placeholder="Vorname Kind" value={form.child_first_name} onChange={e => setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<input required placeholder="Nachname Kind" value={form.child_last_name} onChange={e => setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
<select required value={form.tt_class_id} onChange={e => setForm({ ...form, tt_class_id: e.target.value })} data-testid="parent-class" className={`px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> Klasse waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select value={form.preferred_language} onChange={e => setForm({ ...form, preferred_language: e.target.value })} className={`px-3 py-2 rounded-lg border ${inputClass}`}>
{LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
</select>
<button type="submit" disabled={submitting} data-testid="parent-invite-submit" className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Erstellt…' : 'Einladen'}
</button>
</form>
)}
{lastInvite && (
<div className={`mb-3 p-3 rounded-lg ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`} data-testid="parent-invite-link">
<div className="text-sm font-medium mb-1">Einladungs-Link fuer {lastInvite.parent.email}</div>
<div className="flex gap-2 items-center">
<code className={`flex-1 text-xs px-2 py-1 rounded overflow-x-auto ${isDark ? 'bg-white/10' : 'bg-white'}`}>
{fullLink(lastInvite.magic_url)}
</code>
<button onClick={() => copyLink(lastInvite.magic_url)} className="text-xs px-2 py-1 rounded bg-indigo-500 hover:bg-indigo-600 text-white">Kopieren</button>
</div>
<p className="text-xs opacity-70 mt-1">Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}</p>
</div>
)}
{loading ? (
<div className="opacity-60 py-4 text-center text-sm">Laedt</div>
) : items.length === 0 ? (
<div className="opacity-60 py-4 text-center text-sm">Keine eingeladenen Eltern.</div>
) : (
<table className="w-full text-sm">
<thead className={isDark ? 'opacity-70' : 'opacity-70'}>
<tr>
<th className="text-left py-2">E-Mail</th>
<th className="text-left py-2">Kind</th>
<th className="text-left py-2">Klasse</th>
<th className="text-left py-2">Sprache</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(it => (
<tr key={it.child_id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="py-2">{it.email}</td>
<td className="py-2">{it.child_first_name} {it.child_last_name}</td>
<td className="py-2">{it.class_name}</td>
<td className="py-2">{it.preferred_language}</td>
<td className="py-2 text-right">
<button onClick={() => handleDelete(it.child_id)} className="text-xs text-red-400 hover:text-red-300">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { SchoolYearRolloverResult } from '@/app/schulkalender/types'
interface RolloverWizardProps {
onClose: () => void
onDone: () => void
}
function nextSchoolYearISO(): { start: string; end: string } {
const now = new Date()
let y = now.getFullYear()
if (now.getMonth() + 1 >= 8) y++ // Aug → bumped to next year
return { start: `${y}-08-01`, end: `${y + 1}-07-31` }
}
export function RolloverWizard({ onClose, onDone }: RolloverWizardProps) {
const { isDark } = useTheme()
const defaults = nextSchoolYearISO()
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SchoolYearRolloverResult | null>(null)
const expected = 'SCHULJAHR WECHSELN'
const handleSubmit = async () => {
setSaving(true)
setError(null)
try {
const r = await calendarApi.rolloverSchoolYear(start, end)
setResult(r)
} catch (e) {
setError(e instanceof Error ? e.message : 'Rollover fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="rollover-wizard">
<div className={`w-full max-w-xl rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Schuljahres-Wechsel</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{result ? (
<div className="space-y-3" data-testid="rollover-result">
<div className={`p-4 rounded-xl ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`}>
<div className="font-medium mb-2">Rollover erfolgreich</div>
<ul className="text-sm space-y-1 opacity-90">
<li>{result.classes_promoted} Klassen um eine Stufe aufgerueckt</li>
<li>{result.classes_graduated} Abschlussklassen entfernt</li>
<li>Neues Schuljahr: {result.new_year_start} {result.new_year_end}</li>
</ul>
</div>
<button onClick={onDone} className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium">
Schliessen
</button>
</div>
) : (
<>
<div className={`p-3 rounded-lg text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-100' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
<p className="font-medium mb-1">Was passiert?</p>
<ul className="list-disc list-inside space-y-1 opacity-90">
<li>Alle Klassen ruecken eine Stufe hoeher (5a 6, 6a 7, )</li>
<li>Abschlussklassen (Stufe 13) werden entfernt</li>
<li>Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert</li>
<li>Vorhandene Stundenplaene bleiben als Historie erhalten</li>
</ul>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Beginn</label>
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Ende</label>
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">
Bestaetigung tippe <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>{expected}</code> zur Bestaetigung
</label>
<input
value={confirm}
onChange={e => setConfirm(e.target.value)}
data-testid="rollover-confirm"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2">
<button
onClick={handleSubmit}
disabled={saving || confirm !== expected}
data-testid="rollover-submit"
className="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium disabled:opacity-30"
>
{saving ? 'Wechselt…' : 'Schuljahr wechseln'}
</button>
<button onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-100 hover:bg-slate-200'}`}>
Abbrechen
</button>
</div>
</>
)}
</div>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolCalendarConfig, SchoolEvent } from './types'
import { BUNDESLAENDER } from './types'
import { MonthView } from './_components/MonthView'
import { BundeslandWizard } from './_components/BundeslandWizard'
import { EventModal } from './_components/EventModal'
import { DayDetail } from './_components/DayDetail'
import { RolloverWizard } from './_components/RolloverWizard'
import { ParentManager } from './_components/ParentManager'
function monthRange(year: number, month: number): { from: string; to: string } {
// Render the visible 6-week grid worth of holidays (covers prev/next month edges).
const from = new Date(Date.UTC(year, month - 1, 1))
from.setUTCDate(from.getUTCDate() - 7)
const to = new Date(Date.UTC(year, month, 0))
to.setUTCDate(to.getUTCDate() + 14)
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) }
}
export default function SchulkalenderPage() {
const { isDark } = useTheme()
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [config, setConfig] = useState<SchoolCalendarConfig | null>(null)
const [holidays, setHolidays] = useState<PublicEvent[]>([])
const [schoolEvents, setSchoolEvents] = useState<SchoolEvent[]>([])
const [configLoading, setConfigLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openDay, setOpenDay] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showRollover, setShowRollover] = useState(false)
const loadConfig = useCallback(async () => {
setConfigLoading(true)
try {
const cfg = await calendarApi.getConfig()
setConfig(cfg)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Config laden fehlgeschlagen')
} finally {
setConfigLoading(false)
}
}, [])
useEffect(() => { loadConfig() }, [loadConfig])
const loadHolidays = useCallback(async () => {
if (!config?.bundesland) return
const { from, to } = monthRange(year, month)
try {
const [hd, ev] = await Promise.all([
calendarApi.listHolidays(config.bundesland, from, to),
calendarApi.listEvents(from, to),
])
setHolidays(hd || [])
setSchoolEvents(ev || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ferien/Events laden fehlgeschlagen')
}
}, [config, year, month])
useEffect(() => { loadHolidays() }, [loadHolidays])
const handleSaveBundesland = async (bundesland: string) => {
const cfg = await calendarApi.upsertConfig({ bundesland })
setConfig(cfg)
}
const goPrev = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else setMonth(m => m - 1)
}
const goNext = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else setMonth(m => m + 1)
}
const goToday = () => {
const t = new Date()
setYear(t.getFullYear())
setMonth(t.getMonth() + 1)
}
const bundeslandName = config
? BUNDESLAENDER.find(b => b.code === config.bundesland)?.name || config.bundesland
: ''
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
<div className="relative z-10 p-4"><Sidebar selectedTab="schulkalender" /></div>
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
<div className="max-w-6xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Schulkalender
</h1>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{config ? `Ferien und Feiertage fuer ${bundeslandName}` : 'Ferien, Feiertage und Schultermine'}
</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</header>
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>
)}
{configLoading ? (
<div className={`text-center py-12 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt</div>
) : !config ? (
<BundeslandWizard onSave={handleSaveBundesland} />
) : (
<>
<MonthView
year={year}
month={month}
holidays={holidays}
schoolEvents={schoolEvents}
onPrev={goPrev}
onNext={goNext}
onToday={goToday}
onDayClick={(iso) => setOpenDay(iso)}
onAddEvent={() => setShowAddModal(true)}
onRollover={() => setShowRollover(true)}
/>
{openDay && (
<DayDetail
iso={openDay}
holidays={holidays}
events={schoolEvents}
onClose={() => setOpenDay(null)}
onDeleted={() => { loadHolidays(); setOpenDay(null) }}
/>
)}
{showAddModal && (
<EventModal
defaultDate={openDay || new Date().toISOString().slice(0, 10)}
onClose={() => setShowAddModal(false)}
onCreated={() => { setShowAddModal(false); loadHolidays() }}
/>
)}
{showRollover && (
<RolloverWizard
onClose={() => setShowRollover(false)}
onDone={() => { setShowRollover(false); loadHolidays() }}
/>
)}
<div className="mt-6">
<ParentManager />
</div>
</>
)}
</div>
</main>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
export type PublicEventType = 'public_holiday' | 'school_holiday'
export interface PublicEvent {
id: string
region: string
event_type: PublicEventType
name_de: string
name_en?: string
start_date: string // YYYY-MM-DD
end_date: string
source?: string
created_at?: string
}
export interface SchoolCalendarConfig {
user_id: string
bundesland: string
school_year_start?: string | null
school_year_end?: string | null
created_at?: string
updated_at?: string
}
export interface UpsertSchoolCalendarConfig {
bundesland: string
school_year_start?: string | null
school_year_end?: string | null
}
export type SchoolEventType =
| 'fortbildung'
| 'schulfeier'
| 'klassenfahrt'
| 'projekttag'
| 'eltern_info'
| 'andere'
export interface SchoolEvent {
id: string
created_by_user_id: string
title: string
description?: string
event_type: SchoolEventType
is_school_free: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids: string[]
visible_to_parents: boolean
notify_parents: boolean
notify_students: boolean
notification_lead_days: number[]
created_at?: string
updated_at?: string
}
export interface CreateSchoolEvent {
title: string
description?: string
event_type: SchoolEventType
is_school_free?: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids?: string[]
visible_to_parents?: boolean
notify_parents?: boolean
notify_students?: boolean
notification_lead_days?: number[]
}
export interface SchoolYearRolloverResult {
classes_promoted: number
classes_graduated: number
new_year_start: string
new_year_end: string
}
export const EVENT_TYPE_LABEL: Record<SchoolEventType, string> = {
fortbildung: 'Fortbildung',
schulfeier: 'Schulfeier',
klassenfahrt: 'Klassenfahrt',
projekttag: 'Projekttag',
eltern_info: 'Eltern-Info',
andere: 'Andere',
}
export const EVENT_TYPE_COLOR: Record<SchoolEventType, string> = {
fortbildung: '#0ea5e9',
schulfeier: '#a855f7',
klassenfahrt: '#22c55e',
projekttag: '#f59e0b',
eltern_info: '#ec4899',
andere: '#64748b',
}
export const BUNDESLAENDER: { code: string; name: string }[] = [
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' },
{ code: 'DE-BY', name: 'Bayern' },
{ code: 'DE-BE', name: 'Berlin' },
{ code: 'DE-BB', name: 'Brandenburg' },
{ code: 'DE-HB', name: 'Bremen' },
{ code: 'DE-HH', name: 'Hamburg' },
{ code: 'DE-HE', name: 'Hessen' },
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'DE-NI', name: 'Niedersachsen' },
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
{ code: 'DE-SL', name: 'Saarland' },
{ code: 'DE-SN', name: 'Sachsen' },
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
{ code: 'DE-TH', name: 'Thueringen' },
]
// ---------- Parent invitations (Phase 9c) ----------
export interface ParentAccount {
id: string
email: string
preferred_language: string
}
export interface ParentChild {
id: string
parent_id: string
tt_class_id: string
first_name: string
last_name: string
class_name?: string
}
export interface ParentInviteListItem {
parent_id: string
email: string
preferred_language: string
child_id: string
child_first_name: string
child_last_name: string
class_id: string
class_name: string
created_at: string
}
export interface InviteParentRequest {
email: string
preferred_language?: string
child_first_name: string
child_last_name: string
tt_class_id: string
}
export interface InviteParentResponse {
parent: ParentAccount
child: ParentChild
magic_token: string
magic_url: string
expires_at: string
}
// ---------- Notifications (Phase 9d) ----------
export type NotificationStatus = 'sent' | 'failed' | 'skipped'
export interface NotificationLogRow {
lead_days: number
audience: 'parents' | 'students'
channel: 'matrix' | 'email'
status: NotificationStatus
error_message?: string
run_date: string
created_at: string
}
export interface NotificationRunResult {
date: string
sent: number
failed: number
skipped: number
already_logged: number
}
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { assignmentsApi, classesApi, subjectsApi, teachersApi } from '@/lib/stundenplan/api'
import type {
TimetableAssignment, CreateTimetableAssignment,
TimetableClass, TimetableSubject, TimetableTeacher,
} from '@/app/stundenplan/types'
const initialForm: CreateTimetableAssignment = {
teacher_id: '',
class_id: '',
subject_id: '',
}
export function AssignmentsManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetableAssignment[]>([])
const [classes, setClasses] = useState<TimetableClass[]>([])
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<CreateTimetableAssignment>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [asg, cls, sub, t] = await Promise.all([
assignmentsApi.list(),
classesApi.list(),
subjectsApi.list(),
teachersApi.list(),
])
setItems(asg || [])
setClasses(cls || [])
setSubjects(sub || [])
setTeachers(t || [])
} 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 {
await assignmentsApi.create(form)
setForm(initialForm)
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Lehrauftrag wirklich loeschen?')) return
try {
await assignmentsApi.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' : 'bg-white border-slate-300 text-slate-900'
const prereqMissing = classes.length === 0 || subjects.length === 0 || teachers.length === 0
return (
<div className="space-y-4" data-testid="assignments-manager">
<div className="flex items-center justify-between">
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Lehrauftraege ({items.length})
</h2>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Welcher Lehrer unterrichtet welches Fach in welcher Klasse.
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={prereqMissing}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neuer Lehrauftrag'}
</button>
</div>
{prereqMissing && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Klassen, Faecher und Lehrer anlegen.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={form.teacher_id} onChange={e => setForm({ ...form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> bitte waehlen </option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Klasse</label>
<select required value={form.class_id} onChange={e => setForm({ ...form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> bitte waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> bitte waehlen </option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
</form>
)}
{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 Lehrauftraege.</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">Lehrer</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Klasse</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(a => (
<tr key={a.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{a.teacher_name || a.teacher_id.slice(0, 8) + '…'}</td>
<td className="px-4 py-3">{a.class_name || a.class_id.slice(0, 8) + '…'}</td>
<td className="px-4 py-3">{a.subject_name || a.subject_id.slice(0, 8) + '…'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(a.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { curriculumApi, classesApi, subjectsApi } from '@/lib/stundenplan/api'
import type {
TimetableCurriculum, CreateTimetableCurriculum,
TimetableClass, TimetableSubject,
} from '@/app/stundenplan/types'
const initialForm: CreateTimetableCurriculum = {
class_id: '',
subject_id: '',
weekly_hours: 4,
}
export function CurriculumManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetableCurriculum[]>([])
const [classes, setClasses] = useState<TimetableClass[]>([])
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<CreateTimetableCurriculum>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [curr, cls, sub] = await Promise.all([
curriculumApi.list(),
classesApi.list(),
subjectsApi.list(),
])
setItems(curr || [])
setClasses(cls || [])
setSubjects(sub || [])
} 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 {
await curriculumApi.create(form)
setForm(initialForm)
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Stundentafel-Eintrag wirklich loeschen?')) return
try {
await curriculumApi.remove(id)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
}
}
const className = (id: string): string => {
const c = classes.find(x => x.id === id)
return c ? c.name : id.slice(0, 8) + '…'
}
const subjectName = (id: string): string => {
const s = subjects.find(x => x.id === id)
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
}
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'
const prereqMissing = classes.length === 0 || subjects.length === 0
return (
<div className="space-y-4" data-testid="curriculum-manager">
<div className="flex items-center justify-between">
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Stundentafel ({items.length})
</h2>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Pro Klasse: wie viele Wochenstunden fuer jedes Fach.
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={prereqMissing}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neuer Eintrag'}
</button>
</div>
{prereqMissing && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Klassen und Faecher anlegen.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Klasse</label>
<select required value={form.class_id} onChange={e => setForm({ ...form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> bitte waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value=""> bitte waehlen </option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Stunden/Woche (1-10)</label>
<input type="number" min={1} max={10} required value={form.weekly_hours} onChange={e => setForm({ ...form, weekly_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
</form>
)}
{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 Eintraege.</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">Klasse</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Stunden/Woche</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{c.class_name || className(c.class_id)}</td>
<td className="px-4 py-3">{c.subject_name || subjectName(c.subject_id)}</td>
<td className="px-4 py-3">{c.weekly_hours}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,142 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { subjectsApi } from '@/lib/stundenplan/api'
import type { TimetableSubject, CreateTimetableSubject } from '@/app/stundenplan/types'
const initialForm: CreateTimetableSubject = {
name: '',
short_code: '',
color: '#6366f1',
is_main_subject: false,
required_room_type: '',
}
export function FaecherManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetableSubject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<CreateTimetableSubject>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const data = await subjectsApi.list()
setItems(data || [])
} 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 {
await subjectsApi.create(form)
setForm(initialForm)
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Fach wirklich loeschen? Verbundene Stundentafel-Eintraege werden mitgeloescht.')) return
try {
await subjectsApi.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'
return (
<div className="space-y-4" data-testid="faecher-manager">
<div className="flex items-center justify-between">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Faecher ({items.length})</h2>
<button onClick={() => setShowForm(s => !s)} className={`px-4 py-2 rounded-xl font-medium transition-colors ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>
{showForm ? 'Abbrechen' : '+ Neues Fach'}
</button>
</div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Name</label>
<input required value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="z.B. Mathematik" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Kuerzel</label>
<input required maxLength={10} value={form.short_code} onChange={e => setForm({ ...form, short_code: e.target.value.toUpperCase() })} placeholder="z.B. M" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Farbe</label>
<input type="color" value={form.color || '#6366f1'} onChange={e => setForm({ ...form, color: e.target.value })} className="w-full h-10 rounded-lg border cursor-pointer" />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Benoetigter Raumtyp (optional)</label>
<input value={form.required_room_type || ''} onChange={e => setForm({ ...form, required_room_type: e.target.value })} placeholder="z.B. Sporthalle" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_main" checked={!!form.is_main_subject} onChange={e => setForm({ ...form, is_main_subject: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_main" className="text-sm">Hauptfach</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
</form>
)}
{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 Faecher angelegt.</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">Farbe</th>
<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">Kuerzel</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hauptfach</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Raumtyp</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(s => (
<tr key={s.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3"><span className="inline-block w-5 h-5 rounded" style={{ backgroundColor: s.color || '#94a3b8' }} /></td>
<td className="px-4 py-3 font-medium">{s.name}</td>
<td className="px-4 py-3">{s.short_code}</td>
<td className="px-4 py-3">{s.is_main_subject ? 'Ja' : '—'}</td>
<td className="px-4 py-3 text-sm opacity-70">{s.required_room_type || '—'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(s.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -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,196 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { classesApi } from '@/lib/stundenplan/api'
import type { TimetableClass, CreateTimetableClass } from '@/app/stundenplan/types'
export function KlassenManager() {
const { isDark } = useTheme()
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 [form, setForm] = useState<CreateTimetableClass>({
name: '',
grade_level: 5,
student_count: 0,
notes: '',
})
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await classesApi.list()
setClasses(data || [])
} 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 {
await classesApi.create(form)
setForm({ name: '', grade_level: 5, student_count: 0, notes: '' })
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Klasse wirklich loeschen?')) return
try {
await classesApi.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'
return (
<div className="space-y-4" data-testid="klassen-manager">
<div className="flex items-center justify-between">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Klassen ({classes.length})
</h2>
<button
onClick={() => setShowForm(s => !s)}
className={`px-4 py-2 rounded-xl font-medium transition-colors ${
isDark
? 'bg-indigo-500 hover:bg-indigo-600 text-white'
: 'bg-indigo-600 hover:bg-indigo-700 text-white'
}`}
>
{showForm ? 'Abbrechen' : '+ Neue Klasse'}
</button>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">
{error}
</div>
)}
{showForm && (
<form
onSubmit={handleSubmit}
className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}
>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Name</label>
<input
required
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="z.B. 5a"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Klassenstufe</label>
<input
type="number"
min={1}
max={13}
required
value={form.grade_level}
onChange={e => setForm({ ...form, grade_level: parseInt(e.target.value) || 0 })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Schueleranzahl</label>
<input
type="number"
min={0}
value={form.student_count}
onChange={e => setForm({ ...form, student_count: parseInt(e.target.value) || 0 })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50"
>
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
<div className="mt-3">
<label className="block text-sm mb-1 opacity-70">Notizen (optional)</label>
<input
value={form.notes || ''}
onChange={e => setForm({ ...form, notes: e.target.value })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
</form>
)}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>
Laedt
</div>
) : classes.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>
Noch keine Klassen angelegt.
</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">Stufe</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Schueler</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Notizen</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{classes.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{c.name}</td>
<td className="px-4 py-3">{c.grade_level}</td>
<td className="px-4 py-3">{c.student_count}</td>
<td className="px-4 py-3 text-sm opacity-70">{c.notes || '—'}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(c.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,154 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { teachersApi } from '@/lib/stundenplan/api'
import type { TimetableTeacher, CreateTimetableTeacher } from '@/app/stundenplan/types'
const initialForm: CreateTimetableTeacher = {
first_name: '',
last_name: '',
short_code: '',
employment_percentage: 100,
max_hours_week: 28,
notes: '',
}
export function LehrerManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetableTeacher[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<CreateTimetableTeacher>(initialForm)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await teachersApi.list()
setItems(data || [])
} 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 {
await teachersApi.create(form)
setForm(initialForm)
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Lehrer wirklich loeschen? Verbundene Constraints werden mitgeloescht.')) return
try {
await teachersApi.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'
return (
<div className="space-y-4" data-testid="lehrer-manager">
<div className="flex items-center justify-between">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Lehrer ({items.length})
</h2>
<button
onClick={() => setShowForm(s => !s)}
className={`px-4 py-2 rounded-xl font-medium transition-colors ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neuer Lehrer'}
</button>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>
)}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Vorname</label>
<input required value={form.first_name} onChange={e => setForm({ ...form, first_name: 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">Nachname</label>
<input required value={form.last_name} onChange={e => setForm({ ...form, last_name: 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">Kuerzel</label>
<input required maxLength={10} value={form.short_code} onChange={e => setForm({ ...form, short_code: e.target.value.toUpperCase() })} placeholder="z.B. MUE" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Stellenanteil (%)</label>
<input type="number" min={0} max={100} value={form.employment_percentage ?? 100} onChange={e => setForm({ ...form, employment_percentage: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden/Woche</label>
<input type="number" min={0} max={40} value={form.max_hours_week ?? 28} onChange={e => setForm({ ...form, max_hours_week: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
</form>
)}
{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 Lehrer angelegt.</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">Kuerzel</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Stellenanteil</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Max. h/Woche</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(t => (
<tr key={t.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{t.last_name}, {t.first_name}</td>
<td className="px-4 py-3">{t.short_code}</td>
<td className="px-4 py-3">{t.employment_percentage}%</td>
<td className="px-4 py-3">{t.max_hours_week}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(t.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,169 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { periodsApi } from '@/lib/stundenplan/api'
import type { TimetablePeriod, CreateTimetablePeriod } from '@/app/stundenplan/types'
const DAYS = [
{ v: 1, label: 'Mo' },
{ v: 2, label: 'Di' },
{ v: 3, label: 'Mi' },
{ v: 4, label: 'Do' },
{ v: 5, label: 'Fr' },
{ v: 6, label: 'Sa' },
{ v: 7, label: 'So' },
]
const initialForm: CreateTimetablePeriod = {
day_of_week: 1,
period_index: 1,
start_time: '08:00',
end_time: '08:45',
is_break: false,
label: '',
}
export function PeriodsManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetablePeriod[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<CreateTimetablePeriod>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const data = await periodsApi.list()
setItems(data || [])
} 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 {
await periodsApi.create(form)
setForm({ ...initialForm, day_of_week: form.day_of_week, period_index: form.period_index + 1 })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Zeitraster-Eintrag wirklich loeschen?')) return
try {
await periodsApi.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' : 'bg-white border-slate-300 text-slate-900'
// Group periods by period_index for an at-a-glance week grid.
const periodIndices = Array.from(new Set(items.map(i => i.period_index))).sort((a, b) => a - b)
const periodByDay = (day: number, idx: number) => items.find(p => p.day_of_week === day && p.period_index === idx)
return (
<div className="space-y-4" data-testid="periods-manager">
<div className="flex items-center justify-between">
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Zeitraster ({items.length})
</h2>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Pro Wochentag die Stunden-Slots (z.B. 1. Stunde 08:0008:45).
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
className={`px-4 py-2 rounded-xl font-medium transition-colors ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neuer Slot'}
</button>
</div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
<select value={form.day_of_week} onChange={e => setForm({ ...form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
{DAYS.map(d => <option key={d.v} value={d.v}>{d.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Stunde (1-12)</label>
<input type="number" min={1} max={12} required value={form.period_index} onChange={e => setForm({ ...form, period_index: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_break" checked={!!form.is_break} onChange={e => setForm({ ...form, is_break: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_break" className="text-sm">Pause</label>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Startzeit</label>
<input type="time" required value={form.start_time} onChange={e => setForm({ ...form, start_time: 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">Endzeit</label>
<input type="time" required value={form.end_time} onChange={e => setForm({ ...form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen (+1)'}
</button>
</div>
</div>
</form>
)}
{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 kein Zeitraster definiert.</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">Stunde</th>
{DAYS.map(d => <th key={d.v} className="text-left px-4 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-4 py-3 font-medium">{idx}.</td>
{DAYS.map(d => {
const p = periodByDay(d.v, idx)
if (!p) return <td key={d.v} className="px-4 py-3 opacity-30"></td>
return (
<td key={d.v} className="px-4 py-3">
<div className={`text-sm ${p.is_break ? 'italic opacity-60' : ''}`}>
{p.start_time}{p.end_time}
</div>
<button onClick={() => handleDelete(p.id)} className="text-xs text-red-400 hover:text-red-300 mt-1">×</button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { roomsApi } from '@/lib/stundenplan/api'
import type { TimetableRoom, CreateTimetableRoom } from '@/app/stundenplan/types'
const initialForm: CreateTimetableRoom = {
name: '',
room_type: '',
capacity: 30,
floor_level: 0,
has_elevator: true,
notes: '',
}
export function RaeumeManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetableRoom[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<CreateTimetableRoom>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const data = await roomsApi.list()
setItems(data || [])
} 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 {
await roomsApi.create(form)
setForm(initialForm)
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Raum wirklich loeschen?')) return
try {
await roomsApi.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'
return (
<div className="space-y-4" data-testid="raeume-manager">
<div className="flex items-center justify-between">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Raeume ({items.length})</h2>
<button onClick={() => setShowForm(s => !s)} className={`px-4 py-2 rounded-xl font-medium transition-colors ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>
{showForm ? 'Abbrechen' : '+ Neuer Raum'}
</button>
</div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Name</label>
<input required value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="z.B. A101" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Typ</label>
<input value={form.room_type || ''} onChange={e => setForm({ ...form, room_type: e.target.value })} placeholder="z.B. Sporthalle, Chemie" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Kapazitaet</label>
<input type="number" min={0} value={form.capacity ?? 30} onChange={e => setForm({ ...form, capacity: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Stockwerk</label>
<input type="number" value={form.floor_level ?? 0} onChange={e => setForm({ ...form, floor_level: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="has_elevator" checked={!!form.has_elevator} onChange={e => setForm({ ...form, has_elevator: e.target.checked })} className="w-5 h-5" />
<label htmlFor="has_elevator" className="text-sm">Aufzug erreichbar</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
</form>
)}
{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 Raeume angelegt.</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">Typ</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Kapazitaet</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Stockwerk</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Aufzug</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(r => (
<tr key={r.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3">{r.room_type || '—'}</td>
<td className="px-4 py-3">{r.capacity}</td>
<td className="px-4 py-3">{r.floor_level}</td>
<td className="px-4 py-3">{r.has_elevator ? 'Ja' : 'Nein'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(r.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</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>
)
}
@@ -0,0 +1,140 @@
'use client'
import { useState, useEffect } from 'react'
import { classMaxHoursDayApi, classNoGapsApi, classesApi } from '@/lib/stundenplan/api'
import type {
ClassMaxHoursDay, ClassNoGaps, TimetableClass,
} from '@/app/stundenplan/types'
import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell'
function useClasses() {
const [classes, setClasses] = useState<TimetableClass[]>([])
useEffect(() => { classesApi.list().then(setClasses).catch(() => setClasses([])) }, [])
return classes
}
function cLabel(classes: TimetableClass[], id: string): string {
const c = classes.find(x => x.id === id)
return c ? c.name : id.slice(0, 8) + '…'
}
// ---------- Max Hours / Day ----------
type DayForm = Omit<ClassMaxHoursDay, 'id' | 'created_by_user_id' | 'created_at'>
const initialDay: DayForm = { class_id: '', max_hours: 6, is_hard: true, weight: 100, active: true, note: '' }
export function ClassMaxHoursDayEditor() {
const styles = useShellStyles()
const classes = useClasses()
const crud = useConstraintCrud<ClassMaxHoursDay, DayForm>(classMaxHoursDayApi, initialDay)
return (
<ConstraintShell
testId="class-max-hours-day-editor"
title="Klasse: Max. Stunden / Tag"
description={"Beispiel: 5a hoechstens 6 Stunden pro Tag (jugendgerecht)."}
newLabel="+ Neue Regel"
newDisabled={classes.length === 0}
prereqWarning={classes.length === 0 ? 'Zuerst Klassen anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Klasse', 'Max. Std/Tag', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Klasse</label>
<select required value={crud.form.class_id} onChange={e => crud.setForm({ ...crud.form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value=""> bitte waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-12)</label>
<input type="number" min={1} max={12} required value={crud.form.max_hours} onChange={e => crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_cmhd" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_cmhd" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-4 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as ClassMaxHoursDay
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{cLabel(classes, c.class_id)}</td>
<td className="px-4 py-3">{c.max_hours}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- No Gaps ----------
type GapForm = Omit<ClassNoGaps, 'id' | 'created_by_user_id' | 'created_at'>
const initialGap: GapForm = { class_id: '', is_hard: false, weight: 80, active: true, note: '' }
export function ClassNoGapsEditor() {
const styles = useShellStyles()
const classes = useClasses()
const crud = useConstraintCrud<ClassNoGaps, GapForm>(classNoGapsApi, initialGap)
return (
<ConstraintShell
testId="class-no-gaps-editor"
title="Klasse: Keine Freistunden"
description={"Soft-Regel: Klasse soll keine Loecher zwischen Lessons haben."}
newLabel="+ Neue Regel"
newDisabled={classes.length === 0}
prereqWarning={classes.length === 0 ? 'Zuerst Klassen anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Klasse', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Klasse</label>
<select required value={crud.form.class_id} onChange={e => crud.setForm({ ...crud.form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value=""> bitte waehlen </option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_cng" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_cng" className="text-sm">Harte Regel</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as ClassNoGaps
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{cLabel(classes, c.class_id)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}

Some files were not shown because too many files have changed in this diff Show More