Merge pull request 'integreating Register and Login Endpoint' (#6) from feat/add-register-login-endpoint into main

Reviewed-on: https://owgit.duckdns.org/DrunkenPinguin/LernApp/pulls/6
Reviewed-by: mt <aldemir.mt@icloud.com>
This commit is contained in:
DrunkenPinguin 2026-09-08 12:34:42 +00:00
commit 6ac198a695
8 changed files with 209 additions and 55 deletions

176
README.md
View file

@ -1,33 +1,171 @@
# Kidio_backend # Kidio Backend
This project was created using the [Ktor Project Generator](https://start.ktor.io). 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.
Here are some useful links to get you started: 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.
* [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
Here's a list of features included in this project: Hier ist eine Übersicht der implementierten Kernfunktionen des Kidio-Backends:
| Name | Description | | 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). |
| **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. |
## Building & Running ---
To build or run the project, use one of the following tasks: ## Technologiestack
| Task | Description | - **Framework**: [Ktor](https://ktor.io/) (Kotlin-basiertes asynchrones Web-Framework)
|-------------------|-------------------| - **Datenbank-ORM**: [JetBrains Exposed](https://github.com/JetBrains/Exposed)
| `./gradlew test` | Run the tests | - **Persistenz**: PostgreSQL
| `./gradlew build` | Build the project | - **Caching**: Redis (via Jedis Client)
| `./gradlew run` | Run the server | - **Sicherheit & Auth**: JWT (Java JWT von auth0)
- **Serialization**: Kotlinx Serialization (JSON)
- **Testing**: Ktor Server Testing, JUnit
If the server starts successfully, you'll see the following output: ---
## API-Endpunkte
Alle Endpunkte befinden sich unter dem Präfix `/api/v1`.
### Öffentliche Endpunkte
#### 1. Health-Check
- **Methode**: `GET`
- **Pfad**: `/api/v1/health`
- **Beschreibung**: Prüft, ob der Backend-Server ordnungsgemäß läuft.
- **Antwort**:
```json
{
"status": "OK",
"message": "Kidio Backend läuft!"
}
```
#### 2. Benutzer-Login (Auth-Token abholen)
- **Methode**: `POST`
- **Pfad**: `/api/v1/auth/login`
- **Request-Body**:
```json
{
"userId": "default_user"
}
```
- **Antwort**:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsIn..."
}
```
### Geschützte Endpunkte (Erfordern JWT im `Authorization: Bearer <Token>` Header)
#### 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
}
]
```
#### 4. Antwort überprüfen & Belohnung verbuchen
- **Methode**: `POST`
- **Pfad**: `/api/v1/tasks/verify`
- **Request-Body**:
```json
{
"taskId": "t1",
"selectedAnswer": "27"
}
```
- **Antwort (Erfolg)**:
```json
{
"isCorrect": true,
"earnedMinutes": 10,
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
}
```
- **Antwort (Fehler)**:
```json
{
"isCorrect": false,
"earnedMinutes": 0,
"message": "Schade, das war leider falsch. Versuche es nochmal!"
}
```
#### 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**:
```json
{
"userId": "default_user",
"usedSecondsSinceLastSync": 200
}
```
- **Antwort**:
```json
{
"remainingSeconds": 1600,
"isBlocked": false
}
```
---
## 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:
| Variable | Beschreibung | Standardwert |
|----------|--------------|--------------|
| `DB_HOST` | Hostname der PostgreSQL-Datenbank | `localhost` / aus `application.yaml` |
| `DB_PORT` | Port der PostgreSQL-Datenbank | `5432` / aus `application.yaml` |
| `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` |
---
## Lokale Entwicklung, Bauen & Testen
Zur Ausführung des Projekts werden die folgenden Gradle-Tasks bereitgestellt:
| 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`. |
```bash
# Tests ausführen
./gradlew test
# Server starten
./gradlew run
``` ```
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080 Sobald der Server erfolgreich gestartet ist, ist er unter `http://localhost:8080` erreichbar.
```

View file

@ -26,6 +26,9 @@ dependencies {
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")

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

BIN
src/main/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -19,18 +19,15 @@ object TasksTable : Table("tasks") {
object ScreentimeTable : Table("user_screentime") { object ScreentimeTable : Table("user_screentime") {
val userId = varchar("user_id", 50) val userId = varchar("user_id", 50)
val remainingSeconds = integer("remaining_seconds") val remainingSeconds = integer("remaining_seconds")
val parentPin = varchar("parent_pin", 10)
override val primaryKey = PrimaryKey(userId) override val primaryKey = PrimaryKey(userId)
} }
// Option A: Alles Wichtige für den Haushalt & das Kind in einer Tabelle
object UserTable : Table("users") { object UserTable : Table("users") {
val id = varchar("id", 36) val id = varchar("id", 36)
val email = varchar("email", 255).uniqueIndex() val email = varchar("email", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255) val passwordHash = varchar("password_hash", 255)
val familyName = varchar("family_name", 100) val familyName = varchar("family_name", 100)
val childAge = integer("child_age") // Hier ist das Alter wieder! val childAge = integer("child_age")
val parentPin = varchar("parent_pin", 4) val parentPin = varchar("parent_pin", 4)
@ -57,7 +54,6 @@ object DatabaseFactory {
) )
transaction(database) { transaction(database) {
// ChildrenTable wurde hier entfernt
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable) SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
if (TasksTable.selectAll().empty()) { if (TasksTable.selectAll().empty()) {
@ -81,11 +77,9 @@ object DatabaseFactory {
ScreentimeTable.insert { ScreentimeTable.insert {
it[userId] = "default_user" it[userId] = "default_user"
it[remainingSeconds] = 1800 it[remainingSeconds] = 1800
it[parentPin] = "1234"
} }
} }
// Korrigierter Seed für UserTable (alle Pflichtfelder befüllt)
if (UserTable.selectAll().empty()) { if (UserTable.selectAll().empty()) {
UserTable.insert { UserTable.insert {
it[id] = "default_user" it[id] = "default_user"

View file

@ -52,7 +52,6 @@ data class RegisterRequest(
@Serializable @Serializable
data class LoginRequest( data class LoginRequest(
val userId: String,
val email: String, val email: String,
val passwordPlain: String val passwordPlain: String
) )

View file

@ -3,7 +3,6 @@ package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.Task import com.oliver.kidio.backend.Task
import com.oliver.kidio.backend.User import com.oliver.kidio.backend.User
import com.oliver.kidio.backend.data.database.DatabaseFactory import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.DatabaseFactory.dbQuery
import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.database.ScreentimeTable import com.oliver.kidio.backend.data.database.ScreentimeTable
import com.oliver.kidio.backend.data.database.TasksTable import com.oliver.kidio.backend.data.database.TasksTable
@ -16,7 +15,7 @@ interface KidioRepository {
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(userId: String, seconds: Int)
suspend fun findByEmail(username: String): User? 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 suspend fun registerUser(user: User): Boolean
} }
@ -33,21 +32,25 @@ class ExposedKidioRepository : KidioRepository {
} }
} }
override suspend fun registerUser(user: User): Boolean = dbQuery { override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery {
try {
UserTable.insert { UserTable.insert {
it[UserTable.id] = user.id it[id] = user.id
it[UserTable.email] = user.email it[email] = user.email
it[UserTable.passwordHash] = user.passwordHash it[passwordHash] = user.passwordHash
it[UserTable.familyName] = user.familyName it[familyName] = user.familyName
it[UserTable.childAge] = user.childAge it[childAge] = user.childAge
it[UserTable.parentPin] = user.parentPin it[parentPin] = user.parentPin
} }
ScreentimeTable.insert { ScreentimeTable.insert {
it[ScreentimeTable.userId] = user.id it[userId] = user.id
it[ScreentimeTable.remainingSeconds] = 1800 it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten
it[ScreentimeTable.parentPin] = user.parentPin
} }
true true
} catch (e: Exception) {
println("FEHLER bei Registrierung: ${e.message}")
false
}
} }
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery { override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery {
@ -75,7 +78,7 @@ class ExposedKidioRepository : KidioRepository {
val dbSeconds = DatabaseFactory.dbQuery { val dbSeconds = DatabaseFactory.dbQuery {
ScreentimeTable.selectAll() ScreentimeTable.selectAll()
.where { ScreentimeTable.userId eq userId } .where { ScreentimeTable.userId eq userId }
.map { it[ScreentimeTable.remainingSeconds] } .map { it[ScreentimeTable.remainingSeconds] } // Korrigiert: Abfrage der Sekunden!
.singleOrNull() ?: 1800 .singleOrNull() ?: 1800
} }
@ -97,16 +100,14 @@ class ExposedKidioRepository : KidioRepository {
if (rowsUpdated == 0) { if (rowsUpdated == 0) {
ScreentimeTable.insert { ScreentimeTable.insert {
it[ScreentimeTable.userId] = userId it[ScreentimeTable.userId] = userId
it[remainingSeconds] = seconds it[ScreentimeTable.remainingSeconds] = seconds // Korrigiert: Wert wird wieder mitgegeben!
it[parentPin] = "1234"
} }
} }
} }
override suspend fun findByEmail(username: String): User? = DatabaseFactory.dbQuery { override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery {
UserTable.selectAll() UserTable.selectAll()
.where { UserTable.email eq username } .where { UserTable.email eq email }
.map { .map {
User( User(
id = it[UserTable.id], id = it[UserTable.id],
@ -149,16 +150,17 @@ class InMemoryKidioRepository : KidioRepository {
screentimes[userId] = seconds screentimes[userId] = seconds
} }
override suspend fun findByEmail(username: String): User? { override suspend fun findByEmail(email: String): User? {
return users.find { it.email == username } return users.find { it.email == email }
} }
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
TODO("Not yet implemented") return BCrypt.checkpw(password, passwordHash)
} }
override suspend fun registerUser(user: User): Boolean { override suspend fun registerUser(user: User): Boolean {
users.add(user) users.add(user)
screentimes[user.id] = 1800
return true return true
} }
} }

View file

@ -57,9 +57,27 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
post ("/api/v1/auth/login" ) { post ("/api/v1/auth/login" ) {
val loginRequest = call.receive<LoginRequest>() 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
))
return@post
val token = jwtService.generateToken(userId = loginRequest.userId)
call.respond(mapOf("token" to token))
} }
authenticate("auth-jwt") { authenticate("auth-jwt") {