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)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|
||||
@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
|
||||
from vocabulary.api import router as vocabulary_router
|
||||
app.include_router(vocabulary_router, prefix="/api")
|
||||
|
||||
# --- 4c2. Vocabulary Unit Creation + Translation ---
|
||||
from vocabulary.unit_api import router as vocab_unit_router
|
||||
app.include_router(vocab_unit_router, prefix="/api")
|
||||
|
||||
# --- 4d. User Language Preferences ---
|
||||
from api.user_language import router as user_language_router
|
||||
app.include_router(user_language_router, prefix="/api")
|
||||
|
||||
@@ -22,11 +22,6 @@ from .db import (
|
||||
get_all_pos,
|
||||
VocabularyWord,
|
||||
)
|
||||
from units.learning import (
|
||||
LearningUnitCreate,
|
||||
create_learning_unit,
|
||||
get_learning_unit,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,13 +82,38 @@ async def _search_kaikki(q: str, lang: str, limit: int, offset: int):
|
||||
if isinstance(tr, str):
|
||||
import json as _json
|
||||
tr = _json.loads(tr)
|
||||
|
||||
en_word = ""
|
||||
en_ipa = ""
|
||||
|
||||
if r["lang"] == "en":
|
||||
en_word = r["word"]
|
||||
en_ipa = r["ipa"] or ""
|
||||
else:
|
||||
# Non-EN entries have empty translations — enrich from EN via reverse lookup
|
||||
if not tr or len(tr) < 3:
|
||||
async with pool.acquire() as conn2:
|
||||
en_row = await conn2.fetchrow(
|
||||
"""SELECT word, ipa, translations FROM vocabulary_kaikki
|
||||
WHERE lang = 'en' AND translations->'%s'->>'text' ILIKE $1
|
||||
ORDER BY length(word) LIMIT 1""" % lang,
|
||||
r["word"],
|
||||
)
|
||||
if en_row:
|
||||
en_word = en_row["word"]
|
||||
en_ipa = en_row["ipa"] or ""
|
||||
en_tr = en_row["translations"]
|
||||
if isinstance(en_tr, str):
|
||||
en_tr = _json.loads(en_tr)
|
||||
tr = en_tr
|
||||
|
||||
words.append({
|
||||
"id": str(r["id"]),
|
||||
"english": r["word"] if r["lang"] == "en" else "",
|
||||
"german": tr.get("de", {}).get("text", "") if r["lang"] == "en" else r["word"] if r["lang"] == "de" else "",
|
||||
"english": en_word if r["lang"] != "en" else r["word"],
|
||||
"german": tr.get("de", {}).get("text", "") if r["lang"] != "de" else r["word"],
|
||||
"word": r["word"],
|
||||
"lang": r["lang"],
|
||||
"ipa_en": r["ipa"] if r["lang"] == "en" else "",
|
||||
"ipa_en": en_ipa if r["lang"] != "en" else (r["ipa"] or ""),
|
||||
"ipa_de": r["ipa"] if r["lang"] == "de" else "",
|
||||
"part_of_speech": r["pos"],
|
||||
"syllables_en": [],
|
||||
@@ -239,130 +259,7 @@ async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateUnitFromWordsPayload(BaseModel):
|
||||
title: str
|
||||
word_ids: List[str]
|
||||
grade: Optional[str] = None
|
||||
language: Optional[str] = "de"
|
||||
|
||||
|
||||
@router.post("/units")
|
||||
async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
|
||||
"""Create a learning unit from selected vocabulary word IDs.
|
||||
|
||||
Fetches full word details, creates a LearningUnit in the
|
||||
learning_units system, and stores the vocabulary data.
|
||||
"""
|
||||
if not payload.word_ids:
|
||||
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
|
||||
|
||||
# Fetch all selected words
|
||||
words = []
|
||||
for wid in payload.word_ids:
|
||||
word = await get_word(wid)
|
||||
if word:
|
||||
words.append(word)
|
||||
|
||||
if not words:
|
||||
raise HTTPException(status_code=404, detail="Keine der Woerter gefunden")
|
||||
|
||||
# Create learning unit
|
||||
lu = create_learning_unit(LearningUnitCreate(
|
||||
title=payload.title,
|
||||
topic="Vocabulary",
|
||||
grade_level=payload.grade or "5-8",
|
||||
language=payload.language or "de",
|
||||
status="raw",
|
||||
))
|
||||
|
||||
# Save vocabulary data as analysis JSON for generators
|
||||
import os
|
||||
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
|
||||
os.makedirs(analysis_dir, exist_ok=True)
|
||||
|
||||
vocab_data = [w.to_dict() for w in words]
|
||||
analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json")
|
||||
with open(analysis_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Also save as QA items for flashcards/type trainer
|
||||
qa_items = []
|
||||
for i, w in enumerate(words):
|
||||
qa_items.append({
|
||||
"id": f"qa_{i+1}",
|
||||
"question": w.english,
|
||||
"answer": w.german,
|
||||
"question_type": "knowledge",
|
||||
"key_terms": [w.english],
|
||||
"difficulty": w.difficulty,
|
||||
"source_hint": w.part_of_speech,
|
||||
"leitner_box": 0,
|
||||
"correct_count": 0,
|
||||
"incorrect_count": 0,
|
||||
"last_seen": None,
|
||||
"next_review": None,
|
||||
# Extra fields for enhanced flashcards
|
||||
"ipa_en": w.ipa_en,
|
||||
"ipa_de": w.ipa_de,
|
||||
"syllables_en": w.syllables_en,
|
||||
"syllables_de": w.syllables_de,
|
||||
"example_en": w.example_en,
|
||||
"example_de": w.example_de,
|
||||
"image_url": w.image_url,
|
||||
"audio_url_en": w.audio_url_en,
|
||||
"audio_url_de": w.audio_url_de,
|
||||
"part_of_speech": w.part_of_speech,
|
||||
"translations": w.translations,
|
||||
})
|
||||
|
||||
qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json")
|
||||
with open(qa_path, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"qa_items": qa_items,
|
||||
"metadata": {
|
||||
"subject": "English Vocabulary",
|
||||
"grade_level": payload.grade or "5-8",
|
||||
"source_title": payload.title,
|
||||
"total_questions": len(qa_items),
|
||||
},
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Auto-enrich words with images (Wikipedia + emoji fallback)
|
||||
try:
|
||||
from services.image_service import enrich_words_with_images
|
||||
await enrich_words_with_images(payload.word_ids)
|
||||
except Exception as e:
|
||||
logger.warning(f"Image enrichment failed (non-critical): {e}")
|
||||
|
||||
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
|
||||
|
||||
return {
|
||||
"unit_id": lu.id,
|
||||
"title": payload.title,
|
||||
"word_count": len(words),
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/units/{unit_id}")
|
||||
async def api_get_unit_words(unit_id: str):
|
||||
"""Get all words for a learning unit."""
|
||||
import os
|
||||
vocab_path = os.path.join(
|
||||
os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"),
|
||||
f"{unit_id}_vocab.json",
|
||||
)
|
||||
if not os.path.exists(vocab_path):
|
||||
raise HTTPException(status_code=404, detail="Unit nicht gefunden")
|
||||
|
||||
with open(vocab_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"title": data.get("title", ""),
|
||||
"words": data.get("words", []),
|
||||
}
|
||||
# Unit creation and translation lookup moved to vocabulary/unit_api.py
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
NODE_ENV: production
|
||||
BACKEND_URL: http://backend-lehrer:8001
|
||||
SCHOOL_SERVICE_URL: http://school-service:8084
|
||||
depends_on:
|
||||
- backend-lehrer
|
||||
- school-service
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- breakpilot-network
|
||||
@@ -287,6 +289,26 @@ services:
|
||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
ALLOWED_ORIGINS: "*"
|
||||
LLM_GATEWAY_URL: http://backend-lehrer:8001/llm
|
||||
SOLVER_SERVICE_URL: http://timetable-solver-service:8095
|
||||
depends_on:
|
||||
core-health-check:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- breakpilot-network
|
||||
|
||||
timetable-solver-service:
|
||||
build:
|
||||
context: ./timetable-solver-service
|
||||
dockerfile: Dockerfile
|
||||
container_name: bp-lehrer-timetable-solver
|
||||
platform: linux/arm64
|
||||
ports:
|
||||
- "8095:8095"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}
|
||||
SOLVER_SECONDS_LIMIT: ${SOLVER_SECONDS_LIMIT:-60}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
core-health-check:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Architektur
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Phase 9a — Kalender-Stammdaten
|
||||
|
||||
| Tabelle | Inhalt | Owner |
|
||||
|---------|--------|-------|
|
||||
| `cal_public_event` | Ferien + Feiertage (region, type, name, start, end) | global (alle Bundeslaender) |
|
||||
| `cal_school_config` | Bundesland-Auswahl + Schuljahr-Daten | 1 row per user_id |
|
||||
|
||||
### Phase 9b — Schul-Events
|
||||
|
||||
| Tabelle | Inhalt | Owner |
|
||||
|---------|--------|-------|
|
||||
| `cal_school_event` | Titel + Typ + Datum/Zeit + affected_class_ids + Notification-Flags | created_by_user_id |
|
||||
|
||||
Event-Typen (CHECK constraint): `fortbildung`, `schulfeier`, `klassenfahrt`, `projekttag`, `eltern_info`, `andere`.
|
||||
|
||||
### Phase 9c — Parent-Accounts
|
||||
|
||||
| Tabelle | Inhalt |
|
||||
|---------|--------|
|
||||
| `parent_account` | Email + preferred_language, UNIQUE pro (Lehrer, Email) |
|
||||
| `parent_child` | Vorname/Nachname + FK auf tt_class |
|
||||
| `parent_magic_link` | Einmal-Token (SHA-256 in DB), expires_at 7 Tage |
|
||||
| `parent_session` | Browser-Session-Token (SHA-256 in DB), expires_at 30 Tage |
|
||||
|
||||
### Phase 9d — Notifications
|
||||
|
||||
| Tabelle | Inhalt |
|
||||
|---------|--------|
|
||||
| `notification_log` | Idempotenz: UNIQUE(event_id, lead_days, audience, channel) |
|
||||
|
||||
## Auth-Modell
|
||||
|
||||
**Zwei voneinander unabhaengige Auth-Wege:**
|
||||
|
||||
1. **Lehrer:** JWT in Authorization-Header (oder Dev-Bypass mit Default-User wenn `ENVIRONMENT != "production"`). Routen unter `/api/v1/school/...`.
|
||||
2. **Eltern:** Session-Cookie `bp_parent_session` (HttpOnly, SameSite=Lax), gesetzt vom `/api/v1/parent/auth/redeem` Endpoint. ParentSessionMiddleware resolved Cookie → parent_account.
|
||||
|
||||
Eltern sehen **nie** Daten anderer Eltern. Privacy-Check via `ChildBelongsToParent` in jedem GET, Plus Filterung der Lessons gegen tt_solution des einladenden Lehrers.
|
||||
|
||||
## Bundesland-Wizard
|
||||
|
||||
Erster Aufruf von `/schulkalender` → kein `cal_school_config` → `BundeslandWizard` UI → POST `/calendar/config` mit `{bundesland: "DE-NI"}` → MonthView lädt für die naechsten ~6 Wochen.
|
||||
|
||||
## Schuljahres-Rollover
|
||||
|
||||
POST `/calendar/school-year-rollover` (optional `{new_year_start, new_year_end}`):
|
||||
|
||||
1. `DELETE FROM tt_class WHERE grade_level >= 13` (Abschlusskohorte)
|
||||
2. `UPDATE tt_class SET grade_level = grade_level + 1`
|
||||
3. `UPDATE cal_school_config SET school_year_start/end = ...`
|
||||
|
||||
Alles in einer Transaction. Stundenplan-Lehrer-Faecher-Raum-Bestand bleibt unangetastet.
|
||||
|
||||
## Auth + Messaging outsourced
|
||||
|
||||
Production-Auth, Matrix-Bridge und Email-Gateway werden vom Kollegen gepflegt — siehe globale Memory `stundenplan_auth_and_messaging.md`. Wir definieren nur:
|
||||
|
||||
- Dispatch-Payload-Struct (siehe [notifications.md](notifications.md))
|
||||
- Env-Vars `MATRIX_SERVICE_URL`, `EMAIL_SERVICE_URL` (leer = Stub-Mode)
|
||||
- Endpoint-Vertrag (POST mit JSON-Body, HTTP 2xx = sent)
|
||||
@@ -0,0 +1,71 @@
|
||||
# Ferien + Feiertage
|
||||
|
||||
## Quelle
|
||||
|
||||
[openholidaysapi.org](https://openholidaysapi.org) — EU-Initiative, MIT-Lizenz
|
||||
fuer den API-Code, ODbL fuer die Daten. Liefert sowohl `PublicHolidays` als
|
||||
auch `SchoolHolidays` je Bundesland mit ISO-Codes `DE-BW`, `DE-BY`, ...
|
||||
|
||||
## Build-Time-Snapshot
|
||||
|
||||
Statt zur Laufzeit zu pollen wird ein JSON-Snapshot committed:
|
||||
|
||||
```bash
|
||||
bash scripts/calendar-snapshot.sh 2026 2030
|
||||
```
|
||||
|
||||
Schreibt nach `school-service/internal/seed/calendar_holidays.json`. Das
|
||||
Dockerfile kopiert die Datei ins Image; bei jedem Container-Start importiert
|
||||
`CalendarService.SeedFromSnapshot()` die Eintraege idempotent (UNIQUE auf
|
||||
region, event_type, name_de, start_date).
|
||||
|
||||
**Stand 2026-05-22:** 854 Events fuer alle 16 Bundeslaender × 3 Schuljahre.
|
||||
|
||||
## Aktualisierungs-Workflow
|
||||
|
||||
1. Jaehrlich (z.B. im Mai vor neuem Schuljahr):
|
||||
```bash
|
||||
bash scripts/calendar-snapshot.sh 2027 2031
|
||||
```
|
||||
2. Diff im Git pruefen — sollte nur neue Eintraege haben, nicht alte ueberschreiben.
|
||||
3. Commit + push + Container-Rebuild.
|
||||
4. Beim ersten Boot werden neue Eintraege in `cal_public_event` eingefuegt; bestehende bleiben.
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /api/v1/school/calendar/holidays?region=DE-NI&from=2026-08-01&to=2027-07-31
|
||||
```
|
||||
|
||||
Liefert Array sortiert nach `start_date`. Beispiel-Antwort:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id":"…","region":"DE-NI","event_type":"school_holiday","name_de":"Sommerferien","start_date":"2026-07-02","end_date":"2026-08-12"},
|
||||
{"id":"…","region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit","start_date":"2026-10-03","end_date":"2026-10-03"},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## Format-Mapping (Snapshot-Script)
|
||||
|
||||
OpenHolidaysAPI gibt:
|
||||
```json
|
||||
{"id":"...","startDate":"2026-10-03","endDate":"2026-10-03","type":"Public",
|
||||
"name":[{"language":"DE","text":"Tag der Deutschen Einheit"}]}
|
||||
```
|
||||
|
||||
`scripts/calendar-snapshot.sh` normalisiert via jq:
|
||||
```json
|
||||
{"region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit",
|
||||
"name_en":null,"start_date":"2026-10-03","end_date":"2026-10-03"}
|
||||
```
|
||||
|
||||
## Lizenz-Compliance
|
||||
|
||||
- API-Code: MIT
|
||||
- Daten: ODbL (Open Database License)
|
||||
|
||||
Beides ist fuer kommerzielle Nutzung erlaubt. Die Quelle muss in einer
|
||||
Lizenz-Aufstellung (SBOM) genannt werden — bereits in
|
||||
`sbom/stundenplan/README.md` dokumentiert.
|
||||
@@ -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
|
||||
- Agent-Core:
|
||||
- Uebersicht: services/agent-core/index.md
|
||||
- Stundenplaner:
|
||||
- Uebersicht: services/stundenplan/index.md
|
||||
- Architektur: services/stundenplan/architecture.md
|
||||
- Constraints: services/stundenplan/constraints.md
|
||||
- Solver-Tuning: services/stundenplan/solver-tuning.md
|
||||
- Export: services/stundenplan/export.md
|
||||
- Schulkalender:
|
||||
- Uebersicht: services/schulkalender/index.md
|
||||
- Architektur: services/schulkalender/architecture.md
|
||||
- Ferien-Snapshot: services/schulkalender/holidays.md
|
||||
- Eltern-Workflow: services/schulkalender/parent-flow.md
|
||||
- Notifications: services/schulkalender/notifications.md
|
||||
- Architektur:
|
||||
- Multi-Agent System: architecture/multi-agent.md
|
||||
- Zeugnis-System: architecture/zeugnis-system.md
|
||||
|
||||
@@ -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 --from=builder /app/templates ./templates
|
||||
|
||||
# Copy calendar seed snapshot (Phase 9a — OpenHolidaysAPI data)
|
||||
COPY --from=builder /app/internal/seed ./internal/seed
|
||||
|
||||
# Use non-root user
|
||||
USER appuser
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/config"
|
||||
"github.com/breakpilot/school-service/internal/database"
|
||||
@@ -35,7 +38,41 @@ func main() {
|
||||
}
|
||||
|
||||
// Create handler
|
||||
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL)
|
||||
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL, cfg.MatrixServiceURL, cfg.EmailServiceURL)
|
||||
|
||||
// Phase 9d: daily notification cron. Ticks every hour and runs the
|
||||
// scanner once when the current hour == 6. Idempotent via the
|
||||
// notification_log UNIQUE constraint, so multiple ticks the same day
|
||||
// are safe.
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
now := time.Now()
|
||||
if now.Hour() == 6 {
|
||||
res, err := handler.NotificationService().RunForDate(context.Background(), now)
|
||||
if err != nil {
|
||||
log.Printf("notification cron error: %v", err)
|
||||
} else {
|
||||
log.Printf("notification cron: %+v", res)
|
||||
}
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
|
||||
// Calendar seed — idempotent, runs every boot. Snapshot path is bundled
|
||||
// in the Docker image at /app/internal/seed/calendar_holidays.json. Failures
|
||||
// don't block startup; the holiday table is filled lazily next boot.
|
||||
go func() {
|
||||
seedPath := "internal/seed/calendar_holidays.json"
|
||||
if _, err := os.Stat(seedPath); err != nil {
|
||||
seedPath = "/app/internal/seed/calendar_holidays.json"
|
||||
}
|
||||
if err := handler.CalendarService().SeedFromSnapshot(context.Background(), seedPath); err != nil {
|
||||
log.Printf("calendar seed failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create router
|
||||
router := gin.New()
|
||||
@@ -49,7 +86,7 @@ func main() {
|
||||
|
||||
// API routes (auth required)
|
||||
api := router.Group("/api/v1/school")
|
||||
api.Use(middleware.AuthMiddleware(cfg.JWTSecret))
|
||||
api.Use(middleware.AuthMiddleware(cfg.JWTSecret, cfg.Environment != "production"))
|
||||
{
|
||||
// School Years
|
||||
api.GET("/years", handler.GetSchoolYears)
|
||||
@@ -123,6 +160,155 @@ func main() {
|
||||
api.PUT("/certificates/detail/:id/finalize", handler.FinalizeCertificate)
|
||||
api.GET("/certificates/detail/:id/pdf", handler.GetCertificatePDF)
|
||||
api.DELETE("/certificates/detail/:id", handler.DeleteCertificate)
|
||||
|
||||
// Timetable Scheduler — Stammdaten
|
||||
api.GET("/timetable/classes", handler.ListTimetableClasses)
|
||||
api.POST("/timetable/classes", handler.CreateTimetableClass)
|
||||
api.DELETE("/timetable/classes/:id", handler.DeleteTimetableClass)
|
||||
|
||||
api.GET("/timetable/periods", handler.ListTimetablePeriods)
|
||||
api.POST("/timetable/periods", handler.CreateTimetablePeriod)
|
||||
api.DELETE("/timetable/periods/:id", handler.DeleteTimetablePeriod)
|
||||
|
||||
api.GET("/timetable/rooms", handler.ListTimetableRooms)
|
||||
api.POST("/timetable/rooms", handler.CreateTimetableRoom)
|
||||
api.DELETE("/timetable/rooms/:id", handler.DeleteTimetableRoom)
|
||||
|
||||
api.GET("/timetable/subjects", handler.ListTimetableSubjects)
|
||||
api.POST("/timetable/subjects", handler.CreateTimetableSubject)
|
||||
api.DELETE("/timetable/subjects/:id", handler.DeleteTimetableSubject)
|
||||
|
||||
api.GET("/timetable/teachers", handler.ListTimetableTeachers)
|
||||
api.POST("/timetable/teachers", handler.CreateTimetableTeacher)
|
||||
api.DELETE("/timetable/teachers/:id", handler.DeleteTimetableTeacher)
|
||||
|
||||
// Timetable Scheduler — Relations
|
||||
api.GET("/timetable/curriculum", handler.ListTimetableCurriculum)
|
||||
api.POST("/timetable/curriculum", handler.CreateTimetableCurriculum)
|
||||
api.DELETE("/timetable/curriculum/:id", handler.DeleteTimetableCurriculum)
|
||||
|
||||
api.GET("/timetable/assignments", handler.ListTimetableAssignments)
|
||||
api.POST("/timetable/assignments", handler.CreateTimetableAssignment)
|
||||
api.DELETE("/timetable/assignments/:id", handler.DeleteTimetableAssignment)
|
||||
|
||||
// Timetable Scheduler — Constraints (15 typed tables)
|
||||
// Teacher
|
||||
api.GET("/timetable/constraints/teacher/unavailable-day", handler.ListTeacherUnavailableDays)
|
||||
api.POST("/timetable/constraints/teacher/unavailable-day", handler.CreateTeacherUnavailableDay)
|
||||
api.DELETE("/timetable/constraints/teacher/unavailable-day/:id", handler.DeleteTeacherUnavailableDay)
|
||||
|
||||
api.GET("/timetable/constraints/teacher/unavailable-window", handler.ListTeacherUnavailableWindows)
|
||||
api.POST("/timetable/constraints/teacher/unavailable-window", handler.CreateTeacherUnavailableWindow)
|
||||
api.DELETE("/timetable/constraints/teacher/unavailable-window/:id", handler.DeleteTeacherUnavailableWindow)
|
||||
|
||||
api.GET("/timetable/constraints/teacher/max-hours-day", handler.ListTeacherMaxHoursDay)
|
||||
api.POST("/timetable/constraints/teacher/max-hours-day", handler.CreateTeacherMaxHoursDay)
|
||||
api.DELETE("/timetable/constraints/teacher/max-hours-day/:id", handler.DeleteTeacherMaxHoursDay)
|
||||
|
||||
api.GET("/timetable/constraints/teacher/max-hours-week", handler.ListTeacherMaxHoursWeek)
|
||||
api.POST("/timetable/constraints/teacher/max-hours-week", handler.CreateTeacherMaxHoursWeek)
|
||||
api.DELETE("/timetable/constraints/teacher/max-hours-week/:id", handler.DeleteTeacherMaxHoursWeek)
|
||||
|
||||
api.GET("/timetable/constraints/teacher/excluded-subject", handler.ListTeacherExcludedSubjects)
|
||||
api.POST("/timetable/constraints/teacher/excluded-subject", handler.CreateTeacherExcludedSubject)
|
||||
api.DELETE("/timetable/constraints/teacher/excluded-subject/:id", handler.DeleteTeacherExcludedSubject)
|
||||
|
||||
api.GET("/timetable/constraints/teacher/excluded-room", handler.ListTeacherExcludedRooms)
|
||||
api.POST("/timetable/constraints/teacher/excluded-room", handler.CreateTeacherExcludedRoom)
|
||||
api.DELETE("/timetable/constraints/teacher/excluded-room/:id", handler.DeleteTeacherExcludedRoom)
|
||||
|
||||
// Subject
|
||||
api.GET("/timetable/constraints/subject/min-day-gap", handler.ListSubjectMinDayGaps)
|
||||
api.POST("/timetable/constraints/subject/min-day-gap", handler.CreateSubjectMinDayGap)
|
||||
api.DELETE("/timetable/constraints/subject/min-day-gap/:id", handler.DeleteSubjectMinDayGap)
|
||||
|
||||
api.GET("/timetable/constraints/subject/max-consecutive", handler.ListSubjectMaxConsecutives)
|
||||
api.POST("/timetable/constraints/subject/max-consecutive", handler.CreateSubjectMaxConsecutive)
|
||||
api.DELETE("/timetable/constraints/subject/max-consecutive/:id", handler.DeleteSubjectMaxConsecutive)
|
||||
|
||||
api.GET("/timetable/constraints/subject/contiguous-when-repeated", handler.ListSubjectContiguousWhenRepeated)
|
||||
api.POST("/timetable/constraints/subject/contiguous-when-repeated", handler.CreateSubjectContiguousWhenRepeated)
|
||||
api.DELETE("/timetable/constraints/subject/contiguous-when-repeated/:id", handler.DeleteSubjectContiguousWhenRepeated)
|
||||
|
||||
api.GET("/timetable/constraints/subject/preferred-period", handler.ListSubjectPreferredPeriods)
|
||||
api.POST("/timetable/constraints/subject/preferred-period", handler.CreateSubjectPreferredPeriod)
|
||||
api.DELETE("/timetable/constraints/subject/preferred-period/:id", handler.DeleteSubjectPreferredPeriod)
|
||||
|
||||
api.GET("/timetable/constraints/subject/double-lesson", handler.ListSubjectDoubleLessons)
|
||||
api.POST("/timetable/constraints/subject/double-lesson", handler.CreateSubjectDoubleLesson)
|
||||
api.DELETE("/timetable/constraints/subject/double-lesson/:id", handler.DeleteSubjectDoubleLesson)
|
||||
|
||||
// Class
|
||||
api.GET("/timetable/constraints/class/max-hours-day", handler.ListClassMaxHoursDay)
|
||||
api.POST("/timetable/constraints/class/max-hours-day", handler.CreateClassMaxHoursDay)
|
||||
api.DELETE("/timetable/constraints/class/max-hours-day/:id", handler.DeleteClassMaxHoursDay)
|
||||
|
||||
api.GET("/timetable/constraints/class/no-gaps", handler.ListClassNoGaps)
|
||||
api.POST("/timetable/constraints/class/no-gaps", handler.CreateClassNoGaps)
|
||||
api.DELETE("/timetable/constraints/class/no-gaps/:id", handler.DeleteClassNoGaps)
|
||||
|
||||
// Room
|
||||
api.GET("/timetable/constraints/room/requires-type", handler.ListRoomRequiresTypes)
|
||||
api.POST("/timetable/constraints/room/requires-type", handler.CreateRoomRequiresType)
|
||||
api.DELETE("/timetable/constraints/room/requires-type/:id", handler.DeleteRoomRequiresType)
|
||||
|
||||
api.GET("/timetable/constraints/room/unavailable", handler.ListRoomUnavailable)
|
||||
api.POST("/timetable/constraints/room/unavailable", handler.CreateRoomUnavailable)
|
||||
api.DELETE("/timetable/constraints/room/unavailable/:id", handler.DeleteRoomUnavailable)
|
||||
|
||||
// Timetable Solver — Solutions
|
||||
api.GET("/timetable/solutions", handler.ListTimetableSolutions)
|
||||
api.POST("/timetable/solutions", handler.CreateTimetableSolution)
|
||||
api.GET("/timetable/solutions/:id", handler.GetTimetableSolution)
|
||||
api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution)
|
||||
api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons)
|
||||
|
||||
// Phase 7: pin/unpin individual lessons for the next re-solve.
|
||||
api.PUT("/timetable/lessons/:id/pin", handler.UpdateTimetableLessonPin)
|
||||
|
||||
// Phase 8: exports.
|
||||
api.GET("/timetable/solutions/:id/export.csv", handler.ExportTimetableSolutionCSV)
|
||||
api.GET("/timetable/solutions/:id/export.ics", handler.ExportTimetableSolutionICS)
|
||||
|
||||
// Phase 9a: Schulkalender (holidays + per-user Bundesland config).
|
||||
api.GET("/calendar/holidays", handler.ListCalendarHolidays)
|
||||
api.GET("/calendar/config", handler.GetCalendarConfig)
|
||||
api.PUT("/calendar/config", handler.UpsertCalendarConfig)
|
||||
|
||||
// Phase 9b: school-events CRUD + Schuljahres-Rollover.
|
||||
api.GET("/calendar/events", handler.ListSchoolEvents)
|
||||
api.POST("/calendar/events", handler.CreateSchoolEvent)
|
||||
api.DELETE("/calendar/events/:id", handler.DeleteSchoolEvent)
|
||||
api.POST("/calendar/school-year-rollover", handler.RolloverSchoolYear)
|
||||
|
||||
// Phase 9c: parent invitations (teacher side).
|
||||
api.GET("/calendar/parents", handler.ListParentInvites)
|
||||
api.POST("/calendar/parents/invite", handler.InviteParent)
|
||||
api.DELETE("/calendar/parents/children/:id", handler.DeleteParentInvite)
|
||||
|
||||
// Phase 9d: notifications.
|
||||
api.POST("/calendar/notifications/run-now", handler.RunNotificationsNow)
|
||||
api.GET("/calendar/events/:id/notifications", handler.ListEventNotifications)
|
||||
}
|
||||
|
||||
// Phase 9c: parent-side endpoints. Auth is the parent session cookie,
|
||||
// NOT the teacher JWT. /parent/auth/redeem creates the cookie; the
|
||||
// other routes require it via ParentSessionMiddleware.
|
||||
parentAPI := router.Group("/api/v1/parent")
|
||||
{
|
||||
parentAPI.POST("/auth/redeem", handler.RedeemMagicLink)
|
||||
|
||||
authed := parentAPI.Group("/")
|
||||
authed.Use(middleware.ParentSessionMiddleware(func(ctx context.Context, token string) (string, string, string, error) {
|
||||
p, err := handler.ParentService().ParentFromSession(ctx, token)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return p.ID.String(), p.Email, p.PreferredLanguage, nil
|
||||
}))
|
||||
authed.GET("/me", handler.ParentMe)
|
||||
authed.GET("/me/timetable", handler.ParentTimetable)
|
||||
authed.POST("/auth/logout", handler.ParentLogout)
|
||||
}
|
||||
|
||||
// Start server
|
||||
|
||||
@@ -28,6 +28,13 @@ type Config struct {
|
||||
|
||||
// LLM Gateway (for AI features)
|
||||
LLMGatewayURL string
|
||||
|
||||
// Timetable solver service (Python/FastAPI, port 8095)
|
||||
SolverServiceURL string
|
||||
|
||||
// Notification upstream services (Phase 9d). Empty → stub mode.
|
||||
MatrixServiceURL string
|
||||
EmailServiceURL string
|
||||
}
|
||||
|
||||
// Load loads configuration from environment variables
|
||||
@@ -43,6 +50,9 @@ func Load() (*Config, error) {
|
||||
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
|
||||
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
|
||||
LLMGatewayURL: getEnv("LLM_GATEWAY_URL", "http://backend:8000/llm"),
|
||||
SolverServiceURL: getEnv("SOLVER_SERVICE_URL", "http://timetable-solver-service:8095"),
|
||||
MatrixServiceURL: getEnv("MATRIX_SERVICE_URL", ""),
|
||||
EmailServiceURL: getEnv("EMAIL_SERVICE_URL", ""),
|
||||
}
|
||||
|
||||
// Parse allowed origins
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package database
|
||||
|
||||
// CalendarMigrations creates the three calendar tables for Phase 9a:
|
||||
//
|
||||
// cal_public_event — read-only snapshot of school holidays + public
|
||||
// holidays from OpenHolidaysAPI. Imported on first
|
||||
// boot via seed/calendar_holidays.json.
|
||||
// cal_school_config — per-Rektor bundesland selection (1 row per user).
|
||||
// cal_school_event — user-managed school events (Fortbildung,
|
||||
// Schulfeier, Klassenfahrt etc.).
|
||||
//
|
||||
// cal_public_event is global (no created_by_user_id) because the data is the
|
||||
// same for every school in a given bundesland. School-events are
|
||||
// per-tenant.
|
||||
func CalendarMigrations() []string {
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS cal_public_event (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
region VARCHAR(8) NOT NULL,
|
||||
event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('public_holiday', 'school_holiday')),
|
||||
name_de VARCHAR(255) NOT NULL,
|
||||
name_en VARCHAR(255),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
source VARCHAR(50) DEFAULT 'OpenHolidaysAPI',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(region, event_type, name_de, start_date),
|
||||
CHECK (end_date >= start_date)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS cal_school_config (
|
||||
user_id UUID PRIMARY KEY,
|
||||
bundesland VARCHAR(8) NOT NULL,
|
||||
school_year_start DATE,
|
||||
school_year_end DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS cal_school_event (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
event_type VARCHAR(30) NOT NULL
|
||||
CHECK (event_type IN ('fortbildung','schulfeier','klassenfahrt','projekttag','eltern_info','andere')),
|
||||
is_school_free BOOLEAN DEFAULT false,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
start_time TIME,
|
||||
end_time TIME,
|
||||
affected_class_ids UUID[] DEFAULT '{}',
|
||||
visible_to_parents BOOLEAN DEFAULT true,
|
||||
notify_parents BOOLEAN DEFAULT false,
|
||||
notify_students BOOLEAN DEFAULT false,
|
||||
notification_lead_days INT[] DEFAULT '{7,1}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CHECK (end_date >= start_date)
|
||||
)`,
|
||||
|
||||
// Indexes — public events are queried by region + date range. School
|
||||
// events are queried by owner + date range.
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_public_event_region_date
|
||||
ON cal_public_event(region, start_date, end_date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_school_event_user_date
|
||||
ON cal_school_event(created_by_user_id, start_date, end_date)`,
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,24 @@ func Migrate(db *DB) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_gradebook_class ON gradebook_entries(class_id)`,
|
||||
}
|
||||
|
||||
// Append timetable scheduler migrations (see timetable_migrations.go)
|
||||
migrations = append(migrations, TimetableMigrations()...)
|
||||
|
||||
// Append timetable constraint migrations (see timetable_constraints_migrations.go)
|
||||
migrations = append(migrations, TimetableConstraintMigrations()...)
|
||||
|
||||
// Append timetable solution migrations (see timetable_solution_migrations.go)
|
||||
migrations = append(migrations, TimetableSolutionMigrations()...)
|
||||
|
||||
// Append calendar migrations (see calendar_migrations.go).
|
||||
migrations = append(migrations, CalendarMigrations()...)
|
||||
|
||||
// Append parent migrations (Phase 9c — see parent_migrations.go).
|
||||
migrations = append(migrations, ParentMigrations()...)
|
||||
|
||||
// Append notification log (Phase 9d — see notification_migrations.go).
|
||||
migrations = append(migrations, NotificationMigrations()...)
|
||||
|
||||
for _, migration := range migrations {
|
||||
_, err := db.Pool.Exec(ctx, migration)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package database
|
||||
|
||||
// NotificationMigrations creates the one table Phase 9d needs:
|
||||
//
|
||||
// notification_log — one row per (event, lead_days, audience, channel)
|
||||
// that the cron scanner has already attempted. The UNIQUE constraint
|
||||
// makes the cron idempotent — running it twice on the same day does
|
||||
// not re-send.
|
||||
//
|
||||
// channel ∈ {'matrix', 'email'} — set by the dispatcher.
|
||||
// audience ∈ {'parents', 'students'}.
|
||||
// status ∈ {'sent', 'failed', 'skipped'} — 'skipped' when the upstream
|
||||
// service URL isn't configured, so we know not to count it as failure.
|
||||
func NotificationMigrations() []string {
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS notification_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES cal_school_event(id) ON DELETE CASCADE,
|
||||
lead_days INT NOT NULL,
|
||||
audience VARCHAR(20) NOT NULL CHECK (audience IN ('parents','students')),
|
||||
channel VARCHAR(20) NOT NULL CHECK (channel IN ('matrix','email')),
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('sent','failed','skipped')),
|
||||
error_message TEXT,
|
||||
run_date DATE NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(event_id, lead_days, audience, channel)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_log_event ON notification_log(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_log_run_date ON notification_log(run_date)`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package database
|
||||
|
||||
// ParentMigrations creates the four parent-side tables for Phase 9c:
|
||||
//
|
||||
// parent_account — one row per invited parent (email, language)
|
||||
// parent_child — kids linked to a parent and a tt_class
|
||||
// parent_magic_link — one-shot invite tokens, hashed
|
||||
// parent_session — active browser sessions after redeeming a link
|
||||
//
|
||||
// The teacher owns the invite (created_by_user_id on account); parent sees
|
||||
// only data scoped to their own children's class via tt_class.id.
|
||||
func ParentMigrations() []string {
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS parent_account (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
preferred_language VARCHAR(8) DEFAULT 'de',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(created_by_user_id, email)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS parent_child (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE,
|
||||
tt_class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS parent_magic_link (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS parent_session (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_parent_account_owner ON parent_account(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_parent_child_parent ON parent_child(parent_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_parent_child_class ON parent_child(tt_class_id)`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package database
|
||||
|
||||
// TimetableConstraintMigrations returns the DDL for all 15 constraint tables.
|
||||
// Each table follows the same shape:
|
||||
// - id / created_by_user_id / is_hard / weight / active / note / created_at
|
||||
// - one or more FKs to tt_teacher / tt_class / tt_subject / tt_room
|
||||
//
|
||||
// FK ON DELETE CASCADE removes constraints when their parent (teacher/room/etc.)
|
||||
// is deleted — the rules become meaningless without the referenced resource.
|
||||
func TimetableConstraintMigrations() []string {
|
||||
return []string{
|
||||
// ---------- Teacher constraints (6) ----------
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_unavailable_day (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(teacher_id, day_of_week)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_unavailable_window (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CHECK (end_time > start_time)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_max_hours_day (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 12),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT false,
|
||||
weight INT NOT NULL DEFAULT 50 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(teacher_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_max_hours_week (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 40),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(teacher_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_excluded_subject (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(teacher_id, subject_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_teacher_excluded_room (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
room_id UUID NOT NULL REFERENCES tt_room(id) ON DELETE CASCADE,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(teacher_id, room_id)
|
||||
)`,
|
||||
|
||||
// ---------- Subject constraints (5) ----------
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_min_day_gap (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
min_gap_days INT NOT NULL CHECK (min_gap_days BETWEEN 1 AND 4),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT false,
|
||||
weight INT NOT NULL DEFAULT 70 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(subject_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_max_consecutive (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
max_consecutive INT NOT NULL CHECK (max_consecutive BETWEEN 1 AND 5),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(subject_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_contiguous_when_repeated (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(subject_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_preferred_period (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
period_from INT NOT NULL CHECK (period_from BETWEEN 1 AND 12),
|
||||
period_to INT NOT NULL CHECK (period_to BETWEEN 1 AND 12),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT false,
|
||||
weight INT NOT NULL DEFAULT 40 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CHECK (period_to >= period_from)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_subject_double_lesson (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT false,
|
||||
weight INT NOT NULL DEFAULT 60 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(subject_id)
|
||||
)`,
|
||||
|
||||
// ---------- Class constraints (2) ----------
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_class_max_hours_day (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
|
||||
max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 12),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(class_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_class_no_gaps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT false,
|
||||
weight INT NOT NULL DEFAULT 80 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(class_id)
|
||||
)`,
|
||||
|
||||
// ---------- Room constraints (2) ----------
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_room_requires_type (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
room_type VARCHAR(30) NOT NULL,
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(subject_id, room_type)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_constraint_room_unavailable (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
room_id UUID NOT NULL REFERENCES tt_room(id) ON DELETE CASCADE,
|
||||
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
period_index INT NOT NULL CHECK (period_index BETWEEN 1 AND 12),
|
||||
is_hard BOOLEAN NOT NULL DEFAULT true,
|
||||
weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(room_id, day_of_week, period_index)
|
||||
)`,
|
||||
|
||||
// ---------- Indexes ----------
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_unav_day_teacher ON tt_constraint_teacher_unavailable_day(teacher_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_unav_win_teacher ON tt_constraint_teacher_unavailable_window(teacher_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_excl_subj_teacher ON tt_constraint_teacher_excluded_subject(teacher_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_excl_room_teacher ON tt_constraint_teacher_excluded_room(teacher_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_c_room_unav_room ON tt_constraint_room_unavailable(room_id)`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
// TimetableMigrations returns the SQL statements that create all timetable-related
|
||||
// tables. They are applied idempotently via CREATE TABLE IF NOT EXISTS.
|
||||
func TimetableMigrations() []string {
|
||||
return []string{
|
||||
// Classes (school-wide, distinct from per-teacher `classes` table)
|
||||
`CREATE TABLE IF NOT EXISTS tt_class (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
grade_level INT NOT NULL,
|
||||
student_count INT DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(created_by_user_id, name)
|
||||
)`,
|
||||
|
||||
// Time periods (Mon=1..Sun=7, period_index = 1..N)
|
||||
`CREATE TABLE IF NOT EXISTS tt_period (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
period_index INT NOT NULL CHECK (period_index >= 1),
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
is_break BOOLEAN DEFAULT false,
|
||||
label VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(created_by_user_id, day_of_week, period_index)
|
||||
)`,
|
||||
|
||||
// Rooms
|
||||
`CREATE TABLE IF NOT EXISTS tt_room (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
room_type VARCHAR(30),
|
||||
capacity INT DEFAULT 30,
|
||||
floor_level INT DEFAULT 0,
|
||||
has_elevator BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(created_by_user_id, name)
|
||||
)`,
|
||||
|
||||
// Subjects (school-wide, distinct from per-teacher `subjects`)
|
||||
`CREATE TABLE IF NOT EXISTS tt_subject (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
short_code VARCHAR(10) NOT NULL,
|
||||
color VARCHAR(7),
|
||||
is_main_subject BOOLEAN DEFAULT false,
|
||||
required_room_type VARCHAR(30),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(created_by_user_id, short_code)
|
||||
)`,
|
||||
|
||||
// Teachers (planning resource, NOT a BreakPilot user)
|
||||
`CREATE TABLE IF NOT EXISTS tt_teacher (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
short_code VARCHAR(10) NOT NULL,
|
||||
employment_percentage INT DEFAULT 100,
|
||||
max_hours_week INT DEFAULT 28,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(created_by_user_id, short_code)
|
||||
)`,
|
||||
|
||||
// Curriculum: weekly hour count per class+subject
|
||||
`CREATE TABLE IF NOT EXISTS tt_curriculum (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
weekly_hours INT NOT NULL CHECK (weekly_hours >= 1),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(class_id, subject_id)
|
||||
)`,
|
||||
|
||||
// Assignment: which teacher teaches which subject in which class
|
||||
`CREATE TABLE IF NOT EXISTS tt_assignment (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(teacher_id, class_id, subject_id)
|
||||
)`,
|
||||
|
||||
// Indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_class_user ON tt_class(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_period_user ON tt_period(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_room_user ON tt_room(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_subject_user ON tt_subject(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_teacher_user ON tt_teacher(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_curriculum_class ON tt_curriculum(class_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_assignment_teacher ON tt_assignment(teacher_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_assignment_class ON tt_assignment(class_id)`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package database
|
||||
|
||||
// TimetableSolutionMigrations creates tt_solution + tt_lesson for the solver
|
||||
// pipeline. One run of the solver produces exactly one tt_solution row plus
|
||||
// many tt_lesson rows (one per scheduled class-subject hour).
|
||||
//
|
||||
// Status flow:
|
||||
//
|
||||
// pending → running → completed | failed | infeasible
|
||||
//
|
||||
// hard_score / soft_score come straight from Timefold's HardSoftScore. Lower
|
||||
// (more negative) hard_score means more hard-constraint violations; the UI
|
||||
// only ever offers solutions with hard_score == 0 as "valid".
|
||||
func TimetableSolutionMigrations() []string {
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS tt_solution (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by_user_id UUID NOT NULL,
|
||||
name VARCHAR(120),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
hard_score INT,
|
||||
soft_score INT,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS tt_lesson (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
solution_id UUID NOT NULL REFERENCES tt_solution(id) ON DELETE CASCADE,
|
||||
class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE,
|
||||
subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE,
|
||||
teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE,
|
||||
room_id UUID REFERENCES tt_room(id) ON DELETE SET NULL,
|
||||
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
period_index INT NOT NULL CHECK (period_index BETWEEN 1 AND 12),
|
||||
pinned BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(solution_id, class_id, day_of_week, period_index),
|
||||
UNIQUE(solution_id, teacher_id, day_of_week, period_index),
|
||||
UNIQUE(solution_id, room_id, day_of_week, period_index)
|
||||
)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_solution_user ON tt_solution(created_by_user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_solution ON tt_lesson(solution_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_class ON tt_lesson(class_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_id)`,
|
||||
|
||||
// Phase 7: plan versioning + per-solve solver-timeout override.
|
||||
`ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS parent_solution_id UUID REFERENCES tt_solution(id) ON DELETE SET NULL`,
|
||||
`ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS seconds_limit INT`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListCalendarHolidays returns OpenHolidaysAPI events for a region + range.
|
||||
// Query params: ?region=DE-NI&from=2026-08-01&to=2027-07-31. If omitted,
|
||||
// region falls back to the caller's saved config and the range to the
|
||||
// current calendar year.
|
||||
func (h *Handler) ListCalendarHolidays(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
region := c.Query("region")
|
||||
if region == "" {
|
||||
cfg, err := h.calendarService.GetConfig(c.Request.Context(), uid)
|
||||
if err != nil || cfg == nil {
|
||||
respondError(c, http.StatusBadRequest, "region query param required (no saved config)")
|
||||
return
|
||||
}
|
||||
region = cfg.Bundesland
|
||||
}
|
||||
from := c.DefaultQuery("from", time.Now().Format("2006-01-02"))
|
||||
to := c.DefaultQuery("to", time.Now().AddDate(1, 0, 0).Format("2006-01-02"))
|
||||
|
||||
events, err := h.calendarService.ListHolidays(c.Request.Context(), region, from, to)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to load holidays: "+err.Error())
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []models.PublicEvent{}
|
||||
}
|
||||
respondSuccess(c, events)
|
||||
}
|
||||
|
||||
func (h *Handler) GetCalendarConfig(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
cfg, err := h.calendarService.GetConfig(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
// No row → 200 with null so the wizard knows to prompt.
|
||||
respondSuccess(c, nil)
|
||||
return
|
||||
}
|
||||
respondSuccess(c, cfg)
|
||||
}
|
||||
|
||||
func (h *Handler) UpsertCalendarConfig(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.UpsertSchoolCalendarConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, err := h.calendarService.UpsertConfig(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to save config: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, cfg)
|
||||
}
|
||||
|
||||
// ---------- School Events (Phase 9b) ----------
|
||||
|
||||
func (h *Handler) CreateSchoolEvent(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateSchoolEventRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
ev, err := h.calendarService.CreateEvent(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create event: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, ev)
|
||||
}
|
||||
|
||||
func (h *Handler) ListSchoolEvents(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
events, err := h.calendarService.ListEvents(c.Request.Context(), uid, c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list events: "+err.Error())
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []models.SchoolEvent{}
|
||||
}
|
||||
respondSuccess(c, events)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSchoolEvent(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.calendarService.DeleteEvent(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete event: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Event deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) RolloverSchoolYear(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.SchoolYearRolloverRequest
|
||||
// Body is optional — empty defaults to next-Aug rollover.
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
result, err := h.calendarService.RolloverSchoolYear(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Rollover failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/notifications"
|
||||
"github.com/breakpilot/school-service/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -16,27 +17,59 @@ type Handler struct {
|
||||
gradebookService *services.GradebookService
|
||||
certificateService *services.CertificateService
|
||||
aiService *services.AIService
|
||||
timetableService *services.TimetableService
|
||||
calendarService *services.CalendarService
|
||||
parentService *services.ParentService
|
||||
notificationService *notifications.Service
|
||||
solverServiceURL string
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler with all services
|
||||
func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler {
|
||||
func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL, matrixURL, emailURL string) *Handler {
|
||||
classService := services.NewClassService(db)
|
||||
examService := services.NewExamService(db)
|
||||
gradeService := services.NewGradeService(db)
|
||||
gradebookService := services.NewGradebookService(db)
|
||||
certificateService := services.NewCertificateService(db, gradeService, gradebookService)
|
||||
aiService := services.NewAIService(llmGatewayURL)
|
||||
timetableService := services.NewTimetableService(db)
|
||||
calendarService := services.NewCalendarService(db)
|
||||
parentService := services.NewParentService(db)
|
||||
notificationService := notifications.NewService(db, matrixURL, emailURL)
|
||||
|
||||
return &Handler{
|
||||
classService: classService,
|
||||
examService: examService,
|
||||
gradeService: gradeService,
|
||||
gradebookService: gradebookService,
|
||||
certificateService: certificateService,
|
||||
aiService: aiService,
|
||||
classService: classService,
|
||||
examService: examService,
|
||||
gradeService: gradeService,
|
||||
gradebookService: gradebookService,
|
||||
certificateService: certificateService,
|
||||
aiService: aiService,
|
||||
timetableService: timetableService,
|
||||
calendarService: calendarService,
|
||||
parentService: parentService,
|
||||
notificationService: notificationService,
|
||||
solverServiceURL: solverServiceURL,
|
||||
}
|
||||
}
|
||||
|
||||
// NotificationService exposes the underlying service so main.go can run
|
||||
// the daily cron tick.
|
||||
func (h *Handler) NotificationService() *notifications.Service {
|
||||
return h.notificationService
|
||||
}
|
||||
|
||||
// CalendarService exposes the underlying service so main.go can run the
|
||||
// one-off seed import after migrations.
|
||||
func (h *Handler) CalendarService() *services.CalendarService {
|
||||
return h.calendarService
|
||||
}
|
||||
|
||||
// ParentService exposes the parent service so the parent-session middleware
|
||||
// in main.go can resolve session cookies.
|
||||
func (h *Handler) ParentService() *services.ParentService {
|
||||
return h.parentService
|
||||
}
|
||||
|
||||
// Health returns the service health status
|
||||
func (h *Handler) Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RunNotificationsNow triggers the scanner on demand (UI-test + backfill).
|
||||
// Optional ?date=YYYY-MM-DD lets the teacher replay a past day's send.
|
||||
// Idempotent — already-logged combos are skipped.
|
||||
func (h *Handler) RunNotificationsNow(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
runDate := time.Now()
|
||||
if param := c.Query("date"); param != "" {
|
||||
d, err := time.Parse("2006-01-02", param)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "date must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
runDate = d
|
||||
}
|
||||
res, err := h.notificationService.RunForDate(c.Request.Context(), runDate)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Run failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, res)
|
||||
}
|
||||
|
||||
// ListEventNotifications returns the notification_log rows for one event so
|
||||
// the DayDetail UI can show "Erinnerung verschickt am …".
|
||||
func (h *Handler) ListEventNotifications(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
rows, err := h.notificationService.ListLog(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, rows)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/middleware"
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------- Teacher-side (uses JWT/dev auth from existing middleware) ----------
|
||||
|
||||
func (h *Handler) InviteParent(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.InviteParentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.parentService.InviteParent(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to invite parent: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListParentInvites(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
items, err := h.parentService.ListInvites(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list invites: "+err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []models.ParentInviteListItem{}
|
||||
}
|
||||
respondSuccess(c, items)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteParentInvite(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.parentService.DeleteInvite(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete invite: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Invite removed"})
|
||||
}
|
||||
|
||||
// ---------- Parent-side (uses ParentSessionMiddleware) ----------
|
||||
|
||||
func (h *Handler) RedeemMagicLink(c *gin.Context) {
|
||||
var req models.RedeemMagicLinkRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
session, parent, err := h.parentService.RedeemMagicLink(c.Request.Context(), req.Token)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
// HttpOnly + Lax → cookie survives a fresh redirect from /eltern/login but
|
||||
// isn't sent on cross-site CSRF requests.
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(middleware.ParentSessionCookieName, session,
|
||||
60*60*24*30, "/", "", false, true)
|
||||
respondSuccess(c, parent)
|
||||
}
|
||||
|
||||
func (h *Handler) ParentMe(c *gin.Context) {
|
||||
parentID := c.GetString("parent_id")
|
||||
children, err := h.parentService.ListChildren(c.Request.Context(), parentID)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if children == nil {
|
||||
children = []models.ParentChild{}
|
||||
}
|
||||
respondSuccess(c, gin.H{
|
||||
"parent": gin.H{
|
||||
"id": parentID,
|
||||
"email": c.GetString("parent_email"),
|
||||
"preferred_language": c.GetString("parent_language"),
|
||||
},
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
|
||||
// ParentTimetable returns the latest completed timetable lessons for the
|
||||
// given child's class. Authorization: parent must own a child in that class.
|
||||
func (h *Handler) ParentTimetable(c *gin.Context) {
|
||||
parentID := c.GetString("parent_id")
|
||||
classID := c.Query("class_id")
|
||||
if classID == "" {
|
||||
respondError(c, http.StatusBadRequest, "class_id required")
|
||||
return
|
||||
}
|
||||
ok, err := h.parentService.ChildBelongsToParent(c.Request.Context(), parentID, classID)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
respondError(c, http.StatusForbidden, "Not allowed")
|
||||
return
|
||||
}
|
||||
// Need the teacher's user_id to find the right solution. We re-derive it
|
||||
// from parent_account.created_by_user_id via a small extra query.
|
||||
teacherID, err := h.parentService.TeacherOfParent(c.Request.Context(), parentID)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
lessons, err := h.parentService.LatestCompletedSolutionLessonsForClass(c.Request.Context(), classID, teacherID)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, lessons)
|
||||
}
|
||||
|
||||
func (h *Handler) ParentLogout(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(middleware.ParentSessionCookieName, "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Class- and Room-constraint HTTP handlers.
|
||||
|
||||
// ---------- Class Max Hours / Day ----------
|
||||
|
||||
func (h *Handler) CreateClassMaxHoursDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateClassMaxHoursDayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateClassMaxHoursDay(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListClassMaxHoursDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListClassMaxHoursDay(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteClassMaxHoursDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteClassMaxHoursDay(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Class No Gaps ----------
|
||||
|
||||
func (h *Handler) CreateClassNoGaps(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateClassNoGapsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateClassNoGaps(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListClassNoGaps(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListClassNoGaps(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteClassNoGaps(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteClassNoGaps(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Room Requires Type ----------
|
||||
|
||||
func (h *Handler) CreateRoomRequiresType(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateRoomRequiresTypeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateRoomRequiresType(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListRoomRequiresTypes(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListRoomRequiresTypes(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteRoomRequiresType(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteRoomRequiresType(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Room Unavailable ----------
|
||||
|
||||
func (h *Handler) CreateRoomUnavailable(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateRoomUnavailableRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateRoomUnavailable(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListRoomUnavailable(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListRoomUnavailable(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteRoomUnavailable(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteRoomUnavailable(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Subject-constraint HTTP handlers.
|
||||
|
||||
// ---------- Subject Min Day Gap ----------
|
||||
|
||||
func (h *Handler) CreateSubjectMinDayGap(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateSubjectMinDayGapRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateSubjectMinDayGap(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListSubjectMinDayGaps(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSubjectMinDayGaps(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSubjectMinDayGap(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSubjectMinDayGap(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Subject Max Consecutive ----------
|
||||
|
||||
func (h *Handler) CreateSubjectMaxConsecutive(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateSubjectMaxConsecutiveRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateSubjectMaxConsecutive(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListSubjectMaxConsecutives(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSubjectMaxConsecutives(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSubjectMaxConsecutive(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSubjectMaxConsecutive(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Subject Contiguous When Repeated ----------
|
||||
|
||||
func (h *Handler) CreateSubjectContiguousWhenRepeated(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateSubjectContiguousWhenRepeatedRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateSubjectContiguousWhenRepeated(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListSubjectContiguousWhenRepeated(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSubjectContiguousWhenRepeated(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSubjectContiguousWhenRepeated(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSubjectContiguousWhenRepeated(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Subject Preferred Period ----------
|
||||
|
||||
func (h *Handler) CreateSubjectPreferredPeriod(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateSubjectPreferredPeriodRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateSubjectPreferredPeriod(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListSubjectPreferredPeriods(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSubjectPreferredPeriods(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSubjectPreferredPeriod(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSubjectPreferredPeriod(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Subject Double Lesson ----------
|
||||
|
||||
func (h *Handler) CreateSubjectDoubleLesson(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateSubjectDoubleLessonRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateSubjectDoubleLesson(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListSubjectDoubleLessons(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSubjectDoubleLessons(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSubjectDoubleLesson(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSubjectDoubleLesson(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Teacher-constraint HTTP handlers. They share the same auth + JSON-bind
|
||||
// shape as the existing timetable handlers; per-table the only thing that
|
||||
// differs is the request DTO type and the service method invoked.
|
||||
|
||||
// ---------- Teacher Unavailable Day ----------
|
||||
|
||||
func (h *Handler) CreateTeacherUnavailableDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTeacherUnavailableDayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacherUnavailableDay(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTeacherUnavailableDays(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeacherUnavailableDays(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTeacherUnavailableDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacherUnavailableDay(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Teacher Unavailable Window ----------
|
||||
|
||||
func (h *Handler) CreateTeacherUnavailableWindow(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTeacherUnavailableWindowRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacherUnavailableWindow(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTeacherUnavailableWindows(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeacherUnavailableWindows(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTeacherUnavailableWindow(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacherUnavailableWindow(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Teacher Max Hours / Day ----------
|
||||
|
||||
func (h *Handler) CreateTeacherMaxHoursDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTeacherMaxHoursDayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacherMaxHoursDay(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTeacherMaxHoursDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeacherMaxHoursDay(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTeacherMaxHoursDay(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacherMaxHoursDay(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Teacher Max Hours / Week ----------
|
||||
|
||||
func (h *Handler) CreateTeacherMaxHoursWeek(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTeacherMaxHoursWeekRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacherMaxHoursWeek(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTeacherMaxHoursWeek(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeacherMaxHoursWeek(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTeacherMaxHoursWeek(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacherMaxHoursWeek(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Teacher Excluded Subject ----------
|
||||
|
||||
func (h *Handler) CreateTeacherExcludedSubject(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTeacherExcludedSubjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacherExcludedSubject(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTeacherExcludedSubjects(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeacherExcludedSubjects(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTeacherExcludedSubject(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacherExcludedSubject(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
|
||||
// ---------- Teacher Excluded Room ----------
|
||||
|
||||
func (h *Handler) CreateTeacherExcludedRoom(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTeacherExcludedRoomRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacherExcludedRoom(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTeacherExcludedRooms(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeacherExcludedRooms(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTeacherExcludedRoom(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacherExcludedRoom(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExportTimetableSolutionCSV streams the lessons of a solution as CSV.
|
||||
// The download filename includes the solution UUID so multiple plans don't
|
||||
// clobber each other when saved to disk.
|
||||
func (h *Handler) ExportTimetableSolutionCSV(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
solutionID := c.Param("id")
|
||||
lessons, err := h.timetableService.LoadExportLessons(c.Request.Context(), solutionID, uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to load lessons: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="stundenplan-`+solutionID+`.csv"`)
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
if err := services.WriteCSV(c.Writer, lessons); err != nil {
|
||||
// Best-effort; headers already flushed.
|
||||
_ = c.Writer.Flush
|
||||
}
|
||||
}
|
||||
|
||||
// ExportTimetableSolutionICS emits a single-week iCalendar. The reference
|
||||
// Monday defaults to the next Monday from "now"; callers can override via
|
||||
// ?start=YYYY-MM-DD to align to a school year.
|
||||
func (h *Handler) ExportTimetableSolutionICS(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
solutionID := c.Param("id")
|
||||
|
||||
weekStart := services.NextMonday(time.Now())
|
||||
if param := c.Query("start"); param != "" {
|
||||
parsed, err := time.Parse("2006-01-02", param)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "start must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
weekStart = parsed
|
||||
}
|
||||
|
||||
sol, err := h.timetableService.GetSolution(c.Request.Context(), solutionID, uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "Solution not found")
|
||||
return
|
||||
}
|
||||
lessons, err := h.timetableService.LoadExportLessons(c.Request.Context(), solutionID, uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to load lessons: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/calendar; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="stundenplan-`+solutionID+`.ics"`)
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
if err := services.WriteICS(c.Writer, lessons, weekStart, sol.Name); err != nil {
|
||||
// Same best-effort situation as CSV.
|
||||
_ = c.Writer.Flush
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------- Classes ----------
|
||||
|
||||
func (h *Handler) CreateTimetableClass(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableClassRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateClass(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create class: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableClasses(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListClasses(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list classes: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableClass(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteClass(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete class: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Class deleted"})
|
||||
}
|
||||
|
||||
// ---------- Periods ----------
|
||||
|
||||
func (h *Handler) CreateTimetablePeriod(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetablePeriodRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreatePeriod(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create period: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetablePeriods(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListPeriods(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list periods: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetablePeriod(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeletePeriod(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete period: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Period deleted"})
|
||||
}
|
||||
|
||||
// ---------- Rooms ----------
|
||||
|
||||
func (h *Handler) CreateTimetableRoom(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableRoomRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateRoom(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create room: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableRooms(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListRooms(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list rooms: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableRoom(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteRoom(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete room: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Room deleted"})
|
||||
}
|
||||
|
||||
// ---------- Subjects ----------
|
||||
|
||||
func (h *Handler) CreateTimetableSubject(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableSubjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateSubject(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create subject: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableSubjects(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSubjects(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list subjects: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableSubject(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSubject(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete subject: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Subject deleted"})
|
||||
}
|
||||
|
||||
// ---------- Teachers ----------
|
||||
|
||||
func (h *Handler) CreateTimetableTeacher(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableTeacherRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateTeacher(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create teacher: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableTeachers(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListTeachers(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list teachers: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableTeacher(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteTeacher(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete teacher: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Teacher deleted"})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------- Curriculum ----------
|
||||
|
||||
func (h *Handler) CreateTimetableCurriculum(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableCurriculumRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateCurriculum(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create curriculum: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableCurriculum(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListCurriculum(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list curriculum: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableCurriculum(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteCurriculum(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete curriculum: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Curriculum entry deleted"})
|
||||
}
|
||||
|
||||
// ---------- Assignment ----------
|
||||
|
||||
func (h *Handler) CreateTimetableAssignment(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableAssignmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.CreateAssignment(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create assignment: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondCreated(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableAssignments(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListAssignments(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list assignments: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableAssignment(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteAssignment(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete assignment: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/school-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------- Solutions ----------
|
||||
|
||||
func (h *Handler) CreateTimetableSolution(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.CreateTimetableSolutionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
sol, err := h.timetableService.CreateSolution(c.Request.Context(), uid, &req)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to create solution: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Fire-and-forget the solver invocation; the row is persisted regardless.
|
||||
if err := h.timetableService.TriggerSolve(c.Request.Context(), h.solverServiceURL, sol.ID.String(), uid); err != nil {
|
||||
// Don't fail the request — the solution row already shows status=failed.
|
||||
// The client will see error_message via GET /solutions/:id.
|
||||
respondCreated(c, sol)
|
||||
return
|
||||
}
|
||||
respondCreated(c, sol)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableSolutions(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListSolutions(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list solutions: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) GetTimetableSolution(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
sol, err := h.timetableService.GetSolution(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "Solution not found")
|
||||
return
|
||||
}
|
||||
respondSuccess(c, sol)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTimetableLessons(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
out, err := h.timetableService.ListLessons(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to list lessons: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, out)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTimetableSolution(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.DeleteSolution(c.Request.Context(), c.Param("id"), uid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to delete solution: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Solution deleted"})
|
||||
}
|
||||
|
||||
// UpdateTimetableLessonPin flips the pinned flag on a single lesson.
|
||||
// The solver respects pinned cells via @PlanningPin when this user re-solves.
|
||||
func (h *Handler) UpdateTimetableLessonPin(c *gin.Context) {
|
||||
uid := getUserID(c)
|
||||
if uid == "" {
|
||||
respondError(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
var req models.UpdateLessonPinRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.timetableService.UpdateLessonPin(c.Request.Context(), c.Param("id"), uid, req.Pinned); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "Failed to update lesson pin: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Lesson pin updated", "pinned": req.Pinned})
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func RequestLogger() gin.HandlerFunc {
|
||||
if status >= 400 {
|
||||
gin.DefaultWriter.Write([]byte(
|
||||
c.Request.Method + " " + path + " " +
|
||||
http.StatusText(status) + " " + latency.String() + "\n",
|
||||
http.StatusText(status) + " " + latency.String() + "\n",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -104,11 +104,28 @@ func RateLimiter() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMiddleware validates JWT tokens
|
||||
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
|
||||
// devUserID is the deterministic UUID injected when AuthMiddleware runs in
|
||||
// development mode without a JWT. It's the all-zero UUID's first byte set so
|
||||
// constraint-ownership filters can still match rows created by the dev user.
|
||||
const devUserID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// AuthMiddleware validates JWT tokens.
|
||||
//
|
||||
// devMode=true relaxes the check: requests without an Authorization header
|
||||
// fall back to a fixed dev user instead of being rejected. Useful for
|
||||
// studio-v2 against a local school-service when no real login is wired up.
|
||||
// In production (devMode=false) the original strict behaviour applies.
|
||||
func AuthMiddleware(jwtSecret string, devMode bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
if devMode {
|
||||
c.Set("user_id", devUserID)
|
||||
c.Set("email", "dev@breakpilot.local")
|
||||
c.Set("role", "teacher")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authorization header required",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ParentResolver is the minimum the middleware needs from the parent
|
||||
// service. Defined as interface so handlers can pass their own service
|
||||
// without import cycles.
|
||||
type ParentResolver interface {
|
||||
ParentFromSession(ctx context.Context, token string) (parent interface{}, err error)
|
||||
}
|
||||
|
||||
// ParentSessionCookieName is the name of the HttpOnly cookie that carries
|
||||
// the parent's session token after redeem. Exported so handlers can set it.
|
||||
const ParentSessionCookieName = "bp_parent_session"
|
||||
|
||||
// ParentSessionMiddleware reads the parent session cookie and resolves it
|
||||
// to a parent_account. Stores parent_id (string) in the Gin context for
|
||||
// downstream handlers. Aborts with 401 if the cookie is missing or the
|
||||
// session expired.
|
||||
func ParentSessionMiddleware(resolve func(ctx context.Context, token string) (string, string, string, error)) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, err := c.Cookie(ParentSessionCookieName)
|
||||
if err != nil || token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Parent session required"})
|
||||
return
|
||||
}
|
||||
parentID, email, lang, err := resolve(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session"})
|
||||
return
|
||||
}
|
||||
c.Set("parent_id", parentID)
|
||||
c.Set("parent_email", email)
|
||||
c.Set("parent_language", lang)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PublicEvent is a holiday or school-vacation row imported from
|
||||
// OpenHolidaysAPI. Global (no owner) — same for every school per region.
|
||||
type PublicEvent struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Region string `json:"region" db:"region"` // e.g. "DE-NI"
|
||||
EventType string `json:"event_type" db:"event_type"` // public_holiday | school_holiday
|
||||
NameDe string `json:"name_de" db:"name_de"`
|
||||
NameEn string `json:"name_en,omitempty" db:"name_en"`
|
||||
StartDate string `json:"start_date" db:"start_date"` // YYYY-MM-DD
|
||||
EndDate string `json:"end_date" db:"end_date"`
|
||||
Source string `json:"source,omitempty" db:"source"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// SchoolCalendarConfig stores the Bundesland selection for one school
|
||||
// (= one Rektor account). One row per user.
|
||||
type SchoolCalendarConfig struct {
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
Bundesland string `json:"bundesland" db:"bundesland"` // DE-NI ...
|
||||
SchoolYearStart *string `json:"school_year_start,omitempty" db:"school_year_start"`
|
||||
SchoolYearEnd *string `json:"school_year_end,omitempty" db:"school_year_end"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// SchoolEvent is a user-managed event (Fortbildung, Schulfeier, …).
|
||||
type SchoolEvent struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
EventType string `json:"event_type" db:"event_type"`
|
||||
IsSchoolFree bool `json:"is_school_free" db:"is_school_free"`
|
||||
StartDate string `json:"start_date" db:"start_date"`
|
||||
EndDate string `json:"end_date" db:"end_date"`
|
||||
StartTime *string `json:"start_time,omitempty" db:"start_time"`
|
||||
EndTime *string `json:"end_time,omitempty" db:"end_time"`
|
||||
AffectedClassIDs []uuid.UUID `json:"affected_class_ids" db:"affected_class_ids"`
|
||||
VisibleToParents bool `json:"visible_to_parents" db:"visible_to_parents"`
|
||||
NotifyParents bool `json:"notify_parents" db:"notify_parents"`
|
||||
NotifyStudents bool `json:"notify_students" db:"notify_students"`
|
||||
NotificationLeadDays []int `json:"notification_lead_days" db:"notification_lead_days"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
|
||||
// UpsertSchoolCalendarConfigRequest sets or updates the Bundesland for the
|
||||
// authenticated user. Both school-year dates are optional (defaults to the
|
||||
// running year based on today's date).
|
||||
type UpsertSchoolCalendarConfigRequest struct {
|
||||
Bundesland string `json:"bundesland" binding:"required,len=5"`
|
||||
SchoolYearStart *string `json:"school_year_start,omitempty"`
|
||||
SchoolYearEnd *string `json:"school_year_end,omitempty"`
|
||||
}
|
||||
|
||||
type CreateSchoolEventRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
EventType string `json:"event_type" binding:"required,oneof=fortbildung schulfeier klassenfahrt projekttag eltern_info andere"`
|
||||
IsSchoolFree bool `json:"is_school_free"`
|
||||
StartDate string `json:"start_date" binding:"required"`
|
||||
EndDate string `json:"end_date" binding:"required"`
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
AffectedClassIDs []string `json:"affected_class_ids"`
|
||||
VisibleToParents bool `json:"visible_to_parents"`
|
||||
NotifyParents bool `json:"notify_parents"`
|
||||
NotifyStudents bool `json:"notify_students"`
|
||||
NotificationLeadDays []int `json:"notification_lead_days"`
|
||||
}
|
||||
|
||||
// SchoolYearRolloverRequest moves all classes up by one grade and updates
|
||||
// the config's school-year dates. Optional date pair, otherwise defaults
|
||||
// to next Aug 01 → following Jul 31.
|
||||
type SchoolYearRolloverRequest struct {
|
||||
NewYearStart *string `json:"new_year_start,omitempty"` // YYYY-MM-DD
|
||||
NewYearEnd *string `json:"new_year_end,omitempty"`
|
||||
}
|
||||
|
||||
// SchoolYearRolloverResult is what the endpoint returns so the UI can show
|
||||
// "promoted 8 classes, removed 2 graduating ones".
|
||||
type SchoolYearRolloverResult struct {
|
||||
ClassesPromoted int `json:"classes_promoted"`
|
||||
ClassesGraduated int `json:"classes_graduated"`
|
||||
NewYearStart string `json:"new_year_start"`
|
||||
NewYearEnd string `json:"new_year_end"`
|
||||
}
|
||||
@@ -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 {
|
||||
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 { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
|
||||
/**
|
||||
* Shared layout for ALL /learn/* pages.
|
||||
* Provides: Sidebar + gradient background + language switcher.
|
||||
* Provides: Sidebar + gradient background + language dropdown (flags).
|
||||
* Uses the central LanguageContext (same as all other modules).
|
||||
*/
|
||||
export default function LearnLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isDark } = useTheme()
|
||||
const { nativeLang, setNativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
@@ -23,13 +22,9 @@ export default function LearnLayout({ children }: { children: React.ReactNode })
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
{/* Sticky language switcher at top-right */}
|
||||
{/* Language dropdown at top-right (same as worksheet-editor etc.) */}
|
||||
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
|
||||
<LanguageSwitcher
|
||||
currentLang={nativeLang}
|
||||
onLangChange={setNativeLang}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
{children}
|
||||
<div className="text-center py-4">
|
||||
|
||||
@@ -15,7 +15,7 @@ interface LangOption {
|
||||
rtl: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bp_native_language'
|
||||
const STORAGE_KEY = 'bp_language'
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
|
||||
/**
|
||||
* Shared layout for ALL /parent/* pages.
|
||||
* Same design as learn layout — Sidebar + gradient + language switcher.
|
||||
* Same design as learn layout — Sidebar + gradient + flag language dropdown.
|
||||
*/
|
||||
export default function ParentLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isDark } = useTheme()
|
||||
const { nativeLang, setNativeLang } = useNativeLanguage()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
@@ -23,13 +21,8 @@ export default function ParentLayout({ children }: { children: React.ReactNode }
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
{/* Sticky language switcher at top-right */}
|
||||
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
|
||||
<LanguageSwitcher
|
||||
currentLang={nativeLang}
|
||||
onLangChange={setNativeLang}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { BUNDESLAENDER } from '@/app/schulkalender/types'
|
||||
|
||||
interface BundeslandWizardProps {
|
||||
onSave: (bundesland: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function BundeslandWizard({ onSave }: BundeslandWizardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [selected, setSelected] = useState('DE-NI')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSave(selected)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||
|
||||
return (
|
||||
<div className={`max-w-xl mx-auto rounded-2xl border backdrop-blur-xl p-6 ${cardClass}`} data-testid="bundesland-wizard">
|
||||
<h2 className="text-xl font-semibold mb-2">Willkommen im Schulkalender</h2>
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Waehle das Bundesland deiner Schule. Damit laden wir Ferien und
|
||||
Feiertage aus dem offiziellen Datensatz fuer die naechsten drei
|
||||
Schuljahre.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm mb-1 opacity-70">Bundesland</label>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={e => setSelected(e.target.value)}
|
||||
data-testid="bundesland-select"
|
||||
className={`w-full px-3 py-2 rounded-lg border ${selectClass}`}
|
||||
>
|
||||
{BUNDESLAENDER.map(b => (
|
||||
<option key={b.code} value={b.code}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
data-testid="bundesland-save"
|
||||
className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichert…' : 'Bundesland uebernehmen'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { calendarApi } from '@/lib/schulkalender/api'
|
||||
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
|
||||
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
|
||||
import { NotificationStatus } from './NotificationStatus'
|
||||
|
||||
interface DayDetailProps {
|
||||
iso: string
|
||||
holidays: PublicEvent[]
|
||||
events: SchoolEvent[]
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Termin wirklich loeschen?')) return
|
||||
try {
|
||||
await calendarApi.deleteEvent(id)
|
||||
onDeleted()
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
|
||||
|
||||
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
|
||||
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
|
||||
|
||||
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
|
||||
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{formattedDate}</h2>
|
||||
<button onClick={onClose} className="opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
|
||||
{dayHolidays.length === 0 && dayEvents.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Keine Eintraege fuer diesen Tag.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dayHolidays.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
|
||||
{dayHolidays.map(h => (
|
||||
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
|
||||
<div className="font-medium">{h.name_de}</div>
|
||||
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` – ${h.end_date}` : ''}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{dayEvents.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
|
||||
{dayEvents.map(e => (
|
||||
<div
|
||||
key={e.id}
|
||||
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
|
||||
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{e.title}</div>
|
||||
<div className="text-xs opacity-70 mt-0.5">
|
||||
{EVENT_TYPE_LABEL[e.event_type]}
|
||||
{e.start_time && ` · ${e.start_time}${e.end_time ? `–${e.end_time}` : ''}`}
|
||||
{e.is_school_free && ' · unterrichtsfrei'}
|
||||
</div>
|
||||
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
|
||||
<div className="text-xs opacity-60 mt-1.5">
|
||||
{e.visible_to_parents && '👨👩👧 sichtbar fuer Eltern'}
|
||||
{e.notify_parents && ' · 📧 Eltern erinnern'}
|
||||
{e.notify_students && ' · 💬 Schueler erinnern'}
|
||||
</div>
|
||||
{(e.notify_parents || e.notify_students) && (
|
||||
<NotificationStatus eventId={e.id} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(e.id)}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { calendarApi } from '@/lib/schulkalender/api'
|
||||
import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types'
|
||||
import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
|
||||
|
||||
interface EventModalProps {
|
||||
defaultDate: string // YYYY-MM-DD
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
const initial = (date: string): CreateSchoolEvent => ({
|
||||
title: '',
|
||||
event_type: 'fortbildung',
|
||||
is_school_free: false,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
visible_to_parents: true,
|
||||
notify_parents: false,
|
||||
notify_students: false,
|
||||
notification_lead_days: [7, 1],
|
||||
})
|
||||
|
||||
export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [form, setForm] = useState<CreateSchoolEvent>(initial(defaultDate))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await calendarApi.createEvent(form)
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
|
||||
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="event-modal">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`w-full max-w-2xl rounded-2xl border p-6 space-y-3 max-h-[90vh] overflow-y-auto ${cardClass}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Neuer Termin</h2>
|
||||
<button type="button" onClick={onClose} className="opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Titel</label>
|
||||
<input
|
||||
required
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="z.B. SCHILF: Digitale Tafeln"
|
||||
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||
data-testid="event-title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Typ</label>
|
||||
<select
|
||||
value={form.event_type}
|
||||
onChange={e => setForm({ ...form, event_type: e.target.value as SchoolEventType })}
|
||||
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||
data-testid="event-type"
|
||||
>
|
||||
{(Object.keys(EVENT_TYPE_LABEL) as SchoolEventType[]).map(k => (
|
||||
<option key={k} value={k}>{EVENT_TYPE_LABEL[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is-school-free"
|
||||
checked={form.is_school_free || false}
|
||||
onChange={e => setForm({ ...form, is_school_free: e.target.checked })}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<label htmlFor="is-school-free" className="text-sm">Unterrichtsfrei</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Von</label>
|
||||
<input type="date" required value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Bis</label>
|
||||
<input type="date" required value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Startzeit (optional)</label>
|
||||
<input type="time" value={form.start_time || ''} onChange={e => setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Endzeit (optional)</label>
|
||||
<input type="time" value={form.end_time || ''} onChange={e => setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
value={form.description || ''}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
rows={2}
|
||||
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-white/10">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.visible_to_parents ?? true}
|
||||
onChange={e => setForm({ ...form, visible_to_parents: e.target.checked })}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
Eltern sehen diesen Termin
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.notify_parents ?? false}
|
||||
onChange={e => setForm({ ...form, notify_parents: e.target.checked })}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
Eltern per Mail/Chat erinnern
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.notify_students ?? false}
|
||||
onChange={e => setForm({ ...form, notify_students: e.target.checked })}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
Schueler per Chat erinnern
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
data-testid="event-save"
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichert…' : 'Anlegen'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
|
||||
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
|
||||
|
||||
interface MonthViewProps {
|
||||
year: number
|
||||
month: number // 1-12
|
||||
holidays: PublicEvent[]
|
||||
schoolEvents?: SchoolEvent[]
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onToday: () => void
|
||||
onDayClick?: (iso: string) => void
|
||||
onAddEvent?: () => void
|
||||
onRollover?: () => void
|
||||
}
|
||||
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const MONTHS_DE = [
|
||||
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
inMonth: boolean
|
||||
events: PublicEvent[]
|
||||
}
|
||||
|
||||
function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): Cell[] {
|
||||
// First Monday on or before the 1st of the month.
|
||||
const first = new Date(Date.UTC(year, month - 1, 1))
|
||||
const firstWeekday = (first.getUTCDay() + 6) % 7 // Monday = 0
|
||||
const start = new Date(first)
|
||||
start.setUTCDate(first.getUTCDate() - firstWeekday)
|
||||
|
||||
const cells: Cell[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start)
|
||||
d.setUTCDate(start.getUTCDate() + i)
|
||||
const iso = d.toISOString().slice(0, 10)
|
||||
const events = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
|
||||
cells.push({
|
||||
date: d,
|
||||
inMonth: d.getUTCMonth() === month - 1,
|
||||
events,
|
||||
})
|
||||
if (i >= 27 && d.getUTCMonth() !== month - 1) {
|
||||
// Stop a row early if the rest is fully outside the month.
|
||||
const restAllOutside = cells.slice(i + 1 - ((i + 1) % 7), i + 1).every(c => !c.inMonth)
|
||||
if (restAllOutside) break
|
||||
}
|
||||
}
|
||||
// Pad to multiple of 7 if we cut early.
|
||||
while (cells.length % 7 !== 0) {
|
||||
const last = cells[cells.length - 1].date
|
||||
const d = new Date(last)
|
||||
d.setUTCDate(last.getUTCDate() + 1)
|
||||
cells.push({ date: d, inMonth: false, events: [] })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
export function MonthView({ year, month, holidays, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
|
||||
const { isDark } = useTheme()
|
||||
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
|
||||
|
||||
// School events per ISO date — quick lookup during cell render.
|
||||
const schoolEventsByDate = useMemo(() => {
|
||||
const map = new Map<string, SchoolEvent[]>()
|
||||
for (const ev of schoolEvents) {
|
||||
const start = new Date(ev.start_date)
|
||||
const end = new Date(ev.end_date)
|
||||
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
const iso = d.toISOString().slice(0, 10)
|
||||
const arr = map.get(iso) || []
|
||||
arr.push(ev)
|
||||
map.set(iso, arr)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [schoolEvents])
|
||||
|
||||
const headerClass = isDark ? 'text-white' : 'text-slate-900'
|
||||
const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
|
||||
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
|
||||
const buttonClass = isDark
|
||||
? 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="month-view">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-2xl font-semibold ${headerClass}`}>
|
||||
{MONTHS_DE[month - 1]} {year}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{onAddEvent && (
|
||||
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
|
||||
)}
|
||||
{onRollover && (
|
||||
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
|
||||
)}
|
||||
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>←</button>
|
||||
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
|
||||
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{WEEKDAYS_DE.map(w => (
|
||||
<div key={w} className={`text-xs font-medium text-center ${subtleText}`}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((c, i) => {
|
||||
const iso = c.date.toISOString().slice(0, 10)
|
||||
const isToday = iso === todayIso
|
||||
const publicHoliday = c.events.find(e => e.event_type === 'public_holiday')
|
||||
const schoolHoliday = c.events.find(e => e.event_type === 'school_holiday')
|
||||
|
||||
let bg = isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
|
||||
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
|
||||
|
||||
const dayEvents = schoolEventsByDate.get(iso) || []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid={`day-${iso}`}
|
||||
onClick={() => c.inMonth && onDayClick?.(iso)}
|
||||
className={`relative aspect-square rounded-lg p-2 text-sm border ${
|
||||
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
|
||||
} ${
|
||||
isDark ? 'border-white/10' : 'border-black/5'
|
||||
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
|
||||
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium ${
|
||||
c.inMonth ? (isDark ? 'text-white' : 'text-slate-900') : subtleText
|
||||
}`}>
|
||||
{c.date.getUTCDate()}
|
||||
</div>
|
||||
{c.events.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5 overflow-hidden">
|
||||
{c.events.slice(0, 2).map(e => (
|
||||
<div
|
||||
key={e.id}
|
||||
title={e.name_de}
|
||||
className={`text-[10px] leading-tight truncate ${
|
||||
e.event_type === 'public_holiday'
|
||||
? (isDark ? 'text-rose-200' : 'text-rose-800')
|
||||
: (isDark ? 'text-amber-200' : 'text-amber-800')
|
||||
}`}
|
||||
>
|
||||
{e.name_de}
|
||||
</div>
|
||||
))}
|
||||
{c.events.length > 2 && (
|
||||
<div className={`text-[10px] ${subtleText}`}>+{c.events.length - 2}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{dayEvents.length > 0 && (
|
||||
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
|
||||
{dayEvents.slice(0, 4).map(ev => (
|
||||
<span
|
||||
key={ev.id}
|
||||
title={ev.title}
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-rose-500/40' : 'bg-rose-200'}`}></span>
|
||||
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Feiertag</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-amber-500/40' : 'bg-amber-200'}`}></span>
|
||||
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Schulferien</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { calendarApi } from '@/lib/schulkalender/api'
|
||||
import type { NotificationLogRow } from '@/app/schulkalender/types'
|
||||
|
||||
interface NotificationStatusProps {
|
||||
eventId: string
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<string, string> = {
|
||||
sent: '✓',
|
||||
failed: '✗',
|
||||
skipped: '⏱',
|
||||
}
|
||||
|
||||
export function NotificationStatus({ eventId }: NotificationStatusProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [rows, setRows] = useState<NotificationLogRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
calendarApi.listEventNotifications(eventId)
|
||||
.then(r => { setRows(r || []); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [eventId])
|
||||
|
||||
if (loading || rows.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={`mt-2 pt-2 border-t text-xs ${isDark ? 'border-white/10' : 'border-black/10'}`} data-testid={`notif-status-${eventId}`}>
|
||||
<div className={`font-medium mb-1 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>Erinnerungen</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rows.map((r, i) => (
|
||||
<span
|
||||
key={i}
|
||||
title={r.error_message || `${r.status} ${r.run_date}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
r.status === 'sent' ? (isDark ? 'bg-emerald-500/30 text-emerald-100' : 'bg-emerald-100 text-emerald-900') :
|
||||
r.status === 'failed' ? (isDark ? 'bg-red-500/30 text-red-100' : 'bg-red-100 text-red-900') :
|
||||
(isDark ? 'bg-amber-500/30 text-amber-100' : 'bg-amber-100 text-amber-900')
|
||||
}`}
|
||||
>
|
||||
{STATUS_ICON[r.status]} {r.lead_days === 0 ? 'Heute' : r.lead_days === 1 ? '1 Tag' : `${r.lead_days} Tage`}
|
||||
{' · '}{r.audience === 'parents' ? 'Eltern' : 'Schueler'}
|
||||
{' · '}{r.channel}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { calendarApi } from '@/lib/schulkalender/api'
|
||||
import { classesApi } from '@/lib/stundenplan/api'
|
||||
import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types'
|
||||
import type { TimetableClass } from '@/app/stundenplan/types'
|
||||
|
||||
const LANGS: { code: string; name: string }[] = [
|
||||
{ code: 'de', name: 'Deutsch' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'tr', name: 'Tuerkce' },
|
||||
{ code: 'ar', name: 'العربية' },
|
||||
{ code: 'uk', name: 'Українська' },
|
||||
{ code: 'ru', name: 'Русский' },
|
||||
{ code: 'pl', name: 'Polski' },
|
||||
{ code: 'fr', name: 'Francais' },
|
||||
]
|
||||
|
||||
export function ParentManager() {
|
||||
const { isDark } = useTheme()
|
||||
const [items, setItems] = useState<ParentInviteListItem[]>([])
|
||||
const [classes, setClasses] = useState<TimetableClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [lastInvite, setLastInvite] = useState<InviteParentResponse | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
preferred_language: 'de',
|
||||
child_first_name: '',
|
||||
child_last_name: '',
|
||||
tt_class_id: '',
|
||||
})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()])
|
||||
setItems(list || [])
|
||||
setClasses(cls || [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await calendarApi.inviteParent(form)
|
||||
setLastInvite(res)
|
||||
setForm({ ...form, child_first_name: '', child_last_name: '' })
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (childId: string) => {
|
||||
if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return
|
||||
try { await calendarApi.deleteParentChild(childId); await load() }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') }
|
||||
}
|
||||
|
||||
const fullLink = (path: string) =>
|
||||
typeof window === 'undefined' ? path : `${window.location.origin}${path}`
|
||||
|
||||
const copyLink = (path: string) => {
|
||||
navigator.clipboard?.writeText(fullLink(path))
|
||||
}
|
||||
|
||||
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="parent-manager">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold">Eltern verwalten ({items.length})</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(s => !s)}
|
||||
disabled={classes.length === 0}
|
||||
data-testid="parent-invite-toggle"
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : '+ Eltern einladen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{classes.length === 0 && (
|
||||
<p className={`text-sm mb-2 ${isDark ? 'text-amber-200' : 'text-amber-900'}`}>
|
||||
Zuerst Klassen im Stundenplan-Modul anlegen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input required type="email" placeholder="Eltern-E-Mail" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
<input required placeholder="Vorname Kind" value={form.child_first_name} onChange={e => setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
<input required placeholder="Nachname Kind" value={form.child_last_name} onChange={e => setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
<select required value={form.tt_class_id} onChange={e => setForm({ ...form, tt_class_id: e.target.value })} data-testid="parent-class" className={`px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||
<option value="">— Klasse waehlen —</option>
|
||||
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<select value={form.preferred_language} onChange={e => setForm({ ...form, preferred_language: e.target.value })} className={`px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||
{LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
||||
</select>
|
||||
<button type="submit" disabled={submitting} data-testid="parent-invite-submit" className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||
{submitting ? 'Erstellt…' : 'Einladen'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{lastInvite && (
|
||||
<div className={`mb-3 p-3 rounded-lg ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`} data-testid="parent-invite-link">
|
||||
<div className="text-sm font-medium mb-1">Einladungs-Link fuer {lastInvite.parent.email}</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<code className={`flex-1 text-xs px-2 py-1 rounded overflow-x-auto ${isDark ? 'bg-white/10' : 'bg-white'}`}>
|
||||
{fullLink(lastInvite.magic_url)}
|
||||
</code>
|
||||
<button onClick={() => copyLink(lastInvite.magic_url)} className="text-xs px-2 py-1 rounded bg-indigo-500 hover:bg-indigo-600 text-white">Kopieren</button>
|
||||
</div>
|
||||
<p className="text-xs opacity-70 mt-1">Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="opacity-60 py-4 text-center text-sm">Laedt…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="opacity-60 py-4 text-center text-sm">Keine eingeladenen Eltern.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className={isDark ? 'opacity-70' : 'opacity-70'}>
|
||||
<tr>
|
||||
<th className="text-left py-2">E-Mail</th>
|
||||
<th className="text-left py-2">Kind</th>
|
||||
<th className="text-left py-2">Klasse</th>
|
||||
<th className="text-left py-2">Sprache</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(it => (
|
||||
<tr key={it.child_id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||
<td className="py-2">{it.email}</td>
|
||||
<td className="py-2">{it.child_first_name} {it.child_last_name}</td>
|
||||
<td className="py-2">{it.class_name}</td>
|
||||
<td className="py-2">{it.preferred_language}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button onClick={() => handleDelete(it.child_id)} className="text-xs text-red-400 hover:text-red-300">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { calendarApi } from '@/lib/schulkalender/api'
|
||||
import type { SchoolYearRolloverResult } from '@/app/schulkalender/types'
|
||||
|
||||
interface RolloverWizardProps {
|
||||
onClose: () => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
function nextSchoolYearISO(): { start: string; end: string } {
|
||||
const now = new Date()
|
||||
let y = now.getFullYear()
|
||||
if (now.getMonth() + 1 >= 8) y++ // Aug → bumped to next year
|
||||
return { start: `${y}-08-01`, end: `${y + 1}-07-31` }
|
||||
}
|
||||
|
||||
export function RolloverWizard({ onClose, onDone }: RolloverWizardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const defaults = nextSchoolYearISO()
|
||||
const [start, setStart] = useState(defaults.start)
|
||||
const [end, setEnd] = useState(defaults.end)
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SchoolYearRolloverResult | null>(null)
|
||||
|
||||
const expected = 'SCHULJAHR WECHSELN'
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await calendarApi.rolloverSchoolYear(start, end)
|
||||
setResult(r)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Rollover fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
|
||||
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="rollover-wizard">
|
||||
<div className={`w-full max-w-xl rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Schuljahres-Wechsel</h2>
|
||||
<button onClick={onClose} className="opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="space-y-3" data-testid="rollover-result">
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`}>
|
||||
<div className="font-medium mb-2">Rollover erfolgreich</div>
|
||||
<ul className="text-sm space-y-1 opacity-90">
|
||||
<li>{result.classes_promoted} Klassen um eine Stufe aufgerueckt</li>
|
||||
<li>{result.classes_graduated} Abschlussklassen entfernt</li>
|
||||
<li>Neues Schuljahr: {result.new_year_start} – {result.new_year_end}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button onClick={onDone} className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`p-3 rounded-lg text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-100' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||
<p className="font-medium mb-1">Was passiert?</p>
|
||||
<ul className="list-disc list-inside space-y-1 opacity-90">
|
||||
<li>Alle Klassen ruecken eine Stufe hoeher (5a → 6, 6a → 7, …)</li>
|
||||
<li>Abschlussklassen (Stufe 13) werden entfernt</li>
|
||||
<li>Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert</li>
|
||||
<li>Vorhandene Stundenplaene bleiben als Historie erhalten</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Schuljahr-Beginn</label>
|
||||
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">Schuljahr-Ende</label>
|
||||
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1 opacity-70">
|
||||
Bestaetigung — tippe <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>{expected}</code> zur Bestaetigung
|
||||
</label>
|
||||
<input
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
data-testid="rollover-confirm"
|
||||
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || confirm !== expected}
|
||||
data-testid="rollover-submit"
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium disabled:opacity-30"
|
||||
>
|
||||
{saving ? 'Wechselt…' : 'Schuljahr wechseln'}
|
||||
</button>
|
||||
<button onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-100 hover:bg-slate-200'}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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