Compare commits

..

2 commits

Author SHA1 Message Date
Oliver Wallisch d26d5f688a fixed erros with new user.mdoel
Some checks failed
CI Pipeline / lint-and-test (push) Has been cancelled
2026-09-23 14:55:31 +02:00
Oliver Wallisch ba3e683297 new task fetch integration check
Some checks are pending
CI Pipeline / lint-and-test (push) Waiting to run
2026-09-23 11:13:07 +02:00
34 changed files with 958 additions and 1851 deletions

View file

@ -766,21 +766,6 @@
"earnedMinutes" : { }, "earnedMinutes" : { },
"message" : { } "message" : { }
} }
};
defs["VerifyPinRequest"] = {
"title" : "VerifyPinRequest",
"required" : [ "pin" ],
"properties" : {
"pin" : { }
}
};
defs["VerifyPinResponse"] = {
"title" : "VerifyPinResponse",
"required" : [ "message", "success" ],
"properties" : {
"success" : { },
"message" : { }
}
}; };
</script> </script>
@ -808,9 +793,6 @@
<li data-group="Default" data-name="apiV1HealthGet" class=""> <li data-group="Default" data-name="apiV1HealthGet" class="">
<a href="#api-Default-apiV1HealthGet">apiV1HealthGet</a> <a href="#api-Default-apiV1HealthGet">apiV1HealthGet</a>
</li> </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=""> <li data-group="Default" data-name="apiV1ScreentimeGet" class="">
<a href="#api-Default-apiV1ScreentimeGet">apiV1ScreentimeGet</a> <a href="#api-Default-apiV1ScreentimeGet">apiV1ScreentimeGet</a>
</li> </li>
@ -1648,7 +1630,7 @@ except ApiException as e:
<article id="api-Default-apiV1HealthGet-0" data-group="User" data-name="apiV1HealthGet" data-version="0"> <article id="api-Default-apiV1HealthGet-0" data-group="User" data-name="apiV1HealthGet" data-version="0">
<div class="pull-left"> <div class="pull-left">
<h1>apiV1HealthGet</h1> <h1>apiV1HealthGet</h1>
<p></p> <p>Health-Check</p>
</div> </div>
<div class="pull-right"></div> <div class="pull-right"></div>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -1728,6 +1710,7 @@ public class DefaultApiExample {
<pre class="prettyprint"><code class="language-cpp"> <pre class="prettyprint"><code class="language-cpp">
DefaultApi *apiInstance = [[DefaultApi alloc] init]; DefaultApi *apiInstance = [[DefaultApi alloc] init];
// Health-Check
[apiInstance apiV1HealthGetWithCompletionHandler: [apiInstance apiV1HealthGetWithCompletionHandler:
^(NSError* error) { ^(NSError* error) {
if (error) { if (error) {
@ -1773,6 +1756,7 @@ namespace Example
try try
{ {
// Health-Check
apiInstance.apiV1HealthGet(); apiInstance.apiV1HealthGet();
} }
catch (Exception e) catch (Exception e)
@ -1825,6 +1809,7 @@ from pprint import pprint
api_instance = swagger_client.DefaultApi() api_instance = swagger_client.DefaultApi()
try: try:
# Health-Check
api_instance.api_v1_health_get() api_instance.api_v1_health_get()
except ApiException as e: except ApiException as e:
print("Exception when calling DefaultApi->apiV1HealthGet: %s\n" % e)</code></pre> print("Exception when calling DefaultApi->apiV1HealthGet: %s\n" % e)</code></pre>
@ -1891,468 +1876,6 @@ except ApiException as e:
</article> </article>
</div> </div>
<hr> <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"> <div id="api-Default-apiV1ScreentimeGet">
<article id="api-Default-apiV1ScreentimeGet-0" data-group="User" data-name="apiV1ScreentimeGet" data-version="0"> <article id="api-Default-apiV1ScreentimeGet-0" data-group="User" data-name="apiV1ScreentimeGet" data-version="0">
<div class="pull-left"> <div class="pull-left">
@ -2671,7 +2194,7 @@ except ApiException as e:
<article id="api-Default-apiV1ScreentimeSyncPost-0" data-group="User" data-name="apiV1ScreentimeSyncPost" data-version="0"> <article id="api-Default-apiV1ScreentimeSyncPost-0" data-group="User" data-name="apiV1ScreentimeSyncPost" data-version="0">
<div class="pull-left"> <div class="pull-left">
<h1>apiV1ScreentimeSyncPost</h1> <h1>apiV1ScreentimeSyncPost</h1>
<p></p> <p>3. Bildschirmzeit synchronisieren</p>
</div> </div>
<div class="pull-right"></div> <div class="pull-right"></div>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -2761,6 +2284,7 @@ SyncRequest *body = ; // (optional)
DefaultApi *apiInstance = [[DefaultApi alloc] init]; DefaultApi *apiInstance = [[DefaultApi alloc] init];
// 3. Bildschirmzeit synchronisieren
[apiInstance apiV1ScreentimeSyncPostWith:body [apiInstance apiV1ScreentimeSyncPostWith:body
completionHandler: ^(SyncResponse output, NSError* error) { completionHandler: ^(SyncResponse output, NSError* error) {
if (output) { if (output) {
@ -2816,6 +2340,7 @@ namespace Example
try try
{ {
// 3. Bildschirmzeit synchronisieren
SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body); SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body);
Debug.WriteLine(result); Debug.WriteLine(result);
} }
@ -2877,6 +2402,7 @@ api_instance = swagger_client.DefaultApi()
body = # SyncRequest | (optional) body = # SyncRequest | (optional)
try: try:
# 3. Bildschirmzeit synchronisieren
api_response = api_instance.api_v1_screentime_sync_post(body=body) api_response = api_instance.api_v1_screentime_sync_post(body=body)
pprint(api_response) pprint(api_response)
except ApiException as e: except ApiException as e:
@ -2989,7 +2515,7 @@ except ApiException as e:
<article id="api-Default-apiV1TasksGet-0" data-group="User" data-name="apiV1TasksGet" data-version="0"> <article id="api-Default-apiV1TasksGet-0" data-group="User" data-name="apiV1TasksGet" data-version="0">
<div class="pull-left"> <div class="pull-left">
<h1>apiV1TasksGet</h1> <h1>apiV1TasksGet</h1>
<p></p> <p>1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)</p>
</div> </div>
<div class="pull-right"></div> <div class="pull-right"></div>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -3082,6 +2608,7 @@ public class DefaultApiExample {
DefaultApi *apiInstance = [[DefaultApi alloc] init]; DefaultApi *apiInstance = [[DefaultApi alloc] init];
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
[apiInstance apiV1TasksGetWith:subject [apiInstance apiV1TasksGetWith:subject
age:age age:age
limit:limit limit:limit
@ -3140,6 +2667,7 @@ namespace Example
try try
{ {
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
apiInstance.apiV1TasksGet(subject, age, limit); apiInstance.apiV1TasksGet(subject, age, limit);
} }
catch (Exception e) catch (Exception e)
@ -3204,6 +2732,7 @@ age = # | (optional)
limit = # | (optional) limit = # | (optional)
try: 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) api_instance.api_v1_tasks_get(subject=subject, age=age, limit=limit)
except ApiException as e: except ApiException as e:
print("Exception when calling DefaultApi->apiV1TasksGet: %s\n" % e)</code></pre> print("Exception when calling DefaultApi->apiV1TasksGet: %s\n" % e)</code></pre>
@ -3331,7 +2860,7 @@ except ApiException as e:
<article id="api-Default-apiV1TasksVerifyPost-0" data-group="User" data-name="apiV1TasksVerifyPost" data-version="0"> <article id="api-Default-apiV1TasksVerifyPost-0" data-group="User" data-name="apiV1TasksVerifyPost" data-version="0">
<div class="pull-left"> <div class="pull-left">
<h1>apiV1TasksVerifyPost</h1> <h1>apiV1TasksVerifyPost</h1>
<p></p> <p>2. Antwort auf eine Aufgabe überprüfen</p>
</div> </div>
<div class="pull-right"></div> <div class="pull-right"></div>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -3421,6 +2950,7 @@ TaskVerifyRequest *body = ; // (optional)
DefaultApi *apiInstance = [[DefaultApi alloc] init]; DefaultApi *apiInstance = [[DefaultApi alloc] init];
// 2. Antwort auf eine Aufgabe überprüfen
[apiInstance apiV1TasksVerifyPostWith:body [apiInstance apiV1TasksVerifyPostWith:body
completionHandler: ^(TaskVerifyResponse output, NSError* error) { completionHandler: ^(TaskVerifyResponse output, NSError* error) {
if (output) { if (output) {
@ -3476,6 +3006,7 @@ namespace Example
try try
{ {
// 2. Antwort auf eine Aufgabe überprüfen
TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body); TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body);
Debug.WriteLine(result); Debug.WriteLine(result);
} }
@ -3537,6 +3068,7 @@ api_instance = swagger_client.DefaultApi()
body = # TaskVerifyRequest | (optional) body = # TaskVerifyRequest | (optional)
try: try:
# 2. Antwort auf eine Aufgabe überprüfen
api_response = api_instance.api_v1_tasks_verify_post(body=body) api_response = api_instance.api_v1_tasks_verify_post(body=body)
pprint(api_response) pprint(api_response)
except ApiException as e: except ApiException as e:

View file

@ -1,14 +1,50 @@
package com.oliver.kidio.backend.data.database 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 io.ktor.server.config.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction 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 { object DatabaseFactory {
fun init(config: ApplicationConfig) { fun init(config: ApplicationConfig) {
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver" val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"

View file

@ -2,6 +2,23 @@ package com.oliver.kidio.backend.data.tables
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
/*
* ERSETZT die bisherige TasksTable.kt komplett.
*
* Weg: options (Komma-String), correctAnswer (ein String)
* Hin: prompt_json, content_json, solution_json (je ein JSON-Text, siehe TaskModels.kt)
*
* WICHTIG - Migration: Diese Änderung ist nicht abwärtskompatibel zu vorhandenen Zeilen,
* die noch die alten Spalten "options"/"correct_answer" nutzen. Zwei Optionen:
*
* 1. (Empfohlen für Dev-Datenbanken ohne echte Nutzerdaten) Tabelle leeren und per
* ./gradlew runIngestion neu befüllen lassen, siehe H5PIngestionCli.kt.
*
* 2. (Falls du bestehende Zeilen behalten willst) Vor dem Deploy ein einmaliges
* Migrationsskript schreiben, das pro Zeile aus options+correctAnswer ein
* MultipleChoiceContent/ChoiceSolution baut und in die neuen Spalten schreibt.
* Sag Bescheid, dann schreibe ich dir das als eigenen Schritt.
*/
object TasksTable : Table("tasks") { object TasksTable : Table("tasks") {
val id = varchar("id", 64) val id = varchar("id", 64)
val externalSource = varchar("external_source", 32).default("local") val externalSource = varchar("external_source", 32).default("local")
@ -11,9 +28,12 @@ object TasksTable : Table("tasks") {
val minAge = integer("min_age").default(6) val minAge = integer("min_age").default(6)
val maxAge = integer("max_age").default(12) val maxAge = integer("max_age").default(12)
val taskType = varchar("task_type", 32).default("MULTIPLE_CHOICE") val taskType = varchar("task_type", 32).default("MULTIPLE_CHOICE")
val question = text("question")
val options = text("options") // NEU: ersetzen "question", "options", "correctAnswer"
val correctAnswer = varchar("correct_answer", 255) val promptJson = text("prompt_json")
val contentJson = text("content_json")
val solutionJson = text("solution_json")
val rewardMinutes = integer("reward_minutes").default(5) val rewardMinutes = integer("reward_minutes").default(5)
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)

View file

@ -1,37 +0,0 @@
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

@ -0,0 +1,217 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/*
* ERSETZT die bisherige TaskModels.kt komplett.
*
* Warum: Task/ImportedTask hatten "options: List<String>" + "correctAnswer: String".
* Das kann nur "eine richtige Antwort aus einer Liste" abbilden (Multiple Choice,
* Wahr/Falsch). Lückentexte, Reihenfolgen oder Paare passen strukturell nicht rein.
*
* Neues Prinzip: Jede Aufgabe hat einen TYP (sealed interface TaskContent).
* Die Lösung (TaskSolution) ist ein eigenes, getrenntes Objekt und geht NIE an den Client.
*
* ACHTUNG (Kompilierfehler erwartet): Nach diesem Schritt melden ExposedKidioRepository.kt,
* TasksTable.kt und TaskRoutes.kt Fehler, weil sie noch die alten Klassen Task/ImportedTask/
* TaskVerifyRequest referenzieren. Das ist normal - die fixen wir in Schritt 2 bis 4.
*/
// ---------------------------------------------------------------- Bausteine
@Serializable
data class Media(
val emoji: String? = null,
val imageUrl: String? = null,
val audioUrl: String? = null,
val alt: String? = null,
// repeat = 3 mit emoji "🍎" zeigt drei Äpfel, praktisch zum Zählen für Kleinkinder
val repeat: Int = 1,
)
@Serializable
data class Choice(
val id: String,
val label: String? = null,
val media: Media? = null,
)
@Serializable
data class Prompt(
val text: String,
val media: Media? = null,
// Die App liest den Text per Text-to-Speech vor (wichtig für Kinder, die noch nicht lesen)
val speakAloud: Boolean = false,
)
// ---------------------------------------------------------------- Inhalt (geht an die App)
@Serializable
sealed interface TaskContent
/** Ein oder mehrere richtige Antworten. Auch für Bild-Antworten (Kleinkinder). */
@Serializable
@SerialName("MULTIPLE_CHOICE")
data class MultipleChoiceContent(
val choices: List<Choice>,
val multiSelect: Boolean = false,
) : TaskContent
/** Zwei große Knöpfe. Antwort-IDs sind immer "true" und "false". */
@Serializable
@SerialName("TRUE_FALSE")
object TrueFalseContent : TaskContent {
const val TRUE_ID = "true"
const val FALSE_ID = "false"
}
/** Lückentext. Im Template stehen {1}, {2}, ... für die Lücken. */
@Serializable
@SerialName("FILL_BLANKS")
data class FillBlanksContent(
val template: String,
val blanks: Int,
val wordBank: List<String> = emptyList(),
) : TaskContent
/** Elemente in die richtige Reihenfolge bringen. Kommt gemischt an. */
@Serializable
@SerialName("SORT")
data class SortContent(
val items: List<Choice>,
) : TaskContent
/** Paare verbinden (links zu rechts). */
@Serializable
@SerialName("MATCH_PAIRS")
data class MatchPairsContent(
val left: List<Choice>,
val right: List<Choice>,
) : TaskContent
/** Die richtigen Wörter im Text markieren. */
@Serializable
@SerialName("MARK_WORDS")
data class MarkWordsContent(
val tokens: List<Choice>,
) : TaskContent
// ---------------------------------------------------------------- Lösung (bleibt auf dem Server)
@Serializable
sealed interface TaskSolution
@Serializable
@SerialName("CHOICE")
data class ChoiceSolution(val correctIds: Set<String>) : TaskSolution
/** Pro Lücke eine Liste akzeptierter Schreibweisen. */
@Serializable
@SerialName("FILL")
data class FillSolution(val accepted: List<List<String>>) : TaskSolution
@Serializable
@SerialName("ORDER")
data class OrderSolution(val orderedIds: List<String>) : TaskSolution
@Serializable
@SerialName("PAIRS")
data class PairsSolution(val pairs: Map<String, String>) : TaskSolution
// ---------------------------------------------------------------- Antwort des Kindes
@Serializable
sealed interface TaskAnswer
@Serializable
@SerialName("CHOICE")
data class ChoiceAnswer(val selectedIds: Set<String>) : TaskAnswer
@Serializable
@SerialName("FILL")
data class FillAnswer(val values: List<String>) : TaskAnswer
@Serializable
@SerialName("ORDER")
data class OrderAnswer(val orderedIds: List<String>) : TaskAnswer
@Serializable
@SerialName("PAIRS")
data class PairsAnswer(val pairs: Map<String, String>) : TaskAnswer
// ---------------------------------------------------------------- API-Objekte
/** Ersetzt die alte "Task"-Klasse. Geht an die App, ohne Lösung. */
@Serializable
data class Task(
val id: String,
val subject: String = "Mathematik",
val minAge: Int = 6,
val maxAge: Int = 12,
val prompt: Prompt,
val content: TaskContent,
val rewardMinutes: Int,
val license: String? = null,
val sourceUrl: String? = null,
)
/** Ersetzt die alte "ImportedTask"-Klasse. Interne Datenklasse für den Import. */
data class ImportedTask(
val id: String,
val externalSource: String = "zum.de",
val sourceUrl: String? = null,
val license: String = "CC BY-SA 4.0",
val subject: String,
val minAge: Int,
val maxAge: Int,
val prompt: Prompt,
val content: TaskContent,
val solution: TaskSolution,
val rewardMinutes: Int = 5,
)
/** Ersetzt die alte "TaskVerifyRequest": statt eines Strings jetzt eine typisierte Antwort. */
@Serializable
data class TaskVerifyRequest(
val taskId: String,
val answer: TaskAnswer,
)
/** Unverändert gegenüber vorher. */
@Serializable
data class TaskVerifyResponse(
val isCorrect: Boolean,
val earnedMinutes: Int,
val message: String,
)
// ---------------------------------------------------------------- Prüfung
object TaskChecker {
fun isCorrect(
solution: TaskSolution,
answer: TaskAnswer,
): Boolean =
when {
solution is ChoiceSolution && answer is ChoiceAnswer ->
solution.correctIds == answer.selectedIds
solution is FillSolution && answer is FillAnswer ->
solution.accepted.size == answer.values.size &&
solution.accepted.indices.all { i ->
solution.accepted[i].any { normalize(it) == normalize(answer.values[i]) }
}
solution is OrderSolution && answer is OrderAnswer ->
solution.orderedIds == answer.orderedIds
solution is PairsSolution && answer is PairsAnswer ->
solution.pairs == answer.pairs
// Typ von Antwort und Lösung passen nicht zusammen: immer falsch, nie einen Fehler werfen
else -> false
}
private fun normalize(s: String): String = s.trim().lowercase().replace(Regex("\\s+"), " ")
}

View file

@ -0,0 +1,23 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.json.Json
/*
* NEUE DATEI (noch nicht vorhanden in deinem Projekt).
* Ablage: src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskJson.kt
*
* Eine gemeinsame Json-Konfiguration, die überall dort verwendet wird, wo TaskContent,
* TaskSolution oder TaskAnswer serialisiert/deserialisiert werden: beim Schreiben/Lesen
* der DB-Spalten (content_json, solution_json, prompt_json) und im H5P-Mapper.
*
* classDiscriminator = "type" sorgt dafür, dass im JSON ein Feld "type" steht
* (z. B. "type": "MULTIPLE_CHOICE"), damit die App weiß, welche Variante sie bekommt.
*/
object TaskJson {
val json: Json =
Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
encodeDefaults = true
}
}

View file

@ -1,47 +0,0 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
// Eine Lernaufgabe (z. B. Mathe)
@Serializable
data class Task(
val id: String,
val subject: String = "Mathematik",
val minAge: Int = 6,
val maxAge: Int = 12,
val question: String,
val options: List<String>,
val rewardMinutes: Int,
val license: String? = null,
)
// Interne Datenklasse für importierte Aufgaben (Ingestion)
data class ImportedTask(
val id: String,
val externalSource: String = "zum.de",
val sourceUrl: String? = null,
val license: String = "CC BY-SA 4.0",
val subject: String,
val minAge: Int,
val maxAge: Int,
val taskType: String = "MULTIPLE_CHOICE",
val question: String,
val options: List<String>,
val correctAnswer: String,
val rewardMinutes: Int = 5,
)
// Anfrage des Kindes, wenn es eine Aufgabe löst
@Serializable
data class TaskVerifyRequest(
val taskId: String,
val selectedAnswer: String,
)
// Antwort vom Server nach der Aufgaben-Prüfung
@Serializable
data class TaskVerifyResponse(
val isCorrect: Boolean,
val earnedMinutes: Int,
val message: String,
)

View file

@ -0,0 +1,13 @@
package com.oliver.kidio.backend.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val email: String,
val passwordHash: String,
val familyName: String,
val childAge: Int,
val parentPin: String
)

View file

@ -1,26 +0,0 @@
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

@ -5,12 +5,31 @@ import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.tables.ScreentimeTable import com.oliver.kidio.backend.data.tables.ScreentimeTable
import com.oliver.kidio.backend.data.tables.TasksTable import com.oliver.kidio.backend.data.tables.TasksTable
import com.oliver.kidio.backend.data.tables.UserTable import com.oliver.kidio.backend.data.tables.UserTable
import com.oliver.kidio.backend.domain.models.FillBlanksContent
import com.oliver.kidio.backend.domain.models.ImportedTask import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.MarkWordsContent
import com.oliver.kidio.backend.domain.models.MatchPairsContent
import com.oliver.kidio.backend.domain.models.MultipleChoiceContent
import com.oliver.kidio.backend.domain.models.Prompt
import com.oliver.kidio.backend.domain.models.SortContent
import com.oliver.kidio.backend.domain.models.Task import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskContent
import com.oliver.kidio.backend.domain.models.TaskJson
import com.oliver.kidio.backend.domain.models.TaskSolution
import com.oliver.kidio.backend.domain.models.TrueFalseContent
import com.oliver.kidio.backend.domain.models.User import com.oliver.kidio.backend.domain.models.User
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import kotlin.collections.singleOrNull
/*
* ERSETZT die bisherige ExposedKidioRepository.kt.
* Geändert: getTasks, upsertTask, getTaskAnswerAndReward (JSON-Spalten statt options/correctAnswer).
* Unverändert: getUserById, registerUser, getScreentime, updateScreentime, findByEmail,
* findById, verifyPassword, getExistingTaskIds.
*/
class ExposedKidioRepository : KidioRepository { class ExposedKidioRepository : KidioRepository {
override suspend fun getTasks( override suspend fun getTasks(
subject: String?, subject: String?,
@ -26,18 +45,26 @@ class ExposedKidioRepository : KidioRepository {
query = query.andWhere { (TasksTable.minAge lessEq age) and (TasksTable.maxAge greaterEq age) } query = query.andWhere { (TasksTable.minAge lessEq age) and (TasksTable.maxAge greaterEq age) }
} }
val safeLimit = if (limit <= 0) 10 else limit val safeLimit = if (limit <= 0) 10 else limit
query.limit(safeLimit).map { query.limit(safeLimit).mapNotNull { row -> rowToTaskOrNull(row) }
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],
)
} }
/** Baut eine Task aus einer DB-Zeile. Bei kaputtem JSON wird die Zeile übersprungen statt den Server abstürzen zu lassen. */
private fun rowToTaskOrNull(row: ResultRow): Task? =
try {
Task(
id = row[TasksTable.id],
subject = row[TasksTable.subject],
minAge = row[TasksTable.minAge],
maxAge = row[TasksTable.maxAge],
prompt = TaskJson.json.decodeFromString<Prompt>(row[TasksTable.promptJson]),
content = TaskJson.json.decodeFromString<TaskContent>(row[TasksTable.contentJson]),
rewardMinutes = row[TasksTable.rewardMinutes],
license = row[TasksTable.license],
sourceUrl = row[TasksTable.sourceUrl],
)
} catch (e: Exception) {
println("WARNUNG: Task ${row[TasksTable.id]} konnte nicht gelesen werden (kaputtes JSON?): ${e.message}")
null
} }
override suspend fun getUserById(userId: String): User? = override suspend fun getUserById(userId: String): User? =
@ -71,6 +98,10 @@ class ExposedKidioRepository : KidioRepository {
override suspend fun upsertTask(task: ImportedTask): Boolean = override suspend fun upsertTask(task: ImportedTask): Boolean =
DatabaseFactory.dbQuery { DatabaseFactory.dbQuery {
try { try {
val promptJson = TaskJson.json.encodeToString(task.prompt)
val contentJson = TaskJson.json.encodeToString(task.content)
val solutionJson = TaskJson.json.encodeToString(task.solution)
val updated = val updated =
TasksTable.update({ TasksTable.id eq task.id }) { TasksTable.update({ TasksTable.id eq task.id }) {
it[externalSource] = task.externalSource it[externalSource] = task.externalSource
@ -79,10 +110,10 @@ class ExposedKidioRepository : KidioRepository {
it[subject] = task.subject it[subject] = task.subject
it[minAge] = task.minAge it[minAge] = task.minAge
it[maxAge] = task.maxAge it[maxAge] = task.maxAge
it[taskType] = task.taskType it[taskType] = task.content.typeName()
it[question] = task.question it[TasksTable.promptJson] = promptJson
it[options] = task.options.joinToString(",") it[TasksTable.contentJson] = contentJson
it[correctAnswer] = task.correctAnswer it[TasksTable.solutionJson] = solutionJson
it[rewardMinutes] = task.rewardMinutes it[rewardMinutes] = task.rewardMinutes
} }
if (updated == 0) { if (updated == 0) {
@ -94,10 +125,10 @@ class ExposedKidioRepository : KidioRepository {
it[subject] = task.subject it[subject] = task.subject
it[minAge] = task.minAge it[minAge] = task.minAge
it[maxAge] = task.maxAge it[maxAge] = task.maxAge
it[taskType] = task.taskType it[taskType] = task.content.typeName()
it[question] = task.question it[TasksTable.promptJson] = promptJson
it[options] = task.options.joinToString(",") it[TasksTable.contentJson] = contentJson
it[correctAnswer] = task.correctAnswer it[TasksTable.solutionJson] = solutionJson
it[rewardMinutes] = task.rewardMinutes it[rewardMinutes] = task.rewardMinutes
} }
} }
@ -130,11 +161,14 @@ class ExposedKidioRepository : KidioRepository {
} }
} }
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = override suspend fun getTaskAnswerAndReward(taskId: String): Pair<TaskSolution, Int>? =
DatabaseFactory.dbQuery { DatabaseFactory.dbQuery {
TasksTable.selectAll() TasksTable.selectAll()
.where { TasksTable.id eq taskId } .where { TasksTable.id eq taskId }
.map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] } .map {
val solution = TaskJson.json.decodeFromString<TaskSolution>(it[TasksTable.solutionJson])
solution to it[TasksTable.rewardMinutes]
}
.singleOrNull() .singleOrNull()
} }
@ -240,3 +274,19 @@ class ExposedKidioRepository : KidioRepository {
return BCrypt.checkpw(password, passwordHash) return BCrypt.checkpw(password, passwordHash)
} }
} }
/**
* Liefert den Aufgabentyp als String für die taskType-Spalte (nur zum Filtern/Debuggen
* in der DB, nicht Teil der eigentlichen Prüf-Logik - die läuft über TaskChecker).
* Bewusst als explizites "when" statt Reflection, damit der Compiler bei einer neuen
* TaskContent-Variante hier einen Fehler wirft ("not exhaustive"), falls sie vergessen wird.
*/
private fun TaskContent.typeName(): String =
when (this) {
is MultipleChoiceContent -> "MULTIPLE_CHOICE"
is TrueFalseContent -> "TRUE_FALSE"
is FillBlanksContent -> "FILL_BLANKS"
is SortContent -> "SORT"
is MatchPairsContent -> "MATCH_PAIRS"
is MarkWordsContent -> "MARK_WORDS"
}

View file

@ -2,9 +2,15 @@ package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.domain.models.ImportedTask import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskSolution
import com.oliver.kidio.backend.domain.models.User import com.oliver.kidio.backend.domain.models.User
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
/*
* ERSETZT die bisherige InMemoryKidioRepository.kt.
* Wird u.a. in Tests genutzt (ServerTest o.ä.) - Signatur muss zum neuen KidioRepository-
* Interface passen, sonst kompiliert das Test-Modul nicht mehr.
*/
class InMemoryKidioRepository : KidioRepository { class InMemoryKidioRepository : KidioRepository {
private val tasks = mutableListOf<ImportedTask>() private val tasks = mutableListOf<ImportedTask>()
@ -32,10 +38,11 @@ class InMemoryKidioRepository : KidioRepository {
subject = it.subject, subject = it.subject,
minAge = it.minAge, minAge = it.minAge,
maxAge = it.maxAge, maxAge = it.maxAge,
question = it.question, prompt = it.prompt,
options = it.options, content = it.content,
rewardMinutes = it.rewardMinutes, rewardMinutes = it.rewardMinutes,
license = it.license, license = it.license,
sourceUrl = it.sourceUrl,
) )
} }
} }
@ -57,9 +64,9 @@ class InMemoryKidioRepository : KidioRepository {
return true return true
} }
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? { override suspend fun getTaskAnswerAndReward(taskId: String): Pair<TaskSolution, Int>? {
val task = tasks.find { it.id == taskId } ?: return null val task = tasks.find { it.id == taskId } ?: return null
return task.correctAnswer to task.rewardMinutes return task.solution to task.rewardMinutes
} }
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800 override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800

View file

@ -2,40 +2,20 @@ package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.domain.models.ImportedTask import com.oliver.kidio.backend.domain.models.ImportedTask
import com.oliver.kidio.backend.domain.models.Task import com.oliver.kidio.backend.domain.models.Task
import com.oliver.kidio.backend.domain.models.TaskSolution
import com.oliver.kidio.backend.domain.models.User import com.oliver.kidio.backend.domain.models.User
interface KidioRepository { interface KidioRepository {
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100) suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
suspend fun getUserById(userId: String): User? suspend fun getUserById(userId: String): User?
suspend fun findById(userId: String): User? suspend fun findById(userId: String): User?
suspend fun getTasks(subject: String? = null, age: Int? = null, limit: Int = 10): List<Task>
suspend fun getTasks(
subject: String? = null,
age: Int? = null,
limit: Int = 10,
): List<Task>
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String> suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
suspend fun upsertTask(task: ImportedTask): Boolean suspend fun upsertTask(task: ImportedTask): Boolean
suspend fun getTaskAnswerAndReward(taskId: String): Pair<TaskSolution, Int>?
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: Int)
suspend fun updateScreentime(
userId: String,
seconds: Int,
)
suspend fun findByEmail(email: String): User? suspend fun findByEmail(email: String): User?
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean
suspend fun registerUser(user: User): Boolean suspend fun registerUser(user: User): Boolean
} }

View file

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

View file

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

View file

@ -1,15 +0,0 @@
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,6 +8,17 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse import java.net.http.HttpResponse
import java.time.Duration 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( class ZumApiClient(
private val httpClient: HttpClient = private val httpClient: HttpClient =
HttpClient.newBuilder() HttpClient.newBuilder()
@ -102,39 +113,36 @@ class ZumApiClient(
for (page in 0 until maxPages) { for (page in 0 until maxPages) {
if (results.size >= limit) break if (results.size >= limit) break
processPage(page, subject, limit, existingIds, seenAppIds, results)
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"
} }
if (results.isEmpty()) {
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
return getCuratedOerItems(subject = subject, limit = limit)
}
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
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) val html = fetchString(searchUrl)
if (html == null) { if (html == null) {
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)") println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
return continue
} }
val appIds = extractNewAppIds(html, existingIds, seenAppIds) // 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()) { if (appIds.isEmpty()) {
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...") println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
return continue
} }
for (appId in appIds) { for (appId in appIds) {
@ -144,46 +152,22 @@ class ZumApiClient(
val item = fetchAppDetails(appId, subject) val item = fetchAppDetails(appId, subject)
if (item != null) { if (item != null) {
results.add(item) 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 lib = item.library ?: "H5P"
val gr = item.grade ?: "k.A." val gr = item.grade ?: "k.A."
println("[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]") println(
"[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]",
)
}
}
}
if (results.isEmpty()) {
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
return getCuratedOerItems(subject = subject, limit = limit)
}
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
return results
} }
private fun fetchAppDetails( private fun fetchAppDetails(
@ -232,7 +216,7 @@ class ZumApiClient(
val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull
val license = val license =
when { when {
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> DEFAULT_LICENSE rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> "CC BY-SA 4.0"
rawLicense.contains("CC", ignoreCase = true) -> rawLicense rawLicense.contains("CC", ignoreCase = true) -> rawLicense
else -> "CC BY-SA 4.0 ($rawLicense)" else -> "CC BY-SA 4.0 ($rawLicense)"
} }
@ -354,26 +338,168 @@ class ZumApiClient(
} }
/** /**
* Returns curated ZUM/OER sample metadata items from resources, filterable by subject. * Returns curated ZUM/OER sample metadata items, filterable by subject.
*/ */
fun getCuratedOerItems( fun getCuratedOerItems(
subject: String? = null, subject: String? = null,
limit: Int = 20, limit: Int = 20,
): List<ZumOerItem> { ): List<ZumOerItem> {
val allItems: List<ZumOerItem> = val allItems =
try { listOf(
val inputStream = this::class.java.getResourceAsStream("/fallback_oer_items.json") ZumOerItem(
if (inputStream != null) { id = "zum-math-001",
val jsonText = inputStream.bufferedReader().use { it.readText() } title = "Grundrechenarten: Multiplikation 3. Klasse",
json.decodeFromString(jsonText) subject = "Mathematik",
} else { grade = 3,
println("WARNUNG: fallback_oer_items.json wurde nicht gefunden!") downloadUrl = "https://apps.zum.de/api/h5p/math-001.h5p",
emptyList() 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}
]
} }
} catch (e: Exception) { """.trimIndent(),
println("WARNUNG: Fehler beim Laden der Fallback-Daten: ${e.message}") ),
emptyList() 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}
]
} }
""".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 normalizedSubject = subject?.let { mapSubject(it) }
val filtered = val filtered =

View file

@ -1,17 +0,0 @@
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,12 +1,38 @@
package com.oliver.kidio.backend.plugins package com.oliver.kidio.backend.plugins
import com.oliver.kidio.backend.*
import com.oliver.kidio.backend.domain.repository.KidioRepository import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.routing.*
import com.oliver.kidio.backend.security.JwtService import com.oliver.kidio.backend.security.JwtService
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.plugins.openapi.* import io.ktor.server.plugins.openapi.*
import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
fun 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( fun Application.configureRouting(
repository: KidioRepository, repository: KidioRepository,
@ -15,13 +41,195 @@ fun Application.configureRouting(
routing { routing {
openAPI(path = "openapi") 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") { get("/api/v1/health") {
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
} }
authRoutes(repository, jwtService) post("/api/v1/auth/login") {
taskRoutes(repository) val loginRequest = call.receive<LoginRequest>()
screentimeRoutes(repository) if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) {
parentRoutes(repository) 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,
),
)
}
}
} }
} }

View file

@ -1,93 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,80 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,74 +0,0 @@
[
{
"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.com.oliver.kidio.backend.ingestion package com.oliver.kidio.backend
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.ingestion.H5PIngestionCli import com.oliver.kidio.backend.ingestion.H5PIngestionCli

View file

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

View file

@ -1,126 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,108 +0,0 @@
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,157 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,35 +0,0 @@
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!"))
}
}