feat/task10 - Verify PIN, Architektur-Refactoring & Sonar Fixes #11

Merged
DrunkenPinguin merged 2 commits from feat/task10 into main 2026-09-16 08:42:18 +00:00
31 changed files with 2135 additions and 981 deletions

View file

@ -766,6 +766,21 @@
"earnedMinutes" : { },
"message" : { }
}
};
defs["VerifyPinRequest"] = {
"title" : "VerifyPinRequest",
"required" : [ "pin" ],
"properties" : {
"pin" : { }
}
};
defs["VerifyPinResponse"] = {
"title" : "VerifyPinResponse",
"required" : [ "message", "success" ],
"properties" : {
"success" : { },
"message" : { }
}
};
</script>
@ -793,6 +808,9 @@
<li data-group="Default" data-name="apiV1HealthGet" class="">
<a href="#api-Default-apiV1HealthGet">apiV1HealthGet</a>
</li>
<li data-group="Default" data-name="apiV1ParentVerifyPinPost" class="">
<a href="#api-Default-apiV1ParentVerifyPinPost">apiV1ParentVerifyPinPost</a>
</li>
<li data-group="Default" data-name="apiV1ScreentimeGet" class="">
<a href="#api-Default-apiV1ScreentimeGet">apiV1ScreentimeGet</a>
</li>
@ -1630,7 +1648,7 @@ except ApiException as e:
<article id="api-Default-apiV1HealthGet-0" data-group="User" data-name="apiV1HealthGet" data-version="0">
<div class="pull-left">
<h1>apiV1HealthGet</h1>
<p>Health-Check</p>
<p></p>
</div>
<div class="pull-right"></div>
<div class="clearfix"></div>
@ -1710,7 +1728,6 @@ public class DefaultApiExample {
<pre class="prettyprint"><code class="language-cpp">
DefaultApi *apiInstance = [[DefaultApi alloc] init];
// Health-Check
[apiInstance apiV1HealthGetWithCompletionHandler:
^(NSError* error) {
if (error) {
@ -1756,7 +1773,6 @@ namespace Example
try
{
// Health-Check
apiInstance.apiV1HealthGet();
}
catch (Exception e)
@ -1809,7 +1825,6 @@ from pprint import pprint
api_instance = swagger_client.DefaultApi()
try:
# Health-Check
api_instance.api_v1_health_get()
except ApiException as e:
print("Exception when calling DefaultApi->apiV1HealthGet: %s\n" % e)</code></pre>
@ -1876,6 +1891,468 @@ except ApiException as e:
</article>
</div>
<hr>
<div id="api-Default-apiV1ParentVerifyPinPost">
<article id="api-Default-apiV1ParentVerifyPinPost-0" data-group="User" data-name="apiV1ParentVerifyPinPost" data-version="0">
<div class="pull-left">
<h1>apiV1ParentVerifyPinPost</h1>
<p></p>
</div>
<div class="pull-right"></div>
<div class="clearfix"></div>
<p></p>
<p class="marked"></p>
<p></p>
<br />
<pre class="prettyprint language-html prettyprinted" data-type="post"><code><span class="pln">/api/v1/parent/verify-pin</span></code></pre>
<p>
<h3>Usage and SDK Samples</h3>
</p>
<ul class="nav nav-tabs nav-tabs-examples">
<li class="active"><a href="#examples-Default-apiV1ParentVerifyPinPost-0-curl">Curl</a></li>
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-java">Java</a></li>
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-android">Android</a></li>
<!--<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-groovy">Groovy</a></li>-->
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-objc">Obj-C</a></li>
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-javascript">JavaScript</a></li>
<!--<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-angular">Angular</a></li>-->
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-csharp">C#</a></li>
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-php">PHP</a></li>
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-perl">Perl</a></li>
<li class=""><a href="#examples-Default-apiV1ParentVerifyPinPost-0-python">Python</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="examples-Default-apiV1ParentVerifyPinPost-0-curl">
<pre class="prettyprint"><code class="language-bsh">curl -X POST\
-H "Authorization: Bearer [[accessToken]]"\
-H "Accept: application/json"\
-H "Content-Type: application/json"\
"//api/v1/parent/verify-pin"</code></pre>
</div>
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-java">
<pre class="prettyprint"><code class="language-java">import io.swagger.client.*;
import io.swagger.client.auth.*;
import io.swagger.client.model.*;
import io.swagger.client.api.DefaultApi;
import java.io.File;
import java.util.*;
public class DefaultApiExample {
public static void main(String[] args) {
ApiClient defaultClient = Configuration.getDefaultApiClient();
DefaultApi apiInstance = new DefaultApi();
VerifyPinRequest body = ; // VerifyPinRequest |
try {
VerifyPinResponse result = apiInstance.apiV1ParentVerifyPinPost(body);
System.out.println(result);
} catch (ApiException e) {
System.err.println("Exception when calling DefaultApi#apiV1ParentVerifyPinPost");
e.printStackTrace();
}
}
}</code></pre>
</div>
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-android">
<pre class="prettyprint"><code class="language-java">import io.swagger.client.api.DefaultApi;
public class DefaultApiExample {
public static void main(String[] args) {
DefaultApi apiInstance = new DefaultApi();
VerifyPinRequest body = ; // VerifyPinRequest |
try {
VerifyPinResponse result = apiInstance.apiV1ParentVerifyPinPost(body);
System.out.println(result);
} catch (ApiException e) {
System.err.println("Exception when calling DefaultApi#apiV1ParentVerifyPinPost");
e.printStackTrace();
}
}
}</code></pre>
</div>
<!--
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-groovy">
<pre class="prettyprint language-json prettyprinted" data-type="json"><code>Coming Soon!</code></pre>
</div> -->
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-objc">
<pre class="prettyprint"><code class="language-cpp">Configuration *apiConfig = [Configuration sharedConfig];
VerifyPinRequest *body = ; // (optional)
DefaultApi *apiInstance = [[DefaultApi alloc] init];
[apiInstance apiV1ParentVerifyPinPostWith:body
completionHandler: ^(VerifyPinResponse output, NSError* error) {
if (output) {
NSLog(@"%@", output);
}
if (error) {
NSLog(@"Error: %@", error);
}
}];
</code></pre>
</div>
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-javascript">
<pre class="prettyprint"><code class="language-js">var UntitledApi = require('untitled_api');
var defaultClient = UntitledApi.ApiClient.instance;
var api = new UntitledApi.DefaultApi()
var opts = {
'body': // {{VerifyPinRequest}}
};
var callback = function(error, data, response) {
if (error) {
console.error(error);
} else {
console.log('API called successfully. Returned data: ' + data);
}
};
api.apiV1ParentVerifyPinPost(opts, callback);
</code></pre>
</div>
<!--<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-angular">
<pre class="prettyprint language-json prettyprinted" data-type="json"><code>Coming Soon!</code></pre>
</div>-->
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-csharp">
<pre class="prettyprint"><code class="language-cs">using System;
using System.Diagnostics;
using IO.Swagger.Api;
using IO.Swagger.Client;
using IO.Swagger.Model;
namespace Example
{
public class apiV1ParentVerifyPinPostExample
{
public void main()
{
var apiInstance = new DefaultApi();
var body = new VerifyPinRequest(); // VerifyPinRequest | (optional)
try
{
VerifyPinResponse result = apiInstance.apiV1ParentVerifyPinPost(body);
Debug.WriteLine(result);
}
catch (Exception e)
{
Debug.Print("Exception when calling DefaultApi.apiV1ParentVerifyPinPost: " + e.Message );
}
}
}
}
</code></pre>
</div>
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-php">
<pre class="prettyprint"><code class="language-php"><&#63;php
require_once(__DIR__ . '/vendor/autoload.php');
$api_instance = new Swagger\Client\ApiDefaultApi();
$body = ; // VerifyPinRequest |
try {
$result = $api_instance->apiV1ParentVerifyPinPost($body);
print_r($result);
} catch (Exception $e) {
echo 'Exception when calling DefaultApi->apiV1ParentVerifyPinPost: ', $e->getMessage(), PHP_EOL;
}
?></code></pre>
</div>
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-perl">
<pre class="prettyprint"><code class="language-perl">use Data::Dumper;
use WWW::SwaggerClient::Configuration;
use WWW::SwaggerClient::DefaultApi;
my $api_instance = WWW::SwaggerClient::DefaultApi->new();
my $body = WWW::SwaggerClient::Object::VerifyPinRequest->new(); # VerifyPinRequest |
eval {
my $result = $api_instance->apiV1ParentVerifyPinPost(body => $body);
print Dumper($result);
};
if ($@) {
warn "Exception when calling DefaultApi->apiV1ParentVerifyPinPost: $@\n";
}</code></pre>
</div>
<div class="tab-pane" id="examples-Default-apiV1ParentVerifyPinPost-0-python">
<pre class="prettyprint"><code class="language-python">from __future__ import print_statement
import time
import swagger_client
from swagger_client.rest import ApiException
from pprint import pprint
# create an instance of the API class
api_instance = swagger_client.DefaultApi()
body = # VerifyPinRequest | (optional)
try:
api_response = api_instance.api_v1_parent_verify_pin_post(body=body)
pprint(api_response)
except ApiException as e:
print("Exception when calling DefaultApi->apiV1ParentVerifyPinPost: %s\n" % e)</code></pre>
</div>
</div>
<h2>Parameters</h2>
<div class="methodsubtabletitle">Body parameters</div>
<table id="methodsubtable">
<tr>
<th width="150px">Name</th>
<th>Description</th>
</tr>
<tr><td style="width:150px;">body </td>
<td>
<script>
$(document).ready(function() {
var schemaWrapper = {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/VerifyPinRequest"
}
}
}
};
var schema = schemaWrapper.content["application/json"].schema;
if (schema.$ref != null) {
schema = defsParser.$refs.get(schema.$ref);
} else {
schemaWrapper.components = {};
schemaWrapper.components.schemas = Object.assign({}, defs);
$RefParser.dereference(schemaWrapper).catch(function(err) {
console.log(err);
});
}
var view = new JSONSchemaView(schema,2,{isBodyParam: true});
var result = $('#d2e199_apiV1ParentVerifyPinPost_body');
result.empty();
result.append(view.render());
});
</script>
<div id="d2e199_apiV1ParentVerifyPinPost_body"></div>
</td>
</tr>
</table>
<h2>Responses</h2>
<h3> Status: 401 - </h3>
<ul class="nav nav-tabs nav-tabs-examples" >
<li class="active">
<a data-toggle="tab" href="#responses-apiV1ParentVerifyPinPost-401-schema">Schema</a>
</li>
</ul>
<div class="tab-content" style='margin-bottom: 10px;'>
<div class="tab-pane active" id="responses-apiV1ParentVerifyPinPost-401-schema">
<div id='responses-apiV1ParentVerifyPinPost-401-schema-401' style="padding: 30px; border-left: 1px solid #eee; border-right: 1px solid #eee; border-bottom: 1px solid #eee;">
<script>
$(document).ready(function() {
var schemaWrapper = {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/VerifyPinResponse"
}
}
}
};
var schema = schemaWrapper.content["application/json"].schema;
if (schema.$ref != null) {
schema = defsParser.$refs.get(schema.$ref);
} else {
schemaWrapper.components = {};
schemaWrapper.components.schemas = Object.assign({}, defs);
$RefParser.dereference(schemaWrapper).catch(function(err) {
console.log(err);
});
}
//console.log(JSON.stringify(schema));
var view = new JSONSchemaView(schema, 3);
$('#responses-apiV1ParentVerifyPinPost-401-schema-data').val(stringify(schema));
var result = $('#responses-apiV1ParentVerifyPinPost-401-schema-401');
result.empty();
result.append(view.render());
});
</script>
</div>
<input id='responses-apiV1ParentVerifyPinPost-401-schema-data' type='hidden' value=''></input>
</div>
</div>
<h3> Status: 400 - </h3>
<ul class="nav nav-tabs nav-tabs-examples" >
<li class="active">
<a data-toggle="tab" href="#responses-apiV1ParentVerifyPinPost-400-schema">Schema</a>
</li>
</ul>
<div class="tab-content" style='margin-bottom: 10px;'>
<div class="tab-pane active" id="responses-apiV1ParentVerifyPinPost-400-schema">
<div id='responses-apiV1ParentVerifyPinPost-400-schema-400' style="padding: 30px; border-left: 1px solid #eee; border-right: 1px solid #eee; border-bottom: 1px solid #eee;">
<script>
$(document).ready(function() {
var schemaWrapper = {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/VerifyPinResponse"
}
}
}
};
var schema = schemaWrapper.content["application/json"].schema;
if (schema.$ref != null) {
schema = defsParser.$refs.get(schema.$ref);
} else {
schemaWrapper.components = {};
schemaWrapper.components.schemas = Object.assign({}, defs);
$RefParser.dereference(schemaWrapper).catch(function(err) {
console.log(err);
});
}
//console.log(JSON.stringify(schema));
var view = new JSONSchemaView(schema, 3);
$('#responses-apiV1ParentVerifyPinPost-400-schema-data').val(stringify(schema));
var result = $('#responses-apiV1ParentVerifyPinPost-400-schema-400');
result.empty();
result.append(view.render());
});
</script>
</div>
<input id='responses-apiV1ParentVerifyPinPost-400-schema-data' type='hidden' value=''></input>
</div>
</div>
<h3> Status: 404 - </h3>
<ul class="nav nav-tabs nav-tabs-examples" >
<li class="active">
<a data-toggle="tab" href="#responses-apiV1ParentVerifyPinPost-404-schema">Schema</a>
</li>
</ul>
<div class="tab-content" style='margin-bottom: 10px;'>
<div class="tab-pane active" id="responses-apiV1ParentVerifyPinPost-404-schema">
<div id='responses-apiV1ParentVerifyPinPost-404-schema-404' style="padding: 30px; border-left: 1px solid #eee; border-right: 1px solid #eee; border-bottom: 1px solid #eee;">
<script>
$(document).ready(function() {
var schemaWrapper = {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/VerifyPinResponse"
}
}
}
};
var schema = schemaWrapper.content["application/json"].schema;
if (schema.$ref != null) {
schema = defsParser.$refs.get(schema.$ref);
} else {
schemaWrapper.components = {};
schemaWrapper.components.schemas = Object.assign({}, defs);
$RefParser.dereference(schemaWrapper).catch(function(err) {
console.log(err);
});
}
//console.log(JSON.stringify(schema));
var view = new JSONSchemaView(schema, 3);
$('#responses-apiV1ParentVerifyPinPost-404-schema-data').val(stringify(schema));
var result = $('#responses-apiV1ParentVerifyPinPost-404-schema-404');
result.empty();
result.append(view.render());
});
</script>
</div>
<input id='responses-apiV1ParentVerifyPinPost-404-schema-data' type='hidden' value=''></input>
</div>
</div>
<h3> Status: 200 - </h3>
<ul class="nav nav-tabs nav-tabs-examples" >
<li class="active">
<a data-toggle="tab" href="#responses-apiV1ParentVerifyPinPost-200-schema">Schema</a>
</li>
</ul>
<div class="tab-content" style='margin-bottom: 10px;'>
<div class="tab-pane active" id="responses-apiV1ParentVerifyPinPost-200-schema">
<div id='responses-apiV1ParentVerifyPinPost-200-schema-200' style="padding: 30px; border-left: 1px solid #eee; border-right: 1px solid #eee; border-bottom: 1px solid #eee;">
<script>
$(document).ready(function() {
var schemaWrapper = {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/VerifyPinResponse"
}
}
}
};
var schema = schemaWrapper.content["application/json"].schema;
if (schema.$ref != null) {
schema = defsParser.$refs.get(schema.$ref);
} else {
schemaWrapper.components = {};
schemaWrapper.components.schemas = Object.assign({}, defs);
$RefParser.dereference(schemaWrapper).catch(function(err) {
console.log(err);
});
}
//console.log(JSON.stringify(schema));
var view = new JSONSchemaView(schema, 3);
$('#responses-apiV1ParentVerifyPinPost-200-schema-data').val(stringify(schema));
var result = $('#responses-apiV1ParentVerifyPinPost-200-schema-200');
result.empty();
result.append(view.render());
});
</script>
</div>
<input id='responses-apiV1ParentVerifyPinPost-200-schema-data' type='hidden' value=''></input>
</div>
</div>
</article>
</div>
<hr>
<div id="api-Default-apiV1ScreentimeGet">
<article id="api-Default-apiV1ScreentimeGet-0" data-group="User" data-name="apiV1ScreentimeGet" data-version="0">
<div class="pull-left">
@ -2194,7 +2671,7 @@ except ApiException as e:
<article id="api-Default-apiV1ScreentimeSyncPost-0" data-group="User" data-name="apiV1ScreentimeSyncPost" data-version="0">
<div class="pull-left">
<h1>apiV1ScreentimeSyncPost</h1>
<p>3. Bildschirmzeit synchronisieren</p>
<p></p>
</div>
<div class="pull-right"></div>
<div class="clearfix"></div>
@ -2284,7 +2761,6 @@ SyncRequest *body = ; // (optional)
DefaultApi *apiInstance = [[DefaultApi alloc] init];
// 3. Bildschirmzeit synchronisieren
[apiInstance apiV1ScreentimeSyncPostWith:body
completionHandler: ^(SyncResponse output, NSError* error) {
if (output) {
@ -2340,7 +2816,6 @@ namespace Example
try
{
// 3. Bildschirmzeit synchronisieren
SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body);
Debug.WriteLine(result);
}
@ -2402,7 +2877,6 @@ api_instance = swagger_client.DefaultApi()
body = # SyncRequest | (optional)
try:
# 3. Bildschirmzeit synchronisieren
api_response = api_instance.api_v1_screentime_sync_post(body=body)
pprint(api_response)
except ApiException as e:
@ -2515,7 +2989,7 @@ except ApiException as e:
<article id="api-Default-apiV1TasksGet-0" data-group="User" data-name="apiV1TasksGet" data-version="0">
<div class="pull-left">
<h1>apiV1TasksGet</h1>
<p>1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)</p>
<p></p>
</div>
<div class="pull-right"></div>
<div class="clearfix"></div>
@ -2608,7 +3082,6 @@ public class DefaultApiExample {
DefaultApi *apiInstance = [[DefaultApi alloc] init];
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
[apiInstance apiV1TasksGetWith:subject
age:age
limit:limit
@ -2667,7 +3140,6 @@ namespace Example
try
{
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
apiInstance.apiV1TasksGet(subject, age, limit);
}
catch (Exception e)
@ -2732,7 +3204,6 @@ age = # | (optional)
limit = # | (optional)
try:
# 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
api_instance.api_v1_tasks_get(subject=subject, age=age, limit=limit)
except ApiException as e:
print("Exception when calling DefaultApi->apiV1TasksGet: %s\n" % e)</code></pre>
@ -2860,7 +3331,7 @@ except ApiException as e:
<article id="api-Default-apiV1TasksVerifyPost-0" data-group="User" data-name="apiV1TasksVerifyPost" data-version="0">
<div class="pull-left">
<h1>apiV1TasksVerifyPost</h1>
<p>2. Antwort auf eine Aufgabe überprüfen</p>
<p></p>
</div>
<div class="pull-right"></div>
<div class="clearfix"></div>
@ -2950,7 +3421,6 @@ TaskVerifyRequest *body = ; // (optional)
DefaultApi *apiInstance = [[DefaultApi alloc] init];
// 2. Antwort auf eine Aufgabe überprüfen
[apiInstance apiV1TasksVerifyPostWith:body
completionHandler: ^(TaskVerifyResponse output, NSError* error) {
if (output) {
@ -3006,7 +3476,6 @@ namespace Example
try
{
// 2. Antwort auf eine Aufgabe überprüfen
TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body);
Debug.WriteLine(result);
}
@ -3068,7 +3537,6 @@ api_instance = swagger_client.DefaultApi()
body = # TaskVerifyRequest | (optional)
try:
# 2. Antwort auf eine Aufgabe überprüfen
api_response = api_instance.api_v1_tasks_verify_post(body=body)
pprint(api_response)
except ApiException as e:

View file

@ -1,50 +1,14 @@
package com.oliver.kidio.backend.data.database
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 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", 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")
val question = text("question")
val options = text("options")
val correctAnswer = varchar("correct_answer", 255)
val rewardMinutes = integer("reward_minutes").default(5)
override val primaryKey = PrimaryKey(id)
init {
index("idx_tasks_filter", false, subject, minAge, maxAge)
}
}
object ScreentimeTable : Table("user_screentime") {
val userId = varchar("user_id", 50)
val remainingSeconds = integer("remaining_seconds")
override val primaryKey = PrimaryKey(userId)
}
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)
}
object DatabaseFactory {
fun init(config: ApplicationConfig) {
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"

View file

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

View file

@ -0,0 +1,24 @@
package com.oliver.kidio.backend.data.tables
import org.jetbrains.exposed.sql.Table
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")
val question = text("question")
val options = text("options")
val correctAnswer = varchar("correct_answer", 255)
val rewardMinutes = integer("reward_minutes").default(5)
override val primaryKey = PrimaryKey(id)
init {
index("idx_tasks_filter", false, subject, minAge, maxAge)
}
}

View file

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

View file

@ -0,0 +1,37 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class RegisterRequest(
val email: String,
val passwordPlain: String,
val familyName: String,
val childAge: Int,
val parentPin: String,
)
@Serializable
data class LoginRequest(
val email: String,
val passwordPlain: String,
)
@Serializable
data class AuthResponse(
val token: String,
val userId: String,
val familyName: String,
val childAge: Int,
)
@Serializable
data class VerifyPinRequest(
val pin: String,
)
@Serializable
data class VerifyPinResponse(
val success: Boolean,
val message: String,
)

View file

@ -1,4 +1,4 @@
package com.oliver.kidio.backend
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
@ -45,51 +45,3 @@ data class TaskVerifyResponse(
val earnedMinutes: Int,
val message: String,
)
// Datenabgleich für die Bildschirmzeit (Sync)
@Serializable
data class SyncRequest(
val userId: String,
val usedSecondsSinceLastSync: Int,
)
@Serializable
data class SyncResponse(
val remainingSeconds: Int,
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 email: String,
val passwordPlain: String,
)
@Serializable
data class AuthResponse(
val token: String,
val userId: String,
val familyName: String,
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

@ -0,0 +1,26 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
// 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,
)
// Datenabgleich für die Bildschirmzeit (Sync)
@Serializable
data class SyncRequest(
val userId: String,
val usedSecondsSinceLastSync: Int,
)
@Serializable
data class SyncResponse(
val remainingSeconds: Int,
val isBlocked: Boolean,
)

View file

@ -0,0 +1,242 @@
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.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.User
import org.jetbrains.exposed.sql.*
import org.mindrot.jbcrypt.BCrypt
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)
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 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 findById(userId: String): User? =
DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.id eq userId }
.map {
User(
id = it[UserTable.id],
email = it[UserTable.email],
passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin],
)
}
.singleOrNull()
}
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
}

View file

@ -0,0 +1,94 @@
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.User
import org.mindrot.jbcrypt.BCrypt
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,
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 }
}
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 findById(userId: String): User? {
return users.find { it.id == userId }
}
override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash)
}
override suspend fun registerUser(user: User): Boolean {
users.add(user)
screentimes[user.id] = 1800
return true
}
}

View file

@ -1,32 +1,22 @@
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.User
interface KidioRepository {
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
suspend fun getUserById(userId: String): User?
suspend fun findById(userId: String): User?
suspend fun getTasks(
subject: String? = null,
age: Int? = null,
limit: Int = 10,
): List<Task>
// Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
suspend fun upsertTask(task: ImportedTask): Boolean
@ -49,331 +39,3 @@ interface KidioRepository {
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
}
}

View file

@ -1,7 +1,7 @@
package com.oliver.kidio.backend.ingestion
import com.oliver.kidio.backend.ImportedTask
import com.oliver.kidio.backend.data.database.TasksTable
import com.oliver.kidio.backend.data.tables.TasksTable
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
import com.oliver.kidio.backend.domain.repository.KidioRepository
import kotlinx.coroutines.runBlocking
@ -21,12 +21,9 @@ object H5PIngestionCli {
limit: Int = 50,
): Int =
runBlocking {
// Bereits vorhandene Task-IDs aus der Datenbank auslesen
val existingIds = repository.getExistingTaskIds("zum.de")
println("Bereits in DB vorhandene ZUM-Aufgaben: ${existingIds.size}")
var importedCount = 0
// existingIds an fetchLiveOerItems übergeben, um Duplikate vor dem Download zu filtern
val items =
client.fetchLiveOerItems(
subject = subject,
@ -34,12 +31,39 @@ object H5PIngestionCli {
existingIds = existingIds,
)
var importedCount = 0
for (item in items) {
var parsedList = emptyList<ParsedH5PTask>()
importedCount += processItem(item, client, repository)
}
// 1. Try parsing JSON content directly (fast, no ZIP decompression needed)
println("Ingestion completed: $importedCount tasks successfully imported/updated.")
importedCount
}
private suspend fun processItem(
item: ZumOerItem,
client: ZumApiClient,
repository: KidioRepository,
): Int {
var importedCount = 0
val parsedList = extractTasks(item, client)
for (parsed in parsedList) {
val task = mapToImportedTask(item, parsed)
if (repository.upsertTask(task)) {
importedCount++
logSuccess(task)
}
}
return importedCount
}
private suspend fun extractTasks(
item: ZumOerItem,
client: ZumApiClient,
): List<ParsedH5PTask> {
if (item.rawJsonContent != null) {
parsedList =
val tasks =
H5PParser.parseAllTasks(
contentJson = item.rawJsonContent,
library = item.library ?: "H5P.MultiChoice",
@ -48,23 +72,30 @@ object H5PIngestionCli {
license = item.license,
sourceUrl = item.downloadUrl,
)
if (tasks.isNotEmpty()) return tasks
}
// 2. Fallback to downloading .h5p ZIP archive if remote archive is specified
if (parsedList.isEmpty() && item.downloadUrl.endsWith(".h5p", ignoreCase = true)) {
val h5pBytes = client.downloadH5P(item.downloadUrl)
if (h5pBytes != null) {
if (item.downloadUrl.endsWith(".h5p", ignoreCase = true)) {
val h5pBytes = client.downloadH5P(item.downloadUrl) ?: return emptyList()
val single = H5PParser.parseH5PZip(ByteArrayInputStream(h5pBytes), item.id, item.downloadUrl)
if (single != null) {
parsedList = listOf(single)
}
return listOf(single)
}
}
for (parsed in parsedList) {
return emptyList()
}
private fun mapToImportedTask(
item: ZumOerItem,
parsed: ParsedH5PTask,
): ImportedTask {
val (minAge, maxAge) = ZumApiClient.mapGradeToAgeRange(item.grade)
val task =
ImportedTask(
val isTrueFalse = parsed.options.size == 2 && parsed.options.contains("Wahr")
return ImportedTask(
id = parsed.id,
externalSource = "zum.de",
sourceUrl = parsed.sourceUrl ?: item.downloadUrl,
@ -72,26 +103,19 @@ object H5PIngestionCli {
subject = ZumApiClient.mapSubject(item.subject),
minAge = minAge,
maxAge = maxAge,
taskType = if (parsed.options.size == 2 && parsed.options.contains("Wahr")) "TRUE_FALSE" else "MULTIPLE_CHOICE",
taskType = if (isTrueFalse) "TRUE_FALSE" else "MULTIPLE_CHOICE",
question = parsed.question,
options = parsed.options,
correctAnswer = parsed.correctAnswer,
rewardMinutes = 5,
)
}
val success = repository.upsertTask(task)
if (success) {
importedCount++
private fun logSuccess(task: ImportedTask) {
val shortQ = task.question.take(30)
val ageRange = "${task.minAge}-${task.maxAge}"
println(" -> [Importiert] ${task.id} (${task.subject}, $ageRange): \"$shortQ...\"")
}
}
}
println("Ingestion completed: $importedCount tasks successfully imported/updated.")
importedCount
}
@JvmStatic
fun main(args: Array<String>) {

View file

@ -4,17 +4,6 @@ import kotlinx.serialization.json.*
import java.io.InputStream
import java.util.zip.ZipInputStream
data class ParsedH5PTask(
val id: String,
val title: String,
val library: String,
val question: String,
val options: List<String>,
val correctAnswer: String,
val license: String = "CC BY-SA 4.0",
val sourceUrl: String? = null,
)
object H5PParser {
private val json =
Json {
@ -79,9 +68,9 @@ object H5PParser {
return null
}
var library = "H5P.MultiChoice"
var title = "H5P Task"
var license = "CC BY-SA 4.0"
var library = DEFAULT_LIBRARY
var title = DEFAULT_TITLE
var license = DEFAULT_LICENSE
if (h5pJsonString != null) {
try {
@ -89,7 +78,8 @@ object H5PParser {
library = h5pTree["mainLibrary"]?.jsonPrimitive?.contentOrNull ?: library
title = h5pTree["title"]?.jsonPrimitive?.contentOrNull ?: title
license = h5pTree["license"]?.jsonPrimitive?.contentOrNull ?: license
} catch (_: Exception) {
} catch (e: Exception) {
println("WARNUNG: h5p.json konnte nicht gelesen werden. Nutze Fallback-Werte. Fehler: ${e.message}")
}
}
@ -108,10 +98,10 @@ object H5PParser {
*/
fun parseContentJson(
contentJson: String,
library: String = "H5P.MultiChoice",
library: String = DEFAULT_LIBRARY,
taskId: String = "task-1",
title: String = "H5P Task",
license: String = "CC BY-SA 4.0",
title: String = DEFAULT_TITLE,
license: String = DEFAULT_LICENSE,
sourceUrl: String? = null,
): ParsedH5PTask? {
val element =
@ -133,10 +123,10 @@ object H5PParser {
*/
fun parseAllTasks(
contentJson: String,
library: String = "H5P.MultiChoice",
library: String = DEFAULT_LIBRARY,
taskId: String = "task-1",
title: String = "H5P Task",
license: String = "CC BY-SA 4.0",
title: String = DEFAULT_TITLE,
license: String = DEFAULT_LICENSE,
sourceUrl: String? = null,
): List<ParsedH5PTask> {
val element =
@ -148,40 +138,46 @@ object H5PParser {
if (library.contains("QuestionSet", ignoreCase = true) && element.containsKey("questions")) {
val questionsArray = element["questions"]?.jsonArray ?: return emptyList()
val results = mutableListOf<ParsedH5PTask>()
for ((idx, qElement) in questionsArray.withIndex()) {
val qObj = qElement.jsonObject
val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: "H5P.MultiChoice"
val subParams = qObj["params"]?.jsonObject ?: continue
val subId = "$taskId-q${idx + 1}"
val isTrueFalse =
subLib.contains("TrueFalse", ignoreCase = true) ||
(subParams.containsKey("correct") && !subParams.containsKey("answers"))
val isMultiChoice =
subLib.contains("MultiChoice", ignoreCase = true) || subParams.containsKey("answers")
val parsedSub =
if (isTrueFalse) {
parseTrueFalse(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl)
} else if (isMultiChoice) {
parseMultiChoice(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl)
} else {
null
return questionsArray.mapIndexedNotNull { idx, qElement ->
parseQuestionSetItem(qElement, idx, taskId, title, license, sourceUrl)
}
if (parsedSub != null) {
results.add(parsedSub)
}
}
return results
}
val single = parseContentJson(contentJson, library, taskId, title, license, sourceUrl)
return if (single != null) listOf(single) else emptyList()
}
private fun parseQuestionSetItem(
qElement: JsonElement,
idx: Int,
taskId: String,
title: String,
license: String,
sourceUrl: String?,
): ParsedH5PTask? {
val qObj = qElement.jsonObject
val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: DEFAULT_LIBRARY
val subParams = qObj["params"]?.jsonObject ?: return null
val subId = "$taskId-q${idx + 1}"
val subTitle = "$title #${idx + 1}"
val isTrueFalse =
subLib.contains("TrueFalse", ignoreCase = true) ||
(subParams.containsKey("correct") && !subParams.containsKey("answers"))
val isMultiChoice =
subLib.contains("MultiChoice", ignoreCase = true) ||
subParams.containsKey("answers")
return when {
isTrueFalse -> parseTrueFalse(subParams, subId, subTitle, subLib, license, sourceUrl)
isMultiChoice -> parseMultiChoice(subParams, subId, subTitle, subLib, license, sourceUrl)
else -> null
}
}
private fun parseMultiChoice(
element: JsonObject,
taskId: String,

View file

@ -0,0 +1,15 @@
package com.oliver.kidio.backend.ingestion
const val DEFAULT_LIBRARY: String = "H5P.MultiChoice"
const val DEFAULT_TITLE: String = "H5P Task"
data class ParsedH5PTask(
val id: String,
val title: String,
val library: String,
val question: String,
val options: List<String>,
val correctAnswer: String,
val license: String = DEFAULT_LICENSE,
val sourceUrl: String? = null,
)

View file

@ -8,17 +8,6 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
data class ZumOerItem(
val id: String,
val title: String,
val subject: String,
val grade: Int? = null,
val downloadUrl: String,
val license: String = "CC BY-SA 4.0",
val library: String? = null,
val rawJsonContent: String? = null,
)
class ZumApiClient(
private val httpClient: HttpClient =
HttpClient.newBuilder()
@ -113,52 +102,7 @@ class ZumApiClient(
for (page in 0 until maxPages) {
if (results.size >= limit) break
val searchUrl =
if (subject.isNullOrBlank()) {
"https://apps.zum.de/apps?page=$page"
} else {
val encoded = URLEncoder.encode(subject, "UTF-8")
"https://apps.zum.de/apps?keys=$encoded&page=$page"
}
val html = fetchString(searchUrl)
if (html == null) {
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
continue
}
// Filtert bereits verarbeitete oder in DB vorhandene Apps heraus
val appIds =
Regex("""href=["']/apps/(\d+)["']""")
.findAll(html)
.map { it.groupValues[1] }
.distinct()
.filter { appId ->
val fullId = "zum-$appId"
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
}
.toList()
if (appIds.isEmpty()) {
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
continue
}
for (appId in appIds) {
if (results.size >= limit) break
seenAppIds.add(appId)
val item = fetchAppDetails(appId, subject)
if (item != null) {
results.add(item)
val lib = item.library ?: "H5P"
val gr = item.grade ?: "k.A."
println(
"[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]",
)
}
}
processPage(page, subject, limit, existingIds, seenAppIds, results)
}
if (results.isEmpty()) {
@ -170,6 +114,78 @@ class ZumApiClient(
return results
}
private fun processPage(
page: Int,
subject: String?,
limit: Int,
existingIds: Set<String>,
seenAppIds: MutableSet<String>,
results: MutableList<ZumOerItem>,
) {
val searchUrl = buildSearchUrl(subject, page)
val html = fetchString(searchUrl)
if (html == null) {
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
return
}
val appIds = extractNewAppIds(html, existingIds, seenAppIds)
if (appIds.isEmpty()) {
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
return
}
for (appId in appIds) {
if (results.size >= limit) break
seenAppIds.add(appId)
val item = fetchAppDetails(appId, subject)
if (item != null) {
results.add(item)
logNewItem(appId, item)
}
}
}
private fun buildSearchUrl(
subject: String?,
page: Int,
): String {
return if (subject.isNullOrBlank()) {
"https://apps.zum.de/apps?page=$page"
} else {
val encoded = URLEncoder.encode(subject, "UTF-8")
"https://apps.zum.de/apps?keys=$encoded&page=$page"
}
}
private fun extractNewAppIds(
html: String,
existingIds: Set<String>,
seenAppIds: Set<String>,
): List<String> {
return Regex("""href=["']/apps/(\d+)["']""")
.findAll(html)
.map { it.groupValues[1] }
.distinct()
.filter { appId ->
val fullId = "zum-$appId"
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
}
.toList()
}
private fun logNewItem(
appId: String,
item: ZumOerItem,
) {
val lib = item.library ?: "H5P"
val gr = item.grade ?: "k.A."
println("[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]")
}
private fun fetchAppDetails(
appId: String,
fallbackSubject: String?,
@ -216,7 +232,7 @@ class ZumApiClient(
val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull
val license =
when {
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> "CC BY-SA 4.0"
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> DEFAULT_LICENSE
rawLicense.contains("CC", ignoreCase = true) -> rawLicense
else -> "CC BY-SA 4.0 ($rawLicense)"
}
@ -338,168 +354,26 @@ class ZumApiClient(
}
/**
* Returns curated ZUM/OER sample metadata items, filterable by subject.
* Returns curated ZUM/OER sample metadata items from resources, filterable by subject.
*/
fun getCuratedOerItems(
subject: String? = null,
limit: Int = 20,
): List<ZumOerItem> {
val allItems =
listOf(
ZumOerItem(
id = "zum-math-001",
title = "Grundrechenarten: Multiplikation 3. Klasse",
subject = "Mathematik",
grade = 3,
downloadUrl = "https://apps.zum.de/api/h5p/math-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Was ist das Ergebnis von <strong>6 &times; 8</strong>?</p>",
"answers": [
{"text": "42", "correct": false},
{"text": "48", "correct": true},
{"text": "54", "correct": false},
{"text": "56", "correct": false}
]
val allItems: List<ZumOerItem> =
try {
val inputStream = this::class.java.getResourceAsStream("/fallback_oer_items.json")
if (inputStream != null) {
val jsonText = inputStream.bufferedReader().use { it.readText() }
json.decodeFromString(jsonText)
} else {
println("WARNUNG: fallback_oer_items.json wurde nicht gefunden!")
emptyList()
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-math-002",
title = "Zahlenraum bis 100: Addition",
subject = "Mathematik",
grade = 2,
downloadUrl = "https://apps.zum.de/api/h5p/math-002.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Berechne: 34 + 28</p>",
"answers": [
{"text": "52", "correct": false},
{"text": "62", "correct": true},
{"text": "64", "correct": false}
]
} catch (e: Exception) {
println("WARNUNG: Fehler beim Laden der Fallback-Daten: ${e.message}")
emptyList()
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-math-003",
title = "Geometrie: Formen erkennen",
subject = "Mathematik",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/math-003.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.</p>",
"correct": true
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-de-001",
title = "Wortarten: Nomen erkennen",
subject = "Deutsch",
grade = 2,
downloadUrl = "https://apps.zum.de/api/h5p/de-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Welches der folgenden W&ouml;rter ist ein <strong>Nomen (Namenwort)</strong>?</p>",
"answers": [
{"text": "schnell", "correct": false},
{"text": "laufen", "correct": false},
{"text": "Schule", "correct": true},
{"text": "sch&ouml;n", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-de-002",
title = "Rechtschreibung: Doppel-s oder ss",
subject = "Deutsch",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/de-002.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Wie wird das Wort richtig geschrieben? &bdquo;Der Flu... ist tief.&ldquo;</p>",
"answers": [
{"text": "Flus", "correct": false},
{"text": "Fluss", "correct": true},
{"text": "Fluß", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-sach-001",
title = "Natur & Tiere: Winterschlaf",
subject = "Sachunterricht",
grade = 3,
downloadUrl = "https://apps.zum.de/api/h5p/sach-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Welches dieser Tiere h&auml;lt einen echten <strong>Winterschlaf</strong>?</p>",
"answers": [
{"text": "Igel", "correct": true},
{"text": "Eichh&ouml;rnchen", "correct": false},
{"text": "Amsel", "correct": false},
{"text": "Fuchs", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-sach-002",
title = "Planetensystem: Sonnennähe",
subject = "Sachunterricht",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/sach-002.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Welcher Planet ist der Sonne am n&auml;chsten?</p>",
"answers": [
{"text": "Venus", "correct": false},
{"text": "Mars", "correct": false},
{"text": "Merkur", "correct": true},
{"text": "Jupiter", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-eng-001",
title = "English Vocabulary: Colors & Animals",
subject = "Englisch",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/eng-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>What is the English word for &bdquo;Hund&ldquo;?</p>",
"answers": [
{"text": "Cat", "correct": false},
{"text": "Dog", "correct": true},
{"text": "Bird", "correct": false},
{"text": "Rabbit", "correct": false}
]
}
""".trimIndent(),
),
)
val normalizedSubject = subject?.let { mapSubject(it) }
val filtered =

View file

@ -0,0 +1,17 @@
package com.oliver.kidio.backend.ingestion
import kotlinx.serialization.Serializable
const val DEFAULT_LICENSE: String = "CC BY-SA 4.0"
@Serializable
data class ZumOerItem(
val id: String,
val title: String,
val subject: String,
val grade: Int? = null,
val downloadUrl: String,
val license: String = DEFAULT_LICENSE,
val library: String? = null,
val rawJsonContent: String? = null,
)

View file

@ -1,38 +1,12 @@
package com.oliver.kidio.backend.plugins
import com.oliver.kidio.backend.*
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.routing.*
import com.oliver.kidio.backend.security.JwtService
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.plugins.openapi.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
fun isValidPassword(password: String): Boolean {
if (password.length < 8) return false
val hasUppercase = password.any { it.isUpperCase() }
val hasEmoji =
password.any { char ->
val type = Character.getType(char).toByte()
type == Character.SURROGATE ||
type == Character.OTHER_SYMBOL ||
char.code in 0x1F600..0x1F64F ||
char.code in 0x1F300..0x1F5FF ||
char.code in 0x1F680..0x1F6FF ||
char.code in 0x2600..0x26FF
}
val hasSpecialChar = password.any { !it.isLetterOrDigit() && !hasEmoji }
return hasUppercase && hasSpecialChar && !hasEmoji
}
fun Application.configureRouting(
repository: KidioRepository,
@ -41,195 +15,13 @@ fun Application.configureRouting(
routing {
openAPI(path = "openapi")
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 (!isValidPassword(registerRequest.passwordPlain)) {
call.respond(
HttpStatusCode.BadRequest,
"Das Passwort muss mindestens 8 Zeichen lang sein, " +
"mindestens einen Großbuchstaben und ein Sonderzeichen enthalten.",
)
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
get("/api/v1/health") {
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
}
post("/api/v1/auth/login") {
val loginRequest = call.receive<LoginRequest>()
if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
return@post
}
val getUser = repository.findByEmail(loginRequest.email)
if (getUser == null || !repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)) {
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
return@post
}
val token = jwtService.generateToken(getUser.id)
call.respond(
HttpStatusCode.OK,
AuthResponse(
token,
userId = getUser.id,
familyName = getUser.familyName,
childAge = getUser.childAge,
),
)
return@post
}
authenticate("auth-jwt") {
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
get("/api/v1/tasks") {
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
val subject = call.request.queryParameters["subject"]
// Expliziter Query-Parameter 'age' ODER Fallback auf das Alter des Users
val queryAge = call.request.queryParameters["age"]?.toIntOrNull()
val userAge = userId?.let { repository.getUserById(it)?.childAge }
val effectiveAge = queryAge ?: userAge
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit)
call.respond(tasks)
}
// 2. Antwort auf eine Aufgabe überprüfen
post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Ungültiges oder abgelaufenes Token"),
)
return@post
}
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 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!",
),
)
}
}
get("/api/v1/screentime") {
val principal = call.principal<UserPrincipal>()
val userid =
principal?.userId
?: run {
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token"))
return@get
}
val remainingSeconds = repository.getScreentime(userid)
call.respond(
HttpStatusCode.OK,
SyncResponse(
remainingSeconds = remainingSeconds,
isBlocked = remainingSeconds <= 0,
),
)
}
// 3. Bildschirmzeit synchronisieren
post("/api/v1/screentime/sync") {
val request = call.receive<SyncRequest>()
// Wir holen die echte, fälschungssichere userId aus dem Login-Token:
val principal = call.principal<UserPrincipal>()
val userId =
principal?.userId ?: request.userId // Fallback auf request.userId, falls token noch fehlt
val currentSeconds = repository.getScreentime(userId)
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
if (newSeconds < 0) newSeconds = 0
repository.updateScreentime(userId, newSeconds)
call.respond(
SyncResponse(
remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0,
),
)
}
}
authRoutes(repository, jwtService)
taskRoutes(repository)
screentimeRoutes(repository)
parentRoutes(repository)
}
}

View file

@ -0,0 +1,93 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.AuthResponse
import com.oliver.kidio.backend.domain.models.LoginRequest
import com.oliver.kidio.backend.domain.models.RegisterRequest
import com.oliver.kidio.backend.domain.models.User
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.security.JwtService
import com.oliver.kidio.backend.security.isValidPassword
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
fun Route.authRoutes(
repository: KidioRepository,
jwtService: JwtService,
) {
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 (!isValidPassword(registerRequest.passwordPlain)) {
call.respond(
HttpStatusCode.BadRequest,
"Das Passwort muss mindestens 8 Zeichen lang sein, " +
"mindestens einen Großbuchstaben und ein Sonderzeichen enthalten.",
)
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
}
val userId = UUID.randomUUID().toString()
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
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",
),
)
}
post("/api/v1/auth/login") {
val loginRequest = call.receive<LoginRequest>()
if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
return@post
}
val getUser = repository.findByEmail(loginRequest.email)
if (getUser == null || !repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)) {
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
return@post
}
val token = jwtService.generateToken(getUser.id)
call.respond(
HttpStatusCode.OK,
AuthResponse(
token,
userId = getUser.id,
familyName = getUser.familyName,
childAge = getUser.childAge,
),
)
}
}

View file

@ -0,0 +1,58 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.VerifyPinRequest
import com.oliver.kidio.backend.domain.models.VerifyPinResponse
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.plugins.UserPrincipal
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.parentRoutes(repository: KidioRepository) {
authenticate("auth-jwt") {
post("/api/v1/parent/verify-pin") {
val request = call.receive<VerifyPinRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Ungültiges oder abgelaufenes Token"),
)
return@post
}
if (request.pin.length != 4 || !request.pin.all { it.isDigit() }) {
call.respond(
HttpStatusCode.BadRequest,
VerifyPinResponse(success = false, message = "Die PIN muss aus genau 4 Ziffern bestehen."),
)
return@post
}
val user = repository.findById(userId)
if (user == null) {
call.respond(
HttpStatusCode.NotFound,
VerifyPinResponse(success = false, message = "Benutzer nicht gefunden."),
)
return@post
}
if (user.parentPin == request.pin) {
call.respond(
HttpStatusCode.OK,
VerifyPinResponse(success = true, message = "PIN korrekt. Zugriff gewährt."),
)
} else {
call.respond(
HttpStatusCode.Unauthorized,
VerifyPinResponse(success = false, message = "Falsche PIN."),
)
}
}
}
}

View file

@ -0,0 +1,53 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.SyncRequest
import com.oliver.kidio.backend.domain.models.SyncResponse
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.plugins.UserPrincipal
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.screentimeRoutes(repository: KidioRepository) {
authenticate("auth-jwt") {
get("/api/v1/screentime") {
val principal = call.principal<UserPrincipal>()
val userid =
principal?.userId
?: run {
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token"))
return@get
}
val remainingSeconds = repository.getScreentime(userid)
call.respond(
HttpStatusCode.OK,
SyncResponse(
remainingSeconds = remainingSeconds,
isBlocked = remainingSeconds <= 0,
),
)
}
post("/api/v1/screentime/sync") {
val request = call.receive<SyncRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId ?: request.userId
val currentSeconds = repository.getScreentime(userId)
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
if (newSeconds < 0) newSeconds = 0
repository.updateScreentime(userId, newSeconds)
call.respond(
SyncResponse(
remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0,
),
)
}
}
}

View file

@ -0,0 +1,80 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.TaskVerifyRequest
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.plugins.UserPrincipal
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.taskRoutes(repository: KidioRepository) {
authenticate("auth-jwt") {
get("/api/v1/tasks") {
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
val subject = call.request.queryParameters["subject"]
val queryAge = call.request.queryParameters["age"]?.toIntOrNull()
val userAge = userId?.let { repository.getUserById(it)?.childAge }
val effectiveAge = queryAge ?: userAge
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit)
call.respond(tasks)
}
post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>()
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Ungültiges oder abgelaufenes Token"),
)
return@post
}
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 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!",
),
)
}
}
}
}

View file

@ -0,0 +1,21 @@
package com.oliver.kidio.backend.security
fun isValidPassword(password: String): Boolean {
if (password.length < 8) return false
val hasUppercase = password.any { it.isUpperCase() }
val hasEmoji =
password.any { char ->
val type = Character.getType(char).toByte()
type == Character.SURROGATE ||
type == Character.OTHER_SYMBOL ||
char.code in 0x1F600..0x1F64F ||
char.code in 0x1F300..0x1F5FF ||
char.code in 0x1F680..0x1F6FF ||
char.code in 0x2600..0x26FF
}
val hasSpecialChar = password.any { !it.isLetterOrDigit() && !hasEmoji }
return hasUppercase && hasSpecialChar && !hasEmoji
}

View file

@ -0,0 +1,74 @@
[
{
"id": "zum-math-001",
"title": "Grundrechenarten: Multiplikation 3. Klasse",
"subject": "Mathematik",
"grade": 3,
"downloadUrl": "https://apps.zum.de/api/h5p/math-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Was ist das Ergebnis von <strong>6 &times; 8</strong>?</p>\", \"answers\": [ {\"text\": \"42\", \"correct\": false}, {\"text\": \"48\", \"correct\": true}, {\"text\": \"54\", \"correct\": false}, {\"text\": \"56\", \"correct\": false} ] }"
},
{
"id": "zum-math-002",
"title": "Zahlenraum bis 100: Addition",
"subject": "Mathematik",
"grade": 2,
"downloadUrl": "https://apps.zum.de/api/h5p/math-002.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Berechne: 34 + 28</p>\", \"answers\": [ {\"text\": \"52\", \"correct\": false}, {\"text\": \"62\", \"correct\": true}, {\"text\": \"64\", \"correct\": false} ] }"
},
{
"id": "zum-math-003",
"title": "Geometrie: Formen erkennen",
"subject": "Mathematik",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/math-003.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.</p>\", \"correct\": true }"
},
{
"id": "zum-de-001",
"title": "Wortarten: Nomen erkennen",
"subject": "Deutsch",
"grade": 2,
"downloadUrl": "https://apps.zum.de/api/h5p/de-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Welches der folgenden W&ouml;rter ist ein <strong>Nomen (Namenwort)</strong>?</p>\", \"answers\": [ {\"text\": \"schnell\", \"correct\": false}, {\"text\": \"laufen\", \"correct\": false}, {\"text\": \"Schule\", \"correct\": true}, {\"text\": \"sch&ouml;n\", \"correct\": false} ] }"
},
{
"id": "zum-de-002",
"title": "Rechtschreibung: Doppel-s oder ss",
"subject": "Deutsch",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/de-002.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Wie wird das Wort richtig geschrieben? &bdquo;Der Flu... ist tief.&ldquo;</p>\", \"answers\": [ {\"text\": \"Flus\", \"correct\": false}, {\"text\": \"Fluss\", \"correct\": true}, {\"text\": \"Fluß\", \"correct\": false} ] }"
},
{
"id": "zum-sach-001",
"title": "Natur & Tiere: Winterschlaf",
"subject": "Sachunterricht",
"grade": 3,
"downloadUrl": "https://apps.zum.de/api/h5p/sach-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Welches dieser Tiere h&auml;lt einen echten <strong>Winterschlaf</strong>?</p>\", \"answers\": [ {\"text\": \"Igel\", \"correct\": true}, {\"text\": \"Eichh&ouml;rnchen\", \"correct\": false}, {\"text\": \"Amsel\", \"correct\": false}, {\"text\": \"Fuchs\", \"correct\": false} ] }"
},
{
"id": "zum-sach-002",
"title": "Planetensystem: Sonnennähe",
"subject": "Sachunterricht",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/sach-002.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>Welcher Planet ist der Sonne am n&auml;chsten?</p>\", \"answers\": [ {\"text\": \"Venus\", \"correct\": false}, {\"text\": \"Mars\", \"correct\": false}, {\"text\": \"Merkur\", \"correct\": true}, {\"text\": \"Jupiter\", \"correct\": false} ] }"
},
{
"id": "zum-eng-001",
"title": "English Vocabulary: Colors & Animals",
"subject": "Englisch",
"grade": 4,
"downloadUrl": "https://apps.zum.de/api/h5p/eng-001.h5p",
"license": "CC BY-SA 4.0",
"rawJsonContent": "{ \"question\": \"<p>What is the English word for &bdquo;Hund&ldquo;?</p>\", \"answers\": [ {\"text\": \"Cat\", \"correct\": false}, {\"text\": \"Dog\", \"correct\": true}, {\"text\": \"Bird\", \"correct\": false}, {\"text\": \"Rabbit\", \"correct\": false} ] }"
}
]

View file

@ -1,4 +1,4 @@
package com.oliver.kidio.backend
package com.oliver.kidio.backend.com.oliver.kidio.backend.ingestion
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.ingestion.H5PIngestionCli

View file

@ -0,0 +1,126 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.AuthResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class AuthRoutesTest {
@Test
fun `test registration and login flow`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// 1. Registrierung
val registerResponse =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""
{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}
""".trimIndent(),
)
}
assertEquals(HttpStatusCode.Created, registerResponse.status)
assertTrue(registerResponse.bodyAsText().contains("token"))
// 2. Login mit denselben Daten
val loginResponse =
client.post("/api/v1/auth/login") {
header(
HttpHeaders.ContentType,
ContentType.Application.Json.toString(),
)
setBody(
"""
{
"email": "test@family.de",
"passwordPlain": "StrengGeheim1!"
}
""".trimIndent(),
)
}
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
assertNotNull(loginJson.token)
assertEquals("Familie Schmidt", loginJson.familyName)
}
@Test
fun `test registration rejects invalid passwords`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val invalidPasswords =
listOf(
"Kurz1!",
"keingrossbuch1!",
"KeinSonder12",
"Passwort123!😊",
)
for (password in invalidPasswords) {
val response =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""
{
"email": "test_${System.currentTimeMillis()}@family.de",
"passwordPlain": "$password",
"familyName": "Familie Schmidt",
"childAge": 8,
"parentPin": "4321"
}
""".trimIndent(),
)
}
assertEquals(
HttpStatusCode.BadRequest,
response.status,
"Passwort '$password' hätte abgelehnt werden müssen!",
)
}
}
}

View file

@ -0,0 +1,37 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class HealthRouteTest {
@Test
fun `test health endpoint`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val response = client.get("/api/v1/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
}
}

View file

@ -0,0 +1,108 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.SyncResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class ScreentimeRoutesTest {
@Test
fun `test get screentime returns current status`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) { json() }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// Ohne Token -> 401 Unauthorized
val unauthResponse = client.get("/api/v1/screentime")
assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status)
// Mit Token -> 200 OK
val response =
client.get("/api/v1/screentime") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val json = Json.decodeFromString<SyncResponse>(response.bodyAsText())
assertEquals(1800, json.remainingSeconds)
assertFalse(json.isBlocked)
}
@Test
fun `test screentime sync deducts time repeatedly`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
val syncResponse1 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
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)
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300)
val syncResponse2 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""")
}
assertEquals(HttpStatusCode.OK, syncResponse2.status)
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
assertEquals(1300, syncJson2.remainingSeconds)
assertFalse(syncJson2.isBlocked)
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
val syncResponse3 =
client.post("/api/v1/screentime/sync") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""")
}
assertEquals(HttpStatusCode.OK, syncResponse3.status)
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
}
}

View file

@ -1,5 +1,10 @@
package com.oliver.kidio.backend
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.AuthResponse
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.SyncResponse
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
@ -15,6 +20,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
@ -30,6 +36,41 @@ class ServerTest {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
runBlocking {
repository.upsertTask(
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",
),
)
repository.upsertTask(
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",
),
)
}
application {
install(ServerContentNegotiation) {
json()
@ -189,6 +230,41 @@ class ServerTest {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
runBlocking {
repository.upsertTask(
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",
),
)
repository.upsertTask(
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",
),
)
}
application {
install(ServerContentNegotiation) {
json()

View file

@ -0,0 +1,157 @@
package com.oliver.kidio.backend.routing
import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
class TaskRoutesTest {
@Test
fun `test protected endpoints reject unauthorized requests`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// Ohne Token muss 401 Unauthorized kommen
val tasksResponse = client.get("/api/v1/tasks")
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
}
@Test
fun `test tasks endpoint and verify with jwt`() =
testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
runBlocking {
repository.upsertTask(
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",
),
)
repository.upsertTask(
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",
),
)
}
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// 1. Get Tasks mit Token
val tasksResponse =
client.get("/api/v1/tasks") {
header(HttpHeaders.Authorization, "Bearer $token")
}
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. Filter nach Fach & Alter
val filteredResponse =
client.get("/api/v1/tasks?subject=Mathematik&age=8") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, filteredResponse.status)
val filteredJson = Json.decodeFromString<List<Task>>(filteredResponse.bodyAsText())
assertEquals(2, filteredJson.size)
// 3. Filter mit Alter außerhalb des Bereichs
val age15Response =
client.get("/api/v1/tasks?subject=Mathematik&age=15") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, age15Response.status)
val age15Json = Json.decodeFromString<List<Task>>(age15Response.bodyAsText())
assertEquals(0, age15Json.size)
// 4. Antwort enthält NIEMALS correctAnswer
assertFalse(tasksResponse.bodyAsText().contains("correctAnswer"))
assertFalse(tasksResponse.bodyAsText().contains("correct_answer"))
// 5. Verify Task correct answer
val verifyResponse =
client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
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)
// 6. Verify Task incorrect answer
val verifyWrongResponse =
client.post("/api/v1/tasks/verify") {
header(HttpHeaders.Authorization, "Bearer $token")
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)
}
}

View file

@ -0,0 +1,25 @@
package com.oliver.kidio.backend.security
import com.auth0.jwt.JWT
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class JwtServiceTest {
@Test
fun `test generate token contains correct user id and claims`() {
// Wir nutzen ein Dummy-Secret für den Test
val service = JwtService(secret = "test-secret-für-unit-tests")
val token = service.generateToken("user-123")
assertNotNull(token)
// Token decodieren und prüfen, ob unsere Daten korrekt verpackt wurden
val decoded = JWT.decode(token)
val userIdClaim = decoded.getClaim("userId").asString()
assertEquals("user-123", userIdClaim)
assertEquals("kidio-users", decoded.audience.firstOrNull())
assertEquals("https://kidio.oliver.com/", decoded.issuer)
}
}

View file

@ -0,0 +1,35 @@
package com.oliver.kidio.backend.security
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PasswordUtilsTest {
@Test
fun `test valid passwords`() {
assertTrue(isValidPassword("StrengGeheim1!"))
assertTrue(isValidPassword("Kidio2026_Secure"))
}
@Test
fun `test password too short`() {
assertFalse(isValidPassword("Kurz1!"))
assertFalse(isValidPassword("Geheim!"))
}
@Test
fun `test missing uppercase`() {
assertFalse(isValidPassword("kleingeschrieben1!"))
}
@Test
fun `test missing special character`() {
assertFalse(isValidPassword("KeinSonderzeichen123"))
}
@Test
fun `test password with emojis is rejected`() {
assertFalse(isValidPassword("Passwort123!😊"))
assertFalse(isValidPassword("🚀Geheim123!"))
}
}