make translation api public to fetch on first app start

This commit is contained in:
2026-05-27 17:43:17 +02:00
parent 5a2faa3ecc
commit 0f98393d1d
5 changed files with 57 additions and 28 deletions

View File

@ -4,33 +4,34 @@
* *
* GET (no params) -> ordered questionnaire list with conditions * GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format * GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> global app UI strings only (not questionnaire content) * GET ?translations=1 -> global app UI strings only; **no auth** (login screen).
* *
* POST (coach interview upload) is handled by api/index.php -> handlers/app_questionnaires.php * POST (coach interview upload) is handled by api/index.php -> handlers/app_questionnaires.php
*/ */
require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php'; require_once __DIR__ . '/../db_init.php';
require_once __DIR__ . '/../lib/response.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET' && !empty($_GET['translations'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
qdb_discard($tmpDb, $lockFp);
exit;
}
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($method !== 'GET') {
http_response_code(405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
echo json_encode(["error" => "Method not allowed"]);
exit;
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false); [$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qnID = $_GET['id'] ?? ''; $qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
if ($translations) {
qdb_discard($tmpDb, $lockFp);
echo json_encode(['success' => true, 'translations' => qdb_build_app_translations_map($pdo)]);
exit;
}
if ($qnID) { if ($qnID) {
// Single questionnaire in app JSON format // Single questionnaire in app JSON format

View File

@ -265,6 +265,8 @@ Common failures:
`GET /api/app_questionnaires?translations=1` `GET /api/app_questionnaires?translations=1`
**Authentication:** not required. The Android app calls this on the login screen before the coach signs in. All other `app_questionnaires` routes still require `Authorization: Bearer <token>`.
Success: Success:
```json ```json
@ -307,16 +309,30 @@ Android should apply this map while a questionnaire is open (overlay on top of a
## Recommended Android Sync Flow ## Recommended Android Sync Flow
1. Login with `/api/auth/login` and store the returned token securely. 1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token) and cache locally.
2. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only). 2. Login with `/api/auth/login` and store the returned token securely (EncryptedSharedPreferences on Android).
3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1`. 3. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
4. Fetch the active questionnaire list with `GET /api/app_questionnaires`. 4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
5. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`). 6. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
6. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use. 7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
7. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires`. 8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` (plaintext JSON over HTTPS; server stores answers in the database — not the app's local ciphertext).
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available. There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available.
## Android local data security
The Android app encrypts sensitive data **at rest on the device**:
- **Answer text** and **client codes** in the local Room database use AES-256-GCM with keys in Android Keystore (ciphertext on disk; plaintext only inside the running app).
- **Assigned client list** cache and **session token** use the same encryption / EncryptedSharedPreferences respectively.
- **Questionnaire definitions** and UI translations cached from this API are not encrypted (no user answers).
After a successful `POST /api/app_questionnaires`, answer data is readable on the **server** (normal database storage, RBAC-protected). The upload body is **decrypted plaintext** (AES-GCM applies only to the on-device Room database). The app keeps encrypted local copies so coaches can review, edit, and re-upload work offline.
Re-uploading the same `clientCode` + `questionnaireID` updates server rows in place (see upload behavior below). This matches upcoming editable flows on web and mobile.
Legacy full-database download endpoints (`downloadFull.php`, encrypted SQLite master file) are **not** used by the current Android app.
## Upload Questionnaire Data ## Upload Questionnaire Data
`POST /api/app_questionnaires` `POST /api/app_questionnaires`
@ -377,7 +393,7 @@ Current upload behavior:
- Unknown or empty `questionID` values are skipped. - Unknown or empty `questionID` values are skipped.
- Unknown `answerOptionKey` values are stored as no selected option for that question. - Unknown `answerOptionKey` values are stored as no selected option for that question.
- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer. - Re-uploading the same `clientCode` and `questionID` overwrites the previous answer.
- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum. - Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum (supports correction / re-edit workflows).
- `sumPoints` is calculated from recognized `answerOptionKey` values. - `sumPoints` is calculated from recognized `answerOptionKey` values.
- The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question. - The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question.

View File

@ -3,11 +3,21 @@
* Android app API endpoint. * Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions * GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format * GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> global app UI strings only (not questionnaire content) * GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
* GET ?clients=1 -> list of clients assigned to the authenticated coach * GET ?clients=1 -> list of clients assigned to the authenticated coach
* POST -> submit interview answers for a client (coach / supervisor / admin) * POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
* Mobile clients encrypt answers at rest on device; POST body is plaintext JSON over HTTPS.
*/ */
// Public app UI catalog for the login screen (no token, no PII).
if ($method === 'GET' && !empty($_GET['translations'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
qdb_discard($tmpDb, $lockFp);
return;
}
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
if ($method === 'POST') { if ($method === 'POST') {
@ -252,11 +262,6 @@ if ($fetchClients) {
json_success(['coachID' => $coachID, 'clients' => $clients]); json_success(['coachID' => $coachID, 'clients' => $clients]);
} }
if ($translations) {
qdb_discard($tmpDb, $lockFp);
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
}
if ($qnID) { if ($qnID) {
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); $qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]); $qn->execute([':id' => $qnID]);

View File

@ -602,6 +602,12 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
font-size: .9rem; font-size: .9rem;
margin-bottom: 24px; margin-bottom: 24px;
} }
.login-card .login-privacy-note {
color: var(--text-secondary);
font-size: .8rem;
line-height: 1.4;
margin: -12px 0 20px;
}
.error-text { color: var(--danger); font-size: .85rem; margin-top: 8px; } .error-text { color: var(--danger); font-size: .85rem; margin-top: 8px; }
/* Toast */ /* Toast */

View File

@ -10,6 +10,7 @@ export function loginPage() {
<div class="login-card card"> <div class="login-card card">
<h1>BW Sch&uuml;tzt</h1> <h1>BW Sch&uuml;tzt</h1>
<p class="subtitle">Questionnaire Management</p> <p class="subtitle">Questionnaire Management</p>
<p class="login-privacy-note">Coach app stores interview answers encrypted on device; data shown here is loaded from the server after upload.</p>
<form id="loginForm"> <form id="loginForm">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>