feat: integrate PostgreSQL database with Exposed and implement Repository pattern

This commit is contained in:
Oliver Wallisch 2026-09-02 22:48:17 +02:00
parent 27ff371d4e
commit 1ae05f0c36
6 changed files with 306 additions and 42 deletions

View file

@ -1,2 +1,94 @@
package com.oliver.kidio.backend.data.database
import io.ktor.server.config.*
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
object TasksTable : Table("tasks") {
val id = varchar("id", 50)
val question = text("question")
val options = text("options") // Kommagetrennte Liste der Antwortmöglichkeiten
val correctAnswer = varchar("correct_answer", 255)
val rewardMinutes = integer("reward_minutes")
override val primaryKey = PrimaryKey(id)
}
object ScreentimeTable : Table("user_screentime") {
val userId = varchar("user_id", 50)
val remainingSeconds = integer("remaining_seconds")
val parentPin = varchar("parent_pin", 10)
override val primaryKey = PrimaryKey(userId)
}
object DatabaseFactory {
fun init(config: ApplicationConfig) {
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"
val host = System.getenv("DB_HOST")
?: config.propertyOrNull("storage.host")?.getString()
?: "localhost"
val port = System.getenv("DB_PORT")
?: config.propertyOrNull("storage.port")?.getString()
?: "5432"
val dbName = System.getenv("DB_NAME")
?: config.propertyOrNull("storage.database")?.getString()
?: "kidio_db"
val username = System.getenv("DB_USER")
?: config.propertyOrNull("storage.username")?.getString()
?: "postgres"
val password = System.getenv("DB_PASSWORD")
?: config.propertyOrNull("storage.password")?.getString()
?: "postgres"
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
val database = Database.connect(
url = jdbcURL,
driver = driverClassName,
user = username,
password = password
)
transaction(database) {
SchemaUtils.create(TasksTable, ScreentimeTable)
// Standard aufgaben, sollte es keine mehr geben
if (TasksTable.selectAll().empty()) {
TasksTable.insert {
it[id] = "t1"
it[question] = "Was ist 12 + 15?"
it[options] = "25,27,30,22"
it[correctAnswer] = "27"
it[rewardMinutes] = 10
}
TasksTable.insert {
it[id] = "t2"
it[question] = "Was ist 8 x 7?"
it[options] = "54,56,64,48"
it[correctAnswer] = "56"
it[rewardMinutes] = 15
}
}
// Seed a default user screentime if empty
if (ScreentimeTable.selectAll().empty()) {
ScreentimeTable.insert {
it[userId] = "default_user"
it[remainingSeconds] = 1800
it[parentPin] = "1234"
}
}
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
}

View file

@ -0,0 +1,78 @@
package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.Task
import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.ScreentimeTable
import com.oliver.kidio.backend.data.database.TasksTable
import org.jetbrains.exposed.sql.*
interface KidioRepository {
suspend fun getAllTasks(): List<Task>
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: Int)
}
class ExposedKidioRepository : KidioRepository {
override suspend fun getAllTasks(): List<Task> = DatabaseFactory.dbQuery {
TasksTable.selectAll().map {
Task(
id = it[TasksTable.id],
question = it[TasksTable.question],
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
rewardMinutes = it[TasksTable.rewardMinutes]
)
}
}
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 = DatabaseFactory.dbQuery {
ScreentimeTable.selectAll()
.where { ScreentimeTable.userId eq userId }
.map { it[ScreentimeTable.remainingSeconds] }
.singleOrNull() ?: 1800
}
override suspend fun updateScreentime(userId: String, seconds: Int): Unit = DatabaseFactory.dbQuery {
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
it[remainingSeconds] = seconds
}
if (rowsUpdated == 0) {
ScreentimeTable.insert {
it[ScreentimeTable.userId] = userId
it[remainingSeconds] = seconds
it[parentPin] = "1234"
}
}
}
}
class InMemoryKidioRepository : KidioRepository {
private val tasks = mutableListOf(
Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10),
Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15)
)
private val answers = mutableMapOf(
"t1" to ("27" to 10),
"t2" to ("56" to 15)
)
private val screentimes = mutableMapOf(
"default_user" to 1800
)
override suspend fun getAllTasks(): List<Task> = tasks
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId]
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
override suspend fun updateScreentime(userId: String, seconds: Int) {
screentimes[userId] = seconds
}
}

View file

@ -1,28 +1,13 @@
package com.oliver.kidio.backend
package com.oliver.kidio.backend.plugins
import com.oliver.kidio.backend.*
import com.oliver.kidio.backend.domain.repository.KidioRepository
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
// Dummy-Speicher für die Test-Daten
private var mockRemainingSeconds = 1800
private val mockTasks = mutableListOf(
Task(
id = "t1",
question = "Was ist 12 + 15?",
options = listOf("25", "27", "30", "22"),
rewardMinutes = 10
),
Task(
id = "t2",
question = "Was ist 8 x 7?",
options = listOf("54", "56", "64", "48"),
rewardMinutes = 15
)
)
fun Application.configureRouting() {
fun Application.configureRouting(repository: KidioRepository) {
routing {
// Health-Check
@ -32,22 +17,35 @@ fun Application.configureRouting() {
// 1. Alle verfügbaren Aufgaben abrufen
get("/api/v1/tasks") {
call.respond(mockTasks)
val tasks = repository.getAllTasks()
call.respond(tasks)
}
// 2. Antwort auf eine Aufgabe überprüfen
post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>()
val isCorrect = when (request.taskId) {
"t1" -> request.selectedAnswer == "27"
"t2" -> request.selectedAnswer == "56"
else -> false
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
if (taskInfo == null) {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!"
)
)
return@post
}
val (correctAnswer, reward) = taskInfo
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
if (isCorrect) {
val reward = 10
mockRemainingSeconds += (reward * 60)
val currentScreentime = repository.getScreentime("default_user")
val newSeconds = currentScreentime + (reward * 60)
repository.updateScreentime("default_user", newSeconds)
call.respond(
TaskVerifyResponse(
isCorrect = true,
@ -70,15 +68,18 @@ fun Application.configureRouting() {
post("/api/v1/screentime/sync") {
val request = call.receive<SyncRequest>()
mockRemainingSeconds -= request.usedSecondsSinceLastSync
if (mockRemainingSeconds < 0) mockRemainingSeconds = 0
val currentSeconds = repository.getScreentime(request.userId)
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
if (newSeconds < 0) newSeconds = 0
repository.updateScreentime(request.userId, newSeconds)
call.respond(
SyncResponse(
remainingSeconds = mockRemainingSeconds,
isBlocked = mockRemainingSeconds <= 0
remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0
)
)
}
}
}
}

View file

@ -1,14 +1,19 @@
import com.oliver.kidio.backend.configureRouting
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.module() {
// Datenbank initialisieren
DatabaseFactory.init(environment.config)
// JSON aktivieren
install(ContentNegotiation) {
json()
}
// Ruft die Funktion aus Routing.kt auf
configureRouting()
// Ruft die Funktion aus Routing.kt auf mit dem echten ExposedRepository
configureRouting(ExposedKidioRepository())
}

View file

@ -4,3 +4,11 @@ ktor:
application:
modules:
- MainKt.module
storage:
driverClassName: "org.postgresql.Driver"
host: "82.165.11.162"
port: "5432"
database: "kidio_db"
username: "postgres"
password: "Julian__98"

View file

@ -1,18 +1,98 @@
package com.oliver.kidio.backend
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.testing.*
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.*
import kotlin.test.*
class ServerTest {
@Test
fun `test root endpoint`() = testApplication {
// loads default configuration
configure()
// verify server root returns 200
assertEquals(HttpStatusCode.OK, client.get("/").status)
fun `test health endpoint`() = testApplication {
application {
this.install(ServerContentNegotiation) {
json()
}
configureRouting(InMemoryKidioRepository())
}
val response = client.get("/api/v1/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
}
@Test
fun `test tasks endpoint and verify`() = testApplication {
application {
this.install(ServerContentNegotiation) {
json()
}
configureRouting(InMemoryKidioRepository())
}
// 1. Get Tasks
val tasksResponse = client.get("/api/v1/tasks")
assertEquals(HttpStatusCode.OK, tasksResponse.status)
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
assertEquals(2, tasksJson.size)
assertEquals("t1", tasksJson[0].id)
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
// 2. Verify Task correct answer
val verifyResponse = client.post("/api/v1/tasks/verify") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
}
assertEquals(HttpStatusCode.OK, verifyResponse.status)
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
assertTrue(verifyJson.isCorrect)
assertEquals(10, verifyJson.earnedMinutes)
// 3. Verify Task incorrect answer
val verifyWrongResponse = client.post("/api/v1/tasks/verify") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"99"}""")
}
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
}
@Test
fun `test screentime sync`() = testApplication {
application {
this.install(ServerContentNegotiation) {
json()
}
configureRouting(InMemoryKidioRepository())
}
// Sync first time (user default_user has 1800s seeded)
val syncResponse1 = client.post("/api/v1/screentime/sync") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""")
}
assertEquals(HttpStatusCode.OK, syncResponse1.status)
val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText())
assertEquals(1600, syncJson1.remainingSeconds)
assertFalse(syncJson1.isBlocked)
// Sync and block
val syncResponse2 = client.post("/api/v1/screentime/sync") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":2000}""")
}
assertEquals(HttpStatusCode.OK, syncResponse2.status)
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
assertEquals(0, syncJson2.remainingSeconds)
assertTrue(syncJson2.isBlocked)
}
}