Compare commits
39 Commits
855cc4caf4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c720e2df | |||
| 89011d64f7 | |||
| 8311b33fb3 | |||
| 85957ed5db | |||
| d9858084dd | |||
| 33409352ee | |||
| 3b8df0d294 | |||
| 09f6f5a5e1 | |||
| 97e37837ee | |||
| 65e7ed94f6 | |||
| 306886a42b | |||
| bf5ea860cc | |||
| 612ecec6d9 | |||
| 0744769d88 | |||
| d3f311a32e | |||
| 77650e8092 | |||
| 22f08a232d | |||
| 1f2f304724 | |||
| 53cfe9238f | |||
| f042f2896b | |||
| 082a5bb68c | |||
| a315db0388 | |||
| 7c96d89927 | |||
| c2c09e1cd9 | |||
| 4657589b89 | |||
| 73636f76a2 | |||
| f21ecf293b | |||
| 64e7176267 | |||
| e958f88a2d | |||
| a1488b2fec | |||
| 8d53b1f6b9 | |||
| 399ab88f5f | |||
| d52eb43a32 | |||
| bde0d57b5a | |||
| fc49d87928 | |||
| 0018076ed5 | |||
| a30f10a467 | |||
| a44d360cbc | |||
| 52a15b24fe |
@@ -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)
|
## Wichtige Dateien (Referenz)
|
||||||
|
|
||||||
| Datei | Beschreibung |
|
| Datei | Beschreibung |
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
|
|||||||
from vocabulary.api import router as vocabulary_router
|
from vocabulary.api import router as vocabulary_router
|
||||||
app.include_router(vocabulary_router, prefix="/api")
|
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 ---
|
# --- 4d. User Language Preferences ---
|
||||||
from api.user_language import router as user_language_router
|
from api.user_language import router as user_language_router
|
||||||
app.include_router(user_language_router, prefix="/api")
|
app.include_router(user_language_router, prefix="/api")
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ from .db import (
|
|||||||
get_all_pos,
|
get_all_pos,
|
||||||
VocabularyWord,
|
VocabularyWord,
|
||||||
)
|
)
|
||||||
from units.learning import (
|
|
||||||
LearningUnitCreate,
|
|
||||||
create_learning_unit,
|
|
||||||
get_learning_unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -87,13 +82,38 @@ async def _search_kaikki(q: str, lang: str, limit: int, offset: int):
|
|||||||
if isinstance(tr, str):
|
if isinstance(tr, str):
|
||||||
import json as _json
|
import json as _json
|
||||||
tr = _json.loads(tr)
|
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({
|
words.append({
|
||||||
"id": str(r["id"]),
|
"id": str(r["id"]),
|
||||||
"english": r["word"] if r["lang"] == "en" else "",
|
"english": en_word if r["lang"] != "en" else r["word"],
|
||||||
"german": tr.get("de", {}).get("text", "") if r["lang"] == "en" else r["word"] if r["lang"] == "de" else "",
|
"german": tr.get("de", {}).get("text", "") if r["lang"] != "de" else r["word"],
|
||||||
"word": r["word"],
|
"word": r["word"],
|
||||||
"lang": r["lang"],
|
"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 "",
|
"ipa_de": r["ipa"] if r["lang"] == "de" else "",
|
||||||
"part_of_speech": r["pos"],
|
"part_of_speech": r["pos"],
|
||||||
"syllables_en": [],
|
"syllables_en": [],
|
||||||
@@ -239,130 +259,7 @@ async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class CreateUnitFromWordsPayload(BaseModel):
|
# Unit creation and translation lookup moved to vocabulary/unit_api.py
|
||||||
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", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -109,8 +109,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
BACKEND_URL: http://backend-lehrer:8001
|
BACKEND_URL: http://backend-lehrer:8001
|
||||||
|
SCHOOL_SERVICE_URL: http://school-service:8084
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-lehrer
|
- backend-lehrer
|
||||||
|
- school-service
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- breakpilot-network
|
- breakpilot-network
|
||||||
@@ -287,6 +289,26 @@ services:
|
|||||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||||
ALLOWED_ORIGINS: "*"
|
ALLOWED_ORIGINS: "*"
|
||||||
LLM_GATEWAY_URL: http://backend-lehrer:8001/llm
|
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:
|
depends_on:
|
||||||
core-health-check:
|
core-health-check:
|
||||||
condition: service_completed_successfully
|
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.
|
||||||
@@ -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 13–17 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
|
||||||
@@ -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.
|
||||||
@@ -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 (5–600 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 5–10 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): 30–60 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
@@ -82,6 +82,18 @@ nav:
|
|||||||
- Uebersicht: services/voice-service/index.md
|
- Uebersicht: services/voice-service/index.md
|
||||||
- Agent-Core:
|
- Agent-Core:
|
||||||
- Uebersicht: services/agent-core/index.md
|
- 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:
|
- Architektur:
|
||||||
- Multi-Agent System: architecture/multi-agent.md
|
- Multi-Agent System: architecture/multi-agent.md
|
||||||
- Zeugnis-System: architecture/zeugnis-system.md
|
- Zeugnis-System: architecture/zeugnis-system.md
|
||||||
|
|||||||
@@ -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 | ✅ |
|
||||||
@@ -40,6 +40,9 @@ COPY --from=builder /app/school-service .
|
|||||||
# Copy templates directory
|
# Copy templates directory
|
||||||
COPY --from=builder /app/templates ./templates
|
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
|
# Use non-root user
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/breakpilot/school-service/internal/config"
|
"github.com/breakpilot/school-service/internal/config"
|
||||||
"github.com/breakpilot/school-service/internal/database"
|
"github.com/breakpilot/school-service/internal/database"
|
||||||
@@ -35,7 +38,41 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create handler
|
// 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
|
// Create router
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
@@ -49,7 +86,7 @@ func main() {
|
|||||||
|
|
||||||
// API routes (auth required)
|
// API routes (auth required)
|
||||||
api := router.Group("/api/v1/school")
|
api := router.Group("/api/v1/school")
|
||||||
api.Use(middleware.AuthMiddleware(cfg.JWTSecret))
|
api.Use(middleware.AuthMiddleware(cfg.JWTSecret, cfg.Environment != "production"))
|
||||||
{
|
{
|
||||||
// School Years
|
// School Years
|
||||||
api.GET("/years", handler.GetSchoolYears)
|
api.GET("/years", handler.GetSchoolYears)
|
||||||
@@ -123,6 +160,155 @@ func main() {
|
|||||||
api.PUT("/certificates/detail/:id/finalize", handler.FinalizeCertificate)
|
api.PUT("/certificates/detail/:id/finalize", handler.FinalizeCertificate)
|
||||||
api.GET("/certificates/detail/:id/pdf", handler.GetCertificatePDF)
|
api.GET("/certificates/detail/:id/pdf", handler.GetCertificatePDF)
|
||||||
api.DELETE("/certificates/detail/:id", handler.DeleteCertificate)
|
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
|
// Start server
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ type Config struct {
|
|||||||
|
|
||||||
// LLM Gateway (for AI features)
|
// LLM Gateway (for AI features)
|
||||||
LLMGatewayURL string
|
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
|
// Load loads configuration from environment variables
|
||||||
@@ -43,6 +50,9 @@ func Load() (*Config, error) {
|
|||||||
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
|
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
|
||||||
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
|
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
|
||||||
LLMGatewayURL: getEnv("LLM_GATEWAY_URL", "http://backend:8000/llm"),
|
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
|
// 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)`,
|
`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 {
|
for _, migration := range migrations {
|
||||||
_, err := db.Pool.Exec(ctx, migration)
|
_, err := db.Pool.Exec(ctx, migration)
|
||||||
if err != nil {
|
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)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/notifications"
|
||||||
"github.com/breakpilot/school-service/internal/services"
|
"github.com/breakpilot/school-service/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -16,27 +17,59 @@ type Handler struct {
|
|||||||
gradebookService *services.GradebookService
|
gradebookService *services.GradebookService
|
||||||
certificateService *services.CertificateService
|
certificateService *services.CertificateService
|
||||||
aiService *services.AIService
|
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
|
// 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)
|
classService := services.NewClassService(db)
|
||||||
examService := services.NewExamService(db)
|
examService := services.NewExamService(db)
|
||||||
gradeService := services.NewGradeService(db)
|
gradeService := services.NewGradeService(db)
|
||||||
gradebookService := services.NewGradebookService(db)
|
gradebookService := services.NewGradebookService(db)
|
||||||
certificateService := services.NewCertificateService(db, gradeService, gradebookService)
|
certificateService := services.NewCertificateService(db, gradeService, gradebookService)
|
||||||
aiService := services.NewAIService(llmGatewayURL)
|
aiService := services.NewAIService(llmGatewayURL)
|
||||||
|
timetableService := services.NewTimetableService(db)
|
||||||
|
calendarService := services.NewCalendarService(db)
|
||||||
|
parentService := services.NewParentService(db)
|
||||||
|
notificationService := notifications.NewService(db, matrixURL, emailURL)
|
||||||
|
|
||||||
return &Handler{
|
return &Handler{
|
||||||
classService: classService,
|
classService: classService,
|
||||||
examService: examService,
|
examService: examService,
|
||||||
gradeService: gradeService,
|
gradeService: gradeService,
|
||||||
gradebookService: gradebookService,
|
gradebookService: gradebookService,
|
||||||
certificateService: certificateService,
|
certificateService: certificateService,
|
||||||
aiService: aiService,
|
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
|
// Health returns the service health status
|
||||||
func (h *Handler) Health(c *gin.Context) {
|
func (h *Handler) Health(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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 {
|
if status >= 400 {
|
||||||
gin.DefaultWriter.Write([]byte(
|
gin.DefaultWriter.Write([]byte(
|
||||||
c.Request.Method + " " + path + " " +
|
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
|
// devUserID is the deterministic UUID injected when AuthMiddleware runs in
|
||||||
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
if authHeader == "" {
|
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{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"error": "Authorization header required",
|
"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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ParentAccount struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
||||||
|
Email string `json:"email" db:"email"`
|
||||||
|
PreferredLanguage string `json:"preferred_language" db:"preferred_language"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentChild struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
|
||||||
|
TTClassID uuid.UUID `json:"tt_class_id" db:"tt_class_id"`
|
||||||
|
FirstName string `json:"first_name" db:"first_name"`
|
||||||
|
LastName string `json:"last_name" db:"last_name"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
// Joined for display
|
||||||
|
ClassName string `json:"class_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request DTOs
|
||||||
|
|
||||||
|
// InviteParentRequest is what the teacher posts to invite one parent for one
|
||||||
|
// child. The endpoint creates the account if it doesn't exist, the child
|
||||||
|
// row, and a fresh magic_link. Same parent can be invited for several
|
||||||
|
// children (re-using the account).
|
||||||
|
type InviteParentRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
PreferredLanguage string `json:"preferred_language"`
|
||||||
|
ChildFirstName string `json:"child_first_name" binding:"required"`
|
||||||
|
ChildLastName string `json:"child_last_name" binding:"required"`
|
||||||
|
TTClassID string `json:"tt_class_id" binding:"required,uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteParentResponse carries the freshly-minted magic-link path so the
|
||||||
|
// teacher can copy it into Matrix/Email manually (mass-send comes from the
|
||||||
|
// notification worker in Phase 9d).
|
||||||
|
type InviteParentResponse struct {
|
||||||
|
Parent ParentAccount `json:"parent"`
|
||||||
|
Child ParentChild `json:"child"`
|
||||||
|
MagicToken string `json:"magic_token"`
|
||||||
|
MagicURL string `json:"magic_url"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentInviteListItem is the teacher-facing list row — one entry per
|
||||||
|
// (parent, child) pair, with the joined class name.
|
||||||
|
type ParentInviteListItem struct {
|
||||||
|
ParentID uuid.UUID `json:"parent_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PreferredLanguage string `json:"preferred_language"`
|
||||||
|
ChildID uuid.UUID `json:"child_id"`
|
||||||
|
ChildFirstName string `json:"child_first_name"`
|
||||||
|
ChildLastName string `json:"child_last_name"`
|
||||||
|
ClassID uuid.UUID `json:"class_id"`
|
||||||
|
ClassName string `json:"class_name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemMagicLinkRequest is what /parent/auth/redeem expects.
|
||||||
|
type RedeemMagicLinkRequest struct {
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentMe is what /parent/me returns: the account + every linked child.
|
||||||
|
type ParentMe struct {
|
||||||
|
Parent ParentAccount `json:"parent"`
|
||||||
|
Children []ParentChild `json:"children"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
¬ifyParents, ¬ifyStudents, &leadDays, &classNames); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
distanceDays := int(startDate.Sub(runDate).Hours() / 24)
|
||||||
|
for _, lead := range leadDays {
|
||||||
|
if int(lead) == distanceDays {
|
||||||
|
out = append(out, dueEvent{
|
||||||
|
ID: id, Title: title, EventType: eventType,
|
||||||
|
StartDate: startDate, ClassName: classNames,
|
||||||
|
OwnerUserID: ownerUserID,
|
||||||
|
NotifyParents: notifyParents,
|
||||||
|
NotifyStudents: notifyStudents,
|
||||||
|
LeadDays: int(lead),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vars are the placeholder values rendered into a template string.
|
||||||
|
// The fields here must match the {{…}} markers in templates below.
|
||||||
|
type Vars struct {
|
||||||
|
Title string
|
||||||
|
Date string // YYYY-MM-DD
|
||||||
|
DatePretty string // e.g. "Donnerstag, 15. Oktober"
|
||||||
|
ClassName string // empty = whole school
|
||||||
|
TeacherName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render picks the right template based on event type, audience, lead-day
|
||||||
|
// bucket and language, then substitutes the {{var}} placeholders.
|
||||||
|
//
|
||||||
|
// lead is grouped into three buckets:
|
||||||
|
//
|
||||||
|
// 0 → "today"
|
||||||
|
// 1 → "tomorrow"
|
||||||
|
// >=2 → "in X days" with X = lead value
|
||||||
|
//
|
||||||
|
// Falls back through (lang → de) and (event_type → "andere") so we never
|
||||||
|
// fail to render even with custom rule combos.
|
||||||
|
func Render(eventType, audience string, lead int, lang string, v Vars) (subject, body string) {
|
||||||
|
bucket := bucketFor(lead)
|
||||||
|
lc := strings.ToLower(lang)
|
||||||
|
if _, ok := templates[lc]; !ok {
|
||||||
|
lc = "de"
|
||||||
|
}
|
||||||
|
t := lookup(lc, eventType, audience, bucket)
|
||||||
|
subject = substitute(t.Subject, lead, v)
|
||||||
|
body = substitute(t.Body, lead, v)
|
||||||
|
return subject, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketFor(lead int) string {
|
||||||
|
switch {
|
||||||
|
case lead <= 0:
|
||||||
|
return "today"
|
||||||
|
case lead == 1:
|
||||||
|
return "tomorrow"
|
||||||
|
default:
|
||||||
|
return "days"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup walks the templates map applying the (lang → de) and
|
||||||
|
// (event_type → andere) fallbacks. The bucket is guaranteed to exist for
|
||||||
|
// every audience because we always define today/tomorrow/days in the de
|
||||||
|
// baseline.
|
||||||
|
func lookup(lang, eventType, audience, bucket string) tmpl {
|
||||||
|
byEvent, ok := templates[lang][eventType]
|
||||||
|
if !ok {
|
||||||
|
byEvent = templates[lang]["andere"]
|
||||||
|
}
|
||||||
|
byAudience, ok := byEvent[audience]
|
||||||
|
if !ok {
|
||||||
|
byAudience = byEvent["parents"]
|
||||||
|
}
|
||||||
|
t, ok := byAudience[bucket]
|
||||||
|
if !ok {
|
||||||
|
t = byAudience["days"]
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func substitute(s string, lead int, v Vars) string {
|
||||||
|
classSuffix := ""
|
||||||
|
if v.ClassName != "" {
|
||||||
|
classSuffix = " (" + v.ClassName + ")"
|
||||||
|
}
|
||||||
|
repl := strings.NewReplacer(
|
||||||
|
"{{title}}", v.Title,
|
||||||
|
"{{date}}", v.Date,
|
||||||
|
"{{date_pretty}}", v.DatePretty,
|
||||||
|
"{{class_name}}", v.ClassName,
|
||||||
|
"{{class_suffix}}", classSuffix,
|
||||||
|
"{{teacher_name}}", v.TeacherName,
|
||||||
|
"{{lead}}", fmt.Sprintf("%d", lead),
|
||||||
|
)
|
||||||
|
return repl.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tmpl struct {
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// templates[lang][eventType][audience][bucket] → tmpl
|
||||||
|
//
|
||||||
|
// Only the eight parent languages we ship subject-i18n.ts for are covered.
|
||||||
|
// Custom event_types fall back to the "andere" branch. Custom languages
|
||||||
|
// fall back to "de". This keeps the file under 500 LOC; if more locales
|
||||||
|
// are needed, split into one file per language.
|
||||||
|
var templates = map[string]map[string]map[string]map[string]tmpl{
|
||||||
|
"de": deTemplates(),
|
||||||
|
"en": enTemplates(),
|
||||||
|
"tr": trTemplates(),
|
||||||
|
"ar": arTemplates(),
|
||||||
|
"uk": ukTemplates(),
|
||||||
|
"ru": ruTemplates(),
|
||||||
|
"pl": plTemplates(),
|
||||||
|
"fr": frTemplates(),
|
||||||
|
}
|
||||||
|
|
||||||
|
func deTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
// All event types share the same template family; we only vary by audience
|
||||||
|
// and bucket. Specialise where wording really differs (e.g. fortbildung
|
||||||
|
// is school-free, schulfeier invites attendance).
|
||||||
|
parentToday := tmpl{
|
||||||
|
Subject: "Heute: {{title}}{{class_suffix}}",
|
||||||
|
Body: "Liebe Eltern, heute findet {{title}} statt{{class_suffix}}. Datum: {{date_pretty}}.",
|
||||||
|
}
|
||||||
|
parentTomorrow := tmpl{
|
||||||
|
Subject: "Morgen: {{title}}{{class_suffix}}",
|
||||||
|
Body: "Liebe Eltern, morgen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
|
||||||
|
}
|
||||||
|
parentDays := tmpl{
|
||||||
|
Subject: "In {{lead}} Tagen: {{title}}{{class_suffix}}",
|
||||||
|
Body: "Liebe Eltern, in {{lead}} Tagen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
|
||||||
|
}
|
||||||
|
studentToday := tmpl{
|
||||||
|
Subject: "Heute: {{title}}",
|
||||||
|
Body: "Heute ist {{title}}{{class_suffix}}.",
|
||||||
|
}
|
||||||
|
studentTomorrow := tmpl{
|
||||||
|
Subject: "Morgen: {{title}}",
|
||||||
|
Body: "Morgen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
|
||||||
|
}
|
||||||
|
studentDays := tmpl{
|
||||||
|
Subject: "In {{lead}} Tagen: {{title}}",
|
||||||
|
Body: "In {{lead}} Tagen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
|
||||||
|
}
|
||||||
|
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
|
||||||
|
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
|
||||||
|
}
|
||||||
|
universal := map[string]map[string]tmpl{
|
||||||
|
"parents": bucket(parentToday, parentTomorrow, parentDays),
|
||||||
|
"students": bucket(studentToday, studentTomorrow, studentDays),
|
||||||
|
}
|
||||||
|
return map[string]map[string]map[string]tmpl{
|
||||||
|
"fortbildung": universal,
|
||||||
|
"schulfeier": universal,
|
||||||
|
"klassenfahrt": universal,
|
||||||
|
"projekttag": universal,
|
||||||
|
"eltern_info": universal,
|
||||||
|
"andere": universal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The non-DE templates use the same structure; only the strings change.
|
||||||
|
// Defined in a single helper that takes the translation map and reuses the
|
||||||
|
// DE family glue.
|
||||||
|
|
||||||
|
func makeFamily(
|
||||||
|
pT, pTm, pD, sT, sTm, sD tmpl,
|
||||||
|
) map[string]map[string]map[string]tmpl {
|
||||||
|
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
|
||||||
|
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
|
||||||
|
}
|
||||||
|
universal := map[string]map[string]tmpl{
|
||||||
|
"parents": bucket(pT, pTm, pD),
|
||||||
|
"students": bucket(sT, sTm, sD),
|
||||||
|
}
|
||||||
|
return map[string]map[string]map[string]tmpl{
|
||||||
|
"fortbildung": universal,
|
||||||
|
"schulfeier": universal,
|
||||||
|
"klassenfahrt": universal,
|
||||||
|
"projekttag": universal,
|
||||||
|
"eltern_info": universal,
|
||||||
|
"andere": universal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"Today: {{title}}{{class_suffix}}", "Dear parents, today {{title}} takes place{{class_suffix}}. Date: {{date_pretty}}."},
|
||||||
|
tmpl{"Tomorrow: {{title}}{{class_suffix}}", "Dear parents, tomorrow ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
|
||||||
|
tmpl{"In {{lead}} days: {{title}}{{class_suffix}}", "Dear parents, in {{lead}} days ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
|
||||||
|
tmpl{"Today: {{title}}", "Today is {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Tomorrow: {{title}}", "Tomorrow ({{date_pretty}}) is {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"In {{lead}} days: {{title}}", "In {{lead}} days ({{date_pretty}}) is {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"Bugün: {{title}}{{class_suffix}}", "Sayın veliler, bugün {{title}} gerçekleşiyor{{class_suffix}}. Tarih: {{date_pretty}}."},
|
||||||
|
tmpl{"Yarın: {{title}}{{class_suffix}}", "Sayın veliler, yarın ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
|
||||||
|
tmpl{"{{lead}} gün sonra: {{title}}{{class_suffix}}", "Sayın veliler, {{lead}} gün sonra ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
|
||||||
|
tmpl{"Bugün: {{title}}", "Bugün {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Yarın: {{title}}", "Yarın ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"{{lead}} gün sonra: {{title}}", "{{lead}} gün sonra ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func arTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"اليوم: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، اليوم يقام {{title}}{{class_suffix}}. التاريخ: {{date_pretty}}."},
|
||||||
|
tmpl{"غدًا: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، غدًا ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"بعد {{lead}} أيام: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، بعد {{lead}} أيام ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"اليوم: {{title}}", "اليوم {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"غدًا: {{title}}", "غدًا ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"بعد {{lead}} أيام: {{title}}", "بعد {{lead}} أيام ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ukTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"Сьогодні: {{title}}{{class_suffix}}", "Шановні батьки, сьогодні відбудеться {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
|
||||||
|
tmpl{"Завтра: {{title}}{{class_suffix}}", "Шановні батьки, завтра ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Через {{lead}} днів: {{title}}{{class_suffix}}", "Шановні батьки, через {{lead}} днів ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Сьогодні: {{title}}", "Сьогодні {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Через {{lead}} днів: {{title}}", "Через {{lead}} днів ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"Сегодня: {{title}}{{class_suffix}}", "Уважаемые родители, сегодня состоится {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
|
||||||
|
tmpl{"Завтра: {{title}}{{class_suffix}}", "Уважаемые родители, завтра ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Через {{lead}} дней: {{title}}{{class_suffix}}", "Уважаемые родители, через {{lead}} дней ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Сегодня: {{title}}", "Сегодня {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Через {{lead}} дней: {{title}}", "Через {{lead}} дней ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func plTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"Dzisiaj: {{title}}{{class_suffix}}", "Drodzy rodzice, dzisiaj odbywa się {{title}}{{class_suffix}}. Data: {{date_pretty}}."},
|
||||||
|
tmpl{"Jutro: {{title}}{{class_suffix}}", "Drodzy rodzice, jutro ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Za {{lead}} dni: {{title}}{{class_suffix}}", "Drodzy rodzice, za {{lead}} dni ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Dzisiaj: {{title}}", "Dzisiaj {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Jutro: {{title}}", "Jutro ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Za {{lead}} dni: {{title}}", "Za {{lead}} dni ({{date_pretty}}) {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frTemplates() map[string]map[string]map[string]tmpl {
|
||||||
|
return makeFamily(
|
||||||
|
tmpl{"Aujourd'hui : {{title}}{{class_suffix}}", "Chers parents, aujourd'hui a lieu {{title}}{{class_suffix}}. Date : {{date_pretty}}."},
|
||||||
|
tmpl{"Demain : {{title}}{{class_suffix}}", "Chers parents, demain ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Dans {{lead}} jours : {{title}}{{class_suffix}}", "Chers parents, dans {{lead}} jours ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Aujourd'hui : {{title}}", "Aujourd'hui c'est {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Demain : {{title}}", "Demain ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
|
||||||
|
tmpl{"Dans {{lead}} jours : {{title}}", "Dans {{lead}} jours ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBucketFor(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
lead int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{-1, "today"}, {0, "today"}, {1, "tomorrow"}, {2, "days"}, {7, "days"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := bucketFor(c.lead); got != c.want {
|
||||||
|
t.Errorf("bucketFor(%d) = %q, want %q", c.lead, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_GermanParentsToday(t *testing.T) {
|
||||||
|
subject, body := Render("fortbildung", "parents", 0, "de", Vars{
|
||||||
|
Title: "SCHILF", DatePretty: "10.10.2026", ClassName: "5a",
|
||||||
|
})
|
||||||
|
if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "SCHILF") {
|
||||||
|
t.Errorf("expected today + title in subject, got %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Liebe Eltern") {
|
||||||
|
t.Errorf("expected greeting in body, got %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "(5a)") {
|
||||||
|
t.Errorf("expected class suffix, got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_TurkishStudentTomorrow(t *testing.T) {
|
||||||
|
subject, _ := Render("schulfeier", "students", 1, "tr", Vars{
|
||||||
|
Title: "Yaz şenliği", DatePretty: "12.06.2026",
|
||||||
|
})
|
||||||
|
if !strings.Contains(subject, "Yarın") || !strings.Contains(subject, "Yaz şenliği") {
|
||||||
|
t.Errorf("expected Turkish 'tomorrow' subject, got %q", subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_FallbackLanguage(t *testing.T) {
|
||||||
|
// 'xx' isn't supported → falls back to de.
|
||||||
|
subject, _ := Render("klassenfahrt", "parents", 7, "xx", Vars{
|
||||||
|
Title: "Wattenmeer", DatePretty: "15.05.2026", ClassName: "6b",
|
||||||
|
})
|
||||||
|
if !strings.Contains(subject, "In 7 Tagen") {
|
||||||
|
t.Errorf("expected German fallback, got %q", subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_FallbackEventType(t *testing.T) {
|
||||||
|
// 'unknown_type' falls back to 'andere'.
|
||||||
|
subject, _ := Render("unknown_type", "parents", 0, "de", Vars{
|
||||||
|
Title: "Sondertermin",
|
||||||
|
})
|
||||||
|
if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "Sondertermin") {
|
||||||
|
t.Errorf("expected today subject after fallback, got %q", subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstitute_DropsClassSuffixWhenEmpty(t *testing.T) {
|
||||||
|
out := substitute("X{{class_suffix}}Y", 0, Vars{ClassName: ""})
|
||||||
|
if out != "XY" {
|
||||||
|
t.Errorf("expected XY (no suffix), got %q", out)
|
||||||
|
}
|
||||||
|
out = substitute("X{{class_suffix}}Y", 0, Vars{ClassName: "5a"})
|
||||||
|
if out != "X (5a)Y" {
|
||||||
|
t.Errorf("expected ' (5a)' suffix, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// School-event CRUD. Ownership is per-user via created_by_user_id. Public
|
||||||
|
// holiday/Ferien data lives in cal_public_event and is handled by
|
||||||
|
// calendar_service.go.
|
||||||
|
|
||||||
|
func (s *CalendarService) CreateEvent(ctx context.Context, userID string, req *models.CreateSchoolEventRequest) (*models.SchoolEvent, error) {
|
||||||
|
classIDs, err := parseClassIDs(req.AffectedClassIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var e models.SchoolEvent
|
||||||
|
leadDays := req.NotificationLeadDays
|
||||||
|
if leadDays == nil {
|
||||||
|
leadDays = []int{7, 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.db.QueryRow(ctx, `
|
||||||
|
INSERT INTO cal_school_event
|
||||||
|
(created_by_user_id, title, description, event_type, is_school_free,
|
||||||
|
start_date, end_date, start_time, end_time, affected_class_ids,
|
||||||
|
visible_to_parents, notify_parents, notify_students, notification_lead_days)
|
||||||
|
VALUES ($1, $2, $3, $4, $5,
|
||||||
|
$6::date, $7::date, NULLIF($8, '')::time, NULLIF($9, '')::time,
|
||||||
|
$10, $11, $12, $13, $14)
|
||||||
|
RETURNING id, created_by_user_id, title, COALESCE(description,''), event_type,
|
||||||
|
is_school_free, start_date::text, end_date::text,
|
||||||
|
start_time::text, end_time::text, affected_class_ids,
|
||||||
|
visible_to_parents, notify_parents, notify_students,
|
||||||
|
notification_lead_days, created_at, updated_at
|
||||||
|
`, userID, req.Title, req.Description, req.EventType, req.IsSchoolFree,
|
||||||
|
req.StartDate, req.EndDate, strOrEmpty(req.StartTime), strOrEmpty(req.EndTime),
|
||||||
|
classIDs, req.VisibleToParents, req.NotifyParents, req.NotifyStudents, leadDays)
|
||||||
|
|
||||||
|
if err := scanEvent(row, &e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CalendarService) ListEvents(ctx context.Context, userID, from, to string) ([]models.SchoolEvent, error) {
|
||||||
|
if from == "" {
|
||||||
|
from = "1900-01-01"
|
||||||
|
}
|
||||||
|
if to == "" {
|
||||||
|
to = "2100-12-31"
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT id, created_by_user_id, title, COALESCE(description,''), event_type,
|
||||||
|
is_school_free, start_date::text, end_date::text,
|
||||||
|
start_time::text, end_time::text, affected_class_ids,
|
||||||
|
visible_to_parents, notify_parents, notify_students,
|
||||||
|
notification_lead_days, created_at, updated_at
|
||||||
|
FROM cal_school_event
|
||||||
|
WHERE created_by_user_id = $1
|
||||||
|
AND end_date >= $2::date
|
||||||
|
AND start_date <= $3::date
|
||||||
|
ORDER BY start_date, start_time NULLS FIRST, title
|
||||||
|
`, userID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.SchoolEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.SchoolEvent
|
||||||
|
if err := scanEvent(rows, &e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CalendarService) DeleteEvent(ctx context.Context, id, userID string) error {
|
||||||
|
res, err := s.db.Exec(ctx, `
|
||||||
|
DELETE FROM cal_school_event WHERE id = $1 AND created_by_user_id = $2
|
||||||
|
`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("event not found or not owned")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseClassIDs validates the array of UUID strings the request sent.
|
||||||
|
// Returns a typed []uuid.UUID so asyncpg/pgx encodes it correctly into the
|
||||||
|
// UUID[] column.
|
||||||
|
func parseClassIDs(in []string) ([]uuid.UUID, error) {
|
||||||
|
out := make([]uuid.UUID, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid class_id %q: %w", s, err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// row interface so the same scan logic works for both QueryRow and Rows.
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEvent(r rowScanner, e *models.SchoolEvent) error {
|
||||||
|
var startTime, endTime *string
|
||||||
|
if err := r.Scan(
|
||||||
|
&e.ID, &e.CreatedByUserID, &e.Title, &e.Description, &e.EventType,
|
||||||
|
&e.IsSchoolFree, &e.StartDate, &e.EndDate,
|
||||||
|
&startTime, &endTime, &e.AffectedClassIDs,
|
||||||
|
&e.VisibleToParents, &e.NotifyParents, &e.NotifyStudents,
|
||||||
|
&e.NotificationLeadDays, &e.CreatedAt, &e.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.StartTime = startTime
|
||||||
|
e.EndTime = endTime
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RolloverSchoolYear advances every tt_class for this user by one grade
|
||||||
|
// level, removes graduating classes (grade > 13), and updates the
|
||||||
|
// cal_school_config school-year dates. Operates as a single transaction.
|
||||||
|
//
|
||||||
|
// Stammdaten (teachers, subjects, rooms, periods) bleiben unveraendert —
|
||||||
|
// es aendern sich nur Klassen-Stufen.
|
||||||
|
func (s *CalendarService) RolloverSchoolYear(ctx context.Context, userID string, req *models.SchoolYearRolloverRequest) (*models.SchoolYearRolloverResult, error) {
|
||||||
|
newStart, newEnd := defaultSchoolYearDates(req)
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// 1. Remove the graduating cohort first so they don't get bumped to 14.
|
||||||
|
gradRes, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM tt_class WHERE created_by_user_id = $1 AND grade_level >= 13
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("delete graduating: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Promote everyone else.
|
||||||
|
promRes, err := tx.Exec(ctx, `
|
||||||
|
UPDATE tt_class SET grade_level = grade_level + 1
|
||||||
|
WHERE created_by_user_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("promote classes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update the school-year dates in the config (creates a row if the
|
||||||
|
// user never picked a Bundesland — but that's an edge case; in normal
|
||||||
|
// flow the wizard has run before rollover).
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE cal_school_config
|
||||||
|
SET school_year_start = $1::date,
|
||||||
|
school_year_end = $2::date,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $3
|
||||||
|
`, newStart, newEnd, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.SchoolYearRolloverResult{
|
||||||
|
ClassesPromoted: int(promRes.RowsAffected()),
|
||||||
|
ClassesGraduated: int(gradRes.RowsAffected()),
|
||||||
|
NewYearStart: newStart,
|
||||||
|
NewYearEnd: newEnd,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSchoolYearDates returns the dates from the request if both set,
|
||||||
|
// otherwise the next school year starting Aug 1 of "this year or next"
|
||||||
|
// and ending Jul 31 the year after.
|
||||||
|
func defaultSchoolYearDates(req *models.SchoolYearRolloverRequest) (string, string) {
|
||||||
|
if req != nil && req.NewYearStart != nil && req.NewYearEnd != nil {
|
||||||
|
return *req.NewYearStart, *req.NewYearEnd
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
startYear := now.Year()
|
||||||
|
// If we're past August, the "new" year refers to the next calendar year.
|
||||||
|
if int(now.Month()) >= 8 {
|
||||||
|
startYear++
|
||||||
|
}
|
||||||
|
start := time.Date(startYear, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(startYear+1, 7, 31, 0, 0, 0, 0, time.UTC)
|
||||||
|
return start.Format("2006-01-02"), end.Format("2006-01-02")
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalendarService owns the cal_* tables and the read of the seed snapshot
|
||||||
|
// on first boot. Holidays are global (no owner) — same data for every
|
||||||
|
// school in a given Bundesland.
|
||||||
|
type CalendarService struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCalendarService(db *pgxpool.Pool) *CalendarService {
|
||||||
|
return &CalendarService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedFromSnapshot reads internal/seed/calendar_holidays.json and bulk-inserts
|
||||||
|
// every row that doesn't already exist (idempotent via the unique constraint
|
||||||
|
// on region+event_type+name_de+start_date). Called once at server start.
|
||||||
|
func (s *CalendarService) SeedFromSnapshot(ctx context.Context, path string) error {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Printf("calendar seed file not found at %s — skipping", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []models.PublicEvent
|
||||||
|
if err := json.Unmarshal(raw, &events); err != nil {
|
||||||
|
return fmt.Errorf("parse snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
inserted := 0
|
||||||
|
for _, e := range events {
|
||||||
|
ct, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO cal_public_event (region, event_type, name_de, name_en, start_date, end_date, source)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5::date, $6::date, 'OpenHolidaysAPI')
|
||||||
|
ON CONFLICT (region, event_type, name_de, start_date) DO NOTHING
|
||||||
|
`, e.Region, e.EventType, e.NameDe, e.NameEn, e.StartDate, e.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert event: %w", err)
|
||||||
|
}
|
||||||
|
inserted += int(ct.RowsAffected())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("calendar seed: %d new events inserted (of %d in snapshot)", inserted, len(events))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHolidays returns all public + school holidays for the given region
|
||||||
|
// between from..to (YYYY-MM-DD inclusive).
|
||||||
|
func (s *CalendarService) ListHolidays(ctx context.Context, region, from, to string) ([]models.PublicEvent, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT id, region, event_type, name_de, COALESCE(name_en, ''),
|
||||||
|
start_date::text, end_date::text, COALESCE(source, ''), created_at
|
||||||
|
FROM cal_public_event
|
||||||
|
WHERE region = $1
|
||||||
|
AND end_date >= $2::date
|
||||||
|
AND start_date <= $3::date
|
||||||
|
ORDER BY start_date, event_type, name_de
|
||||||
|
`, region, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.PublicEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.PublicEvent
|
||||||
|
if err := rows.Scan(&e.ID, &e.Region, &e.EventType, &e.NameDe, &e.NameEn,
|
||||||
|
&e.StartDate, &e.EndDate, &e.Source, &e.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the per-user calendar config (Bundesland etc.) or nil
|
||||||
|
// if the user has not configured one yet.
|
||||||
|
func (s *CalendarService) GetConfig(ctx context.Context, userID string) (*models.SchoolCalendarConfig, error) {
|
||||||
|
var c models.SchoolCalendarConfig
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at
|
||||||
|
FROM cal_school_config WHERE user_id = $1
|
||||||
|
`, userID).Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
// pgx returns no-rows error; caller maps to 404.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertConfig inserts or updates the Bundesland selection.
|
||||||
|
func (s *CalendarService) UpsertConfig(ctx context.Context, userID string, req *models.UpsertSchoolCalendarConfigRequest) (*models.SchoolCalendarConfig, error) {
|
||||||
|
var c models.SchoolCalendarConfig
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
INSERT INTO cal_school_config (user_id, bundesland, school_year_start, school_year_end)
|
||||||
|
VALUES ($1, $2, NULLIF($3, '')::date, NULLIF($4, '')::date)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET bundesland = EXCLUDED.bundesland,
|
||||||
|
school_year_start = EXCLUDED.school_year_start,
|
||||||
|
school_year_end = EXCLUDED.school_year_end,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at
|
||||||
|
`, userID, req.Bundesland, strOrEmpty(req.SchoolYearStart), strOrEmpty(req.SchoolYearEnd)).
|
||||||
|
Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
return &c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func strOrEmpty(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpsertSchoolCalendarConfigRequest_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req models.UpsertSchoolCalendarConfigRequest
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid NI", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE-NI"}, false},
|
||||||
|
{"empty bundesland", models.UpsertSchoolCalendarConfigRequest{Bundesland: ""}, true},
|
||||||
|
{"too long", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE-NIE"}, true},
|
||||||
|
{"too short", models.UpsertSchoolCalendarConfigRequest{Bundesland: "DE"}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if (validate.Struct(tt.req) != nil) != tt.wantErr {
|
||||||
|
t.Errorf("unexpected validation outcome for %s", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSchoolEventRequest_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req models.CreateSchoolEventRequest
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid fortbildung", models.CreateSchoolEventRequest{
|
||||||
|
Title: "SCHILF", EventType: "fortbildung",
|
||||||
|
StartDate: "2026-10-01", EndDate: "2026-10-01",
|
||||||
|
}, false},
|
||||||
|
{"missing title", models.CreateSchoolEventRequest{
|
||||||
|
EventType: "fortbildung", StartDate: "2026-10-01", EndDate: "2026-10-01",
|
||||||
|
}, true},
|
||||||
|
{"invalid event type", models.CreateSchoolEventRequest{
|
||||||
|
Title: "X", EventType: "wedding",
|
||||||
|
StartDate: "2026-10-01", EndDate: "2026-10-01",
|
||||||
|
}, true},
|
||||||
|
{"missing dates", models.CreateSchoolEventRequest{
|
||||||
|
Title: "X", EventType: "schulfeier",
|
||||||
|
}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if (validate.Struct(tt.req) != nil) != tt.wantErr {
|
||||||
|
t.Errorf("unexpected validation outcome for %s", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCalendarService_Constructs(t *testing.T) {
|
||||||
|
s := NewCalendarService(nil)
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected non-nil service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultSchoolYearDates_FallbackFormat(t *testing.T) {
|
||||||
|
// No override → deterministic YYYY-MM-DD strings with end > start.
|
||||||
|
start, end := defaultSchoolYearDates(nil)
|
||||||
|
if len(start) != 10 || len(end) != 10 {
|
||||||
|
t.Fatalf("expected YYYY-MM-DD strings, got %q %q", start, end)
|
||||||
|
}
|
||||||
|
if end <= start {
|
||||||
|
t.Errorf("end %q must be after start %q", end, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultSchoolYearDates_ExplicitOverride(t *testing.T) {
|
||||||
|
s, e := "2030-09-01", "2031-06-30"
|
||||||
|
req := &models.SchoolYearRolloverRequest{NewYearStart: &s, NewYearEnd: &e}
|
||||||
|
gotS, gotE := defaultSchoolYearDates(req)
|
||||||
|
if gotS != s || gotE != e {
|
||||||
|
t.Errorf("override ignored: got %q/%q want %q/%q", gotS, gotE, s, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseClassIDs_AcceptsValidAndRejectsGarbage(t *testing.T) {
|
||||||
|
good := []string{"00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"}
|
||||||
|
out, err := parseClassIDs(good)
|
||||||
|
if err != nil || len(out) != 2 {
|
||||||
|
t.Fatalf("expected 2 parsed UUIDs, got %v err=%v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseClassIDs([]string{"not-a-uuid"}); err == nil {
|
||||||
|
t.Errorf("expected error for invalid uuid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty strings are silently dropped (curl convenience).
|
||||||
|
out, err = parseClassIDs([]string{"", "00000000-0000-0000-0000-000000000003", ""})
|
||||||
|
if err != nil || len(out) != 1 {
|
||||||
|
t.Errorf("expected 1 parsed UUID, got %v err=%v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedeemMagicLink validates a one-shot link, marks it used, mints a session
|
||||||
|
// token. Returns the raw session token; caller (HTTP handler) sets it as
|
||||||
|
// HttpOnly cookie.
|
||||||
|
func (s *ParentService) RedeemMagicLink(ctx context.Context, token string) (sessionToken string, parent *models.ParentAccount, err error) {
|
||||||
|
hash := hashToken(token)
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var (
|
||||||
|
linkID uuid.UUID
|
||||||
|
parentID uuid.UUID
|
||||||
|
expiresAt time.Time
|
||||||
|
usedAt *time.Time
|
||||||
|
)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id, parent_id, expires_at, used_at
|
||||||
|
FROM parent_magic_link
|
||||||
|
WHERE token_hash = $1
|
||||||
|
`, hash).Scan(&linkID, &parentID, &expiresAt, &usedAt); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
if usedAt != nil {
|
||||||
|
return "", nil, fmt.Errorf("token already used")
|
||||||
|
}
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
return "", nil, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark used.
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE parent_magic_link SET used_at = NOW() WHERE id = $1`, linkID); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint session token.
|
||||||
|
raw, h, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
sessionExpires := time.Now().Add(parentSessionTTL)
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO parent_session (parent_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`, parentID, h, sessionExpires); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the account so callers (UI) get the email + language back.
|
||||||
|
var p models.ParentAccount
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id, created_by_user_id, email, preferred_language, created_at
|
||||||
|
FROM parent_account WHERE id = $1
|
||||||
|
`, parentID).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return raw, &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentFromSession resolves a session token back to the parent account.
|
||||||
|
// Returns error on missing/expired session. Called by ParentSession
|
||||||
|
// middleware.
|
||||||
|
func (s *ParentService) ParentFromSession(ctx context.Context, sessionToken string) (*models.ParentAccount, error) {
|
||||||
|
hash := hashToken(sessionToken)
|
||||||
|
var p models.ParentAccount
|
||||||
|
var expiresAt time.Time
|
||||||
|
if err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT pa.id, pa.created_by_user_id, pa.email, pa.preferred_language, pa.created_at, ps.expires_at
|
||||||
|
FROM parent_session ps
|
||||||
|
JOIN parent_account pa ON pa.id = ps.parent_id
|
||||||
|
WHERE ps.token_hash = $1
|
||||||
|
`, hash).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt, &expiresAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid session")
|
||||||
|
}
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
return nil, fmt.Errorf("session expired")
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildren returns all parent_child rows for a parent, joined with the
|
||||||
|
// class name from tt_class.
|
||||||
|
func (s *ParentService) ListChildren(ctx context.Context, parentID string) ([]models.ParentChild, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT pc.id, pc.parent_id, pc.tt_class_id, pc.first_name, pc.last_name, pc.created_at, cl.name
|
||||||
|
FROM parent_child pc
|
||||||
|
JOIN tt_class cl ON cl.id = pc.tt_class_id
|
||||||
|
WHERE pc.parent_id = $1
|
||||||
|
ORDER BY pc.last_name, pc.first_name
|
||||||
|
`, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.ParentChild
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.ParentChild
|
||||||
|
if err := rows.Scan(&c.ID, &c.ParentID, &c.TTClassID, &c.FirstName, &c.LastName, &c.CreatedAt, &c.ClassName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeacherOfParent returns the created_by_user_id of the teacher who invited
|
||||||
|
// this parent. Used to scope timetable + calendar queries.
|
||||||
|
func (s *ParentService) TeacherOfParent(ctx context.Context, parentID string) (string, error) {
|
||||||
|
var uid string
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`SELECT created_by_user_id::text FROM parent_account WHERE id = $1`, parentID,
|
||||||
|
).Scan(&uid)
|
||||||
|
return uid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildBelongsToParent checks whether a tt_class is one this parent has a
|
||||||
|
// child in. Used by the timetable + calendar handlers as authorization.
|
||||||
|
func (s *ParentService) ChildBelongsToParent(ctx context.Context, parentID, classID string) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM parent_child
|
||||||
|
WHERE parent_id = $1 AND tt_class_id = $2)
|
||||||
|
`, parentID, classID).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestCompletedSolutionLessonsForClass returns the lessons of the most
|
||||||
|
// recent COMPLETED tt_solution where the given class has rows, owned by
|
||||||
|
// the teacher that originally invited the parent. Joined with subject + room
|
||||||
|
// + teacher names so the parent UI can render directly.
|
||||||
|
func (s *ParentService) LatestCompletedSolutionLessonsForClass(ctx context.Context, classID, teacherUserID string) ([]LessonExport, error) {
|
||||||
|
// Find latest completed solution by the teacher that has at least one
|
||||||
|
// lesson in this class.
|
||||||
|
var solutionID string
|
||||||
|
if err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT s.id::text
|
||||||
|
FROM tt_solution s
|
||||||
|
JOIN tt_lesson l ON l.solution_id = s.id
|
||||||
|
WHERE s.created_by_user_id = $1
|
||||||
|
AND s.status = 'completed'
|
||||||
|
AND l.class_id = $2::uuid
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, teacherUserID, classID).Scan(&solutionID); err != nil {
|
||||||
|
return nil, nil // no plan yet — parent UI shows empty grid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-use the existing export shape with a stricter filter (class only).
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT l.day_of_week, l.period_index,
|
||||||
|
to_char(p.start_time, 'HH24:MI') AS st,
|
||||||
|
to_char(p.end_time, 'HH24:MI') AS et,
|
||||||
|
cl.name, sub.name, sub.short_code,
|
||||||
|
t.last_name || ', ' || t.first_name,
|
||||||
|
COALESCE(r.name, ''),
|
||||||
|
l.pinned
|
||||||
|
FROM tt_lesson l
|
||||||
|
JOIN tt_solution s ON l.solution_id = s.id
|
||||||
|
JOIN tt_class cl ON l.class_id = cl.id
|
||||||
|
JOIN tt_subject sub ON l.subject_id = sub.id
|
||||||
|
JOIN tt_teacher t ON l.teacher_id = t.id
|
||||||
|
LEFT JOIN tt_room r ON l.room_id = r.id
|
||||||
|
LEFT JOIN tt_period p
|
||||||
|
ON p.day_of_week = l.day_of_week
|
||||||
|
AND p.period_index = l.period_index
|
||||||
|
AND p.created_by_user_id = s.created_by_user_id
|
||||||
|
WHERE s.id = $1::uuid AND l.class_id = $2::uuid
|
||||||
|
ORDER BY l.day_of_week, l.period_index
|
||||||
|
`, solutionID, classID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []LessonExport
|
||||||
|
for rows.Next() {
|
||||||
|
var le LessonExport
|
||||||
|
var st, et *string
|
||||||
|
if err := rows.Scan(&le.DayOfWeek, &le.PeriodIndex, &st, &et,
|
||||||
|
&le.ClassName, &le.SubjectName, &le.SubjectCode,
|
||||||
|
&le.TeacherName, &le.RoomName, &le.Pinned); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if st != nil {
|
||||||
|
le.StartTime = *st
|
||||||
|
}
|
||||||
|
if et != nil {
|
||||||
|
le.EndTime = *et
|
||||||
|
}
|
||||||
|
out = append(out, le)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParentService owns the parent_* tables. Magic-link tokens are random
|
||||||
|
// 32-byte values; only the SHA-256 hash is stored in the DB. The raw token
|
||||||
|
// goes back to the teacher exactly once (when they invite a parent) so
|
||||||
|
// they can paste it into a Matrix message or email. After redeem, a
|
||||||
|
// browser session (own table, separate token) carries the parent through
|
||||||
|
// the API.
|
||||||
|
type ParentService struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewParentService(db *pgxpool.Pool) *ParentService {
|
||||||
|
return &ParentService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
magicLinkTTL = 7 * 24 * time.Hour
|
||||||
|
parentSessionTTL = 30 * 24 * time.Hour
|
||||||
|
parentCookieName = "bp_parent_session"
|
||||||
|
tokenLen = 32 // raw bytes; URL-safe base64 encoded
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomToken() (raw string, hash string, err error) {
|
||||||
|
buf := make([]byte, tokenLen)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
raw = base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
hash = hex.EncodeToString(h[:])
|
||||||
|
return raw, hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(raw string) string {
|
||||||
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteParent upserts the parent account, creates a fresh child row, and
|
||||||
|
// issues a magic-link. Caller (teacher) is the owner; child must belong to
|
||||||
|
// one of their tt_class rows.
|
||||||
|
func (s *ParentService) InviteParent(ctx context.Context, userID string, req *models.InviteParentRequest) (*models.InviteParentResponse, error) {
|
||||||
|
tx, err := s.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// 1. Verify class ownership.
|
||||||
|
var owned bool
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM tt_class WHERE id = $1 AND created_by_user_id = $2)`,
|
||||||
|
req.TTClassID, userID,
|
||||||
|
).Scan(&owned); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, fmt.Errorf("tt_class_id not found or not owned by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := req.PreferredLanguage
|
||||||
|
if lang == "" {
|
||||||
|
lang = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Upsert parent_account.
|
||||||
|
var parent models.ParentAccount
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO parent_account (created_by_user_id, email, preferred_language)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (created_by_user_id, email) DO UPDATE
|
||||||
|
SET preferred_language = EXCLUDED.preferred_language
|
||||||
|
RETURNING id, created_by_user_id, email, preferred_language, created_at
|
||||||
|
`, userID, req.Email, lang).Scan(
|
||||||
|
&parent.ID, &parent.CreatedByUserID, &parent.Email, &parent.PreferredLanguage, &parent.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("upsert parent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Insert child.
|
||||||
|
var child models.ParentChild
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO parent_child (parent_id, tt_class_id, first_name, last_name)
|
||||||
|
VALUES ($1, $2::uuid, $3, $4)
|
||||||
|
RETURNING id, parent_id, tt_class_id, first_name, last_name, created_at
|
||||||
|
`, parent.ID, req.TTClassID, req.ChildFirstName, req.ChildLastName).Scan(
|
||||||
|
&child.ID, &child.ParentID, &child.TTClassID, &child.FirstName, &child.LastName, &child.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert child: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Mint a magic-link token (raw goes back, hash goes to DB).
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token gen: %w", err)
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(magicLinkTTL)
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO parent_magic_link (parent_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`, parent.ID, hash, expiresAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert magic link: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.InviteParentResponse{
|
||||||
|
Parent: parent,
|
||||||
|
Child: child,
|
||||||
|
MagicToken: raw,
|
||||||
|
MagicURL: "/eltern/login?token=" + raw,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ParentService) ListInvites(ctx context.Context, userID string) ([]models.ParentInviteListItem, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT pa.id, pa.email, pa.preferred_language,
|
||||||
|
pc.id, pc.first_name, pc.last_name,
|
||||||
|
cl.id, cl.name, pc.created_at
|
||||||
|
FROM parent_account pa
|
||||||
|
JOIN parent_child pc ON pc.parent_id = pa.id
|
||||||
|
JOIN tt_class cl ON cl.id = pc.tt_class_id
|
||||||
|
WHERE pa.created_by_user_id = $1
|
||||||
|
ORDER BY pa.email, pc.last_name
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.ParentInviteListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it models.ParentInviteListItem
|
||||||
|
if err := rows.Scan(&it.ParentID, &it.Email, &it.PreferredLanguage,
|
||||||
|
&it.ChildID, &it.ChildFirstName, &it.ChildLastName,
|
||||||
|
&it.ClassID, &it.ClassName, &it.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvite removes one child row (parent stays if other children still
|
||||||
|
// exist for the same teacher).
|
||||||
|
func (s *ParentService) DeleteInvite(ctx context.Context, childID, userID string) error {
|
||||||
|
res, err := s.db.Exec(ctx, `
|
||||||
|
DELETE FROM parent_child pc
|
||||||
|
USING parent_account pa
|
||||||
|
WHERE pc.id = $1 AND pc.parent_id = pa.id AND pa.created_by_user_id = $2
|
||||||
|
`, childID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("child not found or not owned")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/breakpilot/school-service/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRandomToken_Hashable(t *testing.T) {
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("randomToken error: %v", err)
|
||||||
|
}
|
||||||
|
if len(raw) < 30 {
|
||||||
|
t.Errorf("raw token suspiciously short: %d", len(raw))
|
||||||
|
}
|
||||||
|
if len(hash) != 64 {
|
||||||
|
t.Errorf("sha256 hex hash must be 64 chars, got %d", len(hash))
|
||||||
|
}
|
||||||
|
if hashToken(raw) != hash {
|
||||||
|
t.Errorf("hashToken(raw) must equal the hash randomToken returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomToken_NonRepeating(t *testing.T) {
|
||||||
|
// 16 iterations, all raw tokens must differ.
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
raw, _, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if _, dup := seen[raw]; dup {
|
||||||
|
t.Fatalf("duplicate raw token at iter %d", i)
|
||||||
|
}
|
||||||
|
seen[raw] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashToken_StableHexLowercase(t *testing.T) {
|
||||||
|
h := hashToken("hello world")
|
||||||
|
if strings.ToLower(h) != h {
|
||||||
|
t.Errorf("hash should be lowercase hex")
|
||||||
|
}
|
||||||
|
if len(h) != 64 {
|
||||||
|
t.Errorf("expected 64-char hash, got %d", len(h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteParentRequest_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req models.InviteParentRequest
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", models.InviteParentRequest{
|
||||||
|
Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller",
|
||||||
|
TTClassID: "00000000-0000-0000-0000-000000000001",
|
||||||
|
}, false},
|
||||||
|
{"bad email", models.InviteParentRequest{
|
||||||
|
Email: "not-an-email", ChildFirstName: "Max", ChildLastName: "Mueller",
|
||||||
|
TTClassID: "00000000-0000-0000-0000-000000000001",
|
||||||
|
}, true},
|
||||||
|
{"missing child", models.InviteParentRequest{
|
||||||
|
Email: "a@b.de", TTClassID: "00000000-0000-0000-0000-000000000001",
|
||||||
|
}, true},
|
||||||
|
{"bad class uuid", models.InviteParentRequest{
|
||||||
|
Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller",
|
||||||
|
TTClassID: "not-a-uuid",
|
||||||
|
}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if (validate.Struct(tt.req) != nil) != tt.wantErr {
|
||||||
|
t.Errorf("unexpected validation outcome for %s", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Snapshot Public Holidays + School Holidays for all 16 German Bundeslaender
|
||||||
|
# from openholidaysapi.org. The result is committed to the repo and imported
|
||||||
|
# at first DB boot by school-service. Re-run yearly (or whenever the next
|
||||||
|
# school year's data needs to be added).
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/calendar-snapshot.sh [FIRST_YEAR] [LAST_YEAR]
|
||||||
|
# defaults: current year .. current year + 2
|
||||||
|
#
|
||||||
|
# Output: school-service/internal/seed/calendar_holidays.json
|
||||||
|
# shape: [{ region, event_type, name_de, name_en, start_date, end_date }, ...]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
OUT="$ROOT/school-service/internal/seed/calendar_holidays.json"
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
|
||||||
|
START_YEAR="${1:-$(date +%Y)}"
|
||||||
|
END_YEAR="${2:-$((START_YEAR + 2))}"
|
||||||
|
API="https://openholidaysapi.org"
|
||||||
|
|
||||||
|
# DE-XX codes for all 16 Bundeslaender (alphabetical).
|
||||||
|
REGIONS=(
|
||||||
|
"DE-BW" "DE-BY" "DE-BE" "DE-BB" "DE-HB" "DE-HH" "DE-HE" "DE-MV"
|
||||||
|
"DE-NI" "DE-NW" "DE-RP" "DE-SL" "DE-SN" "DE-ST" "DE-SH" "DE-TH"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "jq is required (brew install jq)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP=$(mktemp)
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
echo '[]' > "$TMP"
|
||||||
|
|
||||||
|
fetch() {
|
||||||
|
local endpoint="$1" region="$2" year="$3"
|
||||||
|
curl -sf -G "$API/$endpoint" \
|
||||||
|
--data-urlencode "countryIsoCode=DE" \
|
||||||
|
--data-urlencode "languageIsoCode=DE" \
|
||||||
|
--data-urlencode "validFrom=${year}-01-01" \
|
||||||
|
--data-urlencode "validTo=${year}-12-31" \
|
||||||
|
--data-urlencode "subdivisionCode=$region" \
|
||||||
|
|| echo '[]'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map OpenHolidaysAPI shape → our DB schema. The API returns an array of:
|
||||||
|
# { id, startDate, endDate, type, name: [{ language, text }], ... }
|
||||||
|
# We keep DE name as canonical, EN name if present, plus dates and a typed
|
||||||
|
# event_type discriminator. PublicHolidays and SchoolHolidays come from two
|
||||||
|
# separate endpoints.
|
||||||
|
normalise_jq='
|
||||||
|
map({
|
||||||
|
region: $region,
|
||||||
|
event_type: $event_type,
|
||||||
|
name_de: ((.name // []) | map(select(.language == "DE")) | .[0].text // ""),
|
||||||
|
name_en: ((.name // []) | map(select(.language == "EN")) | .[0].text // null),
|
||||||
|
start_date: .startDate,
|
||||||
|
end_date: .endDate
|
||||||
|
}) | map(select(.name_de != ""))
|
||||||
|
'
|
||||||
|
|
||||||
|
for region in "${REGIONS[@]}"; do
|
||||||
|
for year in $(seq "$START_YEAR" "$END_YEAR"); do
|
||||||
|
echo " $region $year — public" >&2
|
||||||
|
fetch "PublicHolidays" "$region" "$year" \
|
||||||
|
| jq --arg region "$region" --arg event_type "public_holiday" "$normalise_jq" \
|
||||||
|
| jq -s --slurpfile existing "$TMP" '$existing[0] + .[0]' > "$TMP.new"
|
||||||
|
mv "$TMP.new" "$TMP"
|
||||||
|
|
||||||
|
echo " $region $year — school" >&2
|
||||||
|
fetch "SchoolHolidays" "$region" "$year" \
|
||||||
|
| jq --arg region "$region" --arg event_type "school_holiday" "$normalise_jq" \
|
||||||
|
| jq -s --slurpfile existing "$TMP" '$existing[0] + .[0]' > "$TMP.new"
|
||||||
|
mv "$TMP.new" "$TMP"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# Deduplicate (the API sometimes returns overlapping rows for events that
|
||||||
|
# straddle a year boundary) and sort for a stable diff.
|
||||||
|
jq 'unique_by({region, event_type, name_de, start_date}) | sort_by([.region, .start_date])' \
|
||||||
|
"$TMP" > "$OUT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Wrote $(jq length "$OUT") events to $OUT"
|
||||||
Executable
+60
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { elternApi } from '@/lib/eltern/api'
|
||||||
|
|
||||||
|
function LoginInner() {
|
||||||
|
const router = useRouter()
|
||||||
|
const search = useSearchParams()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = search.get('token')
|
||||||
|
if (!token) {
|
||||||
|
setError('Kein Token in der URL. Bitte den Link aus der Einladung verwenden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elternApi.redeem(token)
|
||||||
|
.then(() => { setDone(true); setTimeout(() => router.replace('/eltern'), 800) })
|
||||||
|
.catch(e => setError(e instanceof Error ? e.message : 'Login fehlgeschlagen'))
|
||||||
|
}, [router, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
|
||||||
|
<div className="max-w-md w-full mx-4 p-6 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl" data-testid="eltern-login">
|
||||||
|
<h1 className="text-2xl font-semibold mb-3">Eltern-Login</h1>
|
||||||
|
{!error && !done && <p className="opacity-80">Pruefe Token …</p>}
|
||||||
|
{done && <p className="text-emerald-200">Erfolgreich angemeldet. Weiterleitung …</p>}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ElternLoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { elternApi, type ParentMeResponse, type ParentLesson } from '@/lib/eltern/api'
|
||||||
|
import { translateSubject } from '@/lib/calendar/subject-i18n'
|
||||||
|
|
||||||
|
const DAY_LABELS: Record<string, string[]> = {
|
||||||
|
de: ['Mo', 'Di', 'Mi', 'Do', 'Fr'],
|
||||||
|
en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
|
||||||
|
tr: ['Pzt', 'Sal', 'Çar', 'Per', 'Cum'],
|
||||||
|
ar: ['الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة'],
|
||||||
|
uk: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
|
||||||
|
ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
|
||||||
|
pl: ['Pon', 'Wt', 'Śr', 'Czw', 'Pt'],
|
||||||
|
fr: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADINGS: Record<string, { greeting: string; selectChild: string; period: string; logout: string; noPlan: string }> = {
|
||||||
|
de: { greeting: 'Willkommen', selectChild: 'Kind auswählen', period: 'Stunde', logout: 'Abmelden', noPlan: 'Noch kein Stundenplan veröffentlicht.' },
|
||||||
|
en: { greeting: 'Welcome', selectChild: 'Select child', period: 'Period', logout: 'Sign out', noPlan: 'No timetable published yet.' },
|
||||||
|
tr: { greeting: 'Hoş geldiniz', selectChild: 'Çocuk seç', period: 'Ders', logout: 'Çıkış', noPlan: 'Henüz ders programı yayımlanmadı.' },
|
||||||
|
ar: { greeting: 'مرحبًا', selectChild: 'اختر الطفل', period: 'حصة', logout: 'خروج', noPlan: 'لم يتم نشر جدول حصص بعد.' },
|
||||||
|
uk: { greeting: 'Ласкаво просимо', selectChild: 'Виберіть дитину', period: 'Урок', logout: 'Вийти', noPlan: 'Розклад ще не опубліковано.' },
|
||||||
|
ru: { greeting: 'Добро пожаловать', selectChild: 'Выберите ребёнка', period: 'Урок', logout: 'Выйти', noPlan: 'Расписание ещё не опубликовано.' },
|
||||||
|
pl: { greeting: 'Witamy', selectChild: 'Wybierz dziecko', period: 'Lekcja', logout: 'Wyloguj', noPlan: 'Plan lekcji nie jest jeszcze opublikowany.' },
|
||||||
|
fr: { greeting: 'Bienvenue', selectChild: 'Choisir un enfant', period: 'Cours', logout: 'Déconnexion', noPlan: 'Aucun emploi du temps publié.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(lang: string, key: keyof typeof HEADINGS['de']): string {
|
||||||
|
const code = (lang || 'de').slice(0, 2)
|
||||||
|
return HEADINGS[code]?.[key] ?? HEADINGS.de[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ElternPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [me, setMe] = useState<ParentMeResponse | null>(null)
|
||||||
|
const [selected, setSelected] = useState<string>('')
|
||||||
|
const [lessons, setLessons] = useState<ParentLesson[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const lang = me?.parent.preferred_language || 'de'
|
||||||
|
const dayLabels = DAY_LABELS[lang.slice(0, 2)] || DAY_LABELS.de
|
||||||
|
|
||||||
|
const loadMe = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await elternApi.me()
|
||||||
|
setMe(data)
|
||||||
|
if (data.children.length > 0) setSelected(data.children[0].tt_class_id)
|
||||||
|
} catch (e) {
|
||||||
|
// Not logged in → redirect to login.
|
||||||
|
if (e instanceof Error && /session/i.test(e.message)) {
|
||||||
|
router.replace('/eltern/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => { loadMe() }, [loadMe])
|
||||||
|
|
||||||
|
const loadTimetable = useCallback(async () => {
|
||||||
|
if (!selected) return
|
||||||
|
try {
|
||||||
|
const data = await elternApi.timetable(selected)
|
||||||
|
setLessons(data || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Stundenplan laden fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}, [selected])
|
||||||
|
|
||||||
|
useEffect(() => { loadTimetable() }, [loadTimetable])
|
||||||
|
|
||||||
|
const periodIndices = useMemo(() => {
|
||||||
|
const set = new Set<number>()
|
||||||
|
for (const l of lessons) set.add(l.PeriodIndex)
|
||||||
|
return Array.from(set).sort((a, b) => a - b)
|
||||||
|
}, [lessons])
|
||||||
|
|
||||||
|
const cell = (day: number, idx: number) =>
|
||||||
|
lessons.find(l => l.DayOfWeek === day && l.PeriodIndex === idx)
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try { await elternApi.logout() } catch { /* ignore */ }
|
||||||
|
router.replace('/eltern/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
|
||||||
|
{error ? <span className="text-red-200">{error}</span> : <span className="opacity-70">Laedt …</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeChild = me.children.find(c => c.tt_class_id === selected)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white p-6" data-testid="eltern-page">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<header className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{t(lang, 'greeting')}, {me.parent.email}</h1>
|
||||||
|
<p className="text-sm text-white/60 mt-1">
|
||||||
|
{activeChild ? `${activeChild.first_name} ${activeChild.last_name} · ${activeChild.class_name}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleLogout} data-testid="eltern-logout" className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-sm">
|
||||||
|
{t(lang, 'logout')}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{me.children.length > 1 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm mb-1 opacity-70">{t(lang, 'selectChild')}</label>
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={e => setSelected(e.target.value)}
|
||||||
|
data-testid="child-selector"
|
||||||
|
className="px-3 py-2 rounded-lg border bg-white/10 border-white/20 text-white"
|
||||||
|
>
|
||||||
|
{me.children.map(c => (
|
||||||
|
<option key={c.id} value={c.tt_class_id} className="text-slate-900">
|
||||||
|
{c.first_name} {c.last_name} ({c.class_name})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="mb-3 p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200">{error}</div>}
|
||||||
|
|
||||||
|
{periodIndices.length === 0 ? (
|
||||||
|
<div className="rounded-2xl bg-white/10 border border-white/20 p-8 text-center opacity-70">
|
||||||
|
{t(lang, 'noPlan')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl bg-white/10 border border-white/20 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-white/5">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-3 text-sm font-medium opacity-70 w-16">{t(lang, 'period')}</th>
|
||||||
|
{dayLabels.map(d => <th key={d} className="text-left px-3 py-3 text-sm font-medium opacity-70">{d}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{periodIndices.map(idx => (
|
||||||
|
<tr key={idx} className="border-t border-white/10">
|
||||||
|
<td className="px-3 py-2 font-medium text-sm">{idx}.</td>
|
||||||
|
{[1, 2, 3, 4, 5].map(d => {
|
||||||
|
const l = cell(d, idx)
|
||||||
|
if (!l) return <td key={d} className="px-3 py-2 opacity-20 text-xs">—</td>
|
||||||
|
return (
|
||||||
|
<td key={d} className="px-2 py-1" data-testid={`eltern-cell-${d}-${idx}`}>
|
||||||
|
<div className="rounded-md p-2 text-xs space-y-0.5 bg-indigo-500/30 border-l-2 border-indigo-300">
|
||||||
|
<div className="font-semibold">{translateSubject(l.SubjectName, lang)}</div>
|
||||||
|
<div className="opacity-80">{l.TeacherName.split(',')[0]}</div>
|
||||||
|
{l.RoomName && <div className="opacity-60">{l.RoomName}</div>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,3 +44,28 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #94a3b8;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import { Sidebar } from '@/components/Sidebar'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||||
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared layout for ALL /learn/* pages.
|
* 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 }) {
|
export default function LearnLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { nativeLang, setNativeLang, isThirdLanguage } = useNativeLanguage()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
@@ -23,13 +22,9 @@ export default function LearnLayout({ children }: { children: React.ReactNode })
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
<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">
|
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
|
||||||
<LanguageSwitcher
|
<LanguageDropdown />
|
||||||
currentLang={nativeLang}
|
|
||||||
onLangChange={setNativeLang}
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface LangOption {
|
|||||||
rtl: boolean
|
rtl: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'bp_native_language'
|
const STORAGE_KEY = 'bp_language'
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -2,16 +2,14 @@
|
|||||||
|
|
||||||
import { Sidebar } from '@/components/Sidebar'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||||
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared layout for ALL /parent/* pages.
|
* 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 }) {
|
export default function ParentLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { nativeLang, setNativeLang } = useNativeLanguage()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
@@ -23,13 +21,8 @@ export default function ParentLayout({ children }: { children: React.ReactNode }
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
<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">
|
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
|
||||||
<LanguageSwitcher
|
<LanguageDropdown />
|
||||||
currentLang={nativeLang}
|
|
||||||
onLangChange={setNativeLang}
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { BUNDESLAENDER } from '@/app/schulkalender/types'
|
||||||
|
|
||||||
|
interface BundeslandWizardProps {
|
||||||
|
onSave: (bundesland: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BundeslandWizard({ onSave }: BundeslandWizardProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [selected, setSelected] = useState('DE-NI')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onSave(selected)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`max-w-xl mx-auto rounded-2xl border backdrop-blur-xl p-6 ${cardClass}`} data-testid="bundesland-wizard">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Willkommen im Schulkalender</h2>
|
||||||
|
<p className={`text-sm mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||||
|
Waehle das Bundesland deiner Schule. Damit laden wir Ferien und
|
||||||
|
Feiertage aus dem offiziellen Datensatz fuer die naechsten drei
|
||||||
|
Schuljahre.
|
||||||
|
</p>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Bundesland</label>
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={e => setSelected(e.target.value)}
|
||||||
|
data-testid="bundesland-select"
|
||||||
|
className={`w-full px-3 py-2 rounded-lg border ${selectClass}`}
|
||||||
|
>
|
||||||
|
{BUNDESLAENDER.map(b => (
|
||||||
|
<option key={b.code} value={b.code}>{b.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
data-testid="bundesland-save"
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichert…' : 'Bundesland uebernehmen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { calendarApi } from '@/lib/schulkalender/api'
|
||||||
|
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
|
||||||
|
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
|
||||||
|
import { NotificationStatus } from './NotificationStatus'
|
||||||
|
|
||||||
|
interface DayDetailProps {
|
||||||
|
iso: string
|
||||||
|
holidays: PublicEvent[]
|
||||||
|
events: SchoolEvent[]
|
||||||
|
onClose: () => void
|
||||||
|
onDeleted: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Termin wirklich loeschen?')) return
|
||||||
|
try {
|
||||||
|
await calendarApi.deleteEvent(id)
|
||||||
|
onDeleted()
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
|
||||||
|
|
||||||
|
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
|
||||||
|
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
|
||||||
|
|
||||||
|
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
|
||||||
|
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">{formattedDate}</h2>
|
||||||
|
<button onClick={onClose} className="opacity-60 hover:opacity-100">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dayHolidays.length === 0 && dayEvents.length === 0 && (
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Keine Eintraege fuer diesen Tag.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dayHolidays.length > 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
|
||||||
|
{dayHolidays.map(h => (
|
||||||
|
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
|
||||||
|
<div className="font-medium">{h.name_de}</div>
|
||||||
|
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` – ${h.end_date}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dayEvents.length > 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
|
||||||
|
{dayEvents.map(e => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
|
||||||
|
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{e.title}</div>
|
||||||
|
<div className="text-xs opacity-70 mt-0.5">
|
||||||
|
{EVENT_TYPE_LABEL[e.event_type]}
|
||||||
|
{e.start_time && ` · ${e.start_time}${e.end_time ? `–${e.end_time}` : ''}`}
|
||||||
|
{e.is_school_free && ' · unterrichtsfrei'}
|
||||||
|
</div>
|
||||||
|
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
|
||||||
|
<div className="text-xs opacity-60 mt-1.5">
|
||||||
|
{e.visible_to_parents && '👨👩👧 sichtbar fuer Eltern'}
|
||||||
|
{e.notify_parents && ' · 📧 Eltern erinnern'}
|
||||||
|
{e.notify_students && ' · 💬 Schueler erinnern'}
|
||||||
|
</div>
|
||||||
|
{(e.notify_parents || e.notify_students) && (
|
||||||
|
<NotificationStatus eventId={e.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(e.id)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { calendarApi } from '@/lib/schulkalender/api'
|
||||||
|
import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types'
|
||||||
|
import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
|
||||||
|
|
||||||
|
interface EventModalProps {
|
||||||
|
defaultDate: string // YYYY-MM-DD
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = (date: string): CreateSchoolEvent => ({
|
||||||
|
title: '',
|
||||||
|
event_type: 'fortbildung',
|
||||||
|
is_school_free: false,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
visible_to_parents: true,
|
||||||
|
notify_parents: false,
|
||||||
|
notify_students: false,
|
||||||
|
notification_lead_days: [7, 1],
|
||||||
|
})
|
||||||
|
|
||||||
|
export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [form, setForm] = useState<CreateSchoolEvent>(initial(defaultDate))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await calendarApi.createEvent(form)
|
||||||
|
onCreated()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="event-modal">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={`w-full max-w-2xl rounded-2xl border p-6 space-y-3 max-h-[90vh] overflow-y-auto ${cardClass}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">Neuer Termin</h2>
|
||||||
|
<button type="button" onClick={onClose} className="opacity-60 hover:opacity-100">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Titel</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||||
|
placeholder="z.B. SCHILF: Digitale Tafeln"
|
||||||
|
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||||
|
data-testid="event-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Typ</label>
|
||||||
|
<select
|
||||||
|
value={form.event_type}
|
||||||
|
onChange={e => setForm({ ...form, event_type: e.target.value as SchoolEventType })}
|
||||||
|
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||||
|
data-testid="event-type"
|
||||||
|
>
|
||||||
|
{(Object.keys(EVENT_TYPE_LABEL) as SchoolEventType[]).map(k => (
|
||||||
|
<option key={k} value={k}>{EVENT_TYPE_LABEL[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is-school-free"
|
||||||
|
checked={form.is_school_free || false}
|
||||||
|
onChange={e => setForm({ ...form, is_school_free: e.target.checked })}
|
||||||
|
className="w-5 h-5"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is-school-free" className="text-sm">Unterrichtsfrei</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Von</label>
|
||||||
|
<input type="date" required value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Bis</label>
|
||||||
|
<input type="date" required value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Startzeit (optional)</label>
|
||||||
|
<input type="time" value={form.start_time || ''} onChange={e => setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Endzeit (optional)</label>
|
||||||
|
<input type="time" value={form.end_time || ''} onChange={e => setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Beschreibung (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description || ''}
|
||||||
|
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2 border-t border-white/10">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.visible_to_parents ?? true}
|
||||||
|
onChange={e => setForm({ ...form, visible_to_parents: e.target.checked })}
|
||||||
|
className="w-5 h-5"
|
||||||
|
/>
|
||||||
|
Eltern sehen diesen Termin
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.notify_parents ?? false}
|
||||||
|
onChange={e => setForm({ ...form, notify_parents: e.target.checked })}
|
||||||
|
className="w-5 h-5"
|
||||||
|
/>
|
||||||
|
Eltern per Mail/Chat erinnern
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.notify_students ?? false}
|
||||||
|
onChange={e => setForm({ ...form, notify_students: e.target.checked })}
|
||||||
|
className="w-5 h-5"
|
||||||
|
/>
|
||||||
|
Schueler per Chat erinnern
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
data-testid="event-save"
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichert…' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'}`}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
|
||||||
|
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
|
||||||
|
|
||||||
|
interface MonthViewProps {
|
||||||
|
year: number
|
||||||
|
month: number // 1-12
|
||||||
|
holidays: PublicEvent[]
|
||||||
|
schoolEvents?: SchoolEvent[]
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onToday: () => void
|
||||||
|
onDayClick?: (iso: string) => void
|
||||||
|
onAddEvent?: () => void
|
||||||
|
onRollover?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
const MONTHS_DE = [
|
||||||
|
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
date: Date
|
||||||
|
inMonth: boolean
|
||||||
|
events: PublicEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): Cell[] {
|
||||||
|
// First Monday on or before the 1st of the month.
|
||||||
|
const first = new Date(Date.UTC(year, month - 1, 1))
|
||||||
|
const firstWeekday = (first.getUTCDay() + 6) % 7 // Monday = 0
|
||||||
|
const start = new Date(first)
|
||||||
|
start.setUTCDate(first.getUTCDate() - firstWeekday)
|
||||||
|
|
||||||
|
const cells: Cell[] = []
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(start)
|
||||||
|
d.setUTCDate(start.getUTCDate() + i)
|
||||||
|
const iso = d.toISOString().slice(0, 10)
|
||||||
|
const events = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
|
||||||
|
cells.push({
|
||||||
|
date: d,
|
||||||
|
inMonth: d.getUTCMonth() === month - 1,
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
if (i >= 27 && d.getUTCMonth() !== month - 1) {
|
||||||
|
// Stop a row early if the rest is fully outside the month.
|
||||||
|
const restAllOutside = cells.slice(i + 1 - ((i + 1) % 7), i + 1).every(c => !c.inMonth)
|
||||||
|
if (restAllOutside) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pad to multiple of 7 if we cut early.
|
||||||
|
while (cells.length % 7 !== 0) {
|
||||||
|
const last = cells[cells.length - 1].date
|
||||||
|
const d = new Date(last)
|
||||||
|
d.setUTCDate(last.getUTCDate() + 1)
|
||||||
|
cells.push({ date: d, inMonth: false, events: [] })
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthView({ year, month, holidays, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
|
||||||
|
|
||||||
|
// School events per ISO date — quick lookup during cell render.
|
||||||
|
const schoolEventsByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, SchoolEvent[]>()
|
||||||
|
for (const ev of schoolEvents) {
|
||||||
|
const start = new Date(ev.start_date)
|
||||||
|
const end = new Date(ev.end_date)
|
||||||
|
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
const iso = d.toISOString().slice(0, 10)
|
||||||
|
const arr = map.get(iso) || []
|
||||||
|
arr.push(ev)
|
||||||
|
map.set(iso, arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [schoolEvents])
|
||||||
|
|
||||||
|
const headerClass = isDark ? 'text-white' : 'text-slate-900'
|
||||||
|
const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
|
||||||
|
const buttonClass = isDark
|
||||||
|
? 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||||
|
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
|
||||||
|
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="month-view">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className={`text-2xl font-semibold ${headerClass}`}>
|
||||||
|
{MONTHS_DE[month - 1]} {year}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{onAddEvent && (
|
||||||
|
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
|
||||||
|
)}
|
||||||
|
{onRollover && (
|
||||||
|
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>←</button>
|
||||||
|
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
|
||||||
|
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
|
{WEEKDAYS_DE.map(w => (
|
||||||
|
<div key={w} className={`text-xs font-medium text-center ${subtleText}`}>{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{cells.map((c, i) => {
|
||||||
|
const iso = c.date.toISOString().slice(0, 10)
|
||||||
|
const isToday = iso === todayIso
|
||||||
|
const publicHoliday = c.events.find(e => e.event_type === 'public_holiday')
|
||||||
|
const schoolHoliday = c.events.find(e => e.event_type === 'school_holiday')
|
||||||
|
|
||||||
|
let bg = isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||||
|
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
|
||||||
|
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
|
||||||
|
|
||||||
|
const dayEvents = schoolEventsByDate.get(iso) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
data-testid={`day-${iso}`}
|
||||||
|
onClick={() => c.inMonth && onDayClick?.(iso)}
|
||||||
|
className={`relative aspect-square rounded-lg p-2 text-sm border ${
|
||||||
|
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
|
||||||
|
} ${
|
||||||
|
isDark ? 'border-white/10' : 'border-black/5'
|
||||||
|
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
|
||||||
|
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`font-medium ${
|
||||||
|
c.inMonth ? (isDark ? 'text-white' : 'text-slate-900') : subtleText
|
||||||
|
}`}>
|
||||||
|
{c.date.getUTCDate()}
|
||||||
|
</div>
|
||||||
|
{c.events.length > 0 && (
|
||||||
|
<div className="mt-1 space-y-0.5 overflow-hidden">
|
||||||
|
{c.events.slice(0, 2).map(e => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
title={e.name_de}
|
||||||
|
className={`text-[10px] leading-tight truncate ${
|
||||||
|
e.event_type === 'public_holiday'
|
||||||
|
? (isDark ? 'text-rose-200' : 'text-rose-800')
|
||||||
|
: (isDark ? 'text-amber-200' : 'text-amber-800')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e.name_de}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{c.events.length > 2 && (
|
||||||
|
<div className={`text-[10px] ${subtleText}`}>+{c.events.length - 2}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dayEvents.length > 0 && (
|
||||||
|
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
|
||||||
|
{dayEvents.slice(0, 4).map(ev => (
|
||||||
|
<span
|
||||||
|
key={ev.id}
|
||||||
|
title={ev.title}
|
||||||
|
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-rose-500/40' : 'bg-rose-200'}`}></span>
|
||||||
|
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Feiertag</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-amber-500/40' : 'bg-amber-200'}`}></span>
|
||||||
|
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Schulferien</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { calendarApi } from '@/lib/schulkalender/api'
|
||||||
|
import type { NotificationLogRow } from '@/app/schulkalender/types'
|
||||||
|
|
||||||
|
interface NotificationStatusProps {
|
||||||
|
eventId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<string, string> = {
|
||||||
|
sent: '✓',
|
||||||
|
failed: '✗',
|
||||||
|
skipped: '⏱',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationStatus({ eventId }: NotificationStatusProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [rows, setRows] = useState<NotificationLogRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
calendarApi.listEventNotifications(eventId)
|
||||||
|
.then(r => { setRows(r || []); setLoading(false) })
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [eventId])
|
||||||
|
|
||||||
|
if (loading || rows.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mt-2 pt-2 border-t text-xs ${isDark ? 'border-white/10' : 'border-black/10'}`} data-testid={`notif-status-${eventId}`}>
|
||||||
|
<div className={`font-medium mb-1 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>Erinnerungen</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
title={r.error_message || `${r.status} ${r.run_date}`}
|
||||||
|
className={`px-2 py-0.5 rounded ${
|
||||||
|
r.status === 'sent' ? (isDark ? 'bg-emerald-500/30 text-emerald-100' : 'bg-emerald-100 text-emerald-900') :
|
||||||
|
r.status === 'failed' ? (isDark ? 'bg-red-500/30 text-red-100' : 'bg-red-100 text-red-900') :
|
||||||
|
(isDark ? 'bg-amber-500/30 text-amber-100' : 'bg-amber-100 text-amber-900')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATUS_ICON[r.status]} {r.lead_days === 0 ? 'Heute' : r.lead_days === 1 ? '1 Tag' : `${r.lead_days} Tage`}
|
||||||
|
{' · '}{r.audience === 'parents' ? 'Eltern' : 'Schueler'}
|
||||||
|
{' · '}{r.channel}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { calendarApi } from '@/lib/schulkalender/api'
|
||||||
|
import { classesApi } from '@/lib/stundenplan/api'
|
||||||
|
import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types'
|
||||||
|
import type { TimetableClass } from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
const LANGS: { code: string; name: string }[] = [
|
||||||
|
{ code: 'de', name: 'Deutsch' },
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'tr', name: 'Tuerkce' },
|
||||||
|
{ code: 'ar', name: 'العربية' },
|
||||||
|
{ code: 'uk', name: 'Українська' },
|
||||||
|
{ code: 'ru', name: 'Русский' },
|
||||||
|
{ code: 'pl', name: 'Polski' },
|
||||||
|
{ code: 'fr', name: 'Francais' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ParentManager() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<ParentInviteListItem[]>([])
|
||||||
|
const [classes, setClasses] = useState<TimetableClass[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [lastInvite, setLastInvite] = useState<InviteParentResponse | null>(null)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: '',
|
||||||
|
preferred_language: 'de',
|
||||||
|
child_first_name: '',
|
||||||
|
child_last_name: '',
|
||||||
|
tt_class_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()])
|
||||||
|
setItems(list || [])
|
||||||
|
setClasses(cls || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await calendarApi.inviteParent(form)
|
||||||
|
setLastInvite(res)
|
||||||
|
setForm({ ...form, child_first_name: '', child_last_name: '' })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (childId: string) => {
|
||||||
|
if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return
|
||||||
|
try { await calendarApi.deleteParentChild(childId); await load() }
|
||||||
|
catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullLink = (path: string) =>
|
||||||
|
typeof window === 'undefined' ? path : `${window.location.origin}${path}`
|
||||||
|
|
||||||
|
const copyLink = (path: string) => {
|
||||||
|
navigator.clipboard?.writeText(fullLink(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="parent-manager">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Eltern verwalten ({items.length})</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
disabled={classes.length === 0}
|
||||||
|
data-testid="parent-invite-toggle"
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Eltern einladen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classes.length === 0 && (
|
||||||
|
<p className={`text-sm mb-2 ${isDark ? 'text-amber-200' : 'text-amber-900'}`}>
|
||||||
|
Zuerst Klassen im Stundenplan-Modul anlegen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<input required type="email" placeholder="Eltern-E-Mail" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
<input required placeholder="Vorname Kind" value={form.child_first_name} onChange={e => setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
<input required placeholder="Nachname Kind" value={form.child_last_name} onChange={e => setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
<select required value={form.tt_class_id} onChange={e => setForm({ ...form, tt_class_id: e.target.value })} data-testid="parent-class" className={`px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— Klasse waehlen —</option>
|
||||||
|
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={form.preferred_language} onChange={e => setForm({ ...form, preferred_language: e.target.value })} className={`px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
{LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="submit" disabled={submitting} data-testid="parent-invite-submit" className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Erstellt…' : 'Einladen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastInvite && (
|
||||||
|
<div className={`mb-3 p-3 rounded-lg ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`} data-testid="parent-invite-link">
|
||||||
|
<div className="text-sm font-medium mb-1">Einladungs-Link fuer {lastInvite.parent.email}</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<code className={`flex-1 text-xs px-2 py-1 rounded overflow-x-auto ${isDark ? 'bg-white/10' : 'bg-white'}`}>
|
||||||
|
{fullLink(lastInvite.magic_url)}
|
||||||
|
</code>
|
||||||
|
<button onClick={() => copyLink(lastInvite.magic_url)} className="text-xs px-2 py-1 rounded bg-indigo-500 hover:bg-indigo-600 text-white">Kopieren</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs opacity-70 mt-1">Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="opacity-60 py-4 text-center text-sm">Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="opacity-60 py-4 text-center text-sm">Keine eingeladenen Eltern.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className={isDark ? 'opacity-70' : 'opacity-70'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2">E-Mail</th>
|
||||||
|
<th className="text-left py-2">Kind</th>
|
||||||
|
<th className="text-left py-2">Klasse</th>
|
||||||
|
<th className="text-left py-2">Sprache</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(it => (
|
||||||
|
<tr key={it.child_id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="py-2">{it.email}</td>
|
||||||
|
<td className="py-2">{it.child_first_name} {it.child_last_name}</td>
|
||||||
|
<td className="py-2">{it.class_name}</td>
|
||||||
|
<td className="py-2">{it.preferred_language}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button onClick={() => handleDelete(it.child_id)} className="text-xs text-red-400 hover:text-red-300">Loeschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { calendarApi } from '@/lib/schulkalender/api'
|
||||||
|
import type { SchoolYearRolloverResult } from '@/app/schulkalender/types'
|
||||||
|
|
||||||
|
interface RolloverWizardProps {
|
||||||
|
onClose: () => void
|
||||||
|
onDone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSchoolYearISO(): { start: string; end: string } {
|
||||||
|
const now = new Date()
|
||||||
|
let y = now.getFullYear()
|
||||||
|
if (now.getMonth() + 1 >= 8) y++ // Aug → bumped to next year
|
||||||
|
return { start: `${y}-08-01`, end: `${y + 1}-07-31` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolloverWizard({ onClose, onDone }: RolloverWizardProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const defaults = nextSchoolYearISO()
|
||||||
|
const [start, setStart] = useState(defaults.start)
|
||||||
|
const [end, setEnd] = useState(defaults.end)
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<SchoolYearRolloverResult | null>(null)
|
||||||
|
|
||||||
|
const expected = 'SCHULJAHR WECHSELN'
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await calendarApi.rolloverSchoolYear(start, end)
|
||||||
|
setResult(r)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Rollover fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="rollover-wizard">
|
||||||
|
<div className={`w-full max-w-xl rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">Schuljahres-Wechsel</h2>
|
||||||
|
<button onClick={onClose} className="opacity-60 hover:opacity-100">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="space-y-3" data-testid="rollover-result">
|
||||||
|
<div className={`p-4 rounded-xl ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`}>
|
||||||
|
<div className="font-medium mb-2">Rollover erfolgreich</div>
|
||||||
|
<ul className="text-sm space-y-1 opacity-90">
|
||||||
|
<li>{result.classes_promoted} Klassen um eine Stufe aufgerueckt</li>
|
||||||
|
<li>{result.classes_graduated} Abschlussklassen entfernt</li>
|
||||||
|
<li>Neues Schuljahr: {result.new_year_start} – {result.new_year_end}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button onClick={onDone} className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`p-3 rounded-lg text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-100' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||||
|
<p className="font-medium mb-1">Was passiert?</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 opacity-90">
|
||||||
|
<li>Alle Klassen ruecken eine Stufe hoeher (5a → 6, 6a → 7, …)</li>
|
||||||
|
<li>Abschlussklassen (Stufe 13) werden entfernt</li>
|
||||||
|
<li>Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert</li>
|
||||||
|
<li>Vorhandene Stundenplaene bleiben als Historie erhalten</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Schuljahr-Beginn</label>
|
||||||
|
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Schuljahr-Ende</label>
|
||||||
|
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">
|
||||||
|
Bestaetigung — tippe <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>{expected}</code> zur Bestaetigung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
data-testid="rollover-confirm"
|
||||||
|
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={saving || confirm !== expected}
|
||||||
|
data-testid="rollover-submit"
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{saving ? 'Wechselt…' : 'Schuljahr wechseln'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-100 hover:bg-slate-200'}`}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||||
|
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||||
|
import { calendarApi } from '@/lib/schulkalender/api'
|
||||||
|
import type { PublicEvent, SchoolCalendarConfig, SchoolEvent } from './types'
|
||||||
|
import { BUNDESLAENDER } from './types'
|
||||||
|
import { MonthView } from './_components/MonthView'
|
||||||
|
import { BundeslandWizard } from './_components/BundeslandWizard'
|
||||||
|
import { EventModal } from './_components/EventModal'
|
||||||
|
import { DayDetail } from './_components/DayDetail'
|
||||||
|
import { RolloverWizard } from './_components/RolloverWizard'
|
||||||
|
import { ParentManager } from './_components/ParentManager'
|
||||||
|
|
||||||
|
function monthRange(year: number, month: number): { from: string; to: string } {
|
||||||
|
// Render the visible 6-week grid worth of holidays (covers prev/next month edges).
|
||||||
|
const from = new Date(Date.UTC(year, month - 1, 1))
|
||||||
|
from.setUTCDate(from.getUTCDate() - 7)
|
||||||
|
const to = new Date(Date.UTC(year, month, 0))
|
||||||
|
to.setUTCDate(to.getUTCDate() + 14)
|
||||||
|
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SchulkalenderPage() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const today = new Date()
|
||||||
|
const [year, setYear] = useState(today.getFullYear())
|
||||||
|
const [month, setMonth] = useState(today.getMonth() + 1)
|
||||||
|
const [config, setConfig] = useState<SchoolCalendarConfig | null>(null)
|
||||||
|
const [holidays, setHolidays] = useState<PublicEvent[]>([])
|
||||||
|
const [schoolEvents, setSchoolEvents] = useState<SchoolEvent[]>([])
|
||||||
|
const [configLoading, setConfigLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [openDay, setOpenDay] = useState<string | null>(null)
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [showRollover, setShowRollover] = useState(false)
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setConfigLoading(true)
|
||||||
|
try {
|
||||||
|
const cfg = await calendarApi.getConfig()
|
||||||
|
setConfig(cfg)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Config laden fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadConfig() }, [loadConfig])
|
||||||
|
|
||||||
|
const loadHolidays = useCallback(async () => {
|
||||||
|
if (!config?.bundesland) return
|
||||||
|
const { from, to } = monthRange(year, month)
|
||||||
|
try {
|
||||||
|
const [hd, ev] = await Promise.all([
|
||||||
|
calendarApi.listHolidays(config.bundesland, from, to),
|
||||||
|
calendarApi.listEvents(from, to),
|
||||||
|
])
|
||||||
|
setHolidays(hd || [])
|
||||||
|
setSchoolEvents(ev || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ferien/Events laden fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}, [config, year, month])
|
||||||
|
|
||||||
|
useEffect(() => { loadHolidays() }, [loadHolidays])
|
||||||
|
|
||||||
|
const handleSaveBundesland = async (bundesland: string) => {
|
||||||
|
const cfg = await calendarApi.upsertConfig({ bundesland })
|
||||||
|
setConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (month === 1) { setYear(y => y - 1); setMonth(12) }
|
||||||
|
else setMonth(m => m - 1)
|
||||||
|
}
|
||||||
|
const goNext = () => {
|
||||||
|
if (month === 12) { setYear(y => y + 1); setMonth(1) }
|
||||||
|
else setMonth(m => m + 1)
|
||||||
|
}
|
||||||
|
const goToday = () => {
|
||||||
|
const t = new Date()
|
||||||
|
setYear(t.getFullYear())
|
||||||
|
setMonth(t.getMonth() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundeslandName = config
|
||||||
|
? BUNDESLAENDER.find(b => b.code === config.bundesland)?.name || config.bundesland
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
|
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||||
|
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||||
|
}`}>
|
||||||
|
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||||
|
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||||
|
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||||
|
|
||||||
|
<div className="relative z-10 p-4"><Sidebar selectedTab="schulkalender" /></div>
|
||||||
|
|
||||||
|
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<header className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Schulkalender
|
||||||
|
</h1>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
{config ? `Ferien und Feiertage fuer ${bundeslandName}` : 'Ferien, Feiertage und Schultermine'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageDropdown />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{configLoading ? (
|
||||||
|
<div className={`text-center py-12 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : !config ? (
|
||||||
|
<BundeslandWizard onSave={handleSaveBundesland} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MonthView
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
holidays={holidays}
|
||||||
|
schoolEvents={schoolEvents}
|
||||||
|
onPrev={goPrev}
|
||||||
|
onNext={goNext}
|
||||||
|
onToday={goToday}
|
||||||
|
onDayClick={(iso) => setOpenDay(iso)}
|
||||||
|
onAddEvent={() => setShowAddModal(true)}
|
||||||
|
onRollover={() => setShowRollover(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{openDay && (
|
||||||
|
<DayDetail
|
||||||
|
iso={openDay}
|
||||||
|
holidays={holidays}
|
||||||
|
events={schoolEvents}
|
||||||
|
onClose={() => setOpenDay(null)}
|
||||||
|
onDeleted={() => { loadHolidays(); setOpenDay(null) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddModal && (
|
||||||
|
<EventModal
|
||||||
|
defaultDate={openDay || new Date().toISOString().slice(0, 10)}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onCreated={() => { setShowAddModal(false); loadHolidays() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRollover && (
|
||||||
|
<RolloverWizard
|
||||||
|
onClose={() => setShowRollover(false)}
|
||||||
|
onDone={() => { setShowRollover(false); loadHolidays() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ParentManager />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
export type PublicEventType = 'public_holiday' | 'school_holiday'
|
||||||
|
|
||||||
|
export interface PublicEvent {
|
||||||
|
id: string
|
||||||
|
region: string
|
||||||
|
event_type: PublicEventType
|
||||||
|
name_de: string
|
||||||
|
name_en?: string
|
||||||
|
start_date: string // YYYY-MM-DD
|
||||||
|
end_date: string
|
||||||
|
source?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchoolCalendarConfig {
|
||||||
|
user_id: string
|
||||||
|
bundesland: string
|
||||||
|
school_year_start?: string | null
|
||||||
|
school_year_end?: string | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertSchoolCalendarConfig {
|
||||||
|
bundesland: string
|
||||||
|
school_year_start?: string | null
|
||||||
|
school_year_end?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchoolEventType =
|
||||||
|
| 'fortbildung'
|
||||||
|
| 'schulfeier'
|
||||||
|
| 'klassenfahrt'
|
||||||
|
| 'projekttag'
|
||||||
|
| 'eltern_info'
|
||||||
|
| 'andere'
|
||||||
|
|
||||||
|
export interface SchoolEvent {
|
||||||
|
id: string
|
||||||
|
created_by_user_id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
event_type: SchoolEventType
|
||||||
|
is_school_free: boolean
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
start_time?: string | null
|
||||||
|
end_time?: string | null
|
||||||
|
affected_class_ids: string[]
|
||||||
|
visible_to_parents: boolean
|
||||||
|
notify_parents: boolean
|
||||||
|
notify_students: boolean
|
||||||
|
notification_lead_days: number[]
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSchoolEvent {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
event_type: SchoolEventType
|
||||||
|
is_school_free?: boolean
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
start_time?: string | null
|
||||||
|
end_time?: string | null
|
||||||
|
affected_class_ids?: string[]
|
||||||
|
visible_to_parents?: boolean
|
||||||
|
notify_parents?: boolean
|
||||||
|
notify_students?: boolean
|
||||||
|
notification_lead_days?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchoolYearRolloverResult {
|
||||||
|
classes_promoted: number
|
||||||
|
classes_graduated: number
|
||||||
|
new_year_start: string
|
||||||
|
new_year_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENT_TYPE_LABEL: Record<SchoolEventType, string> = {
|
||||||
|
fortbildung: 'Fortbildung',
|
||||||
|
schulfeier: 'Schulfeier',
|
||||||
|
klassenfahrt: 'Klassenfahrt',
|
||||||
|
projekttag: 'Projekttag',
|
||||||
|
eltern_info: 'Eltern-Info',
|
||||||
|
andere: 'Andere',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENT_TYPE_COLOR: Record<SchoolEventType, string> = {
|
||||||
|
fortbildung: '#0ea5e9',
|
||||||
|
schulfeier: '#a855f7',
|
||||||
|
klassenfahrt: '#22c55e',
|
||||||
|
projekttag: '#f59e0b',
|
||||||
|
eltern_info: '#ec4899',
|
||||||
|
andere: '#64748b',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUNDESLAENDER: { code: string; name: string }[] = [
|
||||||
|
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' },
|
||||||
|
{ code: 'DE-BY', name: 'Bayern' },
|
||||||
|
{ code: 'DE-BE', name: 'Berlin' },
|
||||||
|
{ code: 'DE-BB', name: 'Brandenburg' },
|
||||||
|
{ code: 'DE-HB', name: 'Bremen' },
|
||||||
|
{ code: 'DE-HH', name: 'Hamburg' },
|
||||||
|
{ code: 'DE-HE', name: 'Hessen' },
|
||||||
|
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
|
||||||
|
{ code: 'DE-NI', name: 'Niedersachsen' },
|
||||||
|
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
|
||||||
|
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
|
||||||
|
{ code: 'DE-SL', name: 'Saarland' },
|
||||||
|
{ code: 'DE-SN', name: 'Sachsen' },
|
||||||
|
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
|
||||||
|
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
|
||||||
|
{ code: 'DE-TH', name: 'Thueringen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------- Parent invitations (Phase 9c) ----------
|
||||||
|
|
||||||
|
export interface ParentAccount {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
preferred_language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentChild {
|
||||||
|
id: string
|
||||||
|
parent_id: string
|
||||||
|
tt_class_id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
class_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentInviteListItem {
|
||||||
|
parent_id: string
|
||||||
|
email: string
|
||||||
|
preferred_language: string
|
||||||
|
child_id: string
|
||||||
|
child_first_name: string
|
||||||
|
child_last_name: string
|
||||||
|
class_id: string
|
||||||
|
class_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteParentRequest {
|
||||||
|
email: string
|
||||||
|
preferred_language?: string
|
||||||
|
child_first_name: string
|
||||||
|
child_last_name: string
|
||||||
|
tt_class_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteParentResponse {
|
||||||
|
parent: ParentAccount
|
||||||
|
child: ParentChild
|
||||||
|
magic_token: string
|
||||||
|
magic_url: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Notifications (Phase 9d) ----------
|
||||||
|
|
||||||
|
export type NotificationStatus = 'sent' | 'failed' | 'skipped'
|
||||||
|
|
||||||
|
export interface NotificationLogRow {
|
||||||
|
lead_days: number
|
||||||
|
audience: 'parents' | 'students'
|
||||||
|
channel: 'matrix' | 'email'
|
||||||
|
status: NotificationStatus
|
||||||
|
error_message?: string
|
||||||
|
run_date: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationRunResult {
|
||||||
|
date: string
|
||||||
|
sent: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
already_logged: number
|
||||||
|
}
|
||||||
@@ -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 — "Keine Lessons" heisst meistens fehlende
|
||||||
|
Lehrauftraege; "Nicht loesbar" 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:00–08: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
Reference in New Issue
Block a user