new task fetch integration check
Some checks are pending
CI Pipeline / lint-and-test (push) Waiting to run
Some checks are pending
CI Pipeline / lint-and-test (push) Waiting to run
This commit is contained in:
parent
a57870f880
commit
ba3e683297
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,162 @@
|
||||||
package com.oliver.kidio.backend
|
package com.oliver.kidio.backend.domain.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
data class Task(
|
data class Task(
|
||||||
val id: String,
|
val id: String,
|
||||||
val subject: String = "Mathematik",
|
val subject: String = "Mathematik",
|
||||||
val minAge: Int = 6,
|
val minAge: Int = 6,
|
||||||
val maxAge: Int = 12,
|
val maxAge: Int = 12,
|
||||||
val question: String,
|
val prompt: Prompt,
|
||||||
val options: List<String>,
|
val content: TaskContent,
|
||||||
val rewardMinutes: Int,
|
val rewardMinutes: Int,
|
||||||
val license: String? = null,
|
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(
|
data class ImportedTask(
|
||||||
val id: String,
|
val id: String,
|
||||||
val externalSource: String = "zum.de",
|
val externalSource: String = "zum.de",
|
||||||
|
|
@ -24,21 +165,20 @@ data class ImportedTask(
|
||||||
val subject: String,
|
val subject: String,
|
||||||
val minAge: Int,
|
val minAge: Int,
|
||||||
val maxAge: Int,
|
val maxAge: Int,
|
||||||
val taskType: String = "MULTIPLE_CHOICE",
|
val prompt: Prompt,
|
||||||
val question: String,
|
val content: TaskContent,
|
||||||
val options: List<String>,
|
val solution: TaskSolution,
|
||||||
val correctAnswer: String,
|
|
||||||
val rewardMinutes: Int = 5,
|
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
|
@Serializable
|
||||||
data class TaskVerifyRequest(
|
data class TaskVerifyRequest(
|
||||||
val taskId: String,
|
val taskId: String,
|
||||||
val selectedAnswer: String,
|
val answer: TaskAnswer,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Antwort vom Server nach der Aufgaben-Prüfung
|
/** Unverändert gegenüber vorher. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TaskVerifyResponse(
|
data class TaskVerifyResponse(
|
||||||
val isCorrect: Boolean,
|
val isCorrect: Boolean,
|
||||||
|
|
@ -46,50 +186,32 @@ data class TaskVerifyResponse(
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Datenabgleich für die Bildschirmzeit (Sync)
|
// ---------------------------------------------------------------- Prüfung
|
||||||
@Serializable
|
|
||||||
data class SyncRequest(
|
|
||||||
val userId: String,
|
|
||||||
val usedSecondsSinceLastSync: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
object TaskChecker {
|
||||||
data class SyncResponse(
|
fun isCorrect(
|
||||||
val remainingSeconds: Int,
|
solution: TaskSolution,
|
||||||
val isBlocked: Boolean,
|
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
|
solution is OrderSolution && answer is OrderAnswer ->
|
||||||
data class RegisterRequest(
|
solution.orderedIds == answer.orderedIds
|
||||||
val email: String,
|
|
||||||
val passwordPlain: String,
|
|
||||||
val familyName: String,
|
|
||||||
val childAge: Int,
|
|
||||||
val parentPin: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
solution is PairsSolution && answer is PairsAnswer ->
|
||||||
data class LoginRequest(
|
solution.pairs == answer.pairs
|
||||||
val email: String,
|
|
||||||
val passwordPlain: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
// Typ von Antwort und Lösung passen nicht zusammen: immer falsch, nie einen Fehler werfen
|
||||||
data class AuthResponse(
|
else -> false
|
||||||
val token: String,
|
}
|
||||||
val userId: String,
|
|
||||||
val familyName: String,
|
|
||||||
val childAge: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Interne Backend-Klasse (Domain Model).
|
private fun normalize(s: String): String = s.trim().lowercase().replace(Regex("\\s+"), " ")
|
||||||
data class User(
|
}
|
||||||
val id: String,
|
|
||||||
val email: String,
|
|
||||||
val passwordHash: String,
|
|
||||||
val familyName: String,
|
|
||||||
val childAge: Int,
|
|
||||||
val parentPin: String,
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,379 +1,21 @@
|
||||||
package com.oliver.kidio.backend.domain.repository
|
package com.oliver.kidio.backend.domain.repository
|
||||||
|
|
||||||
import com.oliver.kidio.backend.ImportedTask
|
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||||
import com.oliver.kidio.backend.Task
|
import com.oliver.kidio.backend.domain.models.Task
|
||||||
import com.oliver.kidio.backend.User
|
import com.oliver.kidio.backend.domain.models.TaskSolution
|
||||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
import com.oliver.kidio.backend.domain.models.User
|
||||||
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
|
|
||||||
|
|
||||||
interface KidioRepository {
|
interface KidioRepository {
|
||||||
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||||
|
|
||||||
suspend fun getUserById(userId: String): User?
|
suspend fun getUserById(userId: String): User?
|
||||||
|
suspend fun findById(userId: String): User?
|
||||||
suspend fun getTasks(
|
suspend fun getTasks(subject: String? = null, age: Int? = null, limit: Int = 10): List<Task>
|
||||||
subject: String? = null,
|
|
||||||
age: Int? = null,
|
|
||||||
limit: Int = 10,
|
|
||||||
): List<Task>
|
|
||||||
|
|
||||||
// Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt
|
|
||||||
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
|
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
|
||||||
|
|
||||||
suspend fun upsertTask(task: ImportedTask): Boolean
|
suspend fun upsertTask(task: ImportedTask): Boolean
|
||||||
|
suspend fun getTaskAnswerAndReward(taskId: String): Pair<TaskSolution, 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(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
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue