548 lines
15 KiB
Markdown
548 lines
15 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`: when the questionnaire was completed on the device (milliseconds), or `null`.
|
|
- `clients[].completedQuestionnaires[].uploadedAt`: when the latest version was received on the server (milliseconds), or `null`.
|
|
|
|
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=<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.
|
|
|
|
Upload behavior:
|
|
|
|
- Unknown or empty `questionID` values are skipped.
|
|
- Unknown `answerOptionKey` values are stored as no selected option for that question.
|
|
- Each successful POST **appends** an immutable `questionnaire_submission` record (version increments per client + questionnaire).
|
|
- The latest answers are also written to the `client_answer` cache used by web results/CSV (always shows newest only).
|
|
- `uploadedAt` is set by the server at POST time (milliseconds); do not send it from the client.
|
|
- `completedAt` in the request is the device completion time; it is stored separately from `uploadedAt`.
|
|
- `sumPoints` is calculated from recognized `answerOptionKey` values.
|
|
- Coaches may re-open, edit, and POST again; each upload creates a new submission version.
|
|
|
|
Success:
|
|
|
|
```json
|
|
{
|
|
"ok": true,
|
|
"data": {
|
|
"submitted": true,
|
|
"sumPoints": 0,
|
|
"submissionID": "sub_…",
|
|
"uploadedAt": 1714471800123,
|
|
"submissionVersion": 2
|
|
}
|
|
}
|
|
```
|
|
|
|
## Fetch Latest Answers (re-edit)
|
|
|
|
`GET /api/app_questionnaires?clientCode={code}&questionnaireID={id}&answers=1`
|
|
|
|
Returns the **latest** cached answers for a client/questionnaire in the same shape as POST `answers[]`. Use before reopening a completed questionnaire on the tablet.
|
|
|
|
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`. |