Files
Barometer/android-questionnaire-api.md

12 KiB

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:
{
  "ok": true,
  "data": {}
}
  • Error responses use:
{
  "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:

{
  "username": "coach1",
  "password": "secret"
}

Success:

{
  "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:

Authorization: Bearer <token>

Success:

{
  "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:

{
  "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 Translations

GET /api/app_questionnaires?translations=1

Authentication: not required (login screen).

Success:

{
  "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. Questionnaire content translations are returned on each GET ?id=<questionnaireID> response in a sibling translations object.

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.
  1. On app start (login screen), fetch app UI translations with GET /api/app_questionnaires?translations=1 (no token).
  2. Login with /api/auth/login and store the returned token securely.
  3. Fetch the active questionnaire list with GET /api/app_questionnaires.
  4. For each list item, fetch details with GET /api/app_questionnaires?id=<id>.
  5. Cache translations, the list, and questionnaire details locally for offline use.
  6. When a questionnaire is completed, upload answers with POST /api/app_questionnaires (plaintext JSON over HTTPS).

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 API traffic uses HTTPS plus AES-256-CBC envelopes keyed from the Bearer token (clientsPayload on login, encrypted ?clients=1 responses, encrypted upload POST). Questionnaire definitions and UI translations stay plain JSON. See nat-as-server/docs/android-questionnaire-api.md for the wire format.

Re-uploading the same clientCode + questionnaireID updates server rows in place (supports correction / re-edit workflows).

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:

{
  "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:

{
  "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:

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
)

Example Retrofit service:

interface QuestionnaireApi {
    @POST("auth/login")
    suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginResponse>

    @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.