Phase 9c: Parent accounts, magic-link login + parent timetable view
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s

Backend (school-service):
  - parent_account, parent_child, parent_magic_link, parent_session
    tables. Tokens are sha256-hashed in DB; raw goes back exactly
    once to the inviting teacher.
  - InviteParent upserts the parent account, links a child to a tt_
    class, mints a 7-day magic link. Returns the link path so the
    teacher can paste it into Matrix/Email.
  - RedeemMagicLink validates + marks used + mints a 30-day session,
    sets HttpOnly bp_parent_session cookie.
  - ParentSessionMiddleware reads the cookie and resolves the parent.
    Lives in its own router group /api/v1/parent — totally separate
    from the teacher JWT path.
  - ParentMe returns the account + list of children (with class name).
  - ParentTimetable returns the latest completed tt_solution's lessons
    for the requested child's class, with full authorization check
    (parent must own a child in that class).

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-22 11:50:35 +02:00
parent 33409352ee
commit d9858084dd
20 changed files with 1568 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* Subject-name translations for the parent-facing weekly grid.
*
* The teacher enters German subject names in tt_subject.name. For parents
* whose preferred_language differs, we look up the German name in this
* table and substitute the localised version. If no match (custom AG,
* Wahlfach, ...), the German original is shown.
*
* Keys are normalised lowercase German subject names. Languages cover the
* 8 most-common parent locales in DE schools; everything else falls back
* to German.
*/
type SupportedLanguage = 'de' | 'en' | 'tr' | 'ar' | 'uk' | 'ru' | 'pl' | 'fr'
interface SubjectTranslation {
de: string
en: string
tr: string
ar: string
uk: string
ru: string
pl: string
fr: string
}
const SUBJECTS: Record<string, SubjectTranslation> = {
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Mathématiques' },
mathe: { de: 'Mathe', en: 'Maths', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Maths' },
deutsch: { de: 'Deutsch', en: 'German', tr: 'Almanca', ar: 'الألمانية', uk: 'Німецька мова', ru: 'Немецкий язык', pl: 'Język niemiecki', fr: 'Allemand' },
englisch: { de: 'Englisch', en: 'English', tr: 'İngilizce', ar: 'الإنجليزية', uk: 'Англійська мова', ru: 'Английский язык', pl: 'Język angielski', fr: 'Anglais' },
franzoesisch: { de: 'Franzoesisch', en: 'French', tr: 'Fransızca', ar: 'الفرنسية', uk: 'Французька мова', ru: 'Французский язык', pl: 'Język francuski', fr: 'Français' },
spanisch: { de: 'Spanisch', en: 'Spanish', tr: 'İspanyolca', ar: 'الإسبانية', uk: 'Іспанська мова', ru: 'Испанский язык', pl: 'Język hiszpański', fr: 'Espagnol' },
latein: { de: 'Latein', en: 'Latin', tr: 'Latince', ar: 'اللاتينية', uk: 'Латинська мова', ru: 'Латинский язык', pl: 'Łacina', fr: 'Latin' },
sachkunde: { de: 'Sachkunde', en: 'General Studies', tr: 'Hayat Bilgisi', ar: 'الدراسات العامة', uk: 'Природознавство', ru: 'Окружающий мир', pl: 'Wiedza o przyrodzie', fr: 'Découverte du monde' },
sport: { de: 'Sport', en: 'PE', tr: 'Beden Eğitimi', ar: 'التربية البدنية', uk: 'Фізкультура', ru: 'Физкультура', pl: 'WF', fr: 'EPS' },
musik: { de: 'Musik', en: 'Music', tr: 'Müzik', ar: 'الموسيقى', uk: 'Музика', ru: 'Музыка', pl: 'Muzyka', fr: 'Musique' },
kunst: { de: 'Kunst', en: 'Art', tr: 'Sanat', ar: 'الفن', uk: 'Мистецтво', ru: 'Искусство', pl: 'Plastyka', fr: 'Arts plastiques' },
religion: { de: 'Religion', en: 'Religion', tr: 'Din Bilgisi', ar: 'الدين', uk: 'Релігія', ru: 'Религия', pl: 'Religia', fr: 'Religion' },
ethik: { de: 'Ethik', en: 'Ethics', tr: 'Etik', ar: 'الأخلاق', uk: 'Етика', ru: 'Этика', pl: 'Etyka', fr: 'Éthique' },
biologie: { de: 'Biologie', en: 'Biology', tr: 'Biyoloji', ar: 'الأحياء', uk: 'Біологія', ru: 'Биология', pl: 'Biologia', fr: 'Biologie' },
chemie: { de: 'Chemie', en: 'Chemistry', tr: 'Kimya', ar: 'الكيمياء', uk: 'Хімія', ru: 'Химия', pl: 'Chemia', fr: 'Chimie' },
physik: { de: 'Physik', en: 'Physics', tr: 'Fizik', ar: 'الفيزياء', uk: 'Фізика', ru: 'Физика', pl: 'Fizyka', fr: 'Physique' },
geschichte: { de: 'Geschichte', en: 'History', tr: 'Tarih', ar: 'التاريخ', uk: 'Історія', ru: 'История', pl: 'Historia', fr: 'Histoire' },
geografie: { de: 'Geografie', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' },
erdkunde: { de: 'Erdkunde', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' },
politik: { de: 'Politik', en: 'Civics', tr: 'Vatandaşlık', ar: 'التربية الوطنية', uk: 'Громадянознавство', ru: 'Обществознание', pl: 'Wiedza o społeczeństwie', fr: 'Éducation civique' },
informatik: { de: 'Informatik', en: 'Computer Science', tr: 'Bilişim', ar: 'علوم الحاسوب', uk: 'Інформатика', ru: 'Информатика', pl: 'Informatyka', fr: 'Informatique' },
wirtschaft: { de: 'Wirtschaft', en: 'Economics', tr: 'Ekonomi', ar: 'الاقتصاد', uk: 'Економіка', ru: 'Экономика', pl: 'Ekonomia', fr: 'Économie' },
}
/**
* Translate a German subject name into the requested language.
* Falls back to the original input if no match in the table or no
* translation for the target language.
*/
export function translateSubject(germanName: string, lang: string): string {
if (!germanName) return germanName
const key = germanName.toLowerCase().trim()
const row = SUBJECTS[key]
if (!row) return germanName
const code = (lang || 'de').slice(0, 2) as SupportedLanguage
return row[code] || row.de || germanName
}