Compare commits
6 commits
main
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c1e334c7 | ||
|
|
e0995dafbc | ||
|
|
dee5d95e7a | ||
|
|
8458d6a3f1 | ||
|
|
b1448884c6 | ||
|
|
cb03279c6d |
|
|
@ -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
|
|
||||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,5 +10,4 @@ build/
|
||||||
*.iml
|
*.iml
|
||||||
*.iws
|
*.iws
|
||||||
*.ipr
|
*.ipr
|
||||||
out/
|
out/
|
||||||
build/
|
|
||||||
10
.idea/.gitignore
vendored
Normal file
10
.idea/.gitignore
vendored
Normal 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
1
.idea/.name
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Kidio_backend
|
||||||
10
.idea/codeStyles/Project.xml
Normal file
10
.idea/codeStyles/Project.xml
Normal 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>
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal 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
6
.idea/compiler.xml
Normal 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
12
.idea/dataSources.xml
Normal 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
16
.idea/gradle.xml
Normal 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
7
.idea/kotlinc.xml
Normal 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
7
.idea/misc.xml
Normal 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
8
.idea/modules.xml
Normal 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>
|
||||||
8
.idea/modules/Kidio_backend.iml
Normal file
8
.idea/modules/Kidio_backend.iml
Normal 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
6
.idea/vcs.xml
Normal 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
9
Kidio_backend.iml
Normal 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
590
README.md
|
|
@ -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
|
## Features
|
||||||
|
|
||||||
| Feature | Beschreibung |
|
Here's a list of features included in this project:
|
||||||
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **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. |
|
|
||||||
|
|
||||||
---
|
| Name | Description |
|
||||||
|
|------|-------------|
|
||||||
|
|
||||||
## Technologiestack
|
## Building & Running
|
||||||
|
|
||||||
* **Framework:** Ktor
|
To build or run the project, use one of the following tasks:
|
||||||
* **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)
|
|
||||||
|
|
||||||
---
|
| 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
|
|
||||||
```
|
```
|
||||||
|
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
|
||||||
### Öffentliche Endpunkte
|
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
|
||||||
|
|
||||||
#### 1. Health-Check
|
|
||||||
|
|
||||||
**Methode:** `GET`
|
|
||||||
|
|
||||||
**Pfad:**
|
|
||||||
|
|
||||||
```text
|
|
||||||
/api/v1/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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>`, ` `) 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).
|
|
||||||

|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.jvm)
|
||||||
alias(ktorLibs.plugins.ktor)
|
alias(ktorLibs.plugins.ktor)
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
|
|
||||||
kotlin("plugin.serialization") version "2.0.0"
|
kotlin("plugin.serialization") version "2.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11,12 +10,6 @@ version = "1.0.0-SNAPSHOT"
|
||||||
application {
|
application {
|
||||||
mainClass = "io.ktor.server.netty.EngineMain"
|
mainClass = "io.ktor.server.netty.EngineMain"
|
||||||
}
|
}
|
||||||
ktor {
|
|
||||||
openApi {
|
|
||||||
enabled = true // Aktiviert die OpenAPI-Code-Generierung
|
|
||||||
codeInferenceEnabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
@ -26,16 +19,13 @@ dependencies {
|
||||||
implementation(ktorLibs.server.config.yaml)
|
implementation(ktorLibs.server.config.yaml)
|
||||||
implementation(ktorLibs.server.core)
|
implementation(ktorLibs.server.core)
|
||||||
implementation(ktorLibs.server.netty)
|
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")
|
||||||
implementation("io.ktor:ktor-server-auth-jwt")
|
implementation("io.ktor:ktor-server-auth-jwt")
|
||||||
|
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
|
|
||||||
// Bcrypt
|
|
||||||
implementation("org.mindrot:jbcrypt:0.4")
|
|
||||||
|
|
||||||
// JSON-Unterstützung und Serialization
|
// JSON-Unterstützung und Serialization
|
||||||
implementation("io.ktor:ktor-server-content-negotiation-jvm")
|
implementation("io.ktor:ktor-server-content-negotiation-jvm")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json-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-core:0.50.0")
|
||||||
implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
|
implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
|
||||||
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
|
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
|
||||||
|
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
testImplementation(ktorLibs.server.testHost)
|
testImplementation(ktorLibs.server.testHost)
|
||||||
|
|
||||||
// redis
|
// redis
|
||||||
implementation("redis.clients:jedis:5.1.2")
|
implementation("redis.clients:jedis:5.1.2")
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register<JavaExec>("runIngestion") {
|
implementation("org.jetbrains.exposed:exposed-java-time:0.50.0")
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
3.0.81
|
|
||||||
5455
docs/index.html
5455
docs/index.html
File diff suppressed because one or more lines are too long
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|
|
||||||
181
gradlew
vendored
181
gradlew
vendored
|
|
@ -71,35 +71,35 @@ app_path=$0
|
||||||
|
|
||||||
# Need this for daisy-chained symlinks.
|
# Need this for daisy-chained symlinks.
|
||||||
while
|
while
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
[ -h "$app_path" ]
|
[ -h "$app_path" ]
|
||||||
do
|
do
|
||||||
ls=$( ls -ld "$app_path" )
|
ls=$(ls -ld "$app_path")
|
||||||
link=${ls#*' -> '}
|
link=${ls#*' -> '}
|
||||||
case $link in #(
|
case $link in #(
|
||||||
/*) app_path=$link ;; #(
|
/*) app_path=$link ;; #(
|
||||||
*) app_path=$APP_HOME$link ;;
|
*) app_path=$APP_HOME$link ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# This is normally unused
|
# This is normally unused
|
||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
APP_BASE_NAME=${0##*/}
|
APP_BASE_NAME=${0##*/}
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
APP_HOME=$(cd -P "${APP_HOME:-./}" >/dev/null && printf '%s\n' "$PWD") || exit
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
|
|
||||||
warn () {
|
warn() {
|
||||||
echo "$*"
|
echo "$*"
|
||||||
} >&2
|
} >&2
|
||||||
|
|
||||||
die () {
|
die() {
|
||||||
echo
|
echo
|
||||||
echo "$*"
|
echo "$*"
|
||||||
echo
|
echo
|
||||||
exit 1
|
exit 1
|
||||||
} >&2
|
} >&2
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
|
@ -107,57 +107,56 @@ cygwin=false
|
||||||
msys=false
|
msys=false
|
||||||
darwin=false
|
darwin=false
|
||||||
nonstop=false
|
nonstop=false
|
||||||
case "$( uname )" in #(
|
case "$(uname)" in #(
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
CYGWIN*) cygwin=true ;; #(
|
||||||
Darwin* ) darwin=true ;; #(
|
Darwin*) darwin=true ;; #(
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
MSYS* | MINGW*) msys=true ;; #(
|
||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP*) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ]; then
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
else
|
else
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
if [ ! -x "$JAVACMD" ]; then
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
JAVACMD=java
|
JAVACMD=java
|
||||||
if ! command -v java >/dev/null 2>&1
|
if ! command -v java >/dev/null 2>&1; then
|
||||||
then
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
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
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
# Increase the maximum file descriptors if we can.
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop"; then
|
||||||
case $MAX_FD in #(
|
case $MAX_FD in #(
|
||||||
max*)
|
max*)
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
# shellcheck disable=SC2039,SC3045
|
# shellcheck disable=SC2039,SC3045
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
MAX_FD=$(ulimit -H -n) ||
|
||||||
warn "Could not query maximum file descriptor limit"
|
warn "Could not query maximum file descriptor limit"
|
||||||
esac
|
;;
|
||||||
case $MAX_FD in #(
|
esac
|
||||||
'' | soft) :;; #(
|
case $MAX_FD in #(
|
||||||
*)
|
'' | soft) : ;; #(
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
*)
|
||||||
# shellcheck disable=SC2039,SC3045
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
ulimit -n "$MAX_FD" ||
|
# shellcheck disable=SC2039,SC3045
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
ulimit -n "$MAX_FD" ||
|
||||||
esac
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
|
@ -169,36 +168,37 @@ fi
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys"; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$(cygpath --path --mixed "$APP_HOME")
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$(cygpath --unix "$JAVACMD")
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
for arg do
|
for arg; do
|
||||||
if
|
if
|
||||||
case $arg in #(
|
case $arg in #(
|
||||||
-*) false ;; # don't mess with options #(
|
-*) 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
|
||||||
*) false ;;
|
[ -e "$t" ]
|
||||||
esac
|
;; #(
|
||||||
then
|
*) false ;;
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
esac
|
||||||
fi
|
then
|
||||||
# Roll the args list around exactly as many times as the number of
|
arg=$(cygpath --path --ignore --mixed "$arg")
|
||||||
# args, so each arg winds up back in the position where it started, but
|
fi
|
||||||
# possibly modified.
|
# Roll the args list around exactly as many times as the number of
|
||||||
#
|
# args, so each arg winds up back in the position where it started, but
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
# possibly modified.
|
||||||
# changing the positional parameters here affects neither the number of
|
#
|
||||||
# iterations, nor the values presented in `arg`.
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
shift # remove old arg
|
# changing the positional parameters here affects neither the number of
|
||||||
set -- "$@" "$arg" # push replacement arg
|
# iterations, nor the values presented in `arg`.
|
||||||
done
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# 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"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
|
@ -209,14 +209,13 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
# treated as '${Hostname}' itself on the command line.
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
if ! command -v xargs >/dev/null 2>&1; then
|
||||||
then
|
die "xargs is not available"
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
# Use "xargs" to parse quoted args.
|
||||||
|
|
@ -239,10 +238,10 @@ fi
|
||||||
#
|
#
|
||||||
|
|
||||||
eval "set -- $(
|
eval "set -- $(
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
xargs -n1 |
|
xargs -n1 |
|
||||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
tr '\n' ' '
|
tr '\n' ' '
|
||||||
)" '"$@"'
|
)" '"$@"'
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
exec "$JAVACMD" "$@"
|
||||||
|
|
|
||||||
164
gradlew.bat
vendored
164
gradlew.bat
vendored
|
|
@ -1,82 +1,82 @@
|
||||||
@rem
|
@rem
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem Copyright 2015 the original author or authors.
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
@rem you may not use this file except in compliance with the License.
|
@rem you may not use this file except in compliance with the License.
|
||||||
@rem You may obtain a copy of the License at
|
@rem You may obtain a copy of the License at
|
||||||
@rem
|
@rem
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
@rem
|
@rem
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem gradlew startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
setlocal EnableExtensions
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute gradlew
|
@rem Execute gradlew
|
||||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
@rem which allows us to clear the local environment before executing the java command
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
:exitWithErrorLevel
|
:exitWithErrorLevel
|
||||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,4 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Kidio_backend"
|
rootProject.name = "Kidio_backend"
|
||||||
|
|
||||||
|
|
|
||||||
BIN
src/.DS_Store
vendored
BIN
src/.DS_Store
vendored
Binary file not shown.
BIN
src/main/.DS_Store
vendored
BIN
src/main/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,71 +1,77 @@
|
||||||
package com.oliver.kidio.backend.data.database
|
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 io.ktor.server.config.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
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 {
|
object DatabaseFactory {
|
||||||
fun init(config: ApplicationConfig) {
|
fun init(config: ApplicationConfig) {
|
||||||
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"
|
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 host = System.getenv("DB_HOST") ?: config.propertyOrNull("storage.host")?.getString() ?: "localhost"
|
val username = System.getenv("DB_USER")
|
||||||
val port = System.getenv("DB_PORT") ?: config.propertyOrNull("storage.port")?.getString() ?: "5432"
|
?: config.propertyOrNull("storage.username")?.getString()
|
||||||
val dbName = System.getenv("DB_NAME") ?: config.propertyOrNull("storage.database")?.getString() ?: "kidio_db"
|
?: "postgres"
|
||||||
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 password = System.getenv("DB_PASSWORD")
|
||||||
|
?: config.propertyOrNull("storage.password")?.getString()
|
||||||
|
?: "postgres"
|
||||||
|
|
||||||
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
|
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
|
||||||
|
|
||||||
val database =
|
val database = Database.connect(
|
||||||
Database.connect(
|
url = jdbcURL,
|
||||||
url = jdbcURL,
|
driver = driverClassName,
|
||||||
driver = driverClassName,
|
user = username,
|
||||||
user = dbUsername,
|
password = password
|
||||||
password = password,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
transaction(database) {
|
transaction(database) {
|
||||||
// Fill existing NULL values if columns exist before SchemaUtils alters constraints
|
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
||||||
exec(
|
|
||||||
"""
|
// Standard aufgaben, sollte es keine mehr geben
|
||||||
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)
|
|
||||||
|
|
||||||
if (TasksTable.selectAll().empty()) {
|
if (TasksTable.selectAll().empty()) {
|
||||||
TasksTable.insert {
|
TasksTable.insert {
|
||||||
it[id] = "t1"
|
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[question] = "Was ist 12 + 15?"
|
||||||
it[options] = "25,27,30,22"
|
it[options] = "25,27,30,22"
|
||||||
it[correctAnswer] = "27"
|
it[correctAnswer] = "27"
|
||||||
|
|
@ -73,38 +79,34 @@ object DatabaseFactory {
|
||||||
}
|
}
|
||||||
TasksTable.insert {
|
TasksTable.insert {
|
||||||
it[id] = "t2"
|
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[question] = "Was ist 8 x 7?"
|
||||||
it[options] = "54,56,64,48"
|
it[options] = "54,56,64,48"
|
||||||
it[correctAnswer] = "56"
|
it[correctAnswer] = "56"
|
||||||
it[rewardMinutes] = 15
|
it[rewardMinutes] = 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed a default user screentime if empty
|
||||||
if (ScreentimeTable.selectAll().empty()) {
|
if (ScreentimeTable.selectAll().empty()) {
|
||||||
ScreentimeTable.insert {
|
ScreentimeTable.insert {
|
||||||
it[userId] = "default_user"
|
it[userId] = "default_user"
|
||||||
it[remainingSeconds] = 1800
|
it[remainingSeconds] = 1800
|
||||||
|
it[parentPin] = "1234"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a standard user if the Users table is empty.
|
||||||
if (UserTable.selectAll().empty()) {
|
if (UserTable.selectAll().empty()) {
|
||||||
UserTable.insert {
|
UserTable.insert {
|
||||||
|
// ID automatische generierung
|
||||||
it[id] = "default_user"
|
it[id] = "default_user"
|
||||||
it[email] = "test@kidio.de"
|
it[user_username] = "default_user"
|
||||||
it[passwordHash] = "1234" // Dummy-Hash für den Start
|
it[passwordHash] = "1234"
|
||||||
it[familyName] = "Familie Test"
|
|
||||||
it[childAge] = 8
|
|
||||||
it[parentPin] = "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() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.oliver.kidio.backend.data.database
|
package com.oliver.kidio.backend.data.database
|
||||||
import io.ktor.server.config.ApplicationConfig
|
import io.ktor.server.config.*
|
||||||
import io.ktor.server.routing.get
|
|
||||||
import redis.clients.jedis.JedisPool
|
import redis.clients.jedis.JedisPool
|
||||||
import redis.clients.jedis.JedisPoolConfig
|
import redis.clients.jedis.JedisPoolConfig
|
||||||
|
|
||||||
|
|
@ -8,28 +7,22 @@ object RedisFactory {
|
||||||
private var jedisPool: JedisPool? = null
|
private var jedisPool: JedisPool? = null
|
||||||
|
|
||||||
fun init(config: ApplicationConfig) {
|
fun init(config: ApplicationConfig) {
|
||||||
val host =
|
val host = System.getenv("REDIS_HOST")
|
||||||
System.getenv("REDIS_HOST")
|
?: config.propertyOrNull("storage.redisHost")?.getString()
|
||||||
?: config.propertyOrNull("storage.redisHost")?.getString()
|
?: "localhost"
|
||||||
?: "localhost"
|
|
||||||
|
|
||||||
val port =
|
val port = (System.getenv("REDIS_PORT")
|
||||||
(
|
?: config.propertyOrNull("storage.redisPort")?.getString()
|
||||||
System.getenv("REDIS_PORT")
|
?: "6379").toInt()
|
||||||
?: config.propertyOrNull("storage.redisPort")?.getString()
|
|
||||||
?: "6379"
|
|
||||||
).toInt()
|
|
||||||
|
|
||||||
val poolConfig =
|
val poolConfig = JedisPoolConfig().apply {
|
||||||
JedisPoolConfig().apply {
|
maxTotal = 32
|
||||||
maxTotal = 32
|
maxIdle = 8
|
||||||
maxIdle = 8
|
minIdle = 2
|
||||||
minIdle = 2
|
}
|
||||||
}
|
|
||||||
jedisPool = JedisPool(poolConfig, host, port)
|
jedisPool = JedisPool(poolConfig, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getResource() =
|
fun getResource() = jedisPool?.resource
|
||||||
jedisPool?.resource
|
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
|
||||||
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +1,137 @@
|
||||||
package com.oliver.kidio.backend.domain.repository
|
package com.oliver.kidio.backend.domain.repository
|
||||||
|
|
||||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
import com.oliver.kidio.backend.Task
|
||||||
import com.oliver.kidio.backend.domain.models.Task
|
import com.oliver.kidio.backend.User
|
||||||
import com.oliver.kidio.backend.domain.models.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 {
|
interface KidioRepository {
|
||||||
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
suspend fun getAllTasks(): List<Task>
|
||||||
|
|
||||||
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 getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
||||||
|
|
||||||
suspend fun getScreentime(userId: String): Int
|
suspend fun getScreentime(userId: String): Int
|
||||||
|
suspend fun updateScreentime(userId: String, seconds: Int)
|
||||||
|
|
||||||
suspend fun updateScreentime(
|
suspend fun findByUsername(username: String): User?
|
||||||
userId: String,
|
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
|
||||||
seconds: Int,
|
}
|
||||||
|
|
||||||
|
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(
|
override suspend fun getAllTasks(): List<Task> = tasks
|
||||||
password: String,
|
|
||||||
passwordHash: String,
|
|
||||||
): Boolean
|
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(" ", " ")
|
|
||||||
.replace("&", "&")
|
|
||||||
.replace(""", "\"")
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("×", "×")
|
|
||||||
.replace("„", "„")
|
|
||||||
.replace("“", "“")
|
|
||||||
.replace("ä", "ä")
|
|
||||||
.replace("Ä", "Ä")
|
|
||||||
.replace("ö", "ö")
|
|
||||||
.replace("Ö", "Ö")
|
|
||||||
.replace("ü", "ü")
|
|
||||||
.replace("Ü", "Ü")
|
|
||||||
.replace("ß", "ß")
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,27 +1,97 @@
|
||||||
package com.oliver.kidio.backend.plugins
|
package com.oliver.kidio.backend.plugins
|
||||||
|
|
||||||
|
import com.oliver.kidio.backend.*
|
||||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||||
import com.oliver.kidio.backend.routing.*
|
|
||||||
import com.oliver.kidio.backend.security.JwtService
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
import io.ktor.server.application.*
|
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.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
fun Application.configureRouting(
|
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
|
||||||
repository: KidioRepository,
|
|
||||||
jwtService: JwtService,
|
|
||||||
) {
|
|
||||||
routing {
|
routing {
|
||||||
openAPI(path = "openapi")
|
|
||||||
|
|
||||||
|
// Health-Check
|
||||||
get("/api/v1/health") {
|
get("/api/v1/health") {
|
||||||
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
||||||
}
|
}
|
||||||
|
|
||||||
authRoutes(repository, jwtService)
|
post ("/api/v1/auth/login" ) {
|
||||||
taskRoutes(repository)
|
val loginRequest = call.receive<LoginRequest>()
|
||||||
screentimeRoutes(repository)
|
|
||||||
parentRoutes(repository)
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import io.ktor.server.auth.jwt.*
|
||||||
/**
|
/**
|
||||||
* Repräsentiert den authentifizierten Benutzer im Request-Kontext.
|
* 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) {
|
fun Application.configureSecurity(jwtService: JwtService) {
|
||||||
install(Authentication) {
|
install(Authentication) {
|
||||||
|
|
@ -26,4 +26,4 @@ fun Application.configureSecurity(jwtService: JwtService) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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."),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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!",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,14 +9,14 @@ class JwtService(
|
||||||
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
|
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
|
||||||
private val issuer: String = "https://kidio.oliver.com/",
|
private val issuer: String = "https://kidio.oliver.com/",
|
||||||
private val audience: String = "kidio-users",
|
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))
|
.require(Algorithm.HMAC256(secret))
|
||||||
.withAudience(audience)
|
.withAudience(audience)
|
||||||
.withIssuer(issuer)
|
.withIssuer(issuer)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generiert ein JWT-Token für einen bestimmten Benutzer.
|
* Generiert ein JWT-Token für einen bestimmten Benutzer.
|
||||||
|
|
@ -29,4 +29,4 @@ class JwtService(
|
||||||
.withExpiresAt(Date(System.currentTimeMillis() + expirationInMs))
|
.withExpiresAt(Date(System.currentTimeMillis() + expirationInMs))
|
||||||
.sign(Algorithm.HMAC256(secret))
|
.sign(Algorithm.HMAC256(secret))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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.DatabaseFactory
|
||||||
import com.oliver.kidio.backend.data.database.RedisFactory
|
import com.oliver.kidio.backend.data.database.RedisFactory
|
||||||
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
|
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.plugins.configureSecurity
|
||||||
import com.oliver.kidio.backend.security.JwtService
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
|
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
// Datenbank initialisieren
|
// Datenbank initialisieren
|
||||||
|
|
@ -26,6 +25,6 @@ fun Application.module() {
|
||||||
|
|
||||||
configureRouting(
|
configureRouting(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
jwtService = jwtService,
|
jwtService = jwtService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
ktor:
|
ktor:
|
||||||
deployment:
|
deployment:
|
||||||
port: 8081
|
port: 8080
|
||||||
application:
|
application:
|
||||||
modules:
|
modules:
|
||||||
- MainKt.module
|
- MainKt.module
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
driverClassName: "org.postgresql.Driver"
|
driverClassName: "org.postgresql.Driver"
|
||||||
host: "localhost"
|
host: "82.165.11.162"
|
||||||
port: "5432"
|
port: "5432"
|
||||||
database: "kidio_db"
|
database: "kidio_db"
|
||||||
username: "kidio_admin"
|
username: "kidio_admin"
|
||||||
|
|
|
||||||
|
|
@ -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 × 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örter ist ein <strong>Nomen (Namenwort)</strong>?</p>\", \"answers\": [ {\"text\": \"schnell\", \"correct\": false}, {\"text\": \"laufen\", \"correct\": false}, {\"text\": \"Schule\", \"correct\": true}, {\"text\": \"schö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? „Der Flu... ist tief.“</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ält einen echten <strong>Winterschlaf</strong>?</p>\", \"answers\": [ {\"text\": \"Igel\", \"correct\": true}, {\"text\": \"Eichhö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ä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 „Hund“?</p>\", \"answers\": [ {\"text\": \"Cat\", \"correct\": false}, {\"text\": \"Dog\", \"correct\": true}, {\"text\": \"Bird\", \"correct\": false}, {\"text\": \"Rabbit\", \"correct\": false} ] }"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
115
src/test/kotlin/ServerTest.kt
Normal file
115
src/test/kotlin/ServerTest.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 × 4</strong>? „Test“ äöü</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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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!",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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!"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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!"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue