Compare commits
No commits in common. "6ac198a695529e3e32d71e43071eb88513abba44" and "b8bfb0b68f97da236b60dbf651803b4b802d45e2" have entirely different histories.
6ac198a695
...
b8bfb0b68f
174
README.md
174
README.md
|
|
@ -1,171 +1,33 @@
|
||||||
# Kidio Backend
|
# 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.
|
This project was created using the [Ktor Project Generator](https://start.ktor.io).
|
||||||
|
|
||||||
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.
|
Here are some useful links to get you started:
|
||||||
|
|
||||||
---
|
* [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
|
||||||
|
|
||||||
Hier ist eine Übersicht der implementierten Kernfunktionen des Kidio-Backends:
|
Here's a list of features included in this project:
|
||||||
|
|
||||||
| Name | Beschreibung |
|
| Name | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| **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
|
||||||
|
|
||||||
## Technologiestack
|
To build or run the project, use one of the following tasks:
|
||||||
|
|
||||||
- **Framework**: [Ktor](https://ktor.io/) (Kotlin-basiertes asynchrones Web-Framework)
|
| Task | Description |
|
||||||
- **Datenbank-ORM**: [JetBrains Exposed](https://github.com/JetBrains/Exposed)
|
|-------------------|-------------------|
|
||||||
- **Persistenz**: PostgreSQL
|
| `./gradlew test` | Run the tests |
|
||||||
- **Caching**: Redis (via Jedis Client)
|
| `./gradlew build` | Build the project |
|
||||||
- **Sicherheit & Auth**: JWT (Java JWT von auth0)
|
| `./gradlew run` | Run the server |
|
||||||
- **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!"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
|
||||||
#### 2. Benutzer-Login (Auth-Token abholen)
|
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
|
||||||
- **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
|
|
||||||
```
|
|
||||||
|
|
||||||
Sobald der Server erfolgreich gestartet ist, ist er unter `http://localhost:8080` erreichbar.
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,6 @@ 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
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.
|
|
@ -19,15 +19,18 @@ 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")
|
val childAge = integer("child_age") // Hier ist das Alter wieder!
|
||||||
val parentPin = varchar("parent_pin", 4)
|
val parentPin = varchar("parent_pin", 4)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,6 +57,7 @@ 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()) {
|
||||||
|
|
@ -77,9 +81,11 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ 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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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
|
||||||
|
|
@ -15,7 +16,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(email: String): User?
|
suspend fun findByEmail(username: 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
|
||||||
}
|
}
|
||||||
|
|
@ -32,25 +33,21 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery {
|
override suspend fun registerUser(user: User): Boolean = dbQuery {
|
||||||
try {
|
|
||||||
UserTable.insert {
|
UserTable.insert {
|
||||||
it[id] = user.id
|
it[UserTable.id] = user.id
|
||||||
it[email] = user.email
|
it[UserTable.email] = user.email
|
||||||
it[passwordHash] = user.passwordHash
|
it[UserTable.passwordHash] = user.passwordHash
|
||||||
it[familyName] = user.familyName
|
it[UserTable.familyName] = user.familyName
|
||||||
it[childAge] = user.childAge
|
it[UserTable.childAge] = user.childAge
|
||||||
it[parentPin] = user.parentPin
|
it[UserTable.parentPin] = user.parentPin
|
||||||
}
|
}
|
||||||
ScreentimeTable.insert {
|
ScreentimeTable.insert {
|
||||||
it[userId] = user.id
|
it[ScreentimeTable.userId] = user.id
|
||||||
it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten
|
it[ScreentimeTable.remainingSeconds] = 1800
|
||||||
|
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 {
|
||||||
|
|
@ -78,7 +75,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] } // Korrigiert: Abfrage der Sekunden!
|
.map { it[ScreentimeTable.remainingSeconds] }
|
||||||
.singleOrNull() ?: 1800
|
.singleOrNull() ?: 1800
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,14 +97,16 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
if (rowsUpdated == 0) {
|
if (rowsUpdated == 0) {
|
||||||
ScreentimeTable.insert {
|
ScreentimeTable.insert {
|
||||||
it[ScreentimeTable.userId] = userId
|
it[ScreentimeTable.userId] = userId
|
||||||
it[ScreentimeTable.remainingSeconds] = seconds // Korrigiert: Wert wird wieder mitgegeben!
|
it[remainingSeconds] = seconds
|
||||||
|
it[parentPin] = "1234"
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery {
|
override suspend fun findByEmail(username: String): User? = DatabaseFactory.dbQuery {
|
||||||
UserTable.selectAll()
|
UserTable.selectAll()
|
||||||
.where { UserTable.email eq email }
|
.where { UserTable.email eq username }
|
||||||
.map {
|
.map {
|
||||||
User(
|
User(
|
||||||
id = it[UserTable.id],
|
id = it[UserTable.id],
|
||||||
|
|
@ -150,17 +149,16 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
screentimes[userId] = seconds
|
screentimes[userId] = seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun findByEmail(email: String): User? {
|
override suspend fun findByEmail(username: String): User? {
|
||||||
return users.find { it.email == email }
|
return users.find { it.email == username }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
||||||
return BCrypt.checkpw(password, passwordHash)
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,27 +57,9 @@ 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") {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue