Files
nat-as-server/docs/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 Assigned Clients

GET /api/app_questionnaires?clients=1

Allowed roles: coach only.

Returns the list of client codes assigned to the authenticated coach.

Headers:

Authorization: Bearer <token>

Success:

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

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

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

data class ClientsResponse(
    val clients: List<String>
)

Example Retrofit service:

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.