feat/gitignore #9

Merged
mt merged 5 commits from feat/gitignore into main 2026-09-14 10:13:13 +00:00
13 changed files with 966 additions and 457 deletions
Showing only changes of commit a8f30d7ef2 - Show all commits

4
.editorconfig Normal file
View file

@ -0,0 +1,4 @@
[*.{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 Normal file
View file

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

508
README.md
View file

@ -1,49 +1,67 @@
# Kidio Backend
Das Kidio-Backend ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder. Die Anwendung basiert auf dem **Ktor**-Framework in Kotlin und kombiniert **PostgreSQL** für die Persistenz mit **Redis** als Caching-Layer für schnelle Zugriffszeiten.
Das **Kidio-Backend** ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder.
Kinder können durch das Lösen von Lernaufgaben (z. B. Mathe-Aufgaben) zusätzliche Bildschirmzeit erwerben, während das System ihre verbrauchte Bildschirmzeit synchronisiert und bei Ablauf sperrt.
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.
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.
---
## Features
Hier ist eine Übersicht der implementierten Kernfunktionen des Kidio-Backends:
| Name | Beschreibung |
|------|-------------|
| **JWT Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten und Belohnungen (z. B. Minuten). |
| Feature | Beschreibung |
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **JWT-Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten und 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. |
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der genutzten Bildschirmzeit. Sinkt die Zeit auf oder unter Null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
| **Zwei-Ebenen-Speicher** | Datenhaltung in **PostgreSQL** (via JetBrains Exposed ORM) kombiniert mit **Redis-Caching** (via Jedis) für optimale Performance bei der Screentime-Abfrage. |
| **Fallback-Mechanismus** | Ist die Redis-Instanz nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
| **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. |
---
## Technologiestack
- **Framework**: [Ktor](https://ktor.io/) (Kotlin-basiertes asynchrones Web-Framework)
- **Datenbank-ORM**: [JetBrains Exposed](https://github.com/JetBrains/Exposed)
- **Persistenz**: PostgreSQL
- **Caching**: Redis (via Jedis Client)
- **Sicherheit & Auth**: JWT (Java JWT von auth0)
- **Serialization**: Kotlinx Serialization (JSON)
- **Testing**: Ktor Server Testing, JUnit
* **Framework:** Ktor
* **Programmiersprache:** Kotlin
* **Datenbank-ORM:** JetBrains Exposed
* **Persistenz:** PostgreSQL
* **Caching:** Redis via Jedis
* **Authentifizierung:** JWT via Java JWT (Auth0)
* **Serialisierung:** Kotlinx Serialization
* **Code Quality / Linter:** KtLint
* **Testing:** Ktor Server Testing, JUnit
* **CI/CD:** Forgejo Actions (Gitea / Act Runner)
---
## API-Endpunkte
Alle Endpunkte befinden sich unter dem Präfix `/api/v1`.
Alle API-Endpunkte befinden sich unter dem Präfix:
```text
/api/v1
```
### Öffentliche Endpunkte
#### 1. Health-Check
- **Methode**: `GET`
- **Pfad**: `/api/v1/health`
- **Beschreibung**: Prüft, ob der Backend-Server ordnungsgemäß läuft.
- **Antwort**:
**Methode:** `GET`
**Pfad:**
```text
/api/v1/health
```
**Beschreibung:**
Prüft, ob der Backend-Server ordnungsgemäß läuft.
**Antwort:**
```json
{
"status": "OK",
@ -51,79 +69,131 @@ Alle Endpunkte befinden sich unter dem Präfix `/api/v1`.
}
```
#### 2. Benutzer-Login (Auth-Token abholen)
- **Methode**: `POST`
- **Pfad**: `/api/v1/auth/login`
- **Request-Body**:
```json
{
"userId": "default_user"
}
---
#### 2. Benutzer-Registrierung
**Methode:** `POST`
**Pfad:**
```text
/api/v1/auth/register
```
- **Antwort**:
**Request-Body:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIsIn..."
"email": "test@family.de",
"passwordPlain": "strengGeheim1",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}
```
### Geschützte Endpunkte (Erfordern JWT im `Authorization: Bearer <Token>` Header)
**Antwort – `201 Created`:**
#### 3. Alle verfügbaren Aufgaben abrufen
- **Methode**: `GET`
- **Pfad**: `/api/v1/tasks`
- **Beschreibung**: Liefert eine Liste aller verfügbaren Lernaufgaben.
- **Antwort**:
```json
[
{
"id": "t1",
"question": "Was ist 12 + 15?",
"options": ["25", "27", "30", "22"],
"rewardMinutes": 10
"token": "eyJhbGciOiJIUzI1NiIsIn...",
"familyName": "Familie Schmidt"
}
]
```
#### 4. Antwort überprüfen & Belohnung verbuchen
- **Methode**: `POST`
- **Pfad**: `/api/v1/tasks/verify`
- **Request-Body**:
---
#### 3. Benutzer-Login
**Methode:** `POST`
**Pfad:**
```text
/api/v1/auth/login
```
**Request-Body:**
```json
{
"taskId": "t1",
"selectedAnswer": "27"
"email": "test@family.de",
"passwordPlain": "strengGeheim1"
}
```
- **Antwort (Erfolg)**:
**Antwort – `200 OK`:**
```json
{
"isCorrect": true,
"earnedMinutes": 10,
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
"token": "eyJhbGciOiJIUzI1NiIsIn...",
"familyName": "Familie Schmidt"
}
```
- **Antwort (Fehler)**:
---
### 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.
**Antwort:**
```json
{
"isCorrect": false,
"earnedMinutes": 0,
"message": "Schade, das war leider falsch. Versuche es nochmal!"
"remainingSeconds": 1800,
"isBlocked": false
}
```
---
#### 5. Bildschirmzeit synchronisieren
- **Methode**: `POST`
- **Pfad**: `/api/v1/screentime/sync`
- **Beschreibung**: Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
- **Request-Body**:
**Methode:** `POST`
**Pfad:**
```text
/api/v1/screentime/sync
```
**Beschreibung:**
Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
**Request-Body:**
```json
{
"userId": "default_user",
"usedSecondsSinceLastSync": 200
}
```
- **Antwort**:
**Antwort:**
```json
{
"remainingSeconds": 1600,
@ -133,14 +203,95 @@ Alle Endpunkte befinden sich unter dem Präfix `/api/v1`.
---
#### 6. Alle verfügbaren Aufgaben abrufen
**Methode:** `GET`
**Pfad:**
```text
/api/v1/tasks
```
**Beschreibung:**
Liefert eine Liste aller verfügbaren Lernaufgaben.
**Antwort:**
```json
[
{
"id": "t1",
"question": "Was ist 12 + 15?",
"options": [
"25",
"27",
"30",
"22"
],
"rewardMinutes": 10
}
]
```
---
#### 7. Antwort überprüfen und Belohnung verbuchen
**Methode:** `POST`
**Pfad:**
```text
/api/v1/tasks/verify
```
**Request-Body:**
```json
{
"taskId": "t1",
"selectedAnswer": "27"
}
```
**Antwort – korrekte Lösung:**
```json
{
"isCorrect": true,
"earnedMinutes": 10,
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
}
```
**Antwort – falsche Lösung:**
```json
{
"isCorrect": false,
"earnedMinutes": 0,
"message": "Schade, das war leider falsch. Versuche es nochmal!"
}
```
---
## Projektkonfiguration und Umgebungsvariablen
Das Projekt wird über die Datei `src/main/resources/application.yaml` konfiguriert. Folgende Umgebungsvariablen können zur flexiblen Docker- oder Deployment-Konfiguration übergeben werden:
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` / aus `application.yaml` |
| `DB_PORT` | Port der PostgreSQL-Datenbank | `5432` / aus `application.yaml` |
| :------------ | :----------------------------------------- | :------------------------------- |
| `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` |
@ -148,24 +299,221 @@ Das Projekt wird über die Datei `src/main/resources/application.yaml` konfiguri
| `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, Bauen & Testen
## Lokale Entwicklung
Zur Ausführung des Projekts werden die folgenden Gradle-Tasks bereitgestellt:
### Voraussetzungen
| Task | Beschreibung |
|------|-------------|
| `./gradlew test` | Führt alle automatisierten Unit- und Integrationstests aus (nutzt die InMemory-Repository-Implementierung). |
| `./gradlew build` | Kompiliert das Projekt und baut das Artefakt. |
| `./gradlew run` | Startet den Ktor-Entwicklungsserver lokal auf Port `8080`. |
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
# Tests ausführen
./gradlew test
# Server starten
./gradlew run
```
Sobald der Server erfolgreich gestartet ist, ist er unter `http://localhost:8080` erreichbar.
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.

View file

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(ktorLibs.plugins.ktor)
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
kotlin("plugin.serialization") version "2.0.0"
}

View file

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

View file

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

View file

@ -22,6 +22,7 @@ object ScreentimeTable : Table("user_screentime") {
override val primaryKey = PrimaryKey(userId)
}
object UserTable : Table("users") {
val id = varchar("id", 36)
val email = varchar("email", 255).uniqueIndex()
@ -30,7 +31,6 @@ object UserTable : Table("users") {
val childAge = integer("child_age")
val parentPin = varchar("parent_pin", 4)
override val primaryKey = PrimaryKey(id)
}
@ -46,11 +46,12 @@ object DatabaseFactory {
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
val database = Database.connect(
val database =
Database.connect(
url = jdbcURL,
driver = driverClassName,
user = dbUsername,
password = password
password = password,
)
transaction(database) {
@ -93,6 +94,5 @@ object DatabaseFactory {
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
}

View file

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

View file

@ -8,14 +8,14 @@ data class Task(
val id: String,
val question: String,
val options: List<String>,
val rewardMinutes: Int // Wie viel Belohnung es gibt
val rewardMinutes: Int,
)
// Anfrage des Kindes, wenn es eine Aufgabe löst
@Serializable
data class TaskVerifyRequest(
val taskId: String,
val selectedAnswer: String
val selectedAnswer: String,
)
// Antwort vom Server nach der Aufgaben-Prüfung
@ -23,20 +23,20 @@ data class TaskVerifyRequest(
data class TaskVerifyResponse(
val isCorrect: Boolean,
val earnedMinutes: Int,
val message: String
val message: String,
)
// Datenabgleich für die Bildschirmzeit (Sync)
@Serializable
data class SyncRequest(
val userId: String,
val usedSecondsSinceLastSync: Int
val usedSecondsSinceLastSync: Int,
)
@Serializable
data class SyncResponse(
val remainingSeconds: Int,
val isBlocked: Boolean
val isBlocked: Boolean,
)
// --- NEU: AUTHENTIFIZIERUNG ---
@ -47,21 +47,21 @@ data class RegisterRequest(
val passwordPlain: String,
val familyName: String,
val childAge: Int,
val parentPin: String
val parentPin: String,
)
@Serializable
data class LoginRequest(
val email: String,
val passwordPlain: String
val passwordPlain: String,
)
@Serializable
data class AuthResponse(
val token: String, // Der digitale "Ausweis" (JWT)
val userId: String, // Wichtig für die App, um Aufgaben zuzuordnen
val familyName: String, // Für "Hallo Familie Müller!"
val childAge: Int
val token: String,
val userId: String,
val familyName: String,
val childAge: Int,
)
// Interne Backend-Klasse (Domain Model).
@ -71,5 +71,5 @@ data class User(
val passwordHash: String,
val familyName: String,
val childAge: Int,
val parentPin: String
val parentPin: String,
)

View file

@ -12,27 +12,41 @@ import org.mindrot.jbcrypt.BCrypt
interface KidioRepository {
suspend fun getAllTasks(): List<Task>
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: Int)
suspend fun updateScreentime(
userId: String,
seconds: Int,
)
suspend fun findByEmail(email: String): User?
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean
suspend fun registerUser(user: User): Boolean
}
class ExposedKidioRepository : KidioRepository {
override suspend fun getAllTasks(): List<Task> = DatabaseFactory.dbQuery {
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]
rewardMinutes = it[TasksTable.rewardMinutes],
)
}
}
override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery {
override suspend fun registerUser(user: User): Boolean =
DatabaseFactory.dbQuery {
try {
UserTable.insert {
it[id] = user.id
@ -53,7 +67,8 @@ class ExposedKidioRepository : KidioRepository {
}
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery {
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] }
@ -75,7 +90,8 @@ class ExposedKidioRepository : KidioRepository {
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
}
val dbSeconds = DatabaseFactory.dbQuery {
val dbSeconds =
DatabaseFactory.dbQuery {
ScreentimeTable.selectAll()
.where { ScreentimeTable.userId eq userId }
.map { it[ScreentimeTable.remainingSeconds] }
@ -93,9 +109,13 @@ class ExposedKidioRepository : KidioRepository {
return dbSeconds
}
override suspend fun updateScreentime(userId: String, seconds: Int) {
override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
DatabaseFactory.dbQuery {
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
val rowsUpdated =
ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
it[remainingSeconds] = seconds
}
if (rowsUpdated == 0) {
@ -116,7 +136,8 @@ class ExposedKidioRepository : KidioRepository {
}
}
override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery {
override suspend fun findByEmail(email: String): User? =
DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.email eq email }
.map {
@ -126,28 +147,34 @@ class ExposedKidioRepository : KidioRepository {
passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin]
parentPin = it[UserTable.parentPin],
)
}
.singleOrNull()
}
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
}
class InMemoryKidioRepository : KidioRepository {
private val tasks = mutableListOf(
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)
Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15),
)
private val answers = mutableMapOf(
private val answers =
mutableMapOf(
"t1" to ("27" to 10),
"t2" to ("56" to 15)
"t2" to ("56" to 15),
)
private val screentimes = mutableMapOf(
"default_user" to 1800
private val screentimes =
mutableMapOf(
"default_user" to 1800,
)
private val users = mutableListOf<User>()
@ -157,7 +184,10 @@ class InMemoryKidioRepository : KidioRepository {
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
override suspend fun updateScreentime(userId: String, seconds: Int) {
override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
screentimes[userId] = seconds
}
@ -165,7 +195,10 @@ class InMemoryKidioRepository : KidioRepository {
return users.find { it.email == email }
}
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}

View file

@ -33,6 +33,10 @@ fun isValidPassword(password: String): Boolean {
}
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
fun Application.configureRouting(
repository: KidioRepository,
jwtService: JwtService,
) {
routing {
post("/api/v1/auth/register") {
val registerRequest = call.receive<RegisterRequest>()
@ -62,7 +66,16 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
// datenbank insert hinzufügen
repository.registerUser(User(userId, registerRequest.email, passwordHash, registerRequest.familyName, registerRequest.childAge, registerRequest.parentPin))
repository.registerUser(
User(
userId,
registerRequest.email,
passwordHash,
registerRequest.familyName,
registerRequest.childAge,
registerRequest.parentPin,
),
)
val token = jwtService.generateToken(userId)
@ -71,8 +84,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
mapOf(
"token" to token,
"userId" to userId,
"message" to "Registrierung erfolgreich"
)
"message" to "Registrierung erfolgreich",
),
)
}
// Health-Check
@ -92,12 +105,15 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
return@post
}
val token = jwtService.generateToken(getUser.id)
call.respond(HttpStatusCode.OK, AuthResponse(
call.respond(
HttpStatusCode.OK,
AuthResponse(
token,
userId = getUser.id,
familyName = getUser.familyName,
childAge = getUser.childAge
))
childAge = getUser.childAge,
),
)
return@post
@ -128,8 +144,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!"
)
message = "Aufgabe nicht gefunden!",
),
)
return@post
}
@ -146,19 +162,36 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen."
)
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!"
)
message = "Schade, das war leider falsch. Versuche es nochmal!",
),
)
}
}
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,
),
)
}
// 3. Bildschirmzeit synchronisieren
post("/api/v1/screentime/sync") {
@ -177,8 +210,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
call.respond(
SyncResponse(
remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0
)
isBlocked = newSeconds <= 0,
),
)
}
}

View file

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

View file

@ -4,20 +4,29 @@ 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.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
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.assertNotNull
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
import io.ktor.server.testing.*
import kotlinx.serialization.json.*
import kotlin.test.*
class ServerTest {
@Test
fun `test health endpoint`() = testApplication {
fun `test health endpoint`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
@ -35,7 +44,37 @@ class ServerTest {
}
@Test
fun `test protected endpoints reject unauthorized requests`() = testApplication {
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()
@ -53,7 +92,8 @@ class ServerTest {
}
@Test
fun `test registration and login flow`() = testApplication {
fun `test registration and login flow`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
@ -66,29 +106,31 @@ class ServerTest {
}
// 1. Registrierung
val registerResponse = client.post("/api/v1/auth/register") {
val registerResponse =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!",
"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") {
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)
@ -139,7 +181,8 @@ class ServerTest {
}
@Test
fun `test tasks endpoint and verify with jwt`() = testApplication {
fun `test tasks endpoint and verify with jwt`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
@ -154,7 +197,8 @@ class ServerTest {
val token = jwtService.generateToken("default_user")
// 1. Get Tasks mit Token
val tasksResponse = client.get("/api/v1/tasks") {
val tasksResponse =
client.get("/api/v1/tasks") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, tasksResponse.status)
@ -164,7 +208,8 @@ class ServerTest {
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
// 2. Verify Task correct answer
val verifyResponse = client.post("/api/v1/tasks/verify") {
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"}""")
@ -175,7 +220,8 @@ class ServerTest {
assertEquals(10, verifyJson.earnedMinutes)
// 3. Verify Task incorrect answer
val verifyWrongResponse = client.post("/api/v1/tasks/verify") {
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"}""")
@ -187,7 +233,8 @@ class ServerTest {
}
@Test
fun `test screentime sync deducts time repeatedly`() = testApplication {
fun `test screentime sync deducts time repeatedly`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
@ -202,7 +249,8 @@ class ServerTest {
val token = jwtService.generateToken("default_user")
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
val syncResponse1 = client.post("/api/v1/screentime/sync") {
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}""")
@ -213,7 +261,8 @@ class ServerTest {
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") {
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}""")
@ -224,7 +273,8 @@ class ServerTest {
assertFalse(syncJson2.isBlocked)
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
val syncResponse3 = client.post("/api/v1/screentime/sync") {
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}""")