Files
nat-as-server/docs/android-questionnaire-api.md
2026-05-27 18:32:00 +02:00

580 lines
17 KiB
Markdown

# 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://<server-host>/api`
- JSON header: `Content-Type: application/json; charset=UTF-8`
- Auth header after login: `Authorization: Bearer <token>`
- 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",
"clients": [
{ "clientCode": "CLIENT-001" },
{ "clientCode": "CLIENT-002" }
]
}
}
```
For `coach` accounts, `clients` lists client codes currently assigned to that coach (`client.coachID`). Other roles omit this field or return an empty array.
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.
### Change password
`POST /api/auth/change-password`
Headers:
```http
Authorization: Bearer <token>
```
The token may be a normal session token or the temporary token returned when `mustChangePassword` is true.
Request:
```json
{
"username": "coach1",
"old_password": "temporary-or-current-password",
"new_password": "new-secret"
}
```
Success:
```json
{
"ok": true,
"data": {
"token": "64-character-hex-token",
"user": "coach1",
"role": "coach"
}
}
```
The server revokes the previous token and returns a new full session token. `new_password` must be at least 6 characters.
Common failures:
- `400 MISSING_FIELDS`: required fields missing.
- `400 PASSWORD_TOO_SHORT`: new password too short.
- `401 INVALID_CREDENTIALS`: old password incorrect.
- `403`: invalid, expired, or mismatched token.
There is no self-service signup endpoint. Coach accounts are created on the web by administrators.
## Fetch Questionnaires
### Fetch Active Questionnaire List
`GET /api/app_questionnaires`
Headers:
```http
Authorization: Bearer <token>
```
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=<questionnaireID>`
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 <token>
```
Success:
```json
{
"ok": true,
"data": {
"clients": [
{
"clientCode": "CLIENT-001",
"completedQuestionnaires": [
{
"questionnaireID": "questionnaire_1_demographic_information",
"sumPoints": 42,
"completedAt": 1714471800
}
]
},
{
"clientCode": "CLIENT-002",
"completedQuestionnaires": []
}
]
}
}
```
Fields:
- `clients`: array of client objects assigned to the coach, ordered alphabetically by `clientCode`.
- `clients[].clientCode`: the client's code string.
- `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none.
- `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint.
- `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire.
- `clients[].completedQuestionnaires[].completedAt`: Unix timestamp (seconds) of when the questionnaire was completed, or `null` if not recorded.
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`
**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:
```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 of **app UI strings only** (screens, toasts, auth labels, etc.). Questionnaire question/option text is **not** included here.
Android lookup for app UI:
1. Load `translations[activeLanguage]`.
2. Look up the string key (e.g. `save`, `client_code`).
3. If missing, fall back to embedded defaults in the app.
## Questionnaire detail translations
`GET /api/app_questionnaires?id=<questionnaireID>` includes a sibling `translations` object with the same shape (language code → key → text), scoped to that questionnaire only:
- question default text (`question` field values)
- answer option default text (`options[].key`)
- questionnaire config string keys (`textKey`, hints, symptoms, etc.), excluding global app UI catalog keys
Android should apply this map while a questionnaire is open (overlay on top of app UI translations), then clear it when leaving the questionnaire.
## Recommended Android Sync Flow
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=<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` using an **encrypted** JSON envelope (see above).
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).
Sensitive mobile traffic uses **two layers**:
1. **HTTPS** (TLS) for transport.
2. **Application payload encryption** for patient-related fields: AES-256-CBC with a per-session key derived from the Bearer token via HKDF-SHA256 (`info` = `qdb-aes`), matching the legacy QDB mobile crypto helpers.
Encrypted wire format (request or response `data`):
```json
{
"encrypted": true,
"payload": "<base64( IV16 || ciphertext )>"
}
```
Endpoints using encrypted payloads:
| Endpoint | Direction |
|----------|-----------|
| `POST /api/auth/login` | Response: `clientsPayload` (when coach has clients) |
| `GET /api/app_questionnaires?clients=1` | Response: entire `data` object encrypted |
| `POST /api/app_questionnaires` | Request body must be encrypted |
Plain JSON (no app-layer encryption): `GET ?translations=1`, questionnaire list, questionnaire detail.
The server decrypts uploads before writing to the database. The web dashboard reads **server-side** plaintext (RBAC). The Android app also keeps **AES-GCM at rest** on device via Android Keystore (separate from wire encryption).
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`
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 (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.
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<T>(
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<String, Any?> = emptyMap()
)
data class QuestionnaireDetail(
val meta: QuestionnaireMeta,
val questions: List<Question>
)
data class QuestionnaireMeta(
val id: String
)
data class Question(
val id: String,
val layout: String,
val question: String,
val options: List<QuestionOption>? = null,
val pointsMap: Map<String, Int>? = 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<String>? = null,
val range: Map<String, Double>? = null,
val constraints: Map<String, Any?>? = null,
val minSelection: Int? = null
)
data class QuestionOption(
val key: String,
val nextQuestionId: String? = null
)
data class TranslationsResponse(
val translations: Map<String, Map<String, String>>
)
data class SubmitQuestionnaireRequest(
val questionnaireID: String,
val clientCode: String,
val startedAt: Long? = null,
val completedAt: Long? = null,
val answers: List<SubmitAnswer>
)
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<ClientInfo>
)
data class ClientInfo(
val clientCode: String,
val completedQuestionnaires: List<CompletedQuestionnaire> = emptyList()
)
data class CompletedQuestionnaire(
val questionnaireID: String,
val sumPoints: Int,
val completedAt: Long?
)
```
Example Retrofit service:
```kotlin
interface QuestionnaireApi {
@POST("auth/login")
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginResponse>
@GET("app_questionnaires")
suspend fun getAssignedClients(
@Header("Authorization") bearerToken: String,
@Query("clients") clients: Int = 1
): ApiEnvelope<ClientsResponse>
@GET("app_questionnaires")
suspend fun getQuestionnaires(
@Header("Authorization") bearerToken: String
): ApiEnvelope<List<QuestionnaireListItem>>
@GET("app_questionnaires")
suspend fun getQuestionnaire(
@Header("Authorization") bearerToken: String,
@Query("id") questionnaireId: String
): ApiEnvelope<QuestionnaireDetail>
@GET("app_questionnaires")
suspend fun getTranslations(
@Header("Authorization") bearerToken: String,
@Query("translations") translations: Int = 1
): ApiEnvelope<TranslationsResponse>
@POST("app_questionnaires")
suspend fun submitQuestionnaire(
@Header("Authorization") bearerToken: String,
@Body body: SubmitQuestionnaireRequest
): ApiEnvelope<SubmitQuestionnaireResponse>
}
```
Pass the token as `Bearer $token`.