Compare commits

...

2 commits

Author SHA1 Message Date
Oliver Wallisch d26d5f688a fixed erros with new user.mdoel
Some checks failed
CI Pipeline / lint-and-test (push) Has been cancelled
2026-09-23 14:55:31 +02:00
Oliver Wallisch ba3e683297 new task fetch integration check
Some checks are pending
CI Pipeline / lint-and-test (push) Waiting to run
2026-09-23 11:13:07 +02:00
9 changed files with 682 additions and 421 deletions

View file

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

View file

@ -0,0 +1,44 @@
package com.oliver.kidio.backend.data.tables
import org.jetbrains.exposed.sql.Table
/*
* ERSETZT die bisherige TasksTable.kt komplett.
*
* Weg: options (Komma-String), correctAnswer (ein String)
* Hin: prompt_json, content_json, solution_json (je ein JSON-Text, siehe TaskModels.kt)
*
* WICHTIG - Migration: Diese Änderung ist nicht abwärtskompatibel zu vorhandenen Zeilen,
* die noch die alten Spalten "options"/"correct_answer" nutzen. Zwei Optionen:
*
* 1. (Empfohlen für Dev-Datenbanken ohne echte Nutzerdaten) Tabelle leeren und per
* ./gradlew runIngestion neu befüllen lassen, siehe H5PIngestionCli.kt.
*
* 2. (Falls du bestehende Zeilen behalten willst) Vor dem Deploy ein einmaliges
* Migrationsskript schreiben, das pro Zeile aus options+correctAnswer ein
* MultipleChoiceContent/ChoiceSolution baut und in die neuen Spalten schreibt.
* Sag Bescheid, dann schreibe ich dir das als eigenen Schritt.
*/
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")
// NEU: ersetzen "question", "options", "correctAnswer"
val promptJson = text("prompt_json")
val contentJson = text("content_json")
val solutionJson = text("solution_json")
val rewardMinutes = integer("reward_minutes").default(5)
override val primaryKey = PrimaryKey(id)
init {
index("idx_tasks_filter", false, subject, minAge, maxAge)
}
}

View file

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

View file

@ -1,21 +1,162 @@
package com.oliver.kidio.backend
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Eine Lernaufgabe (z. B. Mathe)
/*
* ERSETZT die bisherige TaskModels.kt komplett.
*
* Warum: Task/ImportedTask hatten "options: List<String>" + "correctAnswer: String".
* Das kann nur "eine richtige Antwort aus einer Liste" abbilden (Multiple Choice,
* Wahr/Falsch). Lückentexte, Reihenfolgen oder Paare passen strukturell nicht rein.
*
* Neues Prinzip: Jede Aufgabe hat einen TYP (sealed interface TaskContent).
* Die Lösung (TaskSolution) ist ein eigenes, getrenntes Objekt und geht NIE an den Client.
*
* ACHTUNG (Kompilierfehler erwartet): Nach diesem Schritt melden ExposedKidioRepository.kt,
* TasksTable.kt und TaskRoutes.kt Fehler, weil sie noch die alten Klassen Task/ImportedTask/
* TaskVerifyRequest referenzieren. Das ist normal - die fixen wir in Schritt 2 bis 4.
*/
// ---------------------------------------------------------------- Bausteine
@Serializable
data class Media(
val emoji: String? = null,
val imageUrl: String? = null,
val audioUrl: String? = null,
val alt: String? = null,
// repeat = 3 mit emoji "🍎" zeigt drei Äpfel, praktisch zum Zählen für Kleinkinder
val repeat: Int = 1,
)
@Serializable
data class Choice(
val id: String,
val label: String? = null,
val media: Media? = null,
)
@Serializable
data class Prompt(
val text: String,
val media: Media? = null,
// Die App liest den Text per Text-to-Speech vor (wichtig für Kinder, die noch nicht lesen)
val speakAloud: Boolean = false,
)
// ---------------------------------------------------------------- Inhalt (geht an die App)
@Serializable
sealed interface TaskContent
/** Ein oder mehrere richtige Antworten. Auch für Bild-Antworten (Kleinkinder). */
@Serializable
@SerialName("MULTIPLE_CHOICE")
data class MultipleChoiceContent(
val choices: List<Choice>,
val multiSelect: Boolean = false,
) : TaskContent
/** Zwei große Knöpfe. Antwort-IDs sind immer "true" und "false". */
@Serializable
@SerialName("TRUE_FALSE")
object TrueFalseContent : TaskContent {
const val TRUE_ID = "true"
const val FALSE_ID = "false"
}
/** Lückentext. Im Template stehen {1}, {2}, ... für die Lücken. */
@Serializable
@SerialName("FILL_BLANKS")
data class FillBlanksContent(
val template: String,
val blanks: Int,
val wordBank: List<String> = emptyList(),
) : TaskContent
/** Elemente in die richtige Reihenfolge bringen. Kommt gemischt an. */
@Serializable
@SerialName("SORT")
data class SortContent(
val items: List<Choice>,
) : TaskContent
/** Paare verbinden (links zu rechts). */
@Serializable
@SerialName("MATCH_PAIRS")
data class MatchPairsContent(
val left: List<Choice>,
val right: List<Choice>,
) : TaskContent
/** Die richtigen Wörter im Text markieren. */
@Serializable
@SerialName("MARK_WORDS")
data class MarkWordsContent(
val tokens: List<Choice>,
) : TaskContent
// ---------------------------------------------------------------- Lösung (bleibt auf dem Server)
@Serializable
sealed interface TaskSolution
@Serializable
@SerialName("CHOICE")
data class ChoiceSolution(val correctIds: Set<String>) : TaskSolution
/** Pro Lücke eine Liste akzeptierter Schreibweisen. */
@Serializable
@SerialName("FILL")
data class FillSolution(val accepted: List<List<String>>) : TaskSolution
@Serializable
@SerialName("ORDER")
data class OrderSolution(val orderedIds: List<String>) : TaskSolution
@Serializable
@SerialName("PAIRS")
data class PairsSolution(val pairs: Map<String, String>) : TaskSolution
// ---------------------------------------------------------------- Antwort des Kindes
@Serializable
sealed interface TaskAnswer
@Serializable
@SerialName("CHOICE")
data class ChoiceAnswer(val selectedIds: Set<String>) : TaskAnswer
@Serializable
@SerialName("FILL")
data class FillAnswer(val values: List<String>) : TaskAnswer
@Serializable
@SerialName("ORDER")
data class OrderAnswer(val orderedIds: List<String>) : TaskAnswer
@Serializable
@SerialName("PAIRS")
data class PairsAnswer(val pairs: Map<String, String>) : TaskAnswer
// ---------------------------------------------------------------- API-Objekte
/** Ersetzt die alte "Task"-Klasse. Geht an die App, ohne Lösung. */
@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 prompt: Prompt,
val content: TaskContent,
val rewardMinutes: Int,
val license: String? = null,
val sourceUrl: String? = null,
)
// Interne Datenklasse für importierte Aufgaben (Ingestion)
/** Ersetzt die alte "ImportedTask"-Klasse. Interne Datenklasse für den Import. */
data class ImportedTask(
val id: String,
val externalSource: String = "zum.de",
@ -24,21 +165,20 @@ data class ImportedTask(
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 prompt: Prompt,
val content: TaskContent,
val solution: TaskSolution,
val rewardMinutes: Int = 5,
)
// Anfrage des Kindes, wenn es eine Aufgabe löst
/** Ersetzt die alte "TaskVerifyRequest": statt eines Strings jetzt eine typisierte Antwort. */
@Serializable
data class TaskVerifyRequest(
val taskId: String,
val selectedAnswer: String,
val answer: TaskAnswer,
)
// Antwort vom Server nach der Aufgaben-Prüfung
/** Unverändert gegenüber vorher. */
@Serializable
data class TaskVerifyResponse(
val isCorrect: Boolean,
@ -46,50 +186,32 @@ data class TaskVerifyResponse(
val message: String,
)
// Datenabgleich für die Bildschirmzeit (Sync)
@Serializable
data class SyncRequest(
val userId: String,
val usedSecondsSinceLastSync: Int,
)
// ---------------------------------------------------------------- Prüfung
@Serializable
data class SyncResponse(
val remainingSeconds: Int,
val isBlocked: Boolean,
)
object TaskChecker {
fun isCorrect(
solution: TaskSolution,
answer: TaskAnswer,
): Boolean =
when {
solution is ChoiceSolution && answer is ChoiceAnswer ->
solution.correctIds == answer.selectedIds
// --- NEU: AUTHENTIFIZIERUNG ---
solution is FillSolution && answer is FillAnswer ->
solution.accepted.size == answer.values.size &&
solution.accepted.indices.all { i ->
solution.accepted[i].any { normalize(it) == normalize(answer.values[i]) }
}
@Serializable
data class RegisterRequest(
val email: String,
val passwordPlain: String,
val familyName: String,
val childAge: Int,
val parentPin: String,
)
solution is OrderSolution && answer is OrderAnswer ->
solution.orderedIds == answer.orderedIds
@Serializable
data class LoginRequest(
val email: String,
val passwordPlain: String,
)
solution is PairsSolution && answer is PairsAnswer ->
solution.pairs == answer.pairs
@Serializable
data class AuthResponse(
val token: String,
val userId: String,
val familyName: String,
val childAge: Int,
)
// Typ von Antwort und Lösung passen nicht zusammen: immer falsch, nie einen Fehler werfen
else -> false
}
// 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,
)
private fun normalize(s: String): String = s.trim().lowercase().replace(Regex("\\s+"), " ")
}

View file

@ -0,0 +1,23 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.json.Json
/*
* NEUE DATEI (noch nicht vorhanden in deinem Projekt).
* Ablage: src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskJson.kt
*
* Eine gemeinsame Json-Konfiguration, die überall dort verwendet wird, wo TaskContent,
* TaskSolution oder TaskAnswer serialisiert/deserialisiert werden: beim Schreiben/Lesen
* der DB-Spalten (content_json, solution_json, prompt_json) und im H5P-Mapper.
*
* classDiscriminator = "type" sorgt dafür, dass im JSON ein Feld "type" steht
* (z. B. "type": "MULTIPLE_CHOICE"), damit die App weiß, welche Variante sie bekommt.
*/
object TaskJson {
val json: Json =
Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
encodeDefaults = true
}
}

View file

@ -0,0 +1,13 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val email: String,
val passwordHash: String,
val familyName: String,
val childAge: Int,
val parentPin: String
)

View file

@ -0,0 +1,292 @@
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.FillBlanksContent
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.MarkWordsContent
import com.oliver.kidio.backend.domain.models.MatchPairsContent
import com.oliver.kidio.backend.domain.models.MultipleChoiceContent
import com.oliver.kidio.backend.domain.models.Prompt
import com.oliver.kidio.backend.domain.models.SortContent
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskContent
import com.oliver.kidio.backend.domain.models.TaskJson
import com.oliver.kidio.backend.domain.models.TaskSolution
import com.oliver.kidio.backend.domain.models.TrueFalseContent
import com.oliver.kidio.backend.domain.models.User
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import org.jetbrains.exposed.sql.*
import org.mindrot.jbcrypt.BCrypt
import kotlin.collections.singleOrNull
/*
* ERSETZT die bisherige ExposedKidioRepository.kt.
* Geändert: getTasks, upsertTask, getTaskAnswerAndReward (JSON-Spalten statt options/correctAnswer).
* Unverändert: getUserById, registerUser, getScreentime, updateScreentime, findByEmail,
* findById, verifyPassword, getExistingTaskIds.
*/
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).mapNotNull { row -> rowToTaskOrNull(row) }
}
/** Baut eine Task aus einer DB-Zeile. Bei kaputtem JSON wird die Zeile übersprungen statt den Server abstürzen zu lassen. */
private fun rowToTaskOrNull(row: ResultRow): Task? =
try {
Task(
id = row[TasksTable.id],
subject = row[TasksTable.subject],
minAge = row[TasksTable.minAge],
maxAge = row[TasksTable.maxAge],
prompt = TaskJson.json.decodeFromString<Prompt>(row[TasksTable.promptJson]),
content = TaskJson.json.decodeFromString<TaskContent>(row[TasksTable.contentJson]),
rewardMinutes = row[TasksTable.rewardMinutes],
license = row[TasksTable.license],
sourceUrl = row[TasksTable.sourceUrl],
)
} catch (e: Exception) {
println("WARNUNG: Task ${row[TasksTable.id]} konnte nicht gelesen werden (kaputtes JSON?): ${e.message}")
null
}
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 promptJson = TaskJson.json.encodeToString(task.prompt)
val contentJson = TaskJson.json.encodeToString(task.content)
val solutionJson = TaskJson.json.encodeToString(task.solution)
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.content.typeName()
it[TasksTable.promptJson] = promptJson
it[TasksTable.contentJson] = contentJson
it[TasksTable.solutionJson] = solutionJson
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.content.typeName()
it[TasksTable.promptJson] = promptJson
it[TasksTable.contentJson] = contentJson
it[TasksTable.solutionJson] = solutionJson
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<TaskSolution, Int>? =
DatabaseFactory.dbQuery {
TasksTable.selectAll()
.where { TasksTable.id eq taskId }
.map {
val solution = TaskJson.json.decodeFromString<TaskSolution>(it[TasksTable.solutionJson])
solution 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)
}
}
/**
* Liefert den Aufgabentyp als String für die taskType-Spalte (nur zum Filtern/Debuggen
* in der DB, nicht Teil der eigentlichen Prüf-Logik - die läuft über TaskChecker).
* Bewusst als explizites "when" statt Reflection, damit der Compiler bei einer neuen
* TaskContent-Variante hier einen Fehler wirft ("not exhaustive"), falls sie vergessen wird.
*/
private fun TaskContent.typeName(): String =
when (this) {
is MultipleChoiceContent -> "MULTIPLE_CHOICE"
is TrueFalseContent -> "TRUE_FALSE"
is FillBlanksContent -> "FILL_BLANKS"
is SortContent -> "SORT"
is MatchPairsContent -> "MATCH_PAIRS"
is MarkWordsContent -> "MARK_WORDS"
}

View file

@ -0,0 +1,101 @@
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.TaskSolution
import com.oliver.kidio.backend.domain.models.User
import org.mindrot.jbcrypt.BCrypt
/*
* ERSETZT die bisherige InMemoryKidioRepository.kt.
* Wird u.a. in Tests genutzt (ServerTest o.ä.) - Signatur muss zum neuen KidioRepository-
* Interface passen, sonst kompiliert das Test-Modul nicht mehr.
*/
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,
prompt = it.prompt,
content = it.content,
rewardMinutes = it.rewardMinutes,
license = it.license,
sourceUrl = it.sourceUrl,
)
}
}
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<TaskSolution, Int>? {
val task = tasks.find { it.id == taskId } ?: return null
return task.solution to task.rewardMinutes
}
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
screentimes[userId] = seconds
}
override suspend fun findByEmail(email: String): User? {
return users.find { it.email == email }
}
override suspend fun findById(userId: String): User? {
return users.find { it.id == userId }
}
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
override suspend fun registerUser(user: User): Boolean {
users.add(user)
screentimes[user.id] = 1800
return true
}
}

View file

@ -1,379 +1,21 @@
package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.ImportedTask
import com.oliver.kidio.backend.Task
import com.oliver.kidio.backend.User
import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.database.ScreentimeTable
import com.oliver.kidio.backend.data.database.TasksTable
import com.oliver.kidio.backend.data.database.UserTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq
import org.jetbrains.exposed.sql.lowerCase
import org.mindrot.jbcrypt.BCrypt
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskSolution
import com.oliver.kidio.backend.domain.models.User
interface KidioRepository {
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
suspend fun getUserById(userId: String): User?
suspend fun getTasks(
subject: String? = null,
age: Int? = null,
limit: Int = 10,
): List<Task>
// Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt
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<TaskSolution, 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 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)
// NEU: Implementierung für Exposed (PostgreSQL)
override suspend fun getExistingTaskIds(externalSource: String): Set<String> =
DatabaseFactory.dbQuery {
TasksTable
.select(TasksTable.id) // Wir brauchen nur die ID-Spalte
.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 verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
}
class InMemoryKidioRepository : KidioRepository {
private val tasks =
mutableListOf(
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",
),
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",
),
)
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 }
}
// NEU: Implementierung für InMemory (Mock-Daten)
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 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
}
}
}