From ba3e6832978fac186f6ea648490457dab8b12d4f Mon Sep 17 00:00:00 2001 From: Oliver Wallisch Date: Wed, 23 Sep 2026 11:13:07 +0200 Subject: [PATCH] new task fetch integration check --- .../backend/data/tables/ScreentimeTable.kt | 10 + .../kidio/backend/data/tables/TasksTable.kt | 44 ++ .../kidio/backend/data/tables/UserTable.kt | 14 + .../kidio/backend/domain/models/Models.kt | 228 ++++++++--- .../kidio/backend/domain/models/TaskJson.kt | 23 ++ .../repository/ExposedKidioRepository.kt | 292 ++++++++++++++ .../repository/InMemoryKidioRepository.kt | 101 +++++ .../domain/repository/KidioRepository.kt | 378 +----------------- 8 files changed, 669 insertions(+), 421 deletions(-) create mode 100644 src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskJson.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt b/src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt new file mode 100644 index 0000000..b9d49be --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt b/src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt new file mode 100644 index 0000000..ab501fe --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt b/src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt new file mode 100644 index 0000000..034227f --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt index b143dea..77c5f7b 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt @@ -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" + "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, + 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 = emptyList(), +) : TaskContent + +/** Elemente in die richtige Reihenfolge bringen. Kommt gemischt an. */ +@Serializable +@SerialName("SORT") +data class SortContent( + val items: List, +) : TaskContent + +/** Paare verbinden (links zu rechts). */ +@Serializable +@SerialName("MATCH_PAIRS") +data class MatchPairsContent( + val left: List, + val right: List, +) : TaskContent + +/** Die richtigen Wörter im Text markieren. */ +@Serializable +@SerialName("MARK_WORDS") +data class MarkWordsContent( + val tokens: List, +) : TaskContent + +// ---------------------------------------------------------------- Lösung (bleibt auf dem Server) + +@Serializable +sealed interface TaskSolution + +@Serializable +@SerialName("CHOICE") +data class ChoiceSolution(val correctIds: Set) : TaskSolution + +/** Pro Lücke eine Liste akzeptierter Schreibweisen. */ +@Serializable +@SerialName("FILL") +data class FillSolution(val accepted: List>) : TaskSolution + +@Serializable +@SerialName("ORDER") +data class OrderSolution(val orderedIds: List) : TaskSolution + +@Serializable +@SerialName("PAIRS") +data class PairsSolution(val pairs: Map) : TaskSolution + +// ---------------------------------------------------------------- Antwort des Kindes + +@Serializable +sealed interface TaskAnswer + +@Serializable +@SerialName("CHOICE") +data class ChoiceAnswer(val selectedIds: Set) : TaskAnswer + +@Serializable +@SerialName("FILL") +data class FillAnswer(val values: List) : TaskAnswer + +@Serializable +@SerialName("ORDER") +data class OrderAnswer(val orderedIds: List) : TaskAnswer + +@Serializable +@SerialName("PAIRS") +data class PairsAnswer(val pairs: Map) : 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, + 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, - 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+"), " ") +} \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskJson.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskJson.kt new file mode 100644 index 0000000..753581b --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskJson.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt new file mode 100644 index 0000000..c2af157 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt @@ -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 = + 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(row[TasksTable.promptJson]), + content = TaskJson.json.decodeFromString(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 = getTasks(limit = 100) + + override suspend fun getExistingTaskIds(externalSource: String): Set = + 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? = + DatabaseFactory.dbQuery { + TasksTable.selectAll() + .where { TasksTable.id eq taskId } + .map { + val solution = TaskJson.json.decodeFromString(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], + ) + } + .singian leOrNull() + } + + 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" + } \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt new file mode 100644 index 0000000..a792e1a --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt @@ -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() + + private val screentimes = + mutableMapOf( + "default_user" to 1800, + ) + private val users = mutableListOf() + + override suspend fun getAllTasks(): List = getTasks(limit = 100) + + override suspend fun getTasks( + subject: String?, + age: Int?, + limit: Int, + ): List { + 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 { + 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? { + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index 988365c..a07a176 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -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 = getTasks(limit = 100) - suspend fun getUserById(userId: String): User? - - suspend fun getTasks( - subject: String? = null, - age: Int? = null, - limit: Int = 10, - ): List - - // 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 suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set - suspend fun upsertTask(task: ImportedTask): Boolean - - suspend fun getTaskAnswerAndReward(taskId: String): Pair? - + suspend fun getTaskAnswerAndReward(taskId: String): Pair? 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 = - 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 = getTasks(limit = 100) - - // NEU: Implementierung für Exposed (PostgreSQL) - override suspend fun getExistingTaskIds(externalSource: String): Set = - 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? = - 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() - - override suspend fun getAllTasks(): List = getTasks(limit = 100) - - override suspend fun getTasks( - subject: String?, - age: Int?, - limit: Int, - ): List { - 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 { - 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? { - 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 - } -} +} \ No newline at end of file