Compare commits

..

6 commits

Author SHA1 Message Date
aldemirm c2c1e334c7 gitignore 2026-09-04 12:31:02 +02:00
aldemirm e0995dafbc chore: ignore build and gradle temporary directories 2026-09-04 12:28:06 +02:00
aldemirm dee5d95e7a added gitignore 2026-09-04 12:27:29 +02:00
aldemirm 8458d6a3f1 changed username, add UserTable to SchemaUtils 2026-09-04 12:23:20 +02:00
aldemirm b1448884c6 changed username, add UserTable to SchemaUtils 2026-09-04 12:21:51 +02:00
aldemirm cb03279c6d feat: add user authentication and protect endpoints with JWT
Added UsersTable to Exposed database schema

Updated KidioRepository interface and implementations for auth methods

Updated Ktor routing, application setup, and integration tests for JWT auth
2026-09-04 11:35:46 +02:00
66 changed files with 772 additions and 9196 deletions

View file

@ -1,4 +0,0 @@
[*.{kt,kts}]
# Erlaubt Wildcard-Imports für das gesamte Projekt:
ij_kotlin_allow_trailing_comma = true
ktlint_standard_no-wildcard-imports = disabled

View file

@ -1,33 +0,0 @@
name: CI Pipeline
on:
push:
branches: [ "main", "feat/*" ]
pull_request:
branches: [ "main" ]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Code auschecken
uses: https://github.com/actions/checkout@v4
- name: Java 21 einrichten
uses: https://github.com/actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Gradle Rechte vergeben
run: chmod +x gradlew
- name: 1. Code-Style (Linter) prüfen
run: ./gradlew ktlintCheck --no-daemon
- name: 2. Tests ausführen
run: ./gradlew test --no-daemon
- name: 3. Backend bauen
run: ./gradlew build -x test --max-workers=2 --no-daemon -Dorg.gradle.jvmargs="-Xmx1024m"

1
.gitignore vendored
View file

@ -11,4 +11,3 @@ build/
*.iws
*.ipr
out/
build/

10
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

1
.idea/.name Normal file
View file

@ -0,0 +1 @@
Kidio_backend

View file

@ -0,0 +1,10 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

12
.idea/dataSources.xml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="kidio_db@82.165.11.162" uuid="c8ad70de-d5fc-407a-9165-4f24328fa311">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://82.165.11.162:5432/kidio_db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

16
.idea/gradle.xml Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

7
.idea/kotlinc.xml Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.0" />
</component>
</project>

7
.idea/misc.xml Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="homebrew-26" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/Kidio_backend.iml" filepath="$PROJECT_DIR$/.idea/modules/Kidio_backend.iml" />
</modules>
</component>
</project>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="AdditionalModuleElements">
<content url="file://$MODULE_DIR$/../.." dumb="true">
<excludeFolder url="file://$MODULE_DIR$/../dataSources" />
</content>
</component>
</module>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

9
Kidio_backend.iml Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

590
README.md
View file

@ -1,585 +1,33 @@
# Kidio Backend
# Kidio_backend
Das **Kidio-Backend** ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder.
This project was created using the [Ktor Project Generator](https://start.ktor.io).
Die Anwendung basiert auf dem **Ktor-Framework in Kotlin** und kombiniert **PostgreSQL** für die persistente Datenhaltung mit **Redis** als Caching-Layer für schnelle Zugriffszeiten.
Here are some useful links to get you started:
Kinder können durch das Lösen von Lernaufgaben, beispielsweise Mathematikaufgaben, zusätzliche Bildschirmzeit erwerben. Gleichzeitig synchronisiert das System die verbrauchte Bildschirmzeit und sperrt das Gerät automatisch, sobald keine verfügbare Bildschirmzeit mehr vorhanden ist.
---
* [Ktor Documentation](https://ktor.io/docs/home.html)
* [Ktor GitHub page](https://github.com/ktorio/ktor)
* [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). [Request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up).
## Features
| Feature | Beschreibung |
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **JWT-Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten, Filterung nach Fach und Alter sowie Belohnungen in Minuten. |
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
| **OER / H5P Ingestion Pipeline** | Automatisierter Live-Crawler und Batch-Import von freien Bildungsinhalten (ZUM.de / H5P) inklusive HTML-Bereinigung und Lizenzattributierung (CC-BY). |
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der verbrauchten Bildschirmzeit. Sinkt die verbleibende Zeit auf oder unter null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
| **Zwei-Ebenen-Speicher** | PostgreSQL als persistente Datenbank, kombiniert mit Redis-Caching für schnelle Screentime-Abfragen. |
| **Fallback-Mechanismus** | Ist Redis nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
Here's a list of features included in this project:
---
| Name | Description |
|------|-------------|
## Technologiestack
## Building & Running
* **Framework:** Ktor
* **Programmiersprache:** Kotlin
* **Datenbank-ORM:** JetBrains Exposed
* **Persistenz:** PostgreSQL
* **Caching:** Redis via Jedis
* **Authentifizierung:** JWT via Java JWT (Auth0)
* **Serialisierung:** Kotlinx Serialization
* **Code Quality / Linter:** KtLint
* **Testing:** Ktor Server Testing, JUnit
* **CI/CD:** Forgejo Actions (Gitea / Act Runner)
To build or run the project, use one of the following tasks:
---
| Task | Description |
|-------------------|-------------------|
| `./gradlew test` | Run the tests |
| `./gradlew build` | Build the project |
| `./gradlew run` | Run the server |
## API-Endpunkte
If the server starts successfully, you'll see the following output:
Alle API-Endpunkte befinden sich unter dem Präfix:
```text
/api/v1
```
### Öffentliche Endpunkte
#### 1. Health-Check
**Methode:** `GET`
**Pfad:**
```text
/api/v1/health
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
```
**Beschreibung:**
Prüft, ob der Backend-Server ordnungsgemäß läuft.
**Antwort:**
```json
{
"status": "OK",
"message": "Kidio Backend läuft!"
}
```
---
#### 2. Benutzer-Registrierung
**Methode:** `POST`
**Pfad:**
```text
/api/v1/auth/register
```
**Request-Body:**
```json
{
"email": "test@family.de",
"passwordPlain": "strengGeheim1",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}
```
**Antwort – `201 Created`:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIsIn...",
"userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e",
"message": "Registrierung erfolgreich"
}
```
---
#### 3. Benutzer-Login
**Methode:** `POST`
**Pfad:**
```text
/api/v1/auth/login
```
**Request-Body:**
```json
{
"email": "test@family.de",
"passwordPlain": "strengGeheim1"
}
```
**Antwort – `200 OK`:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIsIn...",
"userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e",
"familyName": "Familie Schmidt",
"childAge": 8
}
```
---
### Geschützte Endpunkte
Die folgenden Endpunkte erfordern ein gültiges JWT im HTTP-Header:
```http
Authorization: Bearer <Token>
```
---
#### 4. Bildschirmzeit abrufen
**Methode:** `GET`
**Pfad:**
```text
/api/v1/screentime
```
**Beschreibung:**
Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus des authentifizierten Benutzers.
**Antwort – `200 OK`:**
```json
{
"remainingSeconds": 1800,
"isBlocked": false
}
```
---
#### 5. Bildschirmzeit synchronisieren
**Methode:** `POST`
**Pfad:**
```text
/api/v1/screentime/sync
```
**Beschreibung:**
Zieht die seit dem letzten Abgleich vom Endgerät verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
**Request-Body:**
```json
{
"usedSecondsSinceLastSync": 30
}
```
**Antwort – `200 OK`:**
```json
{
"remainingSeconds": 1770,
"isBlocked": false
}
```
---
#### 6. Lernaufgaben abrufen (mit Fach- & Altersfilter)
**Methode:** `GET`
**Pfad:**
```text
/api/v1/tasks
```
**Optionale Query-Parameter:**
| Parameter | Typ | Standardwert | Beschreibung |
| :--- | :--- | :--- | :--- |
| `subject` | String | `null` | Schulfach (z. B. `Mathematik`, `Deutsch`, `Sachunterricht`, `Englisch`, `Biologie`, `Geographie`, `Geschichte`). |
| `age` | Integer | `null` | Zielalter des Kindes (filtert nach `minAge <= age AND maxAge >= age`). |
| `limit` | Integer | `10` | Maximale Anzahl an zurückgegebenen Aufgaben. |
**Beispiel-Anfrage:**
```text
GET /api/v1/tasks?subject=Mathematik&age=8&limit=5
```
**Beschreibung:**
Liefert kuratierte oder aus OER/H5P importierte Lernaufgaben. Die korrekte Lösung (`correctAnswer`) wird zum Schutz vor Manipulationen serverseitig geheim gehalten und niemals an den Client übertragen.
**Antwort – `200 OK`:**
```json
[
{
"id": "zum-math-001",
"subject": "Mathematik",
"minAge": 8,
"maxAge": 9,
"question": "Was ist das Ergebnis von 6 × 8?",
"options": [
"42",
"48",
"54",
"56"
],
"rewardMinutes": 5,
"license": "CC BY-SA 4.0"
}
]
```
---
#### 7. Antwort überprüfen und Belohnung verbuchen
**Methode:** `POST`
**Pfad:**
```text
/api/v1/tasks/verify
```
**Request-Body:**
```json
{
"taskId": "zum-math-001",
"selectedAnswer": "48"
}
```
**Antwort – korrekte Lösung (`200 OK`):**
```json
{
"isCorrect": true,
"earnedMinutes": 5,
"message": "Super gemacht! Du hast 5 Minuten gewonnen."
}
```
**Antwort – falsche Lösung (`200 OK`):**
```json
{
"isCorrect": false,
"earnedMinutes": 0,
"message": "Schade, das war leider falsch. Versuche es nochmal!"
}
```
---
## H5P / ZUM.de Aufgaben-Import (Standalone CLI)
Das Backend verfügt über ein integriertes CLI-Tool zum automatisierten Importieren und Crawlen interaktiver Lernaufgaben von **ZUM.de / OER (H5P)**.
### Batch-Import ausführen
```bash
# Alle Fächer importieren (z. B. 100 Aufgaben)
./gradlew runIngestion --args="--limit=100"
# Gezielt ein Schulfach importieren (z. B. Mathematik)
./gradlew runIngestion --args="--subject=Mathematik --limit=50"
# Deutsch für Grundschüler importieren
./gradlew runIngestion --args="--subject=Deutsch --limit=30"
```
### CLI-Optionen
| Argument | Standardwert | Beschreibung |
| :--- | :--- | :--- |
| `--subject=<Name>` | *(alle Fächer)* | Filtert Aufgaben nach Fach (z. B. `Mathematik`, `Deutsch`, `Englisch`, `Sachunterricht`). |
| `--limit=<Zahl>` | `50` | Maximale Anzahl an Aufgaben, die im Durchlauf importiert/aktualisiert werden. |
### Funktionsweise der Ingestion-Pipeline
1. **Live-Crawler & Discovery:** Durchsucht `apps.zum.de` nach interaktiven Inhalten (`H5P.MultiChoice`, `H5P.TrueFalse`, `H5P.QuestionSet`).
2. **Textbereinigung:** Entfernt störende HTML-Tags (`<p>`, `<strong>`, `&nbsp;`) für native mobile Darstellung.
3. **Taxonomie- & Alters-Mapping:** Weist Altersgrenzen (`minAge`, `maxAge`) basierend auf Klassenstufen automatisch zu.
4. **Idempotenter Upsert:** Speichert Fragen, Optionen, geheime Lösungen und CC-Lizenzen direkt relational in PostgreSQL (`TasksTable`). Duplikate werden vermieden.
---
## Projektkonfiguration und Umgebungsvariablen
Die Anwendung wird über folgende Konfigurationsdatei konfiguriert:
```text
src/main/resources/application.yaml
```
Für eine flexible Konfiguration in lokalen Entwicklungsumgebungen, Docker-Containern oder Deployments können folgende Umgebungsvariablen gesetzt werden:
| Variable | Beschreibung | Standardwert |
| :------------ | :----------------------------------------- | :------------------------------- |
| `DB_HOST` | Hostname der PostgreSQL-Datenbank | `localhost` |
| `DB_PORT` | Port der PostgreSQL-Datenbank | `5432` |
| `DB_NAME` | Name der PostgreSQL-Datenbank | `kidio_db` |
| `DB_USER` | PostgreSQL-Benutzername | `kidio_admin` |
| `DB_PASSWORD` | PostgreSQL-Passwort | `learning` |
| `REDIS_HOST` | Hostname der Redis-Instanz | `localhost` |
| `REDIS_PORT` | Port der Redis-Instanz | `6379` |
| `JWT_SECRET` | Geheimer Schlüssel zur Signierung der JWTs | `super-geheimes-secret-fuer-dev` |
> **Hinweis:** Die angegebenen Standardwerte sind für die lokale Entwicklung gedacht. Für produktive Umgebungen sollten insbesondere `DB_PASSWORD` und `JWT_SECRET` über sichere Secrets bzw. Umgebungsvariablen gesetzt werden.
---
## Lokale Entwicklung
### Voraussetzungen
Für die lokale Entwicklung werden benötigt:
* JDK / Java
* Kotlin bzw. Gradle Wrapper
* PostgreSQL
* Redis
Die konkreten Versionen richten sich nach der im Projekt konfigurierten Gradle- und Kotlin-Version.
---
## Bauen und Ausführen
Das Projekt verwendet den Gradle Wrapper, sodass Gradle nicht global installiert sein muss.
### Anwendung starten
```bash
./gradlew run
```
Der Ktor-Server ist anschließend standardmäßig unter folgender Adresse erreichbar:
```text
http://localhost:8080
```
### Projekt bauen
```bash
./gradlew build
```
### Tests ausführen
```bash
./gradlew test
```
---
## Code Quality
Für die Einhaltung der Kotlin-Coding-Standards wird **KtLint** verwendet.
### Code automatisch formatieren
```bash
./gradlew ktlintFormat
```
### Code Style prüfen
```bash
./gradlew ktlintCheck
```
Der KtLint-Check wird ebenfalls innerhalb der CI-Pipeline ausgeführt.
---
## Empfohlener Workflow vor Commits / Pull Requests
Vor einem Commit oder Pull Request sollte der folgende Workflow ausgeführt werden:
```bash
# 1. Kotlin-Code automatisch formatieren
./gradlew ktlintFormat
# 2. Code Style überprüfen
./gradlew ktlintCheck
# 3. Tests ausführen
./gradlew test
# 4. Projekt bauen
./gradlew build
# 5. Server lokal starten
./gradlew run
```
Der Server ist anschließend unter:
```text
http://localhost:8080
```
erreichbar.
---
## CI/CD Pipeline
Das Repository verwendet **Forgejo Actions** für Continuous Integration.
Bei jedem Push oder Pull Request auf den `main`-Branch wird automatisch ein Workflow ausgeführt.
Die Pipeline führt unter anderem folgende Prüfungen durch:
1. **Code Style**
```bash
./gradlew ktlintCheck
```
2. **Automatisierte Tests**
```bash
./gradlew test
```
Dadurch wird sichergestellt, dass Änderungen den bestehenden Code-Style einhalten und die automatisierten Tests weiterhin erfolgreich ausgeführt werden können.
---
## Architektur
Das Backend besteht im Wesentlichen aus folgenden Komponenten:
```text
┌───────────────────┐
│ Client │
│ Web / Mobile / │
│ Device │
└─────────┬─────────┘
│
│ HTTP / JSON
▼
┌───────────────────┐
│ Ktor │
│ REST API │
└─────────┬─────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Redis │ │ PostgreSQL │
│ Cache │ │ Persistence │
└────────┬────────┘ └────────┬────────┘
│ │
└────────────┬────────────┘
│
▼
┌───────────────────┐
│ Exposed ORM │
└───────────────────┘
```
### Redis-Fallback
Redis wird für schnelle Zugriffe auf häufig benötigte Daten, insbesondere die verbleibende Bildschirmzeit, verwendet.
Sollte Redis nicht erreichbar sein, greift das Backend automatisch auf PostgreSQL zurück.
Dadurch bleibt die Anwendung auch bei einem Ausfall des Caches funktionsfähig.
---
## Bildschirmzeit-Logik
Die Bildschirmzeit wird intern in Sekunden verwaltet.
Beispielsweise:
```text
Start:
1800 Sekunden
= 30 Minuten
Verbrauch:
200 Sekunden
Verbleibend:
1600 Sekunden
= 26 Minuten 40 Sekunden
```
Sobald die verbleibende Bildschirmzeit auf `0` oder darunter fällt, wird der Benutzer automatisch gesperrt:
```json
{
"remainingSeconds": 0,
"isBlocked": true
}
```
Durch das Lösen einer Aufgabe kann zusätzliche Bildschirmzeit erworben werden.
Beispiel:
```text
Verbleibend: 0 Sekunden
Aufgaben-Belohnung: 10 Minuten
↓
Neue Bildschirmzeit: 600 Sekunden
```
---
## Lizenz
Falls für das Projekt eine bestimmte Lizenz verwendet wird, sollte diese hier ergänzt werden, beispielsweise:
```text
MIT License
```
Eine entsprechende `LICENSE`-Datei sollte sich im Root-Verzeichnis des Repositorys befinden.
---
## Kidio Ökosystem
Für Kinder (Der spielerische Teil - "Kid"):
* Kidio Learn: Interaktive Lernspiele (Mathe, Sprachen, Logik), die sich dem Alter und Lernfortschritt des Kindes anpassen.
* Kidio Create: Tools und Plattformen, um eigene Ideen, Kunstwerke oder kleine Projekte zu entwickeln und zu teilen.
* Kidio Media: Ein sicherer Bereich mit kindgerechten Videos, Hörspielen und Büchern (ähnlich wie YouTube Kids).
![img.png](img.png)

View file

@ -1,7 +1,6 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(ktorLibs.plugins.ktor)
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
kotlin("plugin.serialization") version "2.0.0"
}
@ -11,12 +10,6 @@ version = "1.0.0-SNAPSHOT"
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
ktor {
openApi {
enabled = true // Aktiviert die OpenAPI-Code-Generierung
codeInferenceEnabled = true
}
}
kotlin {
jvmToolchain(21)
@ -26,16 +19,13 @@ dependencies {
implementation(ktorLibs.server.config.yaml)
implementation(ktorLibs.server.core)
implementation(ktorLibs.server.netty)
implementation("io.ktor:ktor-server-openapi")
// JWT Authentication
// JWT Authentication (NEU)
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")
implementation(libs.logback.classic)
// Bcrypt
implementation("org.mindrot:jbcrypt:0.4")
// JSON-Unterstützung und Serialization
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
@ -45,18 +35,11 @@ dependencies {
implementation("org.jetbrains.exposed:exposed-core:0.50.0")
implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
testImplementation(kotlin("test"))
testImplementation(ktorLibs.server.testHost)
// redis
implementation("redis.clients:jedis:5.1.2")
}
tasks.register<JavaExec>("runIngestion") {
group = "application"
description = "Runs the standalone H5P/ZUM task ingestion CLI tool"
mainClass.set("com.oliver.kidio.backend.ingestion.H5PIngestionCli")
classpath = sourceSets["main"].runtimeClasspath
standardInput = System.`in`
implementation("org.jetbrains.exposed:exposed-java-time:0.50.0")
}

View file

@ -1,23 +0,0 @@
# Swagger Codegen Ignore
# Generated by swagger-codegen https://github.com/swagger-api/swagger-codegen
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell Swagger Codgen to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View file

@ -1 +0,0 @@
3.0.81

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500

19
gradlew vendored
View file

@ -114,8 +114,6 @@ case "$( uname )" in #(
NONSTOP*) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
@ -132,8 +130,7 @@ location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
if ! command -v java >/dev/null 2>&1; then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
@ -149,6 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
# shellcheck disable=SC2039,SC3045
MAX_FD=$(ulimit -H -n) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in #(
'' | soft) : ;; #(
@ -157,6 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
@ -175,12 +174,14 @@ if "$cygwin" || "$msys" ; then
JAVACMD=$(cygpath --unix "$JAVACMD")
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
for arg; do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
/?*)
t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ]
;; #(
*) false ;;
esac
then
@ -198,7 +199,6 @@ if "$cygwin" || "$msys" ; then
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@ -214,8 +214,7 @@ set -- \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
if ! command -v xargs >/dev/null 2>&1; then
die "xargs is not available"
fi

BIN
img.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

View file

@ -19,3 +19,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Kidio_backend"

BIN
src/.DS_Store vendored

Binary file not shown.

BIN
src/main/.DS_Store vendored

Binary file not shown.

View file

@ -1,71 +1,77 @@
package com.oliver.kidio.backend.data.database
import com.oliver.kidio.backend.data.tables.ScreentimeTable
import com.oliver.kidio.backend.data.tables.TasksTable
import com.oliver.kidio.backend.data.tables.UserTable
import io.ktor.server.config.*
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
object TasksTable : Table("tasks") {
val id = varchar("id", 50)
val question = text("question")
val options = text("options") // Kommagetrennte Liste der Antwortmöglichkeiten
val correctAnswer = varchar("correct_answer", 255)
val rewardMinutes = integer("reward_minutes")
override val primaryKey = PrimaryKey(id)
}
object ScreentimeTable : Table("user_screentime") {
val userId = varchar("user_id", 50)
val remainingSeconds = integer("remaining_seconds")
val parentPin = varchar("parent_pin", 10)
override val primaryKey = PrimaryKey(userId)
}
object UserTable : Table("users") {
val id = varchar("id", 36)
val user_username = varchar("username", 50).index(isUnique = true)
val passwordHash = varchar("password_hash", 255)
override val primaryKey = PrimaryKey(id)
}
object DatabaseFactory {
fun init(config: ApplicationConfig) {
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"
val host = System.getenv("DB_HOST") ?: config.propertyOrNull("storage.host")?.getString() ?: "localhost"
val port = System.getenv("DB_PORT") ?: config.propertyOrNull("storage.port")?.getString() ?: "5432"
val dbName = System.getenv("DB_NAME") ?: config.propertyOrNull("storage.database")?.getString() ?: "kidio_db"
val dbUsername = System.getenv("DB_USER") ?: config.propertyOrNull("storage.username")?.getString() ?: "postgres"
val password = System.getenv("DB_PASSWORD") ?: config.propertyOrNull("storage.password")?.getString() ?: "postgres"
val host = System.getenv("DB_HOST")
?: config.propertyOrNull("storage.host")?.getString()
?: "localhost"
val port = System.getenv("DB_PORT")
?: config.propertyOrNull("storage.port")?.getString()
?: "5432"
val dbName = System.getenv("DB_NAME")
?: config.propertyOrNull("storage.database")?.getString()
?: "kidio_db"
val username = System.getenv("DB_USER")
?: config.propertyOrNull("storage.username")?.getString()
?: "postgres"
val password = System.getenv("DB_PASSWORD")
?: config.propertyOrNull("storage.password")?.getString()
?: "postgres"
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
val database =
Database.connect(
val database = Database.connect(
url = jdbcURL,
driver = driverClassName,
user = dbUsername,
password = password,
user = username,
password = password
)
transaction(database) {
// Fill existing NULL values if columns exist before SchemaUtils alters constraints
exec(
"""
DO ${'$'}${'$'}
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='users') THEN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='family_name') THEN
UPDATE users SET family_name = 'Familie' WHERE family_name IS NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='child_age') THEN
UPDATE users SET child_age = 8 WHERE child_age IS NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='parent_pin') THEN
UPDATE users SET parent_pin = '1234' WHERE parent_pin IS NULL;
END IF;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='tasks') THEN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='tasks' AND column_name='subject') THEN
UPDATE tasks SET subject = 'Mathematik' WHERE subject IS NULL;
END IF;
END IF;
END ${'$'}${'$'};
""".trimIndent(),
)
SchemaUtils.createMissingTablesAndColumns(TasksTable, ScreentimeTable, UserTable)
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
// Standard aufgaben, sollte es keine mehr geben
if (TasksTable.selectAll().empty()) {
TasksTable.insert {
it[id] = "t1"
it[externalSource] = "local"
it[license] = "CC BY-SA 4.0"
it[subject] = "Mathematik"
it[minAge] = 6
it[maxAge] = 10
it[taskType] = "MULTIPLE_CHOICE"
it[question] = "Was ist 12 + 15?"
it[options] = "25,27,30,22"
it[correctAnswer] = "27"
@ -73,12 +79,6 @@ object DatabaseFactory {
}
TasksTable.insert {
it[id] = "t2"
it[externalSource] = "local"
it[license] = "CC BY-SA 4.0"
it[subject] = "Mathematik"
it[minAge] = 7
it[maxAge] = 12
it[taskType] = "MULTIPLE_CHOICE"
it[question] = "Was ist 8 x 7?"
it[options] = "54,56,64,48"
it[correctAnswer] = "56"
@ -86,25 +86,27 @@ object DatabaseFactory {
}
}
// Seed a default user screentime if empty
if (ScreentimeTable.selectAll().empty()) {
ScreentimeTable.insert {
it[userId] = "default_user"
it[remainingSeconds] = 1800
}
}
if (UserTable.selectAll().empty()) {
UserTable.insert {
it[id] = "default_user"
it[email] = "test@kidio.de"
it[passwordHash] = "1234" // Dummy-Hash für den Start
it[familyName] = "Familie Test"
it[childAge] = 8
it[parentPin] = "1234"
}
}
// Create a standard user if the Users table is empty.
if (UserTable.selectAll().empty()) {
UserTable.insert {
// ID automatische generierung
it[id] = "default_user"
it[user_username] = "default_user"
it[passwordHash] = "1234"
}
}
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
}

View file

@ -1,6 +1,5 @@
package com.oliver.kidio.backend.data.database
import io.ktor.server.config.ApplicationConfig
import io.ktor.server.routing.get
import io.ktor.server.config.*
import redis.clients.jedis.JedisPool
import redis.clients.jedis.JedisPoolConfig
@ -8,20 +7,15 @@ object RedisFactory {
private var jedisPool: JedisPool? = null
fun init(config: ApplicationConfig) {
val host =
System.getenv("REDIS_HOST")
val host = System.getenv("REDIS_HOST")
?: config.propertyOrNull("storage.redisHost")?.getString()
?: "localhost"
val port =
(
System.getenv("REDIS_PORT")
val port = (System.getenv("REDIS_PORT")
?: config.propertyOrNull("storage.redisPort")?.getString()
?: "6379"
).toInt()
?: "6379").toInt()
val poolConfig =
JedisPoolConfig().apply {
val poolConfig = JedisPoolConfig().apply {
maxTotal = 32
maxIdle = 8
minIdle = 2
@ -29,7 +23,6 @@ object RedisFactory {
jedisPool = JedisPool(poolConfig, host, port)
}
fun getResource() =
jedisPool?.resource
fun getResource() = jedisPool?.resource
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
}

View file

@ -1,10 +0,0 @@
package com.oliver.kidio.backend.data.tables
import org.jetbrains.exposed.sql.Table
object ScreentimeTable : Table("user_screentime") {
val userId = varchar("user_id", 50)
val remainingSeconds = integer("remaining_seconds")
override val primaryKey = PrimaryKey(userId)
}

View file

@ -1,24 +0,0 @@
package com.oliver.kidio.backend.data.tables
import org.jetbrains.exposed.sql.Table
object TasksTable : Table("tasks") {
val id = varchar("id", 64)
val externalSource = varchar("external_source", 32).default("local")
val sourceUrl = text("source_url").nullable()
val license = varchar("license", 100).default("CC BY-SA 4.0")
val subject = varchar("subject", 64).default("Mathematik")
val minAge = integer("min_age").default(6)
val maxAge = integer("max_age").default(12)
val taskType = varchar("task_type", 32).default("MULTIPLE_CHOICE")
val question = text("question")
val options = text("options")
val correctAnswer = varchar("correct_answer", 255)
val rewardMinutes = integer("reward_minutes").default(5)
override val primaryKey = PrimaryKey(id)
init {
index("idx_tasks_filter", false, subject, minAge, maxAge)
}
}

View file

@ -1,14 +0,0 @@
package com.oliver.kidio.backend.data.tables
import org.jetbrains.exposed.sql.Table
object UserTable : Table("users") {
val id = varchar("id", 36)
val email = varchar("email", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val familyName = varchar("family_name", 100).default("Familie")
val childAge = integer("child_age").default(8)
val parentPin = varchar("parent_pin", 4).default("1234")
override val primaryKey = PrimaryKey(id)
}

View file

@ -1,37 +0,0 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class RegisterRequest(
val email: String,
val passwordPlain: String,
val familyName: String,
val childAge: Int,
val parentPin: String,
)
@Serializable
data class LoginRequest(
val email: String,
val passwordPlain: String,
)
@Serializable
data class AuthResponse(
val token: String,
val userId: String,
val familyName: String,
val childAge: Int,
)
@Serializable
data class VerifyPinRequest(
val pin: String,
)
@Serializable
data class VerifyPinResponse(
val success: Boolean,
val message: String,
)

View file

@ -1,47 +0,0 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
// Eine Lernaufgabe (z. B. Mathe)
@Serializable
data class Task(
val id: String,
val subject: String = "Mathematik",
val minAge: Int = 6,
val maxAge: Int = 12,
val question: String,
val options: List<String>,
val rewardMinutes: Int,
val license: String? = null,
)
// Interne Datenklasse für importierte Aufgaben (Ingestion)
data class ImportedTask(
val id: String,
val externalSource: String = "zum.de",
val sourceUrl: String? = null,
val license: String = "CC BY-SA 4.0",
val subject: String,
val minAge: Int,
val maxAge: Int,
val taskType: String = "MULTIPLE_CHOICE",
val question: String,
val options: List<String>,
val correctAnswer: String,
val rewardMinutes: Int = 5,
)
// Anfrage des Kindes, wenn es eine Aufgabe löst
@Serializable
data class TaskVerifyRequest(
val taskId: String,
val selectedAnswer: String,
)
// Antwort vom Server nach der Aufgaben-Prüfung
@Serializable
data class TaskVerifyResponse(
val isCorrect: Boolean,
val earnedMinutes: Int,
val message: String,
)

View file

@ -1,26 +0,0 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
// Interne Backend-Klasse (Domain Model).
data class User(
val id: String,
val email: String,
val passwordHash: String,
val familyName: String,
val childAge: Int,
val parentPin: String,
)
// Datenabgleich für die Bildschirmzeit (Sync)
@Serializable
data class SyncRequest(
val userId: String,
val usedSecondsSinceLastSync: Int,
)
@Serializable
data class SyncResponse(
val remainingSeconds: Int,
val isBlocked: Boolean,
)

View file

@ -0,0 +1,52 @@
package com.oliver.kidio.backend
import kotlinx.serialization.Serializable
// Eine Lernaufgabe (z. B. Mathe)
@Serializable
data class Task(
val id: String,
val question: String,
val options: List<String>, // Antwortmöglichkeiten
val rewardMinutes: Int // Wie viel Belohnung es gibt
)
// Anfrage des Kindes, wenn es eine Aufgabe löst
@Serializable
data class TaskVerifyRequest(
val taskId: String,
val selectedAnswer: String
)
// Antwort vom Server nach der Aufgaben-Prüfung
@Serializable
data class TaskVerifyResponse(
val isCorrect: Boolean,
val earnedMinutes: Int,
val message: String
)
// Datenabgleich für die Bildschirmzeit (Sync)
@Serializable
data class SyncRequest(
val userId: String,
val usedSecondsSinceLastSync: Int
)
@Serializable
data class LoginRequest(
val userId: String
)
@Serializable
data class User(
val id: String,
val username: String,
val passwordHash: String
)
@Serializable
data class SyncResponse(
val remainingSeconds: Int,
val isBlocked: Boolean
)

View file

@ -1,242 +0,0 @@
package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.tables.ScreentimeTable
import com.oliver.kidio.backend.data.tables.TasksTable
import com.oliver.kidio.backend.data.tables.UserTable
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.User
import org.jetbrains.exposed.sql.*
import org.mindrot.jbcrypt.BCrypt
class ExposedKidioRepository : KidioRepository {
override suspend fun getTasks(
subject: String?,
age: Int?,
limit: Int,
): List<Task> =
DatabaseFactory.dbQuery {
var query = TasksTable.selectAll()
if (!subject.isNullOrBlank()) {
query = query.where { TasksTable.subject.lowerCase() eq subject.trim().lowercase() }
}
if (age != null) {
query = query.andWhere { (TasksTable.minAge lessEq age) and (TasksTable.maxAge greaterEq age) }
}
val safeLimit = if (limit <= 0) 10 else limit
query.limit(safeLimit).map {
Task(
id = it[TasksTable.id],
subject = it[TasksTable.subject],
minAge = it[TasksTable.minAge],
maxAge = it[TasksTable.maxAge],
question = it[TasksTable.question],
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
rewardMinutes = it[TasksTable.rewardMinutes],
license = it[TasksTable.license],
)
}
}
override suspend fun getUserById(userId: String): User? =
DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.id eq userId }
.map {
User(
id = it[UserTable.id],
email = it[UserTable.email],
passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin],
)
}
.singleOrNull()
}
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
override suspend fun getExistingTaskIds(externalSource: String): Set<String> =
DatabaseFactory.dbQuery {
TasksTable
.select(TasksTable.id)
.where { TasksTable.externalSource eq externalSource }
.map { it[TasksTable.id] }
.toSet()
}
override suspend fun upsertTask(task: ImportedTask): Boolean =
DatabaseFactory.dbQuery {
try {
val updated =
TasksTable.update({ TasksTable.id eq task.id }) {
it[externalSource] = task.externalSource
it[sourceUrl] = task.sourceUrl
it[license] = task.license
it[subject] = task.subject
it[minAge] = task.minAge
it[maxAge] = task.maxAge
it[taskType] = task.taskType
it[question] = task.question
it[options] = task.options.joinToString(",")
it[correctAnswer] = task.correctAnswer
it[rewardMinutes] = task.rewardMinutes
}
if (updated == 0) {
TasksTable.insert {
it[id] = task.id
it[externalSource] = task.externalSource
it[sourceUrl] = task.sourceUrl
it[license] = task.license
it[subject] = task.subject
it[minAge] = task.minAge
it[maxAge] = task.maxAge
it[taskType] = task.taskType
it[question] = task.question
it[options] = task.options.joinToString(",")
it[correctAnswer] = task.correctAnswer
it[rewardMinutes] = task.rewardMinutes
}
}
true
} catch (e: Exception) {
println("FEHLER beim Upsert von Task ${task.id}: ${e.message}")
false
}
}
override suspend fun registerUser(user: User): Boolean =
DatabaseFactory.dbQuery {
try {
UserTable.insert {
it[id] = user.id
it[email] = user.email
it[passwordHash] = user.passwordHash
it[familyName] = user.familyName
it[childAge] = user.childAge
it[parentPin] = user.parentPin
}
ScreentimeTable.insert {
it[userId] = user.id
it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten
}
true
} catch (e: Exception) {
println("FEHLER bei Registrierung: ${e.message}")
false
}
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? =
DatabaseFactory.dbQuery {
TasksTable.selectAll()
.where { TasksTable.id eq taskId }
.map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] }
.singleOrNull()
}
override suspend fun getScreentime(userId: String): Int {
val redisKey = "screentime:$userId"
try {
RedisFactory.getResource().use { jedis ->
val cachedSeconds = jedis.get(redisKey)
if (cachedSeconds != null) {
println("DEBUG: Bildschirmzeit für $userId aus REDIS geladen: $cachedSeconds Sek")
return cachedSeconds.toInt()
}
}
} catch (e: Exception) {
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
}
val dbSeconds =
DatabaseFactory.dbQuery {
ScreentimeTable.selectAll()
.where { ScreentimeTable.userId eq userId }
.map { it[ScreentimeTable.remainingSeconds] }
.singleOrNull() ?: 1800
}
try {
RedisFactory.getResource().use { jedis ->
jedis.setex(redisKey, 3600, dbSeconds.toString())
println("DEBUG: Bildschirmzeit für $userId in REDIS gecacht.")
}
} catch (e: Exception) {
println("WARNUNG: Konnte Wert nicht in Redis speichern: ${e.message}")
}
return dbSeconds
}
override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
DatabaseFactory.dbQuery {
val rowsUpdated =
ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
it[remainingSeconds] = seconds
}
if (rowsUpdated == 0) {
ScreentimeTable.insert {
it[ScreentimeTable.userId] = userId
it[ScreentimeTable.remainingSeconds] = seconds
}
}
}
val redisKey = "screentime:$userId"
try {
RedisFactory.getResource().use { jedis ->
jedis.setex(redisKey, 3600, seconds.toString())
println("DEBUG: Bildschirmzeit für $userId in REDIS aktualisiert auf $seconds Sek")
}
} catch (e: Exception) {
println("WARNUNG: Konnte Redis nicht aktualisieren: ${e.message}")
}
}
override suspend fun findByEmail(email: String): User? =
DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.email eq email }
.map {
User(
id = it[UserTable.id],
email = it[UserTable.email],
passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin],
)
}
.singleOrNull()
}
override suspend fun findById(userId: String): User? =
DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.id eq userId }
.map {
User(
id = it[UserTable.id],
email = it[UserTable.email],
passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin],
)
}
.singleOrNull()
}
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
}

View file

@ -1,94 +0,0 @@
package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.User
import org.mindrot.jbcrypt.BCrypt
class InMemoryKidioRepository : KidioRepository {
private val tasks = mutableListOf<ImportedTask>()
private val screentimes =
mutableMapOf(
"default_user" to 1800,
)
private val users = mutableListOf<User>()
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
override suspend fun getTasks(
subject: String?,
age: Int?,
limit: Int,
): List<Task> {
val safeLimit = if (limit <= 0) 10 else limit
return tasks.filter { task ->
val matchSubject = subject.isNullOrBlank() || task.subject.equals(subject.trim(), ignoreCase = true)
val matchAge = age == null || (task.minAge <= age && task.maxAge >= age)
matchSubject && matchAge
}.take(safeLimit).map {
Task(
id = it.id,
subject = it.subject,
minAge = it.minAge,
maxAge = it.maxAge,
question = it.question,
options = it.options,
rewardMinutes = it.rewardMinutes,
license = it.license,
)
}
}
override suspend fun getUserById(userId: String): User? {
return users.find { it.id == userId }
}
override suspend fun getExistingTaskIds(externalSource: String): Set<String> {
return tasks
.filter { it.externalSource == externalSource }
.map { it.id }
.toSet()
}
override suspend fun upsertTask(task: ImportedTask): Boolean {
tasks.removeIf { it.id == task.id }
tasks.add(task)
return true
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? {
val task = tasks.find { it.id == taskId } ?: return null
return task.correctAnswer to task.rewardMinutes
}
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
screentimes[userId] = seconds
}
override suspend fun findByEmail(email: String): User? {
return users.find { it.email == email }
}
override suspend fun findById(userId: String): User? {
return users.find { it.id == userId }
}
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
override suspend fun registerUser(user: User): Boolean {
users.add(user)
screentimes[user.id] = 1800
return true
}
}

View file

@ -1,41 +1,137 @@
package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.User
import com.oliver.kidio.backend.Task
import com.oliver.kidio.backend.User
import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.database.ScreentimeTable
import com.oliver.kidio.backend.data.database.TasksTable
import com.oliver.kidio.backend.data.database.UserTable
import org.jetbrains.exposed.sql.*
interface KidioRepository {
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
suspend fun getUserById(userId: String): User?
suspend fun findById(userId: String): User?
suspend fun getTasks(
subject: String? = null,
age: Int? = null,
limit: Int = 10,
): List<Task>
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
suspend fun upsertTask(task: ImportedTask): Boolean
suspend fun getAllTasks(): List<Task>
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: Int)
suspend fun updateScreentime(
userId: String,
seconds: Int,
suspend fun findByUsername(username: String): User?
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
}
class ExposedKidioRepository : KidioRepository {
override suspend fun getAllTasks(): List<Task> = DatabaseFactory.dbQuery {
TasksTable.selectAll().map {
Task(
id = it[TasksTable.id],
question = it[TasksTable.question],
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
rewardMinutes = it[TasksTable.rewardMinutes]
)
}
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery {
TasksTable.selectAll()
.where { TasksTable.id eq taskId }
.map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] }
.singleOrNull()
}
override suspend fun getScreentime(userId: String): Int {
var redisKey = "screentime:$userId"
try {
RedisFactory.getResource().use { jedis ->
val cachedSeconds = jedis.get(redisKey)
if (cachedSeconds != null) {
println("DEBUG: Bildschirmzeit für $userId aus REDIS geladen: $cachedSeconds Sek")
return cachedSeconds.toInt()
}
}
} catch (e: Exception) {
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
}
val dbSeconds = DatabaseFactory.dbQuery {
ScreentimeTable.selectAll()
.where { ScreentimeTable.userId eq userId }
.map { it[ScreentimeTable.remainingSeconds] }
.singleOrNull() ?: 1800
}
try {
RedisFactory.getResource().use { jedis ->
jedis.setex(redisKey, 3600, dbSeconds.toString())
println("DEBUG: Bildschirmzeit für $userId in REDIS gecacht.")
}
} catch (e: Exception) {
println("WARNUNG: Konnte Wert nicht in Redis speichern: ${e.message}")
}
return dbSeconds
}
override suspend fun updateScreentime(userId: String, seconds: Int): Unit = DatabaseFactory.dbQuery {
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
it[remainingSeconds] = seconds
}
if (rowsUpdated == 0) {
ScreentimeTable.insert {
it[ScreentimeTable.userId] = userId
it[remainingSeconds] = seconds
it[parentPin] = "1234"
}
}
}
override suspend fun findByUsername(username: String): User? = DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.user_username eq username }
.map {
User(
id = it[UserTable.id],
username = it[UserTable.user_username],
passwordHash = it[UserTable.passwordHash]
)
}
.singleOrNull()
}
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
return password == passwordHash
}
}
class InMemoryKidioRepository : KidioRepository {
private val tasks = mutableListOf(
Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10),
Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15)
)
private val answers = mutableMapOf(
"t1" to ("27" to 10),
"t2" to ("56" to 15)
)
private val screentimes = mutableMapOf(
"default_user" to 1800
)
suspend fun findByEmail(email: String): User?
private val users = mutableListOf(
User(id = "1", username = "max", passwordHash = "geheim123")
)
suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean
override suspend fun getAllTasks(): List<Task> = tasks
suspend fun registerUser(user: User): Boolean
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId]
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
override suspend fun updateScreentime(userId: String, seconds: Int) {
screentimes[userId] = seconds
}
override suspend fun findByUsername(username: String): User? {
return users.find { it.username == username }
}
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
return password == passwordHash
}
}

View file

@ -1,157 +0,0 @@
package com.oliver.kidio.backend.ingestion
import com.oliver.kidio.backend.data.tables.TasksTable
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
import com.oliver.kidio.backend.domain.repository.KidioRepository
import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import java.io.ByteArrayInputStream
object H5PIngestionCli {
/**
* Executes batch ingestion pipeline and upserts tasks into the target repository.
*/
fun ingestBatch(
repository: KidioRepository,
client: ZumApiClient = ZumApiClient(),
subject: String? = null,
limit: Int = 50,
): Int =
runBlocking {
val existingIds = repository.getExistingTaskIds("zum.de")
println("Bereits in DB vorhandene ZUM-Aufgaben: ${existingIds.size}")
val items =
client.fetchLiveOerItems(
subject = subject,
limit = limit,
existingIds = existingIds,
)
var importedCount = 0
for (item in items) {
importedCount += processItem(item, client, repository)
}
println("Ingestion completed: $importedCount tasks successfully imported/updated.")
importedCount
}
private suspend fun processItem(
item: ZumOerItem,
client: ZumApiClient,
repository: KidioRepository,
): Int {
var importedCount = 0
val parsedList = extractTasks(item, client)
for (parsed in parsedList) {
val task = mapToImportedTask(item, parsed)
if (repository.upsertTask(task)) {
importedCount++
logSuccess(task)
}
}
return importedCount
}
private suspend fun extractTasks(
item: ZumOerItem,
client: ZumApiClient,
): List<ParsedH5PTask> {
if (item.rawJsonContent != null) {
val tasks =
H5PParser.parseAllTasks(
contentJson = item.rawJsonContent,
library = item.library ?: "H5P.MultiChoice",
taskId = item.id,
title = item.title,
license = item.license,
sourceUrl = item.downloadUrl,
)
if (tasks.isNotEmpty()) return tasks
}
if (item.downloadUrl.endsWith(".h5p", ignoreCase = true)) {
val h5pBytes = client.downloadH5P(item.downloadUrl) ?: return emptyList()
val single = H5PParser.parseH5PZip(ByteArrayInputStream(h5pBytes), item.id, item.downloadUrl)
if (single != null) {
return listOf(single)
}
}
return emptyList()
}
private fun mapToImportedTask(
item: ZumOerItem,
parsed: ParsedH5PTask,
): ImportedTask {
val (minAge, maxAge) = ZumApiClient.mapGradeToAgeRange(item.grade)
val isTrueFalse = parsed.options.size == 2 && parsed.options.contains("Wahr")
return ImportedTask(
id = parsed.id,
externalSource = "zum.de",
sourceUrl = parsed.sourceUrl ?: item.downloadUrl,
license = parsed.license,
subject = ZumApiClient.mapSubject(item.subject),
minAge = minAge,
maxAge = maxAge,
taskType = if (isTrueFalse) "TRUE_FALSE" else "MULTIPLE_CHOICE",
question = parsed.question,
options = parsed.options,
correctAnswer = parsed.correctAnswer,
rewardMinutes = 5,
)
}
private fun logSuccess(task: ImportedTask) {
val shortQ = task.question.take(30)
val ageRange = "${task.minAge}-${task.maxAge}"
println(" -> [Importiert] ${task.id} (${task.subject}, $ageRange): \"$shortQ...\"")
}
@JvmStatic
fun main(args: Array<String>) {
println("=== Kidio H5P/ZUM Ingestion Tool ===")
val host = System.getenv("DB_HOST") ?: "localhost"
val port = System.getenv("DB_PORT") ?: "5432"
val dbName = System.getenv("DB_NAME") ?: "kidio_db"
val user = System.getenv("DB_USER") ?: "kidio_admin"
val password = System.getenv("DB_PASSWORD") ?: "learning"
var subject: String? = null
var limit = 50
for (arg in args) {
when {
arg.startsWith("--subject=") -> subject = arg.substringAfter("--subject=")
arg.startsWith("--limit=") -> limit = arg.substringAfter("--limit=").toIntOrNull() ?: 50
}
}
val jdbcUrl = "jdbc:postgresql://$host:$port/$dbName"
println("Connecting to database: $jdbcUrl")
val database =
Database.connect(
url = jdbcUrl,
driver = "org.postgresql.Driver",
user = user,
password = password,
)
transaction(database) {
SchemaUtils.createMissingTablesAndColumns(TasksTable)
}
val repository = ExposedKidioRepository()
ingestBatch(repository = repository, subject = subject, limit = limit)
}
}

View file

@ -1,266 +0,0 @@
package com.oliver.kidio.backend.ingestion
import kotlinx.serialization.json.*
import java.io.InputStream
import java.util.zip.ZipInputStream
object H5PParser {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Strips HTML tags and decodes common HTML entities to provide clean text.
*/
fun stripHtml(html: String): String {
var text = html.replace(Regex("(?i)<(br|p|div|li|tr|h[1-6])[^>]*>"), " ")
text = text.replace(Regex("<[^>]*>"), "")
text =
text
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&times;", "×")
.replace("&bdquo;", "„")
.replace("&ldquo;", "“")
.replace("&auml;", "ä")
.replace("&Auml;", "Ä")
.replace("&ouml;", "ö")
.replace("&Ouml;", "Ö")
.replace("&uuml;", "ü")
.replace("&Uuml;", "Ü")
.replace("&szlig;", "ß")
text = text.replace(Regex("\\s+([?.!,:;])"), "$1")
return text.replace(Regex("\\s+"), " ").trim()
}
/**
* Parses an .h5p ZIP archive stream.
*/
fun parseH5PZip(
inputStream: InputStream,
taskId: String,
sourceUrl: String? = null,
): ParsedH5PTask? {
val zip = ZipInputStream(inputStream)
var h5pJsonString: String? = null
var contentJsonString: String? = null
var entry = zip.nextEntry
while (entry != null) {
val name = entry.name
if (name == "h5p.json" || name.endsWith("/h5p.json")) {
h5pJsonString = zip.readBytes().toString(Charsets.UTF_8)
} else if (name == "content/content.json" || name == "content.json" || name.endsWith("/content.json")) {
contentJsonString = zip.readBytes().toString(Charsets.UTF_8)
}
zip.closeEntry()
entry = zip.nextEntry
}
if (contentJsonString == null) {
return null
}
var library = DEFAULT_LIBRARY
var title = DEFAULT_TITLE
var license = DEFAULT_LICENSE
if (h5pJsonString != null) {
try {
val h5pTree = json.parseToJsonElement(h5pJsonString).jsonObject
library = h5pTree["mainLibrary"]?.jsonPrimitive?.contentOrNull ?: library
title = h5pTree["title"]?.jsonPrimitive?.contentOrNull ?: title
license = h5pTree["license"]?.jsonPrimitive?.contentOrNull ?: license
} catch (e: Exception) {
println("WARNUNG: h5p.json konnte nicht gelesen werden. Nutze Fallback-Werte. Fehler: ${e.message}")
}
}
return parseContentJson(
contentJson = contentJsonString,
library = library,
taskId = taskId,
title = title,
license = license,
sourceUrl = sourceUrl,
)
}
/**
* Parses content.json representation for supported H5P libraries (MultiChoice, TrueFalse).
*/
fun parseContentJson(
contentJson: String,
library: String = DEFAULT_LIBRARY,
taskId: String = "task-1",
title: String = DEFAULT_TITLE,
license: String = DEFAULT_LICENSE,
sourceUrl: String? = null,
): ParsedH5PTask? {
val element =
try {
json.parseToJsonElement(contentJson).jsonObject
} catch (e: Exception) {
return null
}
if (library.contains("TrueFalse", ignoreCase = true) || (element.containsKey("correct") && !element.containsKey("answers"))) {
return parseTrueFalse(element, taskId, title, library, license, sourceUrl)
}
return parseMultiChoice(element, taskId, title, library, license, sourceUrl)
}
/**
* Parses one or multiple tasks from content.json (supports MultiChoice, TrueFalse, and QuestionSet).
*/
fun parseAllTasks(
contentJson: String,
library: String = DEFAULT_LIBRARY,
taskId: String = "task-1",
title: String = DEFAULT_TITLE,
license: String = DEFAULT_LICENSE,
sourceUrl: String? = null,
): List<ParsedH5PTask> {
val element =
try {
json.parseToJsonElement(contentJson).jsonObject
} catch (e: Exception) {
return emptyList()
}
if (library.contains("QuestionSet", ignoreCase = true) && element.containsKey("questions")) {
val questionsArray = element["questions"]?.jsonArray ?: return emptyList()
return questionsArray.mapIndexedNotNull { idx, qElement ->
parseQuestionSetItem(qElement, idx, taskId, title, license, sourceUrl)
}
}
val single = parseContentJson(contentJson, library, taskId, title, license, sourceUrl)
return if (single != null) listOf(single) else emptyList()
}
private fun parseQuestionSetItem(
qElement: JsonElement,
idx: Int,
taskId: String,
title: String,
license: String,
sourceUrl: String?,
): ParsedH5PTask? {
val qObj = qElement.jsonObject
val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: DEFAULT_LIBRARY
val subParams = qObj["params"]?.jsonObject ?: return null
val subId = "$taskId-q${idx + 1}"
val subTitle = "$title #${idx + 1}"
val isTrueFalse =
subLib.contains("TrueFalse", ignoreCase = true) ||
(subParams.containsKey("correct") && !subParams.containsKey("answers"))
val isMultiChoice =
subLib.contains("MultiChoice", ignoreCase = true) ||
subParams.containsKey("answers")
return when {
isTrueFalse -> parseTrueFalse(subParams, subId, subTitle, subLib, license, sourceUrl)
isMultiChoice -> parseMultiChoice(subParams, subId, subTitle, subLib, license, sourceUrl)
else -> null
}
}
private fun parseMultiChoice(
element: JsonObject,
taskId: String,
title: String,
library: String,
license: String,
sourceUrl: String?,
): ParsedH5PTask? {
val questionRaw =
element["question"]?.jsonPrimitive?.contentOrNull
?: element["text"]?.jsonPrimitive?.contentOrNull
?: return null
val questionClean = stripHtml(questionRaw)
if (questionClean.isBlank()) return null
val answersArray = element["answers"]?.jsonArray ?: return null
val options = mutableListOf<String>()
var correctAnswer: String? = null
for (item in answersArray) {
val itemObj = item.jsonObject
val textRaw = itemObj["text"]?.jsonPrimitive?.contentOrNull ?: continue
val textClean = stripHtml(textRaw)
if (textClean.isNotBlank()) {
options.add(textClean)
val isCorrect = itemObj["correct"]?.jsonPrimitive?.booleanOrNull ?: false
if (isCorrect && correctAnswer == null) {
correctAnswer = textClean
}
}
}
if (options.isEmpty() || correctAnswer == null) {
return null
}
return ParsedH5PTask(
id = taskId,
title = title,
library = library,
question = questionClean,
options = options,
correctAnswer = correctAnswer,
license = license,
sourceUrl = sourceUrl,
)
}
private fun parseTrueFalse(
element: JsonObject,
taskId: String,
title: String,
library: String,
license: String,
sourceUrl: String?,
): ParsedH5PTask? {
val questionRaw =
element["question"]?.jsonPrimitive?.contentOrNull
?: element["text"]?.jsonPrimitive?.contentOrNull
?: return null
val questionClean = stripHtml(questionRaw)
if (questionClean.isBlank()) return null
val correctPrimitive = element["correct"]?.jsonPrimitive
val isTrueCorrect =
when {
correctPrimitive?.booleanOrNull != null -> correctPrimitive.boolean
correctPrimitive?.contentOrNull != null -> correctPrimitive.content.equals("true", ignoreCase = true)
else -> true
}
val options = listOf("Wahr", "Falsch")
val correctAnswer = if (isTrueCorrect) "Wahr" else "Falsch"
return ParsedH5PTask(
id = taskId,
title = title,
library = library,
question = questionClean,
options = options,
correctAnswer = correctAnswer,
license = license,
sourceUrl = sourceUrl,
)
}
}

View file

@ -1,15 +0,0 @@
package com.oliver.kidio.backend.ingestion
const val DEFAULT_LIBRARY: String = "H5P.MultiChoice"
const val DEFAULT_TITLE: String = "H5P Task"
data class ParsedH5PTask(
val id: String,
val title: String,
val library: String,
val question: String,
val options: List<String>,
val correctAnswer: String,
val license: String = DEFAULT_LICENSE,
val sourceUrl: String? = null,
)

View file

@ -1,388 +0,0 @@
package com.oliver.kidio.backend.ingestion
import kotlinx.serialization.json.*
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
class ZumApiClient(
private val httpClient: HttpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build(),
) {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
companion object {
fun mapSubject(raw: String): String {
val lower = raw.trim().lowercase()
return when {
lower.contains("mathe") -> "Mathematik"
lower.contains("deutsch") || lower.contains("german") -> "Deutsch"
lower.contains("sach") || lower.contains("hsu") || lower.contains("natur") || lower.contains("heimat") -> "Sachunterricht"
lower.contains("englisch") || lower.contains("english") -> "Englisch"
lower.contains("bio") -> "Biologie"
lower.contains("geo") || lower.contains("erdkunde") -> "Geographie"
lower.contains("gesch") -> "Geschichte"
lower.contains("chemie") -> "Chemie"
lower.contains("physik") -> "Physik"
lower.contains("musik") -> "Musik"
lower.contains("kunst") -> "Kunst"
lower.contains("informatik") -> "Informatik"
else -> raw.trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
}
fun mapGradeToAgeRange(grade: Int?): Pair<Int, Int> {
return when (grade) {
1 -> 6 to 7
2 -> 7 to 8
3 -> 8 to 9
4 -> 9 to 10
5 -> 10 to 11
6 -> 11 to 12
7 -> 12 to 13
8 -> 13 to 14
9 -> 14 to 15
10 -> 15 to 16
11, 12, 13 -> 16 to 18
else -> 6 to 12
}
}
}
/**
* Downloads an .h5p package file from a remote URL.
*/
fun downloadH5P(downloadUrl: String): ByteArray? {
return try {
val request =
HttpRequest.newBuilder()
.uri(URI.create(downloadUrl))
.timeout(Duration.ofSeconds(15))
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray())
if (response.statusCode() == 200) {
response.body()
} else {
null
}
} catch (e: Exception) {
println("WARNUNG: Download fehlgeschlagen für $downloadUrl: ${e.message}")
null
}
}
/**
* Fetches live OER items from apps.zum.de via search and Drupal settings extraction.
* Skips items whose IDs are already present in existingIds.
* Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items.
*/
fun fetchLiveOerItems(
subject: String? = null,
limit: Int = 50,
maxPages: Int = 15,
existingIds: Set<String> = emptySet(),
): List<ZumOerItem> {
val results = mutableListOf<ZumOerItem>()
val seenAppIds = mutableSetOf<String>()
println("[ZUM-Apps Live] Starte Live-Abfrage auf apps.zum.de (Fach: ${subject ?: "Alle Fächer"}, Limit: $limit)...")
for (page in 0 until maxPages) {
if (results.size >= limit) break
processPage(page, subject, limit, existingIds, seenAppIds, results)
}
if (results.isEmpty()) {
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
return getCuratedOerItems(subject = subject, limit = limit)
}
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
return results
}
private fun processPage(
page: Int,
subject: String?,
limit: Int,
existingIds: Set<String>,
seenAppIds: MutableSet<String>,
results: MutableList<ZumOerItem>,
) {
val searchUrl = buildSearchUrl(subject, page)
val html = fetchString(searchUrl)
if (html == null) {
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
return
}
val appIds = extractNewAppIds(html, existingIds, seenAppIds)
if (appIds.isEmpty()) {
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
return
}
for (appId in appIds) {
if (results.size >= limit) break
seenAppIds.add(appId)
val item = fetchAppDetails(appId, subject)
if (item != null) {
results.add(item)
logNewItem(appId, item)
}
}
}
private fun buildSearchUrl(
subject: String?,
page: Int,
): String {
return if (subject.isNullOrBlank()) {
"https://apps.zum.de/apps?page=$page"
} else {
val encoded = URLEncoder.encode(subject, "UTF-8")
"https://apps.zum.de/apps?keys=$encoded&page=$page"
}
}
private fun extractNewAppIds(
html: String,
existingIds: Set<String>,
seenAppIds: Set<String>,
): List<String> {
return Regex("""href=["']/apps/(\d+)["']""")
.findAll(html)
.map { it.groupValues[1] }
.distinct()
.filter { appId ->
val fullId = "zum-$appId"
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
}
.toList()
}
private fun logNewItem(
appId: String,
item: ZumOerItem,
) {
val lib = item.library ?: "H5P"
val gr = item.grade ?: "k.A."
println("[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]")
}
private fun fetchAppDetails(
appId: String,
fallbackSubject: String?,
): ZumOerItem? {
val appUrl = "https://apps.zum.de/apps/$appId"
val html = fetchString(appUrl) ?: return null
val settingsMatch = Regex("""data-drupal-selector="drupal-settings-json">([^<]+)<""").find(html) ?: return null
val jsonStr = settingsMatch.groupValues[1]
val drupalSettings =
try {
json.parseToJsonElement(jsonStr).jsonObject
} catch (_: Exception) {
return null
}
val h5pContents =
drupalSettings["h5p"]?.jsonObject
?.get("H5PIntegration")?.jsonObject
?.get("contents")?.jsonObject
?: return null
val contentObj =
h5pContents["cid-$appId"]?.jsonObject
?: h5pContents.values.firstOrNull()?.jsonObject
?: return null
val library = contentObj["library"]?.jsonPrimitive?.contentOrNull ?: ""
if (!library.contains("MultiChoice", ignoreCase = true) &&
!library.contains("TrueFalse", ignoreCase = true) &&
!library.contains("QuestionSet", ignoreCase = true)
) {
return null
}
val jsonContent = contentObj["jsonContent"]?.jsonPrimitive?.contentOrNull ?: return null
val metadata = contentObj["metadata"]?.jsonObject
val title =
metadata?.get("title")?.jsonPrimitive?.contentOrNull
?: Regex("""<title>(.*?)(?:\|.*)?</title>""").find(html)?.groupValues?.get(1)?.trim()
?: "ZUM App #$appId"
val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull
val license =
when {
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> DEFAULT_LICENSE
rawLicense.contains("CC", ignoreCase = true) -> rawLicense
else -> "CC BY-SA 4.0 ($rawLicense)"
}
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
// Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags)
val tags =
Regex("""<a[^>]*>([^<]+)</a>""")
.findAll(html)
.map { it.groupValues[1].trim() }
.filter { it.isNotBlank() && it.length < 60 }
.toList()
val subject = inferSubject(title, tags, fallbackSubject)
val grade = inferGrade(title, html, tags)
return ZumOerItem(
id = "zum-$appId",
title = title,
subject = subject,
grade = grade,
downloadUrl = exportUrl,
license = license,
library = library,
rawJsonContent = jsonContent,
)
}
private fun inferSubject(
title: String,
tags: List<String>,
fallbackSubject: String?,
): String {
for (tag in tags) {
val mapped = mapSubject(tag)
if (mapped != tag ||
tag.equals("Mathematik", ignoreCase = true) ||
tag.equals("Deutsch", ignoreCase = true) ||
tag.equals("Biologie", ignoreCase = true) ||
tag.equals("Englisch", ignoreCase = true)
) {
return mapped
}
}
if (!fallbackSubject.isNullOrBlank()) {
return mapSubject(fallbackSubject)
}
return mapSubject(title)
}
private fun inferGrade(
title: String,
html: String,
tags: List<String>,
): Int? {
val primaryText = (tags + title).joinToString(" ")
// 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl."
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3"
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 3. Muster: "Klasse 3-4", "Klasse 3/4", "3.-4. Klasse"
Regex("""\b(?:Klasse|Kl\.)?\s*(\d+)\s*[-/]\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 4. Fallback: Gesamten HTML-Text der Seite nach Klassenangaben absuchen
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 5. Fallback: Schulstufen-Schlüsselwörter
val combinedLower = (primaryText + " " + html).lowercase()
if (combinedLower.contains("primarstufe") || combinedLower.contains("grundschule")) return 3
val isSek1 =
combinedLower.contains("sekundarstufe i") ||
combinedLower.contains("sek i") ||
combinedLower.contains("realschule") ||
combinedLower.contains("hauptschule")
if (isSek1) return 6
val isSek2 =
combinedLower.contains("sekundarstufe ii") ||
combinedLower.contains("sek ii") ||
combinedLower.contains("gymnasium") ||
combinedLower.contains("abitur")
if (isSek2) return 10
return null
}
private fun fetchString(url: String): String? {
return try {
val request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(12))
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "de-DE,de;q=0.9,en;q=0.8")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
if (response.statusCode() == 200) {
response.body()
} else {
null
}
} catch (e: Exception) {
null
}
}
/**
* Returns curated ZUM/OER sample metadata items from resources, filterable by subject.
*/
fun getCuratedOerItems(
subject: String? = null,
limit: Int = 20,
): List<ZumOerItem> {
val allItems: List<ZumOerItem> =
try {
val inputStream = this::class.java.getResourceAsStream("/fallback_oer_items.json")
if (inputStream != null) {
val jsonText = inputStream.bufferedReader().use { it.readText() }
json.decodeFromString(jsonText)
} else {
println("WARNUNG: fallback_oer_items.json wurde nicht gefunden!")
emptyList()
}
} catch (e: Exception) {
println("WARNUNG: Fehler beim Laden der Fallback-Daten: ${e.message}")
emptyList()
}
val normalizedSubject = subject?.let { mapSubject(it) }
val filtered =
if (normalizedSubject.isNullOrBlank()) {
allItems
} else {
allItems.filter { it.subject.equals(normalizedSubject, ignoreCase = true) }
}
return filtered.take(limit)
}
}

View file

@ -1,17 +0,0 @@
package com.oliver.kidio.backend.ingestion
import kotlinx.serialization.Serializable
const val DEFAULT_LICENSE: String = "CC BY-SA 4.0"
@Serializable
data class ZumOerItem(
val id: String,
val title: String,
val subject: String,
val grade: Int? = null,
val downloadUrl: String,
val license: String = DEFAULT_LICENSE,
val library: String? = null,
val rawJsonContent: String? = null,
)

View file

@ -1,27 +1,97 @@
package com.oliver.kidio.backend.plugins
import com.oliver.kidio.backend.*
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.routing.*
import com.oliver.kidio.backend.security.JwtService
import io.ktor.server.application.*
import io.ktor.server.plugins.openapi.*
import io.ktor.server.auth.authenticate
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting(
repository: KidioRepository,
jwtService: JwtService,
) {
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
routing {
openAPI(path = "openapi")
// Health-Check
get("/api/v1/health") {
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
}
authRoutes(repository, jwtService)
taskRoutes(repository)
screentimeRoutes(repository)
parentRoutes(repository)
post ("/api/v1/auth/login" ) {
val loginRequest = call.receive<LoginRequest>()
val token = jwtService.generateToken(userId = loginRequest.userId)
call.respond(mapOf("token" to token))
}
authenticate("auth-jwt") {
// 1. Alle verfügbaren Aufgaben abrufen
get("/api/v1/tasks") {
val tasks = repository.getAllTasks()
call.respond(tasks)
}
// 2. Antwort auf eine Aufgabe überprüfen
post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>()
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
if (taskInfo == null) {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!"
)
)
return@post
}
val (correctAnswer, reward) = taskInfo
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
if (isCorrect) {
val currentScreentime = repository.getScreentime("default_user")
val newSeconds = currentScreentime + (reward * 60)
repository.updateScreentime("default_user", newSeconds)
call.respond(
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen."
)
)
} else {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Schade, das war leider falsch. Versuche es nochmal!"
)
)
}
}
// 3. Bildschirmzeit synchronisieren
post("/api/v1/screentime/sync") {
val request = call.receive<SyncRequest>()
val currentSeconds = repository.getScreentime(request.userId)
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
if (newSeconds < 0) newSeconds = 0
repository.updateScreentime(request.userId, newSeconds)
call.respond(
SyncResponse(
remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0
)
)
}
}
}
}

View file

@ -8,7 +8,7 @@ import io.ktor.server.auth.jwt.*
/**
* Repräsentiert den authentifizierten Benutzer im Request-Kontext.
*/
data class UserPrincipal(val userId: String)
data class UserPrincipal(val userId: String) : Principal
fun Application.configureSecurity(jwtService: JwtService) {
install(Authentication) {

View file

@ -1,93 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.AuthResponse
import com.oliver.kidio.backend.domain.models.LoginRequest
import com.oliver.kidio.backend.domain.models.RegisterRequest
import com.oliver.kidio.backend.domain.models.User
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.security.JwtService
import com.oliver.kidio.backend.security.isValidPassword
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
fun Route.authRoutes(
repository: KidioRepository,
jwtService: JwtService,
) {
post("/api/v1/auth/register") {
val registerRequest = call.receive<RegisterRequest>()
if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
return@post
}
if (!isValidPassword(registerRequest.passwordPlain)) {
call.respond(
HttpStatusCode.BadRequest,
"Das Passwort muss mindestens 8 Zeichen lang sein, " +
"mindestens einen Großbuchstaben und ein Sonderzeichen enthalten.",
)
return@post
}
if (registerRequest.parentPin.length != 4) {
call.respond(HttpStatusCode.BadRequest, "Die Eltern-PIN muss genau 4 Ziffern lang sein")
return@post
}
val existingUser = repository.findByEmail(registerRequest.email)
if (existingUser != null) {
call.respond(HttpStatusCode.Conflict, "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits")
return@post
}
val userId = UUID.randomUUID().toString()
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
repository.registerUser(
User(
userId,
registerRequest.email,
passwordHash,
registerRequest.familyName,
registerRequest.childAge,
registerRequest.parentPin,
),
)
val token = jwtService.generateToken(userId)
call.respond(
HttpStatusCode.Created,
mapOf(
"token" to token,
"userId" to userId,
"message" to "Registrierung erfolgreich",
),
)
}
post("/api/v1/auth/login") {
val loginRequest = call.receive<LoginRequest>()
if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
return@post
}
val getUser = repository.findByEmail(loginRequest.email)
if (getUser == null || !repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)) {
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
return@post
}
val token = jwtService.generateToken(getUser.id)
call.respond(
HttpStatusCode.OK,
AuthResponse(
token,
userId = getUser.id,
familyName = getUser.familyName,
childAge = getUser.childAge,
),
)
}
}

View file

@ -1,58 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.VerifyPinRequest
import com.oliver.kidio.backend.domain.models.VerifyPinResponse
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.plugins.UserPrincipal
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.parentRoutes(repository: KidioRepository) {
authenticate("auth-jwt") {
post("/api/v1/parent/verify-pin") {
val request = call.receive<VerifyPinRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Ungültiges oder abgelaufenes Token"),
)
return@post
}
if (request.pin.length != 4 || !request.pin.all { it.isDigit() }) {
call.respond(
HttpStatusCode.BadRequest,
VerifyPinResponse(success = false, message = "Die PIN muss aus genau 4 Ziffern bestehen."),
)
return@post
}
val user = repository.findById(userId)
if (user == null) {
call.respond(
HttpStatusCode.NotFound,
VerifyPinResponse(success = false, message = "Benutzer nicht gefunden."),
)
return@post
}
if (user.parentPin == request.pin) {
call.respond(
HttpStatusCode.OK,
VerifyPinResponse(success = true, message = "PIN korrekt. Zugriff gewährt."),
)
} else {
call.respond(
HttpStatusCode.Unauthorized,
VerifyPinResponse(success = false, message = "Falsche PIN."),
)
}
}
}
}

View file

@ -1,53 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.SyncRequest
import com.oliver.kidio.backend.domain.models.SyncResponse
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.plugins.UserPrincipal
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.screentimeRoutes(repository: KidioRepository) {
authenticate("auth-jwt") {
get("/api/v1/screentime") {
val principal = call.principal<UserPrincipal>()
val userid =
principal?.userId
?: run {
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token"))
return@get
}
val remainingSeconds = repository.getScreentime(userid)
call.respond(
HttpStatusCode.OK,
SyncResponse(
remainingSeconds = remainingSeconds,
isBlocked = remainingSeconds <= 0,
),
)
}
post("/api/v1/screentime/sync") {
val request = call.receive<SyncRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId ?: request.userId
val currentSeconds = repository.getScreentime(userId)
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
if (newSeconds < 0) newSeconds = 0
repository.updateScreentime(userId, newSeconds)
call.respond(
SyncResponse(
remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0,
),
)
}
}
}

View file

@ -1,80 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.TaskVerifyRequest
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.plugins.UserPrincipal
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.taskRoutes(repository: KidioRepository) {
authenticate("auth-jwt") {
get("/api/v1/tasks") {
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
val subject = call.request.queryParameters["subject"]
val queryAge = call.request.queryParameters["age"]?.toIntOrNull()
val userAge = userId?.let { repository.getUserById(it)?.childAge }
val effectiveAge = queryAge ?: userAge
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit)
call.respond(tasks)
}
post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Ungültiges oder abgelaufenes Token"),
)
return@post
}
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
if (taskInfo == null) {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!",
),
)
return@post
}
val (correctAnswer, reward) = taskInfo
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
if (isCorrect) {
val currentScreentime = repository.getScreentime(userId)
val newSeconds = currentScreentime + (reward * 60)
repository.updateScreentime(userId, newSeconds)
call.respond(
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen.",
),
)
} else {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Schade, das war leider falsch. Versuche es nochmal!",
),
)
}
}
}
}

View file

@ -9,10 +9,10 @@ class JwtService(
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
private val issuer: String = "https://kidio.oliver.com/",
private val audience: String = "kidio-users",
private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30),
private val expirationInMs: Long = 36_000_000 // 10 Stunden
) {
val verifier: JWTVerifier =
JWT
val verifier: JWTVerifier = JWT
.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)

View file

@ -1,21 +0,0 @@
package com.oliver.kidio.backend.security
fun isValidPassword(password: String): Boolean {
if (password.length < 8) return false
val hasUppercase = password.any { it.isUpperCase() }
val hasEmoji =
password.any { char ->
val type = Character.getType(char).toByte()
type == Character.SURROGATE ||
type == Character.OTHER_SYMBOL ||
char.code in 0x1F600..0x1F64F ||
char.code in 0x1F300..0x1F5FF ||
char.code in 0x1F680..0x1F6FF ||
char.code in 0x2600..0x26FF
}
val hasSpecialChar = password.any { !it.isLetterOrDigit() && !hasEmoji }
return hasUppercase && hasSpecialChar && !hasEmoji
}

View file

@ -1,13 +1,12 @@
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.module() {
// Datenbank initialisieren
@ -26,6 +25,6 @@ fun Application.module() {
configureRouting(
repository = repository,
jwtService = jwtService,
jwtService = jwtService
)
}

View file

@ -1,13 +1,13 @@
ktor:
deployment:
port: 8081
port: 8080
application:
modules:
- MainKt.module
storage:
driverClassName: "org.postgresql.Driver"
host: "localhost"
host: "82.165.11.162"
port: "5432"
database: "kidio_db"
username: "kidio_admin"

View file

@ -1,74 +0,0 @@
[
{
"id": "zum-math-001",
"title": "Grundrechenarten: Multiplikation 3. Klasse",
"subject": "Mathematik",
"grade": 3,
"downloadUrl": "https://apps.zum.de/api/h5p/math-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Was ist das Ergebnis von <strong>6 &times; 8</strong>?</p>\", \"answers\": [ {\"text\": \"42\", \"correct\": false}, {\"text\": \"48\", \"correct\": true}, {\"text\": \"54\", \"correct\": false}, {\"text\": \"56\", \"correct\": false} ] }"
},
{
"id": "zum-math-002",
"title": "Zahlenraum bis 100: Addition",
"subject": "Mathematik",
"grade": 2,
"downloadUrl": "https://apps.zum.de/api/h5p/math-002.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Berechne: 34 + 28</p>\", \"answers\": [ {\"text\": \"52\", \"correct\": false}, {\"text\": \"62\", \"correct\": true}, {\"text\": \"64\", \"correct\": false} ] }"
},
{
"id": "zum-math-003",
"title": "Geometrie: Formen erkennen",
"subject": "Mathematik",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/math-003.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.</p>\", \"correct\": true }"
},
{
"id": "zum-de-001",
"title": "Wortarten: Nomen erkennen",
"subject": "Deutsch",
"grade": 2,
"downloadUrl": "https://apps.zum.de/api/h5p/de-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Welches der folgenden W&ouml;rter ist ein <strong>Nomen (Namenwort)</strong>?</p>\", \"answers\": [ {\"text\": \"schnell\", \"correct\": false}, {\"text\": \"laufen\", \"correct\": false}, {\"text\": \"Schule\", \"correct\": true}, {\"text\": \"sch&ouml;n\", \"correct\": false} ] }"
},
{
"id": "zum-de-002",
"title": "Rechtschreibung: Doppel-s oder ss",
"subject": "Deutsch",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/de-002.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Wie wird das Wort richtig geschrieben? &bdquo;Der Flu... ist tief.&ldquo;</p>\", \"answers\": [ {\"text\": \"Flus\", \"correct\": false}, {\"text\": \"Fluss\", \"correct\": true}, {\"text\": \"Fluß\", \"correct\": false} ] }"
},
{
"id": "zum-sach-001",
"title": "Natur & Tiere: Winterschlaf",
"subject": "Sachunterricht",
"grade": 3,
"downloadUrl": "https://apps.zum.de/api/h5p/sach-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Welches dieser Tiere h&auml;lt einen echten <strong>Winterschlaf</strong>?</p>\", \"answers\": [ {\"text\": \"Igel\", \"correct\": true}, {\"text\": \"Eichh&ouml;rnchen\", \"correct\": false}, {\"text\": \"Amsel\", \"correct\": false}, {\"text\": \"Fuchs\", \"correct\": false} ] }"
},
{
"id": "zum-sach-002",
"title": "Planetensystem: Sonnennähe",
"subject": "Sachunterricht",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/sach-002.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Welcher Planet ist der Sonne am n&auml;chsten?</p>\", \"answers\": [ {\"text\": \"Venus\", \"correct\": false}, {\"text\": \"Mars\", \"correct\": false}, {\"text\": \"Merkur\", \"correct\": true}, {\"text\": \"Jupiter\", \"correct\": false} ] }"
},
{
"id": "zum-eng-001",
"title": "English Vocabulary: Colors & Animals",
"subject": "Englisch",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/eng-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>What is the English word for &bdquo;Hund&ldquo;?</p>\", \"answers\": [ {\"text\": \"Cat\", \"correct\": false}, {\"text\": \"Dog\", \"correct\": true}, {\"text\": \"Bird\", \"correct\": false}, {\"text\": \"Rabbit\", \"correct\": false} ] }"
}
]

View file

@ -0,0 +1,115 @@
package com.oliver.kidio.backend
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.testing.*
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.*
import kotlin.test.*
class ServerTest {
@Test
fun `test health endpoint`() = testApplication {
application {
install(ServerContentNegotiation) {
json()
}
val jwtService = JwtService()
configureSecurity(jwtService)
configureRouting(InMemoryKidioRepository(), jwtService)
}
val response = client.get("/api/v1/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
}
@Test
fun `test tasks endpoint and verify`() = testApplication {
val jwtService = JwtService()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(InMemoryKidioRepository(), jwtService)
}
val token = jwtService.generateToken("default_user")
val tasksResponse = client.get("/api/v1/tasks") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, tasksResponse.status)
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
assertEquals(2, tasksJson.size)
assertEquals("t1", tasksJson[0].id)
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
// 2. Verify Task correct answer (with Auth header)
val verifyResponse = client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
}
assertEquals(HttpStatusCode.OK, verifyResponse.status)
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
assertTrue(verifyJson.isCorrect)
assertEquals(10, verifyJson.earnedMinutes)
// 3. Verify Task incorrect answer (with Auth header)
val verifyWrongResponse = client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"99"}""")
}
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
}
@Test
fun `test screentime sync`() = testApplication {
val jwtService = JwtService()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService) // <-- Added configureSecurity
configureRouting(InMemoryKidioRepository(), jwtService)
}
val token = jwtService.generateToken("default_user")
// Sync first time
val syncResponse1 = client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""")
}
assertEquals(HttpStatusCode.OK, syncResponse1.status)
val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText())
assertEquals(1600, syncJson1.remainingSeconds)
assertFalse(syncJson1.isBlocked)
// Sync and block
val syncResponse2 = client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":2000}""")
}
assertEquals(HttpStatusCode.OK, syncResponse2.status)
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
assertEquals(0, syncJson2.remainingSeconds)
assertTrue(syncJson2.isBlocked)
}
}

View file

@ -1,178 +0,0 @@
package com.oliver.kidio.backend.com.oliver.kidio.backend.ingestion
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.ingestion.H5PIngestionCli
import com.oliver.kidio.backend.ingestion.H5PParser
import com.oliver.kidio.backend.ingestion.ZumApiClient
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class H5PParserTest {
@Test
fun `test HTML stripping and entity decoding`() {
val html = "<p>Was ist <strong>12 &times; 4</strong>? &nbsp; &bdquo;Test&ldquo; &auml;&ouml;&uuml;</p>"
val clean = H5PParser.stripHtml(html)
assertTrue(clean.contains("Was ist 12"))
assertTrue(clean.contains("äöü"))
}
@Test
fun `test parse MultiChoice content json`() {
val json =
"""
{
"question": "<p>Was ist die Hauptstadt von <strong>Frankreich</strong>?</p>",
"answers": [
{"text": "<div>Berlin</div>", "correct": false},
{"text": "<div>Paris</div>", "correct": true},
{"text": "<div>Rom</div>", "correct": false}
]
}
""".trimIndent()
val parsed = H5PParser.parseContentJson(json, library = "H5P.MultiChoice", taskId = "test-1")
assertNotNull(parsed)
assertEquals("test-1", parsed.id)
assertEquals("Was ist die Hauptstadt von Frankreich?", parsed.question)
assertEquals(3, parsed.options.size)
assertEquals("Paris", parsed.correctAnswer)
}
@Test
fun `test parse TrueFalse content json`() {
val json =
"""
{
"question": "<p>Die Erde ist eine Scheibe.</p>",
"correct": false
}
""".trimIndent()
val parsed = H5PParser.parseContentJson(json, library = "H5P.TrueFalse", taskId = "tf-1")
assertNotNull(parsed)
assertEquals("tf-1", parsed.id)
assertEquals("Die Erde ist eine Scheibe.", parsed.question)
assertEquals(listOf("Wahr", "Falsch"), parsed.options)
assertEquals("Falsch", parsed.correctAnswer)
}
@Test
fun `test parse ZIP stream with h5p metadata and content`() {
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zip ->
// Add h5p.json
val h5pEntry = ZipEntry("h5p.json")
zip.putNextEntry(h5pEntry)
zip.write(
"""
{
"title": "Mathematik Quiz",
"mainLibrary": "H5P.MultiChoice",
"license": "CC BY-SA 4.0"
}
""".trimIndent().toByteArray(Charsets.UTF_8),
)
zip.closeEntry()
// Add content/content.json
val contentEntry = ZipEntry("content/content.json")
zip.putNextEntry(contentEntry)
zip.write(
"""
{
"question": "<p>Wie viele Seiten hat ein Dreieck?</p>",
"answers": [
{"text": "3", "correct": true},
{"text": "4", "correct": false},
{"text": "5", "correct": false}
]
}
""".trimIndent().toByteArray(Charsets.UTF_8),
)
zip.closeEntry()
}
val parsed = H5PParser.parseH5PZip(ByteArrayInputStream(baos.toByteArray()), "zip-task-1")
assertNotNull(parsed)
assertEquals("zip-task-1", parsed.id)
assertEquals("Mathematik Quiz", parsed.title)
assertEquals("Wie viele Seiten hat ein Dreieck?", parsed.question)
assertEquals("3", parsed.correctAnswer)
assertEquals(listOf("3", "4", "5"), parsed.options)
assertEquals("CC BY-SA 4.0", parsed.license)
}
@Test
fun `test parse QuestionSet with multiple questions`() {
val json =
"""
{
"questions": [
{
"library": "H5P.MultiChoice 1.16",
"params": {
"question": "<p>Wie viel ist 5 + 5?</p>",
"answers": [
{"text": "10", "correct": true},
{"text": "11", "correct": false}
]
}
},
{
"library": "H5P.TrueFalse 1.8",
"params": {
"question": "<p>Wasser kocht bei 100 Grad Celsius.</p>",
"correct": true
}
}
]
}
""".trimIndent()
val parsedList = H5PParser.parseAllTasks(json, library = "H5P.QuestionSet", taskId = "qs-1", title = "Mini Quiz")
assertEquals(2, parsedList.size)
val q1 = parsedList[0]
assertEquals("qs-1-q1", q1.id)
assertEquals("Wie viel ist 5 + 5?", q1.question)
assertEquals("10", q1.correctAnswer)
val q2 = parsedList[1]
assertEquals("qs-1-q2", q2.id)
assertEquals("Wasser kocht bei 100 Grad Celsius.", q2.question)
assertEquals("Wahr", q2.correctAnswer)
}
@Test
fun `test taxonomy and grade mapping`() {
assertEquals("Mathematik", ZumApiClient.mapSubject("mathe"))
assertEquals("Deutsch", ZumApiClient.mapSubject("german language"))
assertEquals("Sachunterricht", ZumApiClient.mapSubject("Sachkunde"))
assertEquals("Englisch", ZumApiClient.mapSubject("english"))
assertEquals(6 to 7, ZumApiClient.mapGradeToAgeRange(1))
assertEquals(8 to 9, ZumApiClient.mapGradeToAgeRange(3))
assertEquals(6 to 12, ZumApiClient.mapGradeToAgeRange(null))
}
@Test
fun `test ingestion CLI batch process with in-memory repository`() =
runBlocking {
val repository = InMemoryKidioRepository()
val importedCount = H5PIngestionCli.ingestBatch(repository = repository, limit = 10)
assertTrue(importedCount > 0)
val mathTasks = repository.getTasks(subject = "Mathematik", age = 8)
assertTrue(mathTasks.isNotEmpty())
val deTasks = repository.getTasks(subject = "Deutsch")
assertTrue(deTasks.isNotEmpty())
}
}

View file

@ -1,126 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.AuthResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class AuthRoutesTest {
@Test
fun `test registration and login flow`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// 1. Registrierung
val registerResponse =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""
{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}
""".trimIndent(),
)
}
assertEquals(HttpStatusCode.Created, registerResponse.status)
assertTrue(registerResponse.bodyAsText().contains("token"))
// 2. Login mit denselben Daten
val loginResponse =
client.post("/api/v1/auth/login") {
header(
HttpHeaders.ContentType,
ContentType.Application.Json.toString(),
)
setBody(
"""
{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!"
}
""".trimIndent(),
)
}
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
assertNotNull(loginJson.token)
assertEquals("Familie Schmidt", loginJson.familyName)
}
@Test
fun `test registration rejects invalid passwords`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val invalidPasswords =
listOf(
"Kurz1!",
"keingrossbuch1!",
"KeinSonder12",
"Passwort123!😊",
)
for (password in invalidPasswords) {
val response =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""
{
"email": "test_${System.currentTimeMillis()}@family.de",
"passwordPlain": "$password",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}
""".trimIndent(),
)
}
assertEquals(
HttpStatusCode.BadRequest,
response.status,
"Passwort '$password' hätte abgelehnt werden müssen!",
)
}
}
}

View file

@ -1,37 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class HealthRouteTest {
@Test
fun `test health endpoint`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val response = client.get("/api/v1/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
}
}

View file

@ -1,108 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.SyncResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class ScreentimeRoutesTest {
@Test
fun `test get screentime returns current status`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) { json() }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// Ohne Token -> 401 Unauthorized
val unauthResponse = client.get("/api/v1/screentime")
assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status)
// Mit Token -> 200 OK
val response =
client.get("/api/v1/screentime") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val json = Json.decodeFromString<SyncResponse>(response.bodyAsText())
assertEquals(1800, json.remainingSeconds)
assertFalse(json.isBlocked)
}
@Test
fun `test screentime sync deducts time repeatedly`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
val syncResponse1 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""")
}
assertEquals(HttpStatusCode.OK, syncResponse1.status)
val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText())
assertEquals(1600, syncJson1.remainingSeconds)
assertFalse(syncJson1.isBlocked)
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300)
val syncResponse2 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""")
}
assertEquals(HttpStatusCode.OK, syncResponse2.status)
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
assertEquals(1300, syncJson2.remainingSeconds)
assertFalse(syncJson2.isBlocked)
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
val syncResponse3 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""")
}
assertEquals(HttpStatusCode.OK, syncResponse3.status)
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
}
}

View file

@ -1,388 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.AuthResponse
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.SyncResponse
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class ServerTest {
@Test
fun `test health endpoint`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
runBlocking {
repository.upsertTask(
ImportedTask(
id = "t1",
externalSource = "manual",
subject = "Mathematik",
minAge = 6,
maxAge = 10,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 12 + 15?",
options = listOf("25", "27", "30", "22"),
correctAnswer = "27",
rewardMinutes = 10,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
repository.upsertTask(
ImportedTask(
id = "t2",
externalSource = "manual",
subject = "Mathematik",
minAge = 7,
maxAge = 12,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 8 x 7?",
options = listOf("54", "56", "64", "48"),
correctAnswer = "56",
rewardMinutes = 15,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
}
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val response = client.get("/api/v1/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
}
@Test
fun `test get screentime returns current status`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) { json() }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// Ohne Token -> 401 Unauthorized
val unauthResponse = client.get("/api/v1/screentime")
assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status)
// Mit Token -> 200 OK
val response =
client.get("/api/v1/screentime") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val json = Json.decodeFromString<SyncResponse>(response.bodyAsText())
assertEquals(1800, json.remainingSeconds)
assertFalse(json.isBlocked)
}
@Test
fun `test protected endpoints reject unauthorized requests`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// Ohne Token muss 401 Unauthorized kommen
val tasksResponse = client.get("/api/v1/tasks")
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
}
@Test
fun `test registration and login flow`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// 1. Registrierung
val registerResponse =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}""",
)
}
assertEquals(HttpStatusCode.Created, registerResponse.status)
assertTrue(registerResponse.bodyAsText().contains("token"))
// 2. Login mit denselben Daten
val loginResponse =
client.post("/api/v1/auth/login") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!"
}""",
)
}
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
assertNotNull(loginJson.token)
assertEquals("Familie Schmidt", loginJson.familyName)
}
@Test
fun `test registration rejects invalid passwords`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val invalidPasswords =
listOf(
"Kurz1!",
"keingrossbuch1!",
"KeinSonder12",
"Passwort123!😊",
)
for (password in invalidPasswords) {
val response =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""{
"email": "test_${System.currentTimeMillis()}@family.de",
"passwordPlain": "$password",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}""",
)
}
assertEquals(
HttpStatusCode.BadRequest,
response.status,
"Passwort '$password' hätte abgelehnt werden müssen!",
)
}
}
@Test
fun `test tasks endpoint and verify with jwt`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
runBlocking {
repository.upsertTask(
ImportedTask(
id = "t1",
externalSource = "manual",
subject = "Mathematik",
minAge = 6,
maxAge = 10,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 12 + 15?",
options = listOf("25", "27", "30", "22"),
correctAnswer = "27",
rewardMinutes = 10,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
repository.upsertTask(
ImportedTask(
id = "t2",
externalSource = "manual",
subject = "Mathematik",
minAge = 7,
maxAge = 12,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 8 x 7?",
options = listOf("54", "56", "64", "48"),
correctAnswer = "56",
rewardMinutes = 15,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
}
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// 1. Get Tasks mit Token
val tasksResponse =
client.get("/api/v1/tasks") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, tasksResponse.status)
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
assertEquals(2, tasksJson.size)
assertEquals("t1", tasksJson[0].id)
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
// 2. Filter nach Fach & Alter
val filteredResponse =
client.get("/api/v1/tasks?subject=Mathematik&age=8") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, filteredResponse.status)
val filteredJson = Json.decodeFromString<List<Task>>(filteredResponse.bodyAsText())
assertEquals(2, filteredJson.size)
// 3. Filter mit Alter außerhalb des Bereichs
val age15Response =
client.get("/api/v1/tasks?subject=Mathematik&age=15") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, age15Response.status)
val age15Json = Json.decodeFromString<List<Task>>(age15Response.bodyAsText())
assertEquals(0, age15Json.size)
// 4. Antwort enthält NIEMALS correctAnswer
assertFalse(tasksResponse.bodyAsText().contains("correctAnswer"))
assertFalse(tasksResponse.bodyAsText().contains("correct_answer"))
// 5. Verify Task correct answer
val verifyResponse =
client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
}
assertEquals(HttpStatusCode.OK, verifyResponse.status)
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
assertTrue(verifyJson.isCorrect)
assertEquals(10, verifyJson.earnedMinutes)
// 6. Verify Task incorrect answer
val verifyWrongResponse =
client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"99"}""")
}
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
}
@Test
fun `test screentime sync deducts time repeatedly`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
val syncResponse1 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""")
}
assertEquals(HttpStatusCode.OK, syncResponse1.status)
val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText())
assertEquals(1600, syncJson1.remainingSeconds)
assertFalse(syncJson1.isBlocked)
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen!
val syncResponse2 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""")
}
assertEquals(HttpStatusCode.OK, syncResponse2.status)
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
assertEquals(1300, syncJson2.remainingSeconds)
assertFalse(syncJson2.isBlocked)
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
val syncResponse3 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""")
}
assertEquals(HttpStatusCode.OK, syncResponse3.status)
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
}
}

View file

@ -1,157 +0,0 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class TaskRoutesTest {
@Test
fun `test protected endpoints reject unauthorized requests`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// Ohne Token muss 401 Unauthorized kommen
val tasksResponse = client.get("/api/v1/tasks")
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
}
@Test
fun `test tasks endpoint and verify with jwt`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
runBlocking {
repository.upsertTask(
ImportedTask(
id = "t1",
externalSource = "manual",
subject = "Mathematik",
minAge = 6,
maxAge = 10,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 12 + 15?",
options = listOf("25", "27", "30", "22"),
correctAnswer = "27",
rewardMinutes = 10,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
repository.upsertTask(
ImportedTask(
id = "t2",
externalSource = "manual",
subject = "Mathematik",
minAge = 7,
maxAge = 12,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 8 x 7?",
options = listOf("54", "56", "64", "48"),
correctAnswer = "56",
rewardMinutes = 15,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
}
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// 1. Get Tasks mit Token
val tasksResponse =
client.get("/api/v1/tasks") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, tasksResponse.status)
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
assertEquals(2, tasksJson.size)
assertEquals("t1", tasksJson[0].id)
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
// 2. Filter nach Fach & Alter
val filteredResponse =
client.get("/api/v1/tasks?subject=Mathematik&age=8") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, filteredResponse.status)
val filteredJson = Json.decodeFromString<List<Task>>(filteredResponse.bodyAsText())
assertEquals(2, filteredJson.size)
// 3. Filter mit Alter außerhalb des Bereichs
val age15Response =
client.get("/api/v1/tasks?subject=Mathematik&age=15") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, age15Response.status)
val age15Json = Json.decodeFromString<List<Task>>(age15Response.bodyAsText())
assertEquals(0, age15Json.size)
// 4. Antwort enthält NIEMALS correctAnswer
assertFalse(tasksResponse.bodyAsText().contains("correctAnswer"))
assertFalse(tasksResponse.bodyAsText().contains("correct_answer"))
// 5. Verify Task correct answer
val verifyResponse =
client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
}
assertEquals(HttpStatusCode.OK, verifyResponse.status)
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
assertTrue(verifyJson.isCorrect)
assertEquals(10, verifyJson.earnedMinutes)
// 6. Verify Task incorrect answer
val verifyWrongResponse =
client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"99"}""")
}
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
}
}

View file

@ -1,25 +0,0 @@
package com.oliver.kidio.backend.security
import com.auth0.jwt.JWT
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class JwtServiceTest {
@Test
fun `test generate token contains correct user id and claims`() {
// Wir nutzen ein Dummy-Secret für den Test
val service = JwtService(secret = "test-secret-für-unit-tests")
val token = service.generateToken("user-123")
assertNotNull(token)
// Token decodieren und prüfen, ob unsere Daten korrekt verpackt wurden
val decoded = JWT.decode(token)
val userIdClaim = decoded.getClaim("userId").asString()
assertEquals("user-123", userIdClaim)
assertEquals("kidio-users", decoded.audience.firstOrNull())
assertEquals("https://kidio.oliver.com/", decoded.issuer)
}
}

View file

@ -1,35 +0,0 @@
package com.oliver.kidio.backend.security
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PasswordUtilsTest {
@Test
fun `test valid passwords`() {
assertTrue(isValidPassword("StrengGeheim1!"))
assertTrue(isValidPassword("Kidio2026_Secure"))
}
@Test
fun `test password too short`() {
assertFalse(isValidPassword("Kurz1!"))
assertFalse(isValidPassword("Geheim!"))
}
@Test
fun `test missing uppercase`() {
assertFalse(isValidPassword("kleingeschrieben1!"))
}
@Test
fun `test missing special character`() {
assertFalse(isValidPassword("KeinSonderzeichen123"))
}
@Test
fun `test password with emojis is rejected`() {
assertFalse(isValidPassword("Passwort123!😊"))
assertFalse(isValidPassword("🚀Geheim123!"))
}
}