From 72cfe274e10e018bcc3fc2dff9aeafcc3982efd5 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Wed, 13 May 2026 07:36:01 +0200 Subject: [PATCH] added API endpoint to fetch clients assigned to the authenticated coach --- docs/android-questionnaire-api.md | 456 ++++++++++++++++++++++++++++++ handlers/app_questionnaires.php | 22 +- 2 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 docs/android-questionnaire-api.md diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md new file mode 100644 index 0000000..f5ed61f --- /dev/null +++ b/docs/android-questionnaire-api.md @@ -0,0 +1,456 @@ +# Android Questionnaire API Integration + +This document describes the API contract the Android app can use to fetch questionnaires, fetch translations, and upload completed questionnaire answers. + +Use the front-controller routes under `/api/...`. The clean route `/api/app_questionnaires` is preferred because it returns the unified response envelope used by the web app and supports `POST` uploads. + +## Base Requirements + +- Base URL: `https:///api` +- JSON header: `Content-Type: application/json; charset=UTF-8` +- Auth header after login: `Authorization: Bearer ` +- All timestamps are integer Unix timestamps. Use seconds unless the server contract is changed. +- Successful front-controller responses use: + +```json +{ + "ok": true, + "data": {} +} +``` + +- Error responses use: + +```json +{ + "ok": false, + "error": { + "code": "ERROR_CODE", + "message": "Human readable message" + } +} +``` + +Some low-level auth failures can still return a legacy body such as `{"error":"Missing Bearer token"}` or `{"error":"Invalid or expired token"}`. + +## Authentication + +### Login + +`POST /api/auth/login` + +Request: + +```json +{ + "username": "coach1", + "password": "secret" +} +``` + +Success: + +```json +{ + "ok": true, + "data": { + "token": "64-character-hex-token", + "user": "coach1", + "role": "coach" + } +} +``` + +If the account must change its password first, the response contains `mustChangePassword: true` and a temporary token. That temporary token cannot access questionnaire endpoints until the password is changed. + +## Fetch Questionnaires + +### Fetch Active Questionnaire List + +`GET /api/app_questionnaires` + +Headers: + +```http +Authorization: Bearer +``` + +Success: + +```json +{ + "ok": true, + "data": [ + { + "id": "questionnaire_1_demographic_information", + "name": "demographic information", + "showPoints": false, + "condition": {} + } + ] +} +``` + +Fields: + +- `id`: stable questionnaire identifier. Use this when fetching the questionnaire details and uploading answers. +- `name`: display/admin name. +- `showPoints`: whether the app may show the points result. +- `condition`: JSON condition object. Empty conditions are returned as `{}`. + +Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`. + +### Fetch One Questionnaire + +`GET /api/app_questionnaires?id=` + +Example: + +`GET /api/app_questionnaires?id=questionnaire_1_demographic_information` + +Success: + +```json +{ + "ok": true, + "data": { + "meta": { + "id": "questionnaire_1_demographic_information" + }, + "questions": [ + { + "id": "q1", + "layout": "radio_question", + "question": "consent_instruction", + "options": [ + { + "key": "consent_signed", + "nextQuestionId": "q6" + }, + { + "key": "consent_not_signed", + "nextQuestionId": "q2" + } + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + } + ] + } +} +``` + +Question fields: + +- `id`: short question ID used by the Android app and upload payload, for example `q1`. +- `layout`: question renderer type, for example `radio_question`, `multi_check_box_question`, `string_spinner`, `value_spinner`, `client_coach_code_question`, or `last_page`. +- `question`: translation key or default text for the question. +- `options`: answer choices for choice questions and spinner options. +- `options[].key`: answer option key. Send this back as `answerOptionKey` during upload. +- `options[].nextQuestionId`: optional conditional navigation target. +- `pointsMap`: optional map from answer option key to point value. +- `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `minSelection`: optional layout-specific config fields. + +Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received. + +## Fetch Assigned Clients + +`GET /api/app_questionnaires?clients=1` + +Allowed roles: `coach` only. + +Returns the list of client codes assigned to the authenticated coach. + +Headers: + +```http +Authorization: Bearer +``` + +Success: + +```json +{ + "ok": true, + "data": { + "clients": [ + "CLIENT-001", + "CLIENT-002" + ] + } +} +``` + +Fields: + +- `clients`: array of client code strings assigned to the coach, ordered alphabetically. + +Common failures: + +- `401`: missing or invalid token. +- `403`: token belongs to a role other than `coach` (supervisor and admin must use the web `/api/clients` endpoint instead). + +## Fetch Translations + +`GET /api/app_questionnaires?translations=1` + +Success: + +```json +{ + "ok": true, + "data": { + "translations": { + "en": { + "consent_instruction": "Consent instruction text", + "consent_signed": "Consent signed", + "client_code": "Client code" + }, + "de": { + "consent_instruction": "Einwilligungstext", + "consent_signed": "Einwilligung unterschrieben", + "client_code": "Klientencode" + } + } + } +} +``` + +The translation map is grouped by language code. Each language contains a flat key-value map. + +Translation keys can come from: + +- question default text, exposed as `question` +- answer option default text, exposed as `options[].key` +- general string keys from `string_translation`, such as hints, labels, symptoms, and text keys + +Android lookup process: + +1. Load `translations[activeLanguage]`. +2. For each questionnaire string, look up the string value as a key. +3. If a translation is missing, fall back to the raw key from the questionnaire JSON. + +## 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 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=`. +6. Cache the client list, translations, the questionnaire list, and questionnaire details locally for offline use. +7. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires`. + +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. + +## Upload Questionnaire Data + +`POST /api/app_questionnaires` + +Allowed roles: `admin`, `supervisor`, `coach`. + +The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client. + +Request: + +```json +{ + "questionnaireID": "questionnaire_1_demographic_information", + "clientCode": "CLIENT-001", + "startedAt": 1714471200, + "completedAt": 1714471800, + "answers": [ + { + "questionID": "q1", + "answerOptionKey": "consent_signed", + "answeredAt": 1714471210 + }, + { + "questionID": "q9", + "numericValue": 34, + "answeredAt": 1714471300 + }, + { + "questionID": "q12", + "freeTextValue": "Free text answer", + "answeredAt": 1714471400 + } + ] +} +``` + +Required top-level fields: + +- `questionnaireID`: questionnaire ID from the list/detail endpoints. +- `clientCode`: existing client code. +- `answers`: array of answer objects. + +Optional top-level fields: + +- `startedAt`: questionnaire start timestamp. +- `completedAt`: questionnaire completion timestamp. + +Answer fields: + +- `questionID`: required per useful answer. Use the short question ID returned by the detail endpoint. +- `answerOptionKey`: for choice answers. Use `options[].key` from the detail endpoint. +- `freeTextValue`: for text answers. +- `numericValue`: for numeric/range answers. +- `answeredAt`: timestamp for this answer. + +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. +- `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. + +Success: + +```json +{ + "ok": true, + "data": { + "submitted": true, + "sumPoints": 0 + } +} +``` + +Common failures: + +- `400 INVALID_BODY`: request body is not valid JSON. +- `400 MISSING_FIELDS`: one or more required fields are missing. +- `400 INVALID_FIELD`: `answers` is not an array. +- `401`: missing token or invalid credentials. +- `403`: expired token, password change required, or insufficient role. +- `404 NOT_FOUND`: questionnaire does not exist, client does not exist, or user is not authorized for that client. +- `405 METHOD_NOT_ALLOWED`: wrong HTTP method. + +## Retrofit Shape + +Example Kotlin data shapes: + +```kotlin +data class ApiEnvelope( + val ok: Boolean, + val data: T? = null, + val error: ApiError? = null +) + +data class ApiError( + val code: String? = null, + val message: String +) + +data class QuestionnaireListItem( + val id: String, + val name: String, + val showPoints: Boolean, + val condition: Map = emptyMap() +) + +data class QuestionnaireDetail( + val meta: QuestionnaireMeta, + val questions: List +) + +data class QuestionnaireMeta( + val id: String +) + +data class Question( + val id: String, + val layout: String, + val question: String, + val options: List? = null, + val pointsMap: Map? = null, + val textKey: String? = null, + val textKey1: String? = null, + val textKey2: String? = null, + val hint: String? = null, + val hint1: String? = null, + val hint2: String? = null, + val symptoms: List? = null, + val range: Map? = null, + val constraints: Map? = null, + val minSelection: Int? = null +) + +data class QuestionOption( + val key: String, + val nextQuestionId: String? = null +) + +data class TranslationsResponse( + val translations: Map> +) + +data class SubmitQuestionnaireRequest( + val questionnaireID: String, + val clientCode: String, + val startedAt: Long? = null, + val completedAt: Long? = null, + val answers: List +) + +data class SubmitAnswer( + val questionID: String, + val answerOptionKey: String? = null, + val freeTextValue: String? = null, + val numericValue: Double? = null, + val answeredAt: Long? = null +) + +data class SubmitQuestionnaireResponse( + val submitted: Boolean, + val sumPoints: Int +) + +data class ClientsResponse( + val clients: List +) +``` + +Example Retrofit service: + +```kotlin +interface QuestionnaireApi { + @POST("auth/login") + suspend fun login(@Body body: LoginRequest): ApiEnvelope + + @GET("app_questionnaires") + suspend fun getAssignedClients( + @Header("Authorization") bearerToken: String, + @Query("clients") clients: Int = 1 + ): ApiEnvelope + + @GET("app_questionnaires") + suspend fun getQuestionnaires( + @Header("Authorization") bearerToken: String + ): ApiEnvelope> + + @GET("app_questionnaires") + suspend fun getQuestionnaire( + @Header("Authorization") bearerToken: String, + @Query("id") questionnaireId: String + ): ApiEnvelope + + @GET("app_questionnaires") + suspend fun getTranslations( + @Header("Authorization") bearerToken: String, + @Query("translations") translations: Int = 1 + ): ApiEnvelope + + @POST("app_questionnaires") + suspend fun submitQuestionnaire( + @Header("Authorization") bearerToken: String, + @Body body: SubmitQuestionnaireRequest + ): ApiEnvelope +} +``` + +Pass the token as `Bearer $token`. diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 97278d7..0720a44 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -4,6 +4,7 @@ * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> all translations merged across all tables + * GET ?clients=1 -> list of clients assigned to the authenticated coach * POST -> submit interview answers for a client (coach / supervisor / admin) */ @@ -158,8 +159,27 @@ if ($method !== 'GET') { [$pdo, $tmpDb, $lockFp] = qdb_open(false); -$qnID = $_GET['id'] ?? ''; +$qnID = $_GET['id'] ?? ''; $translations = $_GET['translations'] ?? ''; +$fetchClients = $_GET['clients'] ?? ''; + +if ($fetchClients) { + require_role(['coach'], $tokenRec); + + $coachID = $tokenRec['entityID'] ?? ''; + + $stmt = $pdo->prepare(" + SELECT clientCode + FROM client + WHERE coachID = :cid + ORDER BY clientCode + "); + $stmt->execute([':cid' => $coachID]); + $clients = $stmt->fetchAll(PDO::FETCH_COLUMN); + + qdb_discard($tmpDb, $lockFp); + json_success(['coachID' => $coachID, 'clients' => $clients]); +} if ($translations) { $result = [];