diff --git a/api/app_questionnaires.php b/api/app_questionnaires.php index db60c8e..1005a54 100644 --- a/api/app_questionnaires.php +++ b/api/app_questionnaires.php @@ -4,33 +4,34 @@ * * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> 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 */ require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../db_init.php'; +require_once __DIR__ . '/../lib/response.php'; 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(); -if ($_SERVER['REQUEST_METHOD'] !== 'GET') { - http_response_code(405); - echo json_encode(["error" => "Method not allowed"]); - exit; +if ($method !== 'GET') { + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } [$pdo, $tmpDb, $lockFp] = qdb_open(false); -$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; -} +$qnID = $_GET['id'] ?? ''; if ($qnID) { // Single questionnaire in app JSON format diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 2501876..712fe4e 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -265,6 +265,8 @@ Common failures: `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 `. + Success: ```json @@ -307,16 +309,30 @@ Android should apply this map while a questionnaire is open (overlay on top of a ## Recommended Android Sync Flow -1. Login with `/api/auth/login` and store the returned token securely. -2. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only). -3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1`. -4. Fetch the active questionnaire list with `GET /api/app_questionnaires`. -5. For each list item, fetch details with `GET /api/app_questionnaires?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. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires`. +1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token) and cache locally. +2. Login with `/api/auth/login` and store the returned token securely (EncryptedSharedPreferences on Android). +3. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only). +4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh). +6. For each list item, fetch details with `GET /api/app_questionnaires?id=` (includes per-questionnaire `translations`). +7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use. +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. +## 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 `POST /api/app_questionnaires` @@ -377,7 +393,7 @@ Current upload behavior: - Unknown or empty `questionID` values are skipped. - 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 `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. - 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. diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index f4dd198..4219ade 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -3,11 +3,21 @@ * Android app API endpoint. * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> 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 - * 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(); if ($method === 'POST') { @@ -252,11 +262,6 @@ if ($fetchClients) { json_success(['coachID' => $coachID, 'clients' => $clients]); } -if ($translations) { - qdb_discard($tmpDb, $lockFp); - json_success(['translations' => qdb_build_app_translations_map($pdo)]); -} - if ($qnID) { $qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); $qn->execute([':id' => $qnID]); diff --git a/website/css/style.css b/website/css/style.css index 93a1e66..868a2b7 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -602,6 +602,12 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } font-size: .9rem; 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; } /* Toast */ diff --git a/website/js/pages/login.js b/website/js/pages/login.js index 7a25d89..b212021 100644 --- a/website/js/pages/login.js +++ b/website/js/pages/login.js @@ -10,6 +10,7 @@ export function loginPage() {