add integration of registration and changed datbase schema for single family #5

Merged
DrunkenPinguin merged 1 commit from feat/integration-registration-and-login into main 2026-09-07 13:20:39 +00:00
5 changed files with 181 additions and 95 deletions
Showing only changes of commit 71f81a97dd - Show all commits

View file

@ -9,7 +9,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
object TasksTable : Table("tasks") { object TasksTable : Table("tasks") {
val id = varchar("id", 50) val id = varchar("id", 50)
val question = text("question") val question = text("question")
val options = text("options") // Kommagetrennte Liste der Antwortmöglichkeiten val options = text("options")
val correctAnswer = varchar("correct_answer", 255) val correctAnswer = varchar("correct_answer", 255)
val rewardMinutes = integer("reward_minutes") val rewardMinutes = integer("reward_minutes")
@ -24,10 +24,15 @@ object ScreentimeTable : Table("user_screentime") {
override val primaryKey = PrimaryKey(userId) override val primaryKey = PrimaryKey(userId)
} }
// Option A: Alles Wichtige für den Haushalt & das Kind in einer Tabelle
object UserTable : Table("users") { object UserTable : Table("users") {
val id = varchar("id", 36) val id = varchar("id", 36)
val username = varchar("username", 50).index(isUnique = true) val email = varchar("email", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255) val passwordHash = varchar("password_hash", 255)
val familyName = varchar("family_name", 100)
val childAge = integer("child_age") // Hier ist das Alter wieder!
val parentPin = varchar("parent_pin", 4)
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
@ -35,26 +40,12 @@ object UserTable : Table("users") {
object DatabaseFactory { object DatabaseFactory {
fun init(config: ApplicationConfig) { fun init(config: ApplicationConfig) {
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver" 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 dbUsername = System.getenv("DB_USER") val host = System.getenv("DB_HOST") ?: config.propertyOrNull("storage.host")?.getString() ?: "localhost"
?: config.propertyOrNull("storage.username")?.getString() val port = System.getenv("DB_PORT") ?: config.propertyOrNull("storage.port")?.getString() ?: "5432"
?: "postgres" val dbName = System.getenv("DB_NAME") ?: config.propertyOrNull("storage.database")?.getString() ?: "kidio_db"
val dbUsername = System.getenv("DB_USER") ?: config.propertyOrNull("storage.username")?.getString() ?: "postgres"
val password = System.getenv("DB_PASSWORD") val password = System.getenv("DB_PASSWORD") ?: config.propertyOrNull("storage.password")?.getString() ?: "postgres"
?: config.propertyOrNull("storage.password")?.getString()
?: "postgres"
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName" val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
@ -66,9 +57,9 @@ object DatabaseFactory {
) )
transaction(database) { transaction(database) {
// ChildrenTable wurde hier entfernt
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable) SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
// Standard aufgaben, sollte es keine mehr geben
if (TasksTable.selectAll().empty()) { if (TasksTable.selectAll().empty()) {
TasksTable.insert { TasksTable.insert {
it[id] = "t1" it[id] = "t1"
@ -85,8 +76,7 @@ object DatabaseFactory {
it[rewardMinutes] = 15 it[rewardMinutes] = 15
} }
} }
// Seed a default user screentime if empty
if (ScreentimeTable.selectAll().empty()) { if (ScreentimeTable.selectAll().empty()) {
ScreentimeTable.insert { ScreentimeTable.insert {
it[userId] = "default_user" it[userId] = "default_user"
@ -95,13 +85,15 @@ object DatabaseFactory {
} }
} }
// Create a standard user if the Users table is empty. // Korrigierter Seed für UserTable (alle Pflichtfelder befüllt)
if (UserTable.selectAll().empty()) { if (UserTable.selectAll().empty()) {
UserTable.insert { UserTable.insert {
// ID automatische generierung
it[id] = "default_user" it[id] = "default_user"
it[username] = "default_user" it[email] = "test@kidio.de"
it[passwordHash] = "1234" it[passwordHash] = "1234" // Dummy-Hash für den Start
it[familyName] = "Familie Test"
it[childAge] = 8
it[parentPin] = "1234"
} }
} }
} }
@ -109,4 +101,4 @@ object DatabaseFactory {
suspend fun <T> dbQuery(block: suspend () -> T): T = suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() } newSuspendedTransaction(Dispatchers.IO) { block() }
} }

View file

@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
data class Task( data class Task(
val id: String, val id: String,
val question: String, val question: String,
val options: List<String>, // Antwortmöglichkeiten val options: List<String>,
val rewardMinutes: Int // Wie viel Belohnung es gibt val rewardMinutes: Int // Wie viel Belohnung es gibt
) )
@ -33,20 +33,44 @@ data class SyncRequest(
val usedSecondsSinceLastSync: Int val usedSecondsSinceLastSync: Int
) )
@Serializable
data class LoginRequest(
val userId: String
)
@Serializable
data class User(
val id: String,
val username: String,
val passwordHash: String
)
@Serializable @Serializable
data class SyncResponse( data class SyncResponse(
val remainingSeconds: Int, val remainingSeconds: Int,
val isBlocked: Boolean val isBlocked: Boolean
) )
// --- NEU: AUTHENTIFIZIERUNG ---
@Serializable
data class RegisterRequest(
val email: String,
val passwordPlain: String,
val familyName: String,
val childAge: Int,
val parentPin: String
)
@Serializable
data class LoginRequest(
val userId: String,
val email: String,
val passwordPlain: String
)
@Serializable
data class AuthResponse(
val token: String, // Der digitale "Ausweis" (JWT)
val userId: String, // Wichtig für die App, um Aufgaben zuzuordnen
val familyName: String, // Für "Hallo Familie Müller!"
val childAge: Int
)
// 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
)

View file

@ -3,20 +3,22 @@ package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.Task import com.oliver.kidio.backend.Task
import com.oliver.kidio.backend.User import com.oliver.kidio.backend.User
import com.oliver.kidio.backend.data.database.DatabaseFactory import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.DatabaseFactory.dbQuery
import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.database.ScreentimeTable import com.oliver.kidio.backend.data.database.ScreentimeTable
import com.oliver.kidio.backend.data.database.TasksTable import com.oliver.kidio.backend.data.database.TasksTable
import com.oliver.kidio.backend.data.database.UserTable import com.oliver.kidio.backend.data.database.UserTable
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.mindrot.jbcrypt.BCrypt
interface KidioRepository { interface KidioRepository {
suspend fun getAllTasks(): List<Task> suspend fun getAllTasks(): List<Task>
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: Int) suspend fun updateScreentime(userId: String, seconds: Int)
suspend fun findByEmail(username: String): User?
suspend fun findByUsername(username: String): User?
suspend fun verifyPassword(password: String, passwordHash: String): Boolean suspend fun verifyPassword(password: String, passwordHash: String): Boolean
suspend fun registerUser(user: User): Boolean
} }
class ExposedKidioRepository : KidioRepository { class ExposedKidioRepository : KidioRepository {
@ -31,6 +33,23 @@ class ExposedKidioRepository : KidioRepository {
} }
} }
override suspend fun registerUser(user: User): Boolean = dbQuery {
UserTable.insert {
it[UserTable.id] = user.id
it[UserTable.email] = user.email
it[UserTable.passwordHash] = user.passwordHash
it[UserTable.familyName] = user.familyName
it[UserTable.childAge] = user.childAge
it[UserTable.parentPin] = user.parentPin
}
ScreentimeTable.insert {
it[ScreentimeTable.userId] = user.id
it[ScreentimeTable.remainingSeconds] = 1800
it[ScreentimeTable.parentPin] = user.parentPin
}
true
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery { override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery {
TasksTable.selectAll() TasksTable.selectAll()
.where { TasksTable.id eq taskId } .where { TasksTable.id eq taskId }
@ -52,12 +71,14 @@ class ExposedKidioRepository : KidioRepository {
} catch (e: Exception) { } catch (e: Exception) {
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}") println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
} }
val dbSeconds = DatabaseFactory.dbQuery { val dbSeconds = DatabaseFactory.dbQuery {
ScreentimeTable.selectAll() ScreentimeTable.selectAll()
.where { ScreentimeTable.userId eq userId } .where { ScreentimeTable.userId eq userId }
.map { it[ScreentimeTable.remainingSeconds] } .map { it[ScreentimeTable.remainingSeconds] }
.singleOrNull() ?: 1800 .singleOrNull() ?: 1800
} }
try { try {
RedisFactory.getResource().use { jedis -> RedisFactory.getResource().use { jedis ->
jedis.setex(redisKey, 3600, dbSeconds.toString()) jedis.setex(redisKey, 3600, dbSeconds.toString())
@ -69,7 +90,7 @@ class ExposedKidioRepository : KidioRepository {
return dbSeconds return dbSeconds
} }
override suspend fun updateScreentime(userId: String, seconds: Int): Unit = DatabaseFactory.dbQuery { override suspend fun updateScreentime(userId: String, seconds: Int) = DatabaseFactory.dbQuery {
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
it[remainingSeconds] = seconds it[remainingSeconds] = seconds
} }
@ -78,25 +99,29 @@ class ExposedKidioRepository : KidioRepository {
it[ScreentimeTable.userId] = userId it[ScreentimeTable.userId] = userId
it[remainingSeconds] = seconds it[remainingSeconds] = seconds
it[parentPin] = "1234" it[parentPin] = "1234"
} }
} }
} }
override suspend fun findByUsername(username: String): User? = DatabaseFactory.dbQuery { override suspend fun findByEmail(username: String): User? = DatabaseFactory.dbQuery {
UserTable.selectAll() UserTable.selectAll()
.where { UserTable.username eq username } .where { UserTable.email eq username }
.map { .map {
User( User(
id = it[UserTable.id], id = it[UserTable.id],
username = it[UserTable.username], email = it[UserTable.email],
passwordHash = it[UserTable.passwordHash] passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin]
) )
} }
.singleOrNull() .singleOrNull()
} }
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
return password == passwordHash return BCrypt.checkpw(password, passwordHash)
} }
} }
@ -112,10 +137,7 @@ class InMemoryKidioRepository : KidioRepository {
private val screentimes = mutableMapOf( private val screentimes = mutableMapOf(
"default_user" to 1800 "default_user" to 1800
) )
private val users = mutableListOf<User>()
private val users = mutableListOf(
User(id = "1", username = "max", passwordHash = "geheim123")
)
override suspend fun getAllTasks(): List<Task> = tasks override suspend fun getAllTasks(): List<Task> = tasks
@ -127,11 +149,16 @@ class InMemoryKidioRepository : KidioRepository {
screentimes[userId] = seconds screentimes[userId] = seconds
} }
override suspend fun findByUsername(username: String): User? { override suspend fun findByEmail(username: String): User? {
return users.find { it.username == username } return users.find { it.email == username }
} }
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
return password == passwordHash TODO("Not yet implemented")
} }
}
override suspend fun registerUser(user: User): Boolean {
users.add(user)
return true
}
}

View file

@ -3,15 +3,53 @@ package com.oliver.kidio.backend.plugins
import com.oliver.kidio.backend.* import com.oliver.kidio.backend.*
import com.oliver.kidio.backend.domain.repository.KidioRepository import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.security.JwtService import com.oliver.kidio.backend.security.JwtService
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) { fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
routing { routing {
post("/api/v1/auth/register") {
val registerRequest = call.receive<RegisterRequest>()
if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
return@post
}
if (registerRequest.parentPin.length != 4) {
call.respond(HttpStatusCode.BadRequest, "Die Eltern-PIN muss genau 4 Ziffern lang sein")
return@post
}
val existingUser = repository.findByEmail(registerRequest.email)
if (existingUser != null) {
call.respond(HttpStatusCode.Conflict, "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits")
return@post
}
// Backend generiert die eindeutige User-ID
val userId = UUID.randomUUID().toString()
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
//datenbank insert hinzufügen
repository.registerUser(User(userId, registerRequest.email, passwordHash, registerRequest.familyName, registerRequest.childAge, registerRequest.parentPin))
val token = jwtService.generateToken(userId)
call.respond(
HttpStatusCode.Created,
mapOf(
"token" to token,
"userId" to userId,
"message" to "Registrierung erfolgreich"
)
)
}
// Health-Check // Health-Check
get("/api/v1/health") { get("/api/v1/health") {
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
@ -33,47 +71,52 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
} }
// 2. Antwort auf eine Aufgabe überprüfen // 2. Antwort auf eine Aufgabe überprüfen
post("/api/v1/tasks/verify") { post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>() val request = call.receive<TaskVerifyRequest>()
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim("userId")?.asString()
val taskInfo = repository.getTaskAnswerAndReward(request.taskId) if (userId == null){
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token"))
if (taskInfo == null) {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!"
)
)
return@post return@post
} }
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
val (correctAnswer, reward) = taskInfo if (taskInfo == null) {
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim() call.respond(
TaskVerifyResponse(
if (isCorrect) { isCorrect = false,
val currentScreentime = repository.getScreentime("default_user") earnedMinutes = 0,
val newSeconds = currentScreentime + (reward * 60) message = "Aufgabe nicht gefunden!"
repository.updateScreentime("default_user", newSeconds) )
call.respond(
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen."
) )
) return@post
} else { }
call.respond(
TaskVerifyResponse( val (correctAnswer, reward) = taskInfo
isCorrect = false, val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
earnedMinutes = 0,
message = "Schade, das war leider falsch. Versuche es nochmal!" if (isCorrect) {
val currentScreentime = repository.getScreentime(userId)
val newSeconds = currentScreentime + (reward * 60)
repository.updateScreentime(userId, newSeconds)
call.respond(
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen."
)
) )
) } else {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Schade, das war leider falsch. Versuche es nochmal!"
)
)
}
} }
}
// 3. Bildschirmzeit synchronisieren // 3. Bildschirmzeit synchronisieren
post("/api/v1/screentime/sync") { post("/api/v1/screentime/sync") {

View file

@ -8,7 +8,7 @@ import io.ktor.server.auth.jwt.*
/** /**
* Repräsentiert den authentifizierten Benutzer im Request-Kontext. * Repräsentiert den authentifizierten Benutzer im Request-Kontext.
*/ */
data class UserPrincipal(val userId: String) : Principal data class UserPrincipal(val userId: String)
fun Application.configureSecurity(jwtService: JwtService) { fun Application.configureSecurity(jwtService: JwtService) {
install(Authentication) { install(Authentication) {