diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 528acce..c3da277 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -1,6 +1,6 @@ # API Coverage -> 139 of 139 API operations covered (100%): 136 direct CLI commands + 3 used internally — source: `docs/public_api.json` +> 159 of 159 API operations covered (100%): 156 direct CLI commands + 3 used internally — source: `docs/public_api.json` > > PATCH and PUT are collapsed into a single "update" operation throughout. > ✅ = direct CLI command · ⚠️ = used internally · ❌ = not implemented @@ -257,6 +257,43 @@ Sub-item commands take `--screen-id` and `--section-id` flags for list/add/reord | PATCH | `/api/public/screens/{id}/story_sections/{section_id}/social_items/{item_id}` | `stqry screens sections social update ` | ✅ | | DELETE | `/api/public/screens/{id}/story_sections/{section_id}/social_items/{item_id}` | `stqry screens sections social remove ` | ✅ | +## Quizzes + +Quizzes are a three-level hierarchy: `quiz` → `questions` → `answers`. Questions and answers take positional parent IDs (``, then ``), mirroring `collections items`. Reorder takes the parent IDs followed by child IDs in the desired order; positions are 0-based here (the quiz `update_positions` concern writes literal positions), unlike the 1-based `collections items reorder`. + +| Method | Endpoint | CLI Command | Status | +|--------|----------|-------------|--------| +| GET | `/api/public/quizzes` | `stqry quizzes list` | ✅ | +| POST | `/api/public/quizzes` | `stqry quizzes create` | ✅ | +| GET | `/api/public/quizzes/{id}` | `stqry quizzes get ` | ✅ | +| PATCH | `/api/public/quizzes/{id}` | `stqry quizzes update ` | ✅ | +| DELETE | `/api/public/quizzes/{id}` | `stqry quizzes delete ` | ✅ | +| GET | `/api/public/quizzes/{id}/appears_in` | `stqry quizzes appears-in ` | ✅ | + +### Quiz Questions + +| Method | Endpoint | CLI Command | Status | +|--------|----------|-------------|--------| +| GET | `/api/public/quizzes/{quiz_id}/questions` | `stqry quizzes questions list ` | ✅ | +| POST | `/api/public/quizzes/{quiz_id}/questions` | `stqry quizzes questions add ` | ✅ | +| POST | `/api/public/quizzes/{quiz_id}/questions/update_positions` | `stqry quizzes questions reorder ` | ✅ | +| GET | `/api/public/quizzes/{quiz_id}/questions/{id}` | `stqry quizzes questions get ` | ✅ | +| PATCH | `/api/public/quizzes/{quiz_id}/questions/{id}` | `stqry quizzes questions update ` | ✅ | +| DELETE | `/api/public/quizzes/{quiz_id}/questions/{id}` | `stqry quizzes questions remove ` | ✅ | +| GET | `/api/public/quizzes/{quiz_id}/questions/{id}/appears_in` | `stqry quizzes questions appears-in ` | ✅ | + +### Quiz Question Answers + +| Method | Endpoint | CLI Command | Status | +|--------|----------|-------------|--------| +| GET | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers` | `stqry quizzes questions answers list ` | ✅ | +| POST | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers` | `stqry quizzes questions answers add ` | ✅ | +| POST | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/update_positions` | `stqry quizzes questions answers reorder ` | ✅ | +| GET | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/{id}` | `stqry quizzes questions answers get ` | ✅ | +| PATCH | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/{id}` | `stqry quizzes questions answers update ` | ✅ | +| DELETE | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/{id}` | `stqry quizzes questions answers remove ` | ✅ | +| GET | `/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/{id}/appears_in` | `stqry quizzes questions answers appears-in ` | ✅ | + ## Uploaded Files > Metadata-only CRUD is exposed via `stqry uploaded-files`. The presigned/process endpoints stay internal — they're orchestrated by `stqry media upload`, which is the right command when you have actual binary content to upload. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba8acc..faacb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `stqry quizzes` — full CRUD for the Quizzes Public API across the quiz → questions → answers hierarchy, with matching MCP tools, shell completion, and reference/coverage docs. Translated fields respect `--lang`, `--question-type` is validated client-side, answers carry a `--correct` flag, and `delete`/`remove` accept `--lang` for per-locale translation deletes. + ## [0.10.33] - 2026-05-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4c3f7ca..d473924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,5 +99,6 @@ Media types: `map`, `webpackage`, `animation`, `audio`, `image`, `video`, `webvi Collection types, tour types, geofence modes — `internal/cli/collections.go` Section types, header layouts, link types, social networks, link-item types, section layouts, text-position, map-type — `internal/cli/screens.go` Project subtypes (`Project::App` / `Kiosk` / `Signage`) — `internal/cli/projects.go` (`validProjectableTypes`) +Quiz question types (`single_text_choice`, `single_image_choice`, `multi_text_choice`, `multi_image_choice`, `free_text`) — `internal/cli/quizzes.go` (`validQuizQuestionTypes`) Region→URL mapping (`us`, `ca`, `eu`, `sg`, `au`) — `internal/cli/config.go` (`var regionURLs`); add new regions there. Supported content languages — defined in `internal/api/languages_gen.go`. Validated by `api.ValidateLanguage`. diff --git a/docs/public_api.json b/docs/public_api.json index c8c3de2..5619968 100644 --- a/docs/public_api.json +++ b/docs/public_api.json @@ -57,6 +57,15 @@ { "name": "Projects" }, + { + "name": "QuizQuestionAnswers" + }, + { + "name": "QuizQuestions" + }, + { + "name": "Quizzes" + }, { "name": "Screens" }, @@ -8124,78 +8133,2451 @@ } } } - } - }, - "components": { - "securitySchemes": { - "ApiKeyAuth": { - "type": "apiKey", - "in": "header", - "name": "X-Api-Token" - } }, - "schemas": { - "Timezone": { - "type": "string", - "description": "Timezone as defined by TZInfo" - }, - "Date": { - "oneOf": [ + "/api/public/quizzes": { + "get": { + "summary": "Returns a list of quizzes for an account", + "operationId": "QuizzesIndex", + "tags": [ + "Quizzes" + ], + "parameters": [ { - "type": "string", - "format": "date", - "example": "2021-01-01" + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Page number" + }, + "description": "Page number" }, { - "type": "null" - } - ] - }, - "DateTime": { - "oneOf": [ + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 1000, + "description": "Number of items per page, max 1000" + }, + "description": "Number of items per page, max 1000" + }, { - "type": "string", - "format": "date-time", - "example": "2017-07-21T17:32:28Z" + "name": "sort_field", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "id", + "name", + "created_at", + "updated_at" + ], + "description": "Field to sort by" + }, + "description": "Field to sort by" }, { - "type": "null" + "name": "sort_direction", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "ASC", + "enum": [ + "ASC", + "DESC" + ], + "description": "Direction to sort by" + }, + "description": "Direction to sort by" + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Search query" + }, + "description": "Search query" } - ] + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizzesIndexResponse", + "properties": { + "meta": { + "$ref": "#/components/schemas/Pagination" + }, + "quizzes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Quiz" + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } }, - "TagList": { - "type": "array", - "items": { - "type": "string" + "post": { + "summary": "Creates a new quiz", + "operationId": "QuizzesCreate", + "tags": [ + "Quizzes" + ], + "requestBody": { + "required": true, + "description": "Quiz", + "content": { + "application/json": { + "schema": { + "title": "CreateQuizBody", + "$ref": "#/components/schemas/CreateQuizInput" + } + } + } }, - "description": "Array of tags to help orgainse and search" - }, - "Pagination": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "example": 1 + "responses": { + "201": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizzesCreateResponse", + "properties": { + "quiz": { + "$ref": "#/components/schemas/Quiz" + } + } + } + } + } }, - "pages": { - "type": "integer", - "example": 10 + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } }, - "per_page": { - "type": "integer", - "example": 30 + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } }, - "count": { - "type": "integer", - "example": 305 + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } } - }, - "required": [ - "page", - "pages", - "per_page", - "count" - ], + } + } + }, + "/api/public/quizzes/{id}": { + "get": { + "summary": "Returns a single quiz", + "operationId": "QuizzesShow", + "tags": [ + "Quizzes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the Quiz to retrieve" + }, + "description": "ID of the Quiz to retrieve" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizzesShowResponse", + "properties": { + "quiz": { + "$ref": "#/components/schemas/Quiz" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "patch": { + "summary": "Updates an existing quiz", + "operationId": "QuizzesUpdate", + "tags": [ + "Quizzes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the Quiz to update" + }, + "description": "ID of the Quiz to update" + } + ], + "requestBody": { + "required": true, + "description": "Quiz", + "content": { + "application/json": { + "schema": { + "title": "UpdateQuizBody", + "$ref": "#/components/schemas/UpdateQuizInput" + } + } + } + }, + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizzesUpdateResponse", + "properties": { + "quiz": { + "$ref": "#/components/schemas/Quiz" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "put": { + "summary": "Updates an existing quiz", + "operationId": "QuizzesUpdate", + "tags": [ + "Quizzes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the Quiz to update" + }, + "description": "ID of the Quiz to update" + } + ], + "requestBody": { + "required": true, + "description": "Quiz", + "content": { + "application/json": { + "schema": { + "title": "UpdateQuizBody", + "$ref": "#/components/schemas/UpdateQuizInput" + } + } + } + }, + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizzesUpdateResponse", + "properties": { + "quiz": { + "$ref": "#/components/schemas/Quiz" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "delete": { + "summary": "Deletes an existing quiz", + "operationId": "QuizzesDestroy", + "tags": [ + "Quizzes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the Quiz to delete" + }, + "description": "ID of the Quiz to delete" + }, + { + "name": "language", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Language locale to delete (deletes only that translation instead of the whole quiz)" + }, + "description": "Language locale to delete (deletes only that translation instead of the whole quiz)" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizzesDestroyTranslationResponse", + "properties": { + "quiz": { + "$ref": "#/components/schemas/Quiz" + } + } + } + } + } + }, + "204": { + "description": "success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{id}/appears_in": { + "get": { + "summary": "Get appears_in graph for a Quiz", + "operationId": "QuizzesAppearsIn", + "tags": [ + "Quizzes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the Quiz" + }, + "description": "ID of the Quiz" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "title": "QuizzesAppearsInResponse", + "$ref": "#/components/schemas/AppearsInPartial" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions": { + "get": { + "summary": "Returns a list of quiz questions for a quiz", + "operationId": "QuizQuestionsIndex", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Page number" + }, + "description": "Page number" + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 1000, + "description": "Number of items per page, max 1000" + }, + "description": "Number of items per page, max 1000" + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Search query (matches name)" + }, + "description": "Search query (matches name)" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsIndexResponse", + "properties": { + "meta": { + "$ref": "#/components/schemas/Pagination" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuizQuestion" + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "post": { + "summary": "Creates a new quiz question", + "operationId": "QuizQuestionsCreate", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + } + ], + "requestBody": { + "required": true, + "description": "QuizQuestion", + "content": { + "application/json": { + "schema": { + "title": "CreateQuizQuestionBody", + "$ref": "#/components/schemas/QuizQuestionInput" + } + } + } + }, + "responses": { + "201": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsCreateResponse", + "properties": { + "question": { + "$ref": "#/components/schemas/QuizQuestion" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/{id}": { + "get": { + "summary": "Returns a single quiz question", + "operationId": "QuizQuestionsShow", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestion to retrieve" + }, + "description": "ID of the QuizQuestion to retrieve" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsShowResponse", + "properties": { + "question": { + "$ref": "#/components/schemas/QuizQuestion" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "patch": { + "summary": "Updates an existing quiz question", + "operationId": "QuizQuestionsUpdate", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestion to update" + }, + "description": "ID of the QuizQuestion to update" + } + ], + "requestBody": { + "required": true, + "description": "QuizQuestion", + "content": { + "application/json": { + "schema": { + "title": "UpdateQuizQuestionBody", + "$ref": "#/components/schemas/QuizQuestionInput" + } + } + } + }, + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsUpdateResponse", + "properties": { + "question": { + "$ref": "#/components/schemas/QuizQuestion" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "put": { + "summary": "Updates an existing quiz question", + "operationId": "QuizQuestionsUpdate", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestion to update" + }, + "description": "ID of the QuizQuestion to update" + } + ], + "requestBody": { + "required": true, + "description": "QuizQuestion", + "content": { + "application/json": { + "schema": { + "title": "UpdateQuizQuestionBody", + "$ref": "#/components/schemas/QuizQuestionInput" + } + } + } + }, + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsUpdateResponse", + "properties": { + "question": { + "$ref": "#/components/schemas/QuizQuestion" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "delete": { + "summary": "Deletes an existing quiz question", + "operationId": "QuizQuestionsDestroy", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestion to delete" + }, + "description": "ID of the QuizQuestion to delete" + }, + { + "name": "language", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Language locale to delete (deletes only that translation instead of the whole question)" + }, + "description": "Language locale to delete (deletes only that translation instead of the whole question)" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsDestroyTranslationResponse", + "properties": { + "question": { + "$ref": "#/components/schemas/QuizQuestion" + } + } + } + } + } + }, + "204": { + "description": "success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/{id}/appears_in": { + "get": { + "summary": "Get appears_in graph for a QuizQuestion", + "operationId": "QuizQuestionsAppearsIn", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestion" + }, + "description": "ID of the QuizQuestion" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "title": "QuizQuestionsAppearsInResponse", + "$ref": "#/components/schemas/AppearsInPartial" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/update_positions": { + "post": { + "summary": "Updates the positions of questions within a quiz", + "operationId": "QuizQuestionsUpdatePositions", + "tags": [ + "QuizQuestions" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + } + ], + "requestBody": { + "required": true, + "description": "Positions to update", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionsUpdatePositionsBody", + "properties": { + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the QuizQuestion" + }, + "position": { + "type": "integer", + "description": "New position value" + } + }, + "required": [ + "id", + "position" + ] + }, + "description": "Array of question position updates" + } + }, + "required": [ + "positions" + ] + } + } + } + }, + "responses": { + "204": { + "description": "success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/{question_id}/answers": { + "get": { + "summary": "Returns a list of answers for a question", + "operationId": "QuizQuestionAnswersIndex", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Page number" + }, + "description": "Page number" + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 1000, + "description": "Number of items per page, max 1000" + }, + "description": "Number of items per page, max 1000" + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Search query (matches answer_text)" + }, + "description": "Search query (matches answer_text)" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersIndexResponse", + "properties": { + "meta": { + "$ref": "#/components/schemas/Pagination" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuizQuestionAnswer" + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "post": { + "summary": "Creates a new answer", + "operationId": "QuizQuestionAnswersCreate", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + } + ], + "requestBody": { + "required": true, + "description": "QuizQuestionAnswer", + "content": { + "application/json": { + "schema": { + "title": "CreateQuizQuestionAnswerBody", + "$ref": "#/components/schemas/QuizQuestionAnswerInput" + } + } + } + }, + "responses": { + "201": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersCreateResponse", + "properties": { + "answer": { + "$ref": "#/components/schemas/QuizQuestionAnswer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/{id}": { + "get": { + "summary": "Returns a single answer", + "operationId": "QuizQuestionAnswersShow", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestionAnswer to retrieve" + }, + "description": "ID of the QuizQuestionAnswer to retrieve" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersShowResponse", + "properties": { + "answer": { + "$ref": "#/components/schemas/QuizQuestionAnswer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "patch": { + "summary": "Updates an existing answer", + "operationId": "QuizQuestionAnswersUpdate", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestionAnswer to update" + }, + "description": "ID of the QuizQuestionAnswer to update" + } + ], + "requestBody": { + "required": true, + "description": "QuizQuestionAnswer", + "content": { + "application/json": { + "schema": { + "title": "UpdateQuizQuestionAnswerBody", + "$ref": "#/components/schemas/QuizQuestionAnswerInput" + } + } + } + }, + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersUpdateResponse", + "properties": { + "answer": { + "$ref": "#/components/schemas/QuizQuestionAnswer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "put": { + "summary": "Updates an existing answer", + "operationId": "QuizQuestionAnswersUpdate", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestionAnswer to update" + }, + "description": "ID of the QuizQuestionAnswer to update" + } + ], + "requestBody": { + "required": true, + "description": "QuizQuestionAnswer", + "content": { + "application/json": { + "schema": { + "title": "UpdateQuizQuestionAnswerBody", + "$ref": "#/components/schemas/QuizQuestionAnswerInput" + } + } + } + }, + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersUpdateResponse", + "properties": { + "answer": { + "$ref": "#/components/schemas/QuizQuestionAnswer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + }, + "delete": { + "summary": "Deletes an existing answer", + "operationId": "QuizQuestionAnswersDestroy", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestionAnswer to delete" + }, + "description": "ID of the QuizQuestionAnswer to delete" + }, + { + "name": "language", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Language locale to delete (deletes only that translation instead of the whole answer)" + }, + "description": "Language locale to delete (deletes only that translation instead of the whole answer)" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersDestroyTranslationResponse", + "properties": { + "answer": { + "$ref": "#/components/schemas/QuizQuestionAnswer" + } + } + } + } + } + }, + "204": { + "description": "success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/{id}/appears_in": { + "get": { + "summary": "Get appears_in graph for a QuizQuestionAnswer", + "operationId": "QuizQuestionAnswersAppearsIn", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the QuizQuestionAnswer" + }, + "description": "ID of the QuizQuestionAnswer" + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "title": "QuizQuestionAnswersAppearsInResponse", + "$ref": "#/components/schemas/AppearsInPartial" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + }, + "/api/public/quizzes/{quiz_id}/questions/{question_id}/answers/update_positions": { + "post": { + "summary": "Updates the positions of answers within a question", + "operationId": "QuizQuestionAnswersUpdatePositions", + "tags": [ + "QuizQuestionAnswers" + ], + "parameters": [ + { + "name": "quiz_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent Quiz" + }, + "description": "ID of the parent Quiz" + }, + { + "name": "question_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "ID of the parent QuizQuestion" + }, + "description": "ID of the parent QuizQuestion" + } + ], + "requestBody": { + "required": true, + "description": "Positions to update", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "QuizQuestionAnswersUpdatePositionsBody", + "properties": { + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the QuizQuestionAnswer" + }, + "position": { + "type": "integer", + "description": "New position value" + } + }, + "required": [ + "id", + "position" + ] + }, + "description": "Array of answer position updates" + } + }, + "required": [ + "positions" + ] + } + } + } + }, + "responses": { + "204": { + "description": "success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "title": "BadRequestResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "403": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "title": "UnauthorizedResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "title": "UnprocessableContentResponse", + "$ref": "#/components/schemas/Errors" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Token" + } + }, + "schemas": { + "Timezone": { + "type": "string", + "description": "Timezone as defined by TZInfo" + }, + "Date": { + "oneOf": [ + { + "type": "string", + "format": "date", + "example": "2021-01-01" + }, + { + "type": "null" + } + ] + }, + "DateTime": { + "oneOf": [ + { + "type": "string", + "format": "date-time", + "example": "2017-07-21T17:32:28Z" + }, + { + "type": "null" + } + ] + }, + "TagList": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tags to help orgainse and search" + }, + "Pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "pages": { + "type": "integer", + "example": 10 + }, + "per_page": { + "type": "integer", + "example": 30 + }, + "count": { + "type": "integer", + "example": 305 + } + }, + "required": [ + "page", + "pages", + "per_page", + "count" + ], "description": "Pagination information" }, "ContentLanguage": { @@ -8273,32 +10655,476 @@ "zu" ] }, - "TranslatedString": { + "TranslatedString": { + "type": "object", + "example": { + "en": "English text", + "fr": "Texte français" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": "A locale map of translated strings keyed by language code (e.g. { \"en\": \"Hello\", \"fr\": \"Bonjour\" })" + }, + "GpsSettings": { + "type": "object", + "properties": { + "radius": { + "type": "integer", + "description": "Geofence radius in meters" + }, + "angle": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "altitude_max": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "altitude_min": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "geofence_lat": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "geofence_lng": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "geofence_content": { + "type": "boolean", + "description": "Whether geofence triggers content" + } + }, + "description": "GPS geofence settings" + }, + "BeaconSettings": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Beacon UUID" + }, + "major": { + "type": "integer", + "description": "Beacon major value (1-65535)" + }, + "minor": { + "type": "integer", + "description": "Beacon minor value (1-65535)" + }, + "distance": { + "type": "string", + "enum": [ + "near", + "immediate", + "far", + "distance" + ], + "description": "Beacon distance mode" + }, + "meters": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "geofence_content": { + "type": "boolean", + "description": "Whether geofence triggers content" + } + }, + "description": "Beacon geofence settings" + }, + "KioskSettings": { + "type": "object", + "properties": { + "lat": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "lng": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "zoom": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "home_node_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "description": "Kiosk screen settings" + }, + "Errors": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Error" + } + } + }, + "required": [ + "errors" + ], + "description": "Error response" + }, + "Error": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "description": "A Single error object, Consists of a code and a message" + }, + "CodePartial": { + "type": "object", + "properties": { + "linked_id": { + "type": "integer" + }, + "linked_type": { + "type": "string", + "enum": [ + "Project", + "Collection" + ] + }, + "coupon_code": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "valid_from": { + "$ref": "#/components/schemas/Date" + }, + "valid_to": { + "$ref": "#/components/schemas/Date" + }, + "max_redemptions": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "expire_after": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "timezone": { + "$ref": "#/components/schemas/Timezone" + }, + "tags": { + "$ref": "#/components/schemas/TagList" + } + }, + "description": "Writable code fields" + }, + "Code": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "expired", + "future", + "valid", + "invalid", + "collection_unlocked", + "current", + "max_redemptions", + "requires_email" + ] + }, + "redemption_count": { + "type": "integer" + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "status", + "redemption_count", + "created_at", + "updated_at" + ] + }, + { + "required": [ + "linked_id", + "linked_type", + "coupon_code", + "project_id" + ], + "$ref": "#/components/schemas/CodePartial" + } + ] + }, + "CreateCodeInput": { "type": "object", - "example": { - "en": "English text", - "fr": "Texte français" + "properties": { + "code": { + "allOf": [ + { + "required": [ + "linked_id", + "linked_type", + "coupon_code", + "project_id" + ], + "$ref": "#/components/schemas/CodePartial" + } + ] + } }, - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "required": [ + "code" + ], + "description": "Request body for creating a code" + }, + "UpdateCodeInput": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/CodePartial" + } }, - "description": "A locale map of translated strings keyed by language code (e.g. { \"en\": \"Hello\", \"fr\": \"Bonjour\" })" + "required": [ + "code" + ], + "description": "Request body for updating a code" }, - "GpsSettings": { + "Project": { "type": "object", "properties": { - "radius": { - "type": "integer", - "description": "Geofence radius in meters" + "id": { + "type": "integer" }, - "angle": { + "name": { + "type": "string" + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "name", + "created_at", + "updated_at" + ], + "description": "A single project" + }, + "CollectionPartial": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_language": { + "$ref": "#/components/schemas/ContentLanguage" + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "short_title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "description": { + "$ref": "#/components/schemas/TranslatedString" + }, + "code_title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "code_text": { + "$ref": "#/components/schemas/TranslatedString" + }, + "file_size": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "stqry_directory_description": { + "$ref": "#/components/schemas/TranslatedString" + }, + "stqry_directory_about": { + "$ref": "#/components/schemas/TranslatedString" + }, + "stqry_directory_tips": { + "$ref": "#/components/schemas/TranslatedString" + }, + "header_layout": { + "type": "string", + "enum": [ + "image_and_title", + "image", + "none", + "short", + "tall" + ] + }, + "list_layout": { + "type": "string", + "enum": [ + "wide", + "grid", + "list" + ] + }, + "tour_mode": { + "type": "string", + "enum": [ + "self_guided", + "guided", + "auto_play" + ] + }, + "initial_layout": { + "type": "string", + "enum": [ + "default", + "map" + ] + }, + "map_view_enabled": { + "type": "boolean" + }, + "search_enabled": { + "type": "boolean" + }, + "keypad_enabled": { + "type": "boolean" + }, + "qr_enabled": { + "type": "boolean" + }, + "sort_by": { + "type": "string", + "enum": [ + "list_order", + "alphabetical", + "distance" + ] + }, + "code_lock": { + "type": "boolean" + }, + "cover_image_media_item_id": { "oneOf": [ { "type": "integer" @@ -8308,7 +11134,7 @@ } ] }, - "altitude_max": { + "cover_image_grid_media_item_id": { "oneOf": [ { "type": "integer" @@ -8318,7 +11144,7 @@ } ] }, - "altitude_min": { + "cover_image_wide_media_item_id": { "oneOf": [ { "type": "integer" @@ -8328,211 +11154,397 @@ } ] }, - "geofence_lat": { + "code_background_media_item_id": { "oneOf": [ { - "type": "number", - "format": "float" + "type": "integer" }, { "type": "null" } ] }, - "geofence_lng": { + "logo_media_item_id": { "oneOf": [ { - "type": "number", - "format": "float" + "type": "integer" }, { "type": "null" } ] - }, - "geofence_content": { - "type": "boolean", - "description": "Whether geofence triggers content" } }, - "description": "GPS geofence settings" + "description": "Writable collection fields" }, - "BeaconSettings": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Beacon UUID" + "Collection": { + "oneOf": [ + { + "$ref": "#/components/schemas/CollectionList" }, - "major": { - "type": "integer", - "description": "Beacon major value (1-65535)" + { + "$ref": "#/components/schemas/CollectionTour" }, - "minor": { - "type": "integer", - "description": "Beacon minor value (1-65535)" + { + "$ref": "#/components/schemas/CollectionOrganization" }, - "distance": { - "type": "string", - "enum": [ - "near", - "immediate", - "far", - "distance" + { + "$ref": "#/components/schemas/CollectionMenu" + }, + { + "$ref": "#/components/schemas/CollectionSearch" + } + ], + "description": "A single collection" + }, + "CollectionList": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" + ] + }, + { + "required": [ + "name", + "primary_language", + "header_layout", + "list_layout", + "tour_mode", + "initial_layout", + "map_view_enabled", + "search_enabled", + "keypad_enabled", + "qr_enabled", + "sort_by", + "code_lock" ], - "description": "Beacon distance mode" + "$ref": "#/components/schemas/CollectionPartial" }, - "meters": { - "oneOf": [ - { + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "preview_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "CollectionTour": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "integer" }, - { - "type": "null" + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" + ] + }, + { + "required": [ + "name", + "primary_language", + "header_layout", + "list_layout", + "tour_mode", + "initial_layout", + "map_view_enabled", + "search_enabled", + "keypad_enabled", + "qr_enabled", + "sort_by", + "code_lock" + ], + "$ref": "#/components/schemas/CollectionPartial" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tour" + ] + }, + "tour_type": { + "type": "string", + "enum": [ + "walking", + "4wd", + "aboriginal_site", + "airplane", + "bus", + "canoeing", + "cycling", + "driving", + "gallery", + "helicopter", + "historic_house", + "horse_trail", + "museum", + "nature_trail", + "scavenger_hunt", + "ship", + "train" + ], + "description": "Tour type" + }, + "tour_subtype": { + "type": "string", + "enum": [ + "grade_1", + "grade_2", + "grade_3", + "grade_4", + "grade_5" + ], + "description": "Tour subtype" + }, + "audio_mode": { + "type": "string", + "enum": [ + "replace", + "skip", + "queue" + ], + "description": "Audio mode" + }, + "audio_preview_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "length_min": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "length_max": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "distance": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "distance_loop": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "stop_count": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] } - ] - }, - "geofence_content": { - "type": "boolean", - "description": "Whether geofence triggers content" + } } - }, - "description": "Beacon geofence settings" + ] }, - "KioskSettings": { - "type": "object", - "properties": { - "lat": { - "oneOf": [ - { - "type": "number", - "format": "float" + "CollectionOrganization": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - { - "type": "null" - } - ] - }, - "lng": { - "oneOf": [ - { - "type": "number", - "format": "float" + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } }, - { - "type": "null" - } - ] - }, - "zoom": { - "oneOf": [ - { - "type": "integer" + "created_at": { + "$ref": "#/components/schemas/DateTime" }, - { - "type": "null" + "updated_at": { + "$ref": "#/components/schemas/DateTime" } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" ] }, - "home_node_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" + { + "required": [ + "name", + "primary_language", + "header_layout", + "list_layout", + "tour_mode", + "initial_layout", + "map_view_enabled", + "search_enabled", + "keypad_enabled", + "qr_enabled", + "sort_by", + "code_lock" + ], + "$ref": "#/components/schemas/CollectionPartial" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "organization" + ] } - ] - } - }, - "description": "Kiosk screen settings" - }, - "Errors": { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Error" } } - }, - "required": [ - "errors" - ], - "description": "Error response" - }, - "Error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "description": "A Single error object, Consists of a code and a message" + ] }, - "CodePartial": { - "type": "object", - "properties": { - "linked_id": { - "type": "integer" - }, - "linked_type": { - "type": "string", - "enum": [ - "Project", - "Collection" - ] - }, - "coupon_code": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "valid_from": { - "$ref": "#/components/schemas/Date" - }, - "valid_to": { - "$ref": "#/components/schemas/Date" - }, - "max_redemptions": { - "oneOf": [ - { + "CollectionMenu": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "integer" }, - { - "type": "null" - } - ] - }, - "expire_after": { - "oneOf": [ - { - "type": "integer" + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } }, - { - "type": "null" + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" ] }, - "timezone": { - "$ref": "#/components/schemas/Timezone" + { + "required": [ + "name", + "primary_language", + "header_layout", + "list_layout", + "tour_mode", + "initial_layout", + "map_view_enabled", + "search_enabled", + "keypad_enabled", + "qr_enabled", + "sort_by", + "code_lock" + ], + "$ref": "#/components/schemas/CollectionPartial" }, - "tags": { - "$ref": "#/components/schemas/TagList" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "menu" + ] + } + } } - }, - "description": "Writable code fields" + ] }, - "Code": { + "CollectionSearch": { "allOf": [ { "type": "object", @@ -8540,22 +11552,12 @@ "id": { "type": "integer" }, - "status": { - "type": "string", - "enum": [ - "expired", - "future", - "valid", - "invalid", - "collection_unlocked", - "current", - "max_redemptions", - "requires_email" - ] - }, - "redemption_count": { - "type": "integer" - }, + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } + }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -8565,257 +11567,144 @@ }, "required": [ "id", - "status", - "redemption_count", + "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "linked_id", - "linked_type", - "coupon_code", - "project_id" + "name", + "primary_language", + "header_layout", + "list_layout", + "tour_mode", + "initial_layout", + "map_view_enabled", + "search_enabled", + "keypad_enabled", + "qr_enabled", + "sort_by", + "code_lock" ], - "$ref": "#/components/schemas/CodePartial" - } - ] - }, - "CreateCodeInput": { - "type": "object", - "properties": { - "code": { - "allOf": [ - { - "required": [ - "linked_id", - "linked_type", - "coupon_code", - "project_id" - ], - "$ref": "#/components/schemas/CodePartial" - } - ] - } - }, - "required": [ - "code" - ], - "description": "Request body for creating a code" - }, - "UpdateCodeInput": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/CodePartial" - } - }, - "required": [ - "code" - ], - "description": "Request body for updating a code" - }, - "Project": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" - }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" - } - }, - "required": [ - "id", - "name", - "created_at", - "updated_at" - ], - "description": "A single project" - }, - "CollectionPartial": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "primary_language": { - "$ref": "#/components/schemas/ContentLanguage" - }, - "title": { - "$ref": "#/components/schemas/TranslatedString" - }, - "short_title": { - "$ref": "#/components/schemas/TranslatedString" - }, - "description": { - "$ref": "#/components/schemas/TranslatedString" - }, - "code_title": { - "$ref": "#/components/schemas/TranslatedString" - }, - "code_text": { - "$ref": "#/components/schemas/TranslatedString" + "$ref": "#/components/schemas/CollectionPartial" }, - "file_size": { + { "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - } - }, - "stqry_directory_description": { - "$ref": "#/components/schemas/TranslatedString" - }, - "stqry_directory_about": { - "$ref": "#/components/schemas/TranslatedString" - }, - "stqry_directory_tips": { - "$ref": "#/components/schemas/TranslatedString" - }, - "header_layout": { - "type": "string", - "enum": [ - "image_and_title", - "image", - "none", - "short", - "tall" - ] - }, - "list_layout": { - "type": "string", - "enum": [ - "wide", - "grid", - "list" - ] - }, - "tour_mode": { - "type": "string", - "enum": [ - "self_guided", - "guided", - "auto_play" - ] - }, - "initial_layout": { - "type": "string", - "enum": [ - "default", - "map" - ] - }, - "map_view_enabled": { - "type": "boolean" - }, - "search_enabled": { - "type": "boolean" - }, - "keypad_enabled": { - "type": "boolean" - }, - "qr_enabled": { - "type": "boolean" - }, - "sort_by": { - "type": "string", - "enum": [ - "list_order", - "alphabetical", - "distance" - ] - }, - "code_lock": { - "type": "boolean" - }, - "cover_image_media_item_id": { - "oneOf": [ - { - "type": "integer" + "properties": { + "type": { + "type": "string", + "enum": [ + "search" + ] }, - { - "type": "null" - } - ] - }, - "cover_image_grid_media_item_id": { - "oneOf": [ - { - "type": "integer" + "parent_collection_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "cover_image_wide_media_item_id": { - "oneOf": [ - { - "type": "integer" + "search_types": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "default_search_types": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] } - ] + } + } + ] + }, + "TranslatedUploadedFileId": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "object", + "example": { + "en": 123, + "fr": 456 + }, + "additionalProperties": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + } + ], + "description": "An uploaded file ID, either a single integer or a locale map of IDs (e.g. { \"en\": 123, \"fr\": 456 })" + }, + "MediaItemPartial": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - "code_background_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "primary_language": { + "$ref": "#/components/schemas/ContentLanguage" }, - "logo_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "file_uploaded_file_id": { + "$ref": "#/components/schemas/TranslatedUploadedFileId" } }, - "description": "Writable collection fields" + "description": "Writable media item fields" }, - "Collection": { + "MediaItem": { "oneOf": [ { - "$ref": "#/components/schemas/CollectionList" + "$ref": "#/components/schemas/MediaItemImage" }, { - "$ref": "#/components/schemas/CollectionTour" + "$ref": "#/components/schemas/MediaItemAudio" }, { - "$ref": "#/components/schemas/CollectionOrganization" + "$ref": "#/components/schemas/MediaItemVideo" }, { - "$ref": "#/components/schemas/CollectionMenu" + "$ref": "#/components/schemas/MediaItemAr" }, { - "$ref": "#/components/schemas/CollectionSearch" + "$ref": "#/components/schemas/MediaItemWebpackage" + }, + { + "$ref": "#/components/schemas/MediaItemMap" + }, + { + "$ref": "#/components/schemas/MediaItemAnimation" + }, + { + "$ref": "#/components/schemas/MediaItemWebvideo" + }, + { + "$ref": "#/components/schemas/MediaItemData" } ], - "description": "A single collection" + "description": "A single media item" }, - "CollectionList": { + "MediaItemImage": { "allOf": [ { "type": "object", @@ -8846,19 +11735,9 @@ { "required": [ "name", - "primary_language", - "header_layout", - "list_layout", - "tour_mode", - "initial_layout", - "map_view_enabled", - "search_enabled", - "keypad_enabled", - "qr_enabled", - "sort_by", - "code_lock" + "primary_language" ], - "$ref": "#/components/schemas/CollectionPartial" + "$ref": "#/components/schemas/MediaItemPartial" }, { "type": "object", @@ -8866,24 +11745,73 @@ "type": { "type": "string", "enum": [ - "list" + "image" ] }, - "preview_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "caption": { + "$ref": "#/components/schemas/TranslatedString" + }, + "attribution": { + "$ref": "#/components/schemas/TranslatedString" + }, + "description": { + "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "CollectionTour": { + "AudioUrlFile": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ] + }, + "media_url": { + "type": "string", + "format": "uri" + }, + "status": { + "type": "string" + }, + "duration": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] + }, + "file_size": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "content_type": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "description": "Audio file metadata for URL-sourced audio" + }, + "MediaItemAudio": { "allOf": [ { "type": "object", @@ -8914,19 +11842,9 @@ { "required": [ "name", - "primary_language", - "header_layout", - "list_layout", - "tour_mode", - "initial_layout", - "map_view_enabled", - "search_enabled", - "keypad_enabled", - "qr_enabled", - "sort_by", - "code_lock" + "primary_language" ], - "$ref": "#/components/schemas/CollectionPartial" + "$ref": "#/components/schemas/MediaItemPartial" }, { "type": "object", @@ -8934,53 +11852,10 @@ "type": { "type": "string", "enum": [ - "tour" + "audio" ] }, - "tour_type": { - "type": "string", - "enum": [ - "walking", - "4wd", - "aboriginal_site", - "airplane", - "bus", - "canoeing", - "cycling", - "driving", - "gallery", - "helicopter", - "historic_house", - "horse_trail", - "museum", - "nature_trail", - "scavenger_hunt", - "ship", - "train" - ], - "description": "Tour type" - }, - "tour_subtype": { - "type": "string", - "enum": [ - "grade_1", - "grade_2", - "grade_3", - "grade_4", - "grade_5" - ], - "description": "Tour subtype" - }, - "audio_mode": { - "type": "string", - "enum": [ - "replace", - "skip", - "queue" - ], - "description": "Audio mode" - }, - "audio_preview_media_item_id": { + "thumbnail_media_item_id": { "oneOf": [ { "type": "integer" @@ -8990,49 +11865,108 @@ } ] }, - "length_min": { + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "transcription": { + "$ref": "#/components/schemas/TranslatedString" + }, + "source": { "oneOf": [ { - "type": "integer" + "type": "string", + "enum": [ + "polly", + "upload", + "url" + ] }, { "type": "null" } ] }, - "length_max": { + "url": { "oneOf": [ { - "type": "integer" + "type": "string", + "format": "uri" }, { "type": "null" } ] }, - "distance": { + "file": { "oneOf": [ { - "type": "number", - "format": "float" + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/components/schemas/AudioUrlFile" + }, + { + "type": "null" + } + ] + }, + "description": "Locale map of URL audio file metadata, keyed by language code" }, { "type": "null" } ] + } + } + } + ] + }, + "MediaItemVideo": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - "distance_loop": { - "oneOf": [ - { - "type": "number", - "format": "float" - }, - { - "type": "null" - } + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" + ] + }, + { + "required": [ + "name", + "primary_language" + ], + "$ref": "#/components/schemas/MediaItemPartial" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "video" ] }, - "stop_count": { + "thumbnail_media_item_id": { "oneOf": [ { "type": "integer" @@ -9041,12 +11975,18 @@ "type": "null" } ] + }, + "transcription": { + "$ref": "#/components/schemas/TranslatedString" + }, + "soundtrack_uploaded_file_id": { + "$ref": "#/components/schemas/TranslatedUploadedFileId" } } } ] }, - "CollectionOrganization": { + "MediaItemAr": { "allOf": [ { "type": "object", @@ -9077,19 +12017,9 @@ { "required": [ "name", - "primary_language", - "header_layout", - "list_layout", - "tour_mode", - "initial_layout", - "map_view_enabled", - "search_enabled", - "keypad_enabled", - "qr_enabled", - "sort_by", - "code_lock" + "primary_language" ], - "$ref": "#/components/schemas/CollectionPartial" + "$ref": "#/components/schemas/MediaItemPartial" }, { "type": "object", @@ -9097,14 +12027,24 @@ "type": { "type": "string", "enum": [ - "organization" + "ar" + ] + }, + "thumbnail_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } ] } } } ] }, - "CollectionMenu": { + "MediaItemWebpackage": { "allOf": [ { "type": "object", @@ -9135,19 +12075,9 @@ { "required": [ "name", - "primary_language", - "header_layout", - "list_layout", - "tour_mode", - "initial_layout", - "map_view_enabled", - "search_enabled", - "keypad_enabled", - "qr_enabled", - "sort_by", - "code_lock" + "primary_language" ], - "$ref": "#/components/schemas/CollectionPartial" + "$ref": "#/components/schemas/MediaItemPartial" }, { "type": "object", @@ -9155,14 +12085,14 @@ "type": { "type": "string", "enum": [ - "menu" + "webpackage" ] } } } ] }, - "CollectionSearch": { + "MediaItemMap": { "allOf": [ { "type": "object", @@ -9193,19 +12123,9 @@ { "required": [ "name", - "primary_language", - "header_layout", - "list_layout", - "tour_mode", - "initial_layout", - "map_view_enabled", - "search_enabled", - "keypad_enabled", - "qr_enabled", - "sort_by", - "code_lock" + "primary_language" ], - "$ref": "#/components/schemas/CollectionPartial" + "$ref": "#/components/schemas/MediaItemPartial" }, { "type": "object", @@ -9213,116 +12133,139 @@ "type": { "type": "string", "enum": [ - "search" - ] - }, - "parent_collection_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } + "map" ] }, - "search_types": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "georeferenced": { + "type": "boolean", + "description": "Whether the map is georeferenced" }, - "default_search_types": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "title": { + "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "TranslatedUploadedFileId": { - "oneOf": [ - { - "type": "integer" - }, + "MediaItemAnimation": { + "allOf": [ { "type": "object", - "example": { - "en": 123, - "fr": 456 - }, - "additionalProperties": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" + "properties": { + "id": { + "type": "integer" + }, + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" } - ] - } - } - ], - "description": "An uploaded file ID, either a single integer or a locale map of IDs (e.g. { \"en\": 123, \"fr\": 456 })" - }, - "MediaItemPartial": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "primary_language": { - "$ref": "#/components/schemas/ContentLanguage" - }, - "file_uploaded_file_id": { - "$ref": "#/components/schemas/TranslatedUploadedFileId" - } - }, - "description": "Writable media item fields" - }, - "MediaItem": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaItemImage" - }, - { - "$ref": "#/components/schemas/MediaItemAudio" - }, - { - "$ref": "#/components/schemas/MediaItemVideo" - }, - { - "$ref": "#/components/schemas/MediaItemAr" + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" + ] }, { - "$ref": "#/components/schemas/MediaItemWebpackage" + "required": [ + "name", + "primary_language" + ], + "$ref": "#/components/schemas/MediaItemPartial" }, { - "$ref": "#/components/schemas/MediaItemMap" - }, + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "animation" + ] + }, + "caption": { + "$ref": "#/components/schemas/TranslatedString" + }, + "attribution": { + "$ref": "#/components/schemas/TranslatedString" + }, + "description": { + "$ref": "#/components/schemas/TranslatedString" + } + } + } + ] + }, + "MediaItemWebvideo": { + "allOf": [ { - "$ref": "#/components/schemas/MediaItemAnimation" + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "translated_languages", + "created_at", + "updated_at" + ] }, { - "$ref": "#/components/schemas/MediaItemWebvideo" + "required": [ + "name", + "primary_language" + ], + "$ref": "#/components/schemas/MediaItemPartial" }, { - "$ref": "#/components/schemas/MediaItemData" + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "webvideo" + ] + }, + "thumbnail_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + } + } } - ], - "description": "A single media item" + ] }, - "MediaItemImage": { + "MediaItemData": { "allOf": [ { "type": "object", @@ -9363,50 +12306,75 @@ "type": { "type": "string", "enum": [ - "image" + "data" ] - }, - "caption": { - "$ref": "#/components/schemas/TranslatedString" - }, - "attribution": { - "$ref": "#/components/schemas/TranslatedString" - }, - "description": { - "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "AudioUrlFile": { + "ScreenPartial": { "type": "object", "properties": { - "type": { + "name": { + "type": "string" + }, + "primary_language": { + "$ref": "#/components/schemas/ContentLanguage" + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "short_title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "stqry_directory_short_description": { + "$ref": "#/components/schemas/TranslatedString" + }, + "stqry_directory_full_description": { + "$ref": "#/components/schemas/TranslatedString" + }, + "header_layout": { "type": "string", "enum": [ - "audio" + "image_and_title", + "image", + "none", + "short", + "tall" ] }, - "media_url": { - "type": "string", - "format": "uri" + "cover_image_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - "status": { - "type": "string" + "cover_image_grid_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - "duration": { + "cover_image_wide_media_item_id": { "oneOf": [ { - "type": "number", - "format": "float" + "type": "integer" }, { "type": "null" } ] }, - "file_size": { + "background_image_media_item_id": { "oneOf": [ { "type": "integer" @@ -9416,10 +12384,10 @@ } ] }, - "content_type": { + "logo_media_item_id": { "oneOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" @@ -9427,9 +12395,48 @@ ] } }, - "description": "Audio file metadata for URL-sourced audio" + "description": "Writable screen fields" }, - "MediaItemAudio": { + "Screen": { + "oneOf": [ + { + "$ref": "#/components/schemas/ScreenStory" + }, + { + "$ref": "#/components/schemas/ScreenWeb" + }, + { + "$ref": "#/components/schemas/ScreenPanorama" + }, + { + "$ref": "#/components/schemas/ScreenAr" + }, + { + "$ref": "#/components/schemas/ScreenKiosk" + } + ], + "description": "A single screen" + }, + "ScreenDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Screen" + }, + { + "type": "object", + "properties": { + "story_sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StorySection" + } + } + } + } + ], + "description": "A single screen with full details including story sections" + }, + "ScreenStory": { "allOf": [ { "type": "object", @@ -9462,7 +12469,7 @@ "name", "primary_language" ], - "$ref": "#/components/schemas/MediaItemPartial" + "$ref": "#/components/schemas/ScreenPartial" }, { "type": "object", @@ -9470,77 +12477,14 @@ "type": { "type": "string", "enum": [ - "audio" - ] - }, - "thumbnail_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "title": { - "$ref": "#/components/schemas/TranslatedString" - }, - "transcription": { - "$ref": "#/components/schemas/TranslatedString" - }, - "source": { - "oneOf": [ - { - "type": "string", - "enum": [ - "polly", - "upload", - "url" - ] - }, - { - "type": "null" - } - ] - }, - "url": { - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] - }, - "file": { - "oneOf": [ - { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/components/schemas/AudioUrlFile" - }, - { - "type": "null" - } - ] - }, - "description": "Locale map of URL audio file metadata, keyed by language code" - }, - { - "type": "null" - } + "story" ] } } } ] }, - "MediaItemVideo": { + "ScreenWeb": { "allOf": [ { "type": "object", @@ -9573,7 +12517,7 @@ "name", "primary_language" ], - "$ref": "#/components/schemas/MediaItemPartial" + "$ref": "#/components/schemas/ScreenPartial" }, { "type": "object", @@ -9581,10 +12525,20 @@ "type": { "type": "string", "enum": [ - "video" + "web" ] }, - "thumbnail_media_item_id": { + "page_type": { + "type": "string", + "enum": [ + "URL", + "HTML", + "ZIP", + "NA" + ], + "description": "Page type" + }, + "webpackage_media_item_id": { "oneOf": [ { "type": "integer" @@ -9594,17 +12548,24 @@ } ] }, - "transcription": { + "use_default_os_web_view": { + "type": "boolean", + "description": "Use default OS web view" + }, + "url": { "$ref": "#/components/schemas/TranslatedString" }, - "soundtrack_uploaded_file_id": { - "$ref": "#/components/schemas/TranslatedUploadedFileId" + "html": { + "$ref": "#/components/schemas/TranslatedString" + }, + "css": { + "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "MediaItemAr": { + "ScreenPanorama": { "allOf": [ { "type": "object", @@ -9637,7 +12598,7 @@ "name", "primary_language" ], - "$ref": "#/components/schemas/MediaItemPartial" + "$ref": "#/components/schemas/ScreenPartial" }, { "type": "object", @@ -9645,10 +12606,18 @@ "type": { "type": "string", "enum": [ - "ar" + "panorama" ] }, - "thumbnail_media_item_id": { + "image_type": { + "type": "string", + "enum": [ + "panorama", + "flat" + ], + "description": "Image type" + }, + "panorama_media_item_id": { "oneOf": [ { "type": "integer" @@ -9662,7 +12631,7 @@ } ] }, - "MediaItemWebpackage": { + "ScreenAr": { "allOf": [ { "type": "object", @@ -9695,7 +12664,7 @@ "name", "primary_language" ], - "$ref": "#/components/schemas/MediaItemPartial" + "$ref": "#/components/schemas/ScreenPartial" }, { "type": "object", @@ -9703,14 +12672,43 @@ "type": { "type": "string", "enum": [ - "webpackage" + "ar" + ] + }, + "page_type": { + "type": "string", + "enum": [ + "URL", + "HTML", + "ZIP", + "UNITY" + ], + "description": "Page type" + }, + "webpackage_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } ] + }, + "url": { + "$ref": "#/components/schemas/TranslatedString" + }, + "html": { + "$ref": "#/components/schemas/TranslatedString" + }, + "css": { + "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "MediaItemMap": { + "ScreenKiosk": { "allOf": [ { "type": "object", @@ -9743,7 +12741,7 @@ "name", "primary_language" ], - "$ref": "#/components/schemas/MediaItemPartial" + "$ref": "#/components/schemas/ScreenPartial" }, { "type": "object", @@ -9751,139 +12749,209 @@ "type": { "type": "string", "enum": [ + "kiosk" + ] + }, + "kiosk_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "layout": { + "type": "string", + "enum": [ + "hotspots", + "web", + "canvas", "map" + ], + "description": "Layout" + }, + "map_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } ] }, - "georeferenced": { - "type": "boolean", - "description": "Whether the map is georeferenced" + "page_type": { + "type": "string", + "enum": [ + "URL", + "HTML", + "ZIP", + "NA" + ], + "description": "Page type" }, - "title": { + "scrolling": { + "type": "string", + "enum": [ + "none", + "vertical", + "horizontal", + "zoomable" + ], + "description": "Scrolling" + }, + "webpackage_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "settings": { + "$ref": "#/components/schemas/KioskSettings" + }, + "url": { + "$ref": "#/components/schemas/TranslatedString" + }, + "html": { + "$ref": "#/components/schemas/TranslatedString" + }, + "css": { "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "MediaItemAnimation": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { + "CollectionItemPartial": { + "type": "object", + "properties": { + "position": { + "type": "integer" + }, + "item_type": { + "type": "string", + "enum": [ + "Collection", + "Screen", + "CollectionItem", + "CrossRegionLink" + ], + "description": "Type of the linked item" + }, + "item_id": { + "type": "integer" + }, + "item_number": { + "oneOf": [ + { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" + } + ] + }, + "lat": { + "oneOf": [ + { + "type": "number", + "format": "float" }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "translated_languages", - "created_at", - "updated_at" ] }, - { - "required": [ - "name", - "primary_language" - ], - "$ref": "#/components/schemas/MediaItemPartial" + "lng": { + "oneOf": [ + { + "type": "number", + "format": "float" + }, + { + "type": "null" + } + ] }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "animation" - ] + "map_pin_icon": { + "oneOf": [ + { + "type": "string" }, - "caption": { - "$ref": "#/components/schemas/TranslatedString" + { + "type": "null" + } + ] + }, + "map_pin_style": { + "oneOf": [ + { + "type": "string" }, - "attribution": { - "$ref": "#/components/schemas/TranslatedString" + { + "type": "null" + } + ] + }, + "map_pin_colour": { + "oneOf": [ + { + "type": "string" }, - "description": { - "$ref": "#/components/schemas/TranslatedString" + { + "type": "null" } - } - } - ] - }, - "MediaItemWebvideo": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { + ] + }, + "map_layer_id": { + "oneOf": [ + { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" - }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "translated_languages", - "created_at", - "updated_at" ] }, - { - "required": [ - "name", - "primary_language" + "geofence": { + "type": "string", + "enum": [ + "off", + "gps", + "beacon" ], - "$ref": "#/components/schemas/MediaItemPartial" + "description": "Geofence mode" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "webvideo" - ] + "gps_settings": { + "oneOf": [ + { + "$ref": "#/components/schemas/GpsSettings" }, - "thumbnail_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "beacon_settings": { + "oneOf": [ + { + "$ref": "#/components/schemas/BeaconSettings" }, - "title": { - "$ref": "#/components/schemas/TranslatedString" + { + "type": "null" } - } + ] } - ] + }, + "description": "Writable collection item fields" }, - "MediaItemData": { + "CollectionItem": { "allOf": [ { "type": "object", @@ -9891,12 +12959,6 @@ "id": { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -9906,63 +12968,41 @@ }, "required": [ "id", - "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "name", - "primary_language" + "position", + "item_type", + "item_id", + "geofence" ], - "$ref": "#/components/schemas/MediaItemPartial" - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "data" - ] - } - } + "$ref": "#/components/schemas/CollectionItemPartial" } ] }, - "ScreenPartial": { + "CollectionItemInput": { + "$ref": "#/components/schemas/CollectionItemPartial", + "description": "Request body for a collection item" + }, + "UploadedFilePartial": { "type": "object", "properties": { - "name": { + "filename": { "type": "string" }, - "primary_language": { - "$ref": "#/components/schemas/ContentLanguage" - }, - "title": { - "$ref": "#/components/schemas/TranslatedString" - }, - "short_title": { - "$ref": "#/components/schemas/TranslatedString" - }, - "stqry_directory_short_description": { - "$ref": "#/components/schemas/TranslatedString" + "content_type": { + "type": "string" }, - "stqry_directory_full_description": { - "$ref": "#/components/schemas/TranslatedString" + "file_size": { + "type": "integer" }, - "header_layout": { - "type": "string", - "enum": [ - "image_and_title", - "image", - "none", - "short", - "tall" - ] + "file_hash": { + "type": "string" }, - "cover_image_media_item_id": { + "width": { "oneOf": [ { "type": "integer" @@ -9972,7 +13012,7 @@ } ] }, - "cover_image_grid_media_item_id": { + "height": { "oneOf": [ { "type": "integer" @@ -9982,7 +13022,7 @@ } ] }, - "cover_image_wide_media_item_id": { + "duration": { "oneOf": [ { "type": "integer" @@ -9992,69 +13032,144 @@ } ] }, - "background_image_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } + "status": { + "type": "string", + "enum": [ + "ready", + "transcoder_started", + "transcoder_invalid_file", + "transcoder_error", + "transcoder_submitted", + "transcoder_progressing", + "transcoder_canceled", + "transcoder_complete", + "mbtiles_started", + "unzip_started", + "unzip_complete", + "mbtiles_downloading", + "mbtiles_to_geotiff", + "mbtiles_to_mbtiles", + "mbtiles_upload_png", + "mbtiles_upload_png8", + "mbtiles_upload_jpeg", + "mbtiles_complete" ] }, - "logo_media_item_id": { - "oneOf": [ - { + "focal_point": { + "type": "object" + } + }, + "description": "Writable uploaded file fields" + }, + "UploadedFile": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "integer" }, - { - "type": "null" + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" } + }, + "required": [ + "id", + "created_at", + "updated_at" ] + }, + { + "required": [ + "filename", + "content_type", + "file_size", + "file_hash", + "status" + ], + "$ref": "#/components/schemas/UploadedFilePartial" + } + ] + }, + "UploadedFileInput": { + "$ref": "#/components/schemas/UploadedFilePartial", + "description": "Request body for an uploaded file" + }, + "StorySectionPartial": { + "type": "object", + "properties": { + "position": { + "type": "integer" + }, + "collapsable": { + "type": "boolean" + }, + "exposable_content": { + "type": "boolean" + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "hide": { + "$ref": "#/components/schemas/TranslatedString" } }, - "description": "Writable screen fields" + "description": "Writable story section fields" }, - "Screen": { + "StorySection": { "oneOf": [ { - "$ref": "#/components/schemas/ScreenStory" + "$ref": "#/components/schemas/StorySectionText" }, { - "$ref": "#/components/schemas/ScreenWeb" + "$ref": "#/components/schemas/StorySectionSingleMedia" }, { - "$ref": "#/components/schemas/ScreenPanorama" + "$ref": "#/components/schemas/StorySectionMediaGroup" }, { - "$ref": "#/components/schemas/ScreenAr" + "$ref": "#/components/schemas/StorySectionLinkGroup" }, { - "$ref": "#/components/schemas/ScreenKiosk" - } - ], - "description": "A single screen" - }, - "ScreenDetail": { - "allOf": [ + "$ref": "#/components/schemas/StorySectionSocialGroup" + }, { - "$ref": "#/components/schemas/Screen" + "$ref": "#/components/schemas/StorySectionImageSlider" }, { - "type": "object", - "properties": { - "story_sections": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StorySection" - } - } - } + "$ref": "#/components/schemas/StorySectionLocation" + }, + { + "$ref": "#/components/schemas/StorySectionMenu" + }, + { + "$ref": "#/components/schemas/StorySectionQuizQuestion" + }, + { + "$ref": "#/components/schemas/StorySectionQuizScore" + }, + { + "$ref": "#/components/schemas/StorySectionForm" + }, + { + "$ref": "#/components/schemas/StorySectionCustomWidget" + }, + { + "$ref": "#/components/schemas/StorySectionBadgeGroup" + }, + { + "$ref": "#/components/schemas/StorySectionPriceGroup" + }, + { + "$ref": "#/components/schemas/StorySectionOpeningTimeGroup" } ], - "description": "A single screen with full details including story sections" + "description": "A single story section" }, - "ScreenStory": { + "StorySectionText": { "allOf": [ { "type": "object", @@ -10062,12 +13177,6 @@ "id": { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -10077,17 +13186,17 @@ }, "required": [ "id", - "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "name", - "primary_language" + "position", + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/ScreenPartial" + "$ref": "#/components/schemas/StorySectionPartial" }, { "type": "object", @@ -10095,14 +13204,20 @@ "type": { "type": "string", "enum": [ - "story" + "text" ] + }, + "subtitle": { + "$ref": "#/components/schemas/TranslatedString" + }, + "body": { + "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "ScreenWeb": { + "StorySectionSingleMedia": { "allOf": [ { "type": "object", @@ -10110,12 +13225,6 @@ "id": { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -10125,17 +13234,17 @@ }, "required": [ "id", - "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "name", - "primary_language" + "position", + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/ScreenPartial" + "$ref": "#/components/schemas/StorySectionPartial" }, { "type": "object", @@ -10143,20 +13252,10 @@ "type": { "type": "string", "enum": [ - "web" + "single_media" ] }, - "page_type": { - "type": "string", - "enum": [ - "URL", - "HTML", - "ZIP", - "NA" - ], - "description": "Page type" - }, - "webpackage_media_item_id": { + "media_item_id": { "oneOf": [ { "type": "integer" @@ -10166,24 +13265,96 @@ } ] }, - "use_default_os_web_view": { + "text_position": { + "type": "string", + "enum": [ + "left", + "right", + "top", + "bottom", + "none" + ], + "description": "Text position" + }, + "auto_play": { "type": "boolean", - "description": "Use default OS web view" + "description": "Auto play" }, - "url": { - "$ref": "#/components/schemas/TranslatedString" + "zoomable": { + "type": "boolean", + "description": "Zoomable" }, - "html": { + "description": { "$ref": "#/components/schemas/TranslatedString" + } + } + } + ] + }, + "StorySectionMediaGroup": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - "css": { + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "created_at", + "updated_at" + ] + }, + { + "required": [ + "position", + "collapsable", + "exposable_content" + ], + "$ref": "#/components/schemas/StorySectionPartial" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "media_group" + ] + }, + "full_screen": { + "type": "boolean", + "description": "Full screen" + }, + "slideshow_enabled": { + "type": "boolean", + "description": "Slideshow enabled" + }, + "zoomable": { + "type": "boolean", + "description": "Zoomable" + }, + "description": { "$ref": "#/components/schemas/TranslatedString" + }, + "media_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StorySectionMediaItem" + } } } } ] }, - "ScreenPanorama": { + "StorySectionLinkGroup": { "allOf": [ { "type": "object", @@ -10191,11 +13362,69 @@ "id": { "type": "integer" }, - "translated_languages": { + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "created_at", + "updated_at" + ] + }, + { + "required": [ + "position", + "collapsable", + "exposable_content" + ], + "$ref": "#/components/schemas/StorySectionPartial" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link_group" + ] + }, + "layout": { + "type": "string", + "enum": [ + "list", + "button", + "icon", + "list_no_icon", + "button_no_icon", + "list_with_icon", + "button_with_icon", + "grid_image", + "wide_image", + "horizontal_slider" + ], + "description": "Layout" + }, + "link_items": { "type": "array", "items": { - "$ref": "#/components/schemas/ContentLanguage" + "$ref": "#/components/schemas/StorySectionLinkItem" } + } + } + } + ] + }, + "StorySectionSocialGroup": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" }, "created_at": { "$ref": "#/components/schemas/DateTime" @@ -10206,17 +13435,17 @@ }, "required": [ "id", - "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "name", - "primary_language" + "position", + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/ScreenPartial" + "$ref": "#/components/schemas/StorySectionPartial" }, { "type": "object", @@ -10224,32 +13453,28 @@ "type": { "type": "string", "enum": [ - "panorama" + "social_group" ] }, - "image_type": { + "layout": { "type": "string", "enum": [ - "panorama", - "flat" + "icons", + "list" ], - "description": "Image type" + "description": "Layout" }, - "panorama_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "social_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StorySectionSocialItem" + } } } } ] }, - "ScreenAr": { + "StorySectionImageSlider": { "allOf": [ { "type": "object", @@ -10257,12 +13482,6 @@ "id": { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -10272,17 +13491,17 @@ }, "required": [ "id", - "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "name", - "primary_language" + "position", + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/ScreenPartial" + "$ref": "#/components/schemas/StorySectionPartial" }, { "type": "object", @@ -10290,20 +13509,20 @@ "type": { "type": "string", "enum": [ - "ar" + "image_slider" ] }, - "page_type": { - "type": "string", - "enum": [ - "URL", - "HTML", - "ZIP", - "UNITY" - ], - "description": "Page type" + "left_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - "webpackage_media_item_id": { + "right_media_item_id": { "oneOf": [ { "type": "integer" @@ -10313,20 +13532,22 @@ } ] }, - "url": { - "$ref": "#/components/schemas/TranslatedString" + "aspect_ratio": { + "type": "string", + "description": "Aspect ratio nn:nn" }, - "html": { - "$ref": "#/components/schemas/TranslatedString" + "start_location": { + "type": "integer", + "description": "Start location 0-100" }, - "css": { + "caption": { "$ref": "#/components/schemas/TranslatedString" } } } ] }, - "ScreenKiosk": { + "StorySectionLocation": { "allOf": [ { "type": "object", @@ -10334,12 +13555,6 @@ "id": { "type": "integer" }, - "translated_languages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentLanguage" - } - }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -10349,17 +13564,17 @@ }, "required": [ "id", - "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "name", - "primary_language" + "position", + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/ScreenPartial" + "$ref": "#/components/schemas/StorySectionPartial" }, { "type": "object", @@ -10367,209 +13582,61 @@ "type": { "type": "string", "enum": [ - "kiosk" + "location" ] }, - "kiosk_id": { + "lat": { "oneOf": [ { - "type": "integer" + "type": "number", + "format": "float" }, { "type": "null" } ] }, - "layout": { - "type": "string", - "enum": [ - "hotspots", - "web", - "canvas", - "map" - ], - "description": "Layout" - }, - "map_id": { + "lng": { "oneOf": [ { - "type": "integer" + "type": "number", + "format": "float" }, { "type": "null" } ] }, - "page_type": { - "type": "string", - "enum": [ - "URL", - "HTML", - "ZIP", - "NA" - ], - "description": "Page type" - }, - "scrolling": { + "map_type": { "type": "string", "enum": [ - "none", - "vertical", - "horizontal", - "zoomable" + "single_location", + "multiple_locations" ], - "description": "Scrolling" - }, - "webpackage_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "settings": { - "$ref": "#/components/schemas/KioskSettings" - }, - "url": { - "$ref": "#/components/schemas/TranslatedString" - }, - "html": { - "$ref": "#/components/schemas/TranslatedString" - }, - "css": { - "$ref": "#/components/schemas/TranslatedString" - } - } - } - ] - }, - "CollectionItemPartial": { - "type": "object", - "properties": { - "position": { - "type": "integer" - }, - "item_type": { - "type": "string", - "enum": [ - "Collection", - "Screen", - "CollectionItem", - "CrossRegionLink" - ], - "description": "Type of the linked item" - }, - "item_id": { - "type": "integer" - }, - "item_number": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "lat": { - "oneOf": [ - { - "type": "number", - "format": "float" - }, - { - "type": "null" - } - ] - }, - "lng": { - "oneOf": [ - { - "type": "number", - "format": "float" - }, - { - "type": "null" - } - ] - }, - "map_pin_icon": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "map_pin_style": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "map_pin_colour": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "map_layer_id": { - "oneOf": [ - { - "type": "integer" + "description": "Map type" }, - { - "type": "null" - } - ] - }, - "geofence": { - "type": "string", - "enum": [ - "off", - "gps", - "beacon" - ], - "description": "Geofence mode" - }, - "gps_settings": { - "oneOf": [ - { - "$ref": "#/components/schemas/GpsSettings" + "map_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "beacon_settings": { - "oneOf": [ - { - "$ref": "#/components/schemas/BeaconSettings" + "directions_enabled": { + "type": "boolean", + "description": "Directions enabled" }, - { - "type": "null" + "display_address": { + "$ref": "#/components/schemas/TranslatedString" } - ] + } } - }, - "description": "Writable collection item fields" + ] }, - "CollectionItem": { + "StorySectionMenu": { "allOf": [ { "type": "object", @@ -10593,93 +13660,39 @@ { "required": [ "position", - "item_type", - "item_id", - "geofence" + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/CollectionItemPartial" - } - ] - }, - "CollectionItemInput": { - "$ref": "#/components/schemas/CollectionItemPartial", - "description": "Request body for a collection item" - }, - "UploadedFilePartial": { - "type": "object", - "properties": { - "filename": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "file_size": { - "type": "integer" - }, - "file_hash": { - "type": "string" - }, - "width": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "$ref": "#/components/schemas/StorySectionPartial" }, - "height": { - "oneOf": [ - { - "type": "integer" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "menu" + ] }, - { - "type": "null" - } - ] - }, - "duration": { - "oneOf": [ - { - "type": "integer" + "collection_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "hide_label": { + "type": "boolean", + "description": "Hide label" } - ] - }, - "status": { - "type": "string", - "enum": [ - "ready", - "transcoder_started", - "transcoder_invalid_file", - "transcoder_error", - "transcoder_submitted", - "transcoder_progressing", - "transcoder_canceled", - "transcoder_complete", - "mbtiles_started", - "unzip_started", - "unzip_complete", - "mbtiles_downloading", - "mbtiles_to_geotiff", - "mbtiles_to_mbtiles", - "mbtiles_upload_png", - "mbtiles_upload_png8", - "mbtiles_upload_jpeg", - "mbtiles_complete" - ] - }, - "focal_point": { - "type": "object" + } } - }, - "description": "Writable uploaded file fields" + ] }, - "UploadedFile": { + "StorySectionQuizQuestion": { "allOf": [ { "type": "object", @@ -10702,92 +13715,92 @@ }, { "required": [ - "filename", - "content_type", - "file_size", - "file_hash", - "status" + "position", + "collapsable", + "exposable_content" ], - "$ref": "#/components/schemas/UploadedFilePartial" - } - ] - }, - "UploadedFileInput": { - "$ref": "#/components/schemas/UploadedFilePartial", - "description": "Request body for an uploaded file" - }, - "StorySectionPartial": { - "type": "object", - "properties": { - "position": { - "type": "integer" - }, - "collapsable": { - "type": "boolean" - }, - "exposable_content": { - "type": "boolean" - }, - "title": { - "$ref": "#/components/schemas/TranslatedString" + "$ref": "#/components/schemas/StorySectionPartial" }, - "hide": { - "$ref": "#/components/schemas/TranslatedString" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "quiz_question" + ] + }, + "quiz_question_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "zoomable": { + "type": "boolean", + "description": "Zoomable" + } + } } - }, - "description": "Writable story section fields" + ] }, - "StorySection": { - "oneOf": [ - { - "$ref": "#/components/schemas/StorySectionText" - }, - { - "$ref": "#/components/schemas/StorySectionSingleMedia" - }, - { - "$ref": "#/components/schemas/StorySectionMediaGroup" - }, - { - "$ref": "#/components/schemas/StorySectionLinkGroup" - }, - { - "$ref": "#/components/schemas/StorySectionSocialGroup" - }, - { - "$ref": "#/components/schemas/StorySectionImageSlider" - }, - { - "$ref": "#/components/schemas/StorySectionLocation" - }, - { - "$ref": "#/components/schemas/StorySectionMenu" - }, - { - "$ref": "#/components/schemas/StorySectionQuizQuestion" - }, - { - "$ref": "#/components/schemas/StorySectionQuizScore" - }, - { - "$ref": "#/components/schemas/StorySectionForm" - }, - { - "$ref": "#/components/schemas/StorySectionCustomWidget" - }, + "StorySectionQuizScore": { + "allOf": [ { - "$ref": "#/components/schemas/StorySectionBadgeGroup" + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "created_at": { + "$ref": "#/components/schemas/DateTime" + }, + "updated_at": { + "$ref": "#/components/schemas/DateTime" + } + }, + "required": [ + "id", + "created_at", + "updated_at" + ] }, { - "$ref": "#/components/schemas/StorySectionPriceGroup" + "required": [ + "position", + "collapsable", + "exposable_content" + ], + "$ref": "#/components/schemas/StorySectionPartial" }, { - "$ref": "#/components/schemas/StorySectionOpeningTimeGroup" + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "quiz_score" + ] + }, + "quiz_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + } } - ], - "description": "A single story section" + ] }, - "StorySectionText": { + "StorySectionForm": { "allOf": [ { "type": "object", @@ -10822,20 +13835,24 @@ "type": { "type": "string", "enum": [ - "text" + "form" ] }, - "subtitle": { - "$ref": "#/components/schemas/TranslatedString" - }, - "body": { - "$ref": "#/components/schemas/TranslatedString" + "form_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] } } } ] }, - "StorySectionSingleMedia": { + "StorySectionCustomWidget": { "allOf": [ { "type": "object", @@ -10870,10 +13887,10 @@ "type": { "type": "string", "enum": [ - "single_media" + "custom_widget" ] }, - "media_item_id": { + "custom_widget_id": { "oneOf": [ { "type": "integer" @@ -10883,33 +13900,15 @@ } ] }, - "text_position": { + "content": { "type": "string", - "enum": [ - "left", - "right", - "top", - "bottom", - "none" - ], - "description": "Text position" - }, - "auto_play": { - "type": "boolean", - "description": "Auto play" - }, - "zoomable": { - "type": "boolean", - "description": "Zoomable" - }, - "description": { - "$ref": "#/components/schemas/TranslatedString" + "description": "Custom widget content JSON" } } } ] }, - "StorySectionMediaGroup": { + "StorySectionBadgeGroup": { "allOf": [ { "type": "object", @@ -10944,35 +13943,20 @@ "type": { "type": "string", "enum": [ - "media_group" + "badge_group" ] }, - "full_screen": { - "type": "boolean", - "description": "Full screen" - }, - "slideshow_enabled": { - "type": "boolean", - "description": "Slideshow enabled" - }, - "zoomable": { - "type": "boolean", - "description": "Zoomable" - }, - "description": { - "$ref": "#/components/schemas/TranslatedString" - }, - "media_items": { + "badge_items": { "type": "array", "items": { - "$ref": "#/components/schemas/StorySectionMediaItem" + "$ref": "#/components/schemas/StorySectionBadgeItem" } } } } ] }, - "StorySectionLinkGroup": { + "StorySectionPriceGroup": { "allOf": [ { "type": "object", @@ -11007,36 +13991,20 @@ "type": { "type": "string", "enum": [ - "link_group" + "price_group" ] }, - "layout": { - "type": "string", - "enum": [ - "list", - "button", - "icon", - "list_no_icon", - "button_no_icon", - "list_with_icon", - "button_with_icon", - "grid_image", - "wide_image", - "horizontal_slider" - ], - "description": "Layout" - }, - "link_items": { + "price_items": { "type": "array", "items": { - "$ref": "#/components/schemas/StorySectionLinkItem" + "$ref": "#/components/schemas/StorySectionPriceItem" } } } } ] }, - "StorySectionSocialGroup": { + "StorySectionOpeningTimeGroup": { "allOf": [ { "type": "object", @@ -11071,28 +14039,52 @@ "type": { "type": "string", "enum": [ - "social_group" + "opening_time_group" ] }, - "layout": { - "type": "string", - "enum": [ - "icons", - "list" - ], - "description": "Layout" - }, - "social_items": { + "opening_time_items": { "type": "array", "items": { - "$ref": "#/components/schemas/StorySectionSocialItem" + "$ref": "#/components/schemas/StorySectionOpeningTimeItem" } } } } ] }, - "StorySectionImageSlider": { + "StorySectionMediaItemPartial": { + "type": "object", + "properties": { + "media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "start_time": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "auto_play": { + "type": "boolean" + }, + "position": { + "type": "integer" + } + }, + "description": "Writable story section media item fields" + }, + "StorySectionMediaItem": { "allOf": [ { "type": "object", @@ -11115,57 +14107,95 @@ }, { "required": [ - "position", - "collapsable", - "exposable_content" + "position" ], - "$ref": "#/components/schemas/StorySectionPartial" + "$ref": "#/components/schemas/StorySectionMediaItemPartial" + } + ] + }, + "StorySectionMediaItemInput": { + "$ref": "#/components/schemas/StorySectionMediaItemPartial", + "description": "Request body for a story section media item" + }, + "StorySectionLinkItemPartial": { + "type": "object", + "properties": { + "link_type": { + "type": "string", + "enum": [ + "twitter", + "whatsapp", + "wechat", + "facebook", + "instagram", + "pinterest", + "youtube", + "vimeo", + "linkedin", + "tiktok", + "weibo", + "bluesky", + "internal", + "url", + "email", + "phone", + "live_tours", + "settings", + "badges", + "favourites", + "download", + "app_rating", + "search" + ] + }, + "icon_type": { + "type": "string", + "enum": [ + "media_item", + "stock_icon", + "clear" + ] + }, + "stock_icon": { + "type": "string" + }, + "item_type": { + "type": "string", + "enum": [ + "Bundle", + "Collection", + "CollectionItem", + "Screen", + "MediaItem", + "CrossRegionLink" + ] }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "image_slider" - ] - }, - "left_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "right_media_item_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "aspect_ratio": { - "type": "string", - "description": "Aspect ratio nn:nn" - }, - "start_location": { - "type": "integer", - "description": "Start location 0-100" + "item_id": { + "oneOf": [ + { + "type": "integer" }, - "caption": { - "$ref": "#/components/schemas/TranslatedString" + { + "type": "null" } - } + ] + }, + "position": { + "type": "integer" + }, + "link_text": { + "$ref": "#/components/schemas/TranslatedString" + }, + "url": { + "$ref": "#/components/schemas/TranslatedString" + }, + "username": { + "$ref": "#/components/schemas/TranslatedString" } - ] + }, + "description": "Writable story section link item fields" }, - "StorySectionLocation": { + "StorySectionLinkItem": { "allOf": [ { "type": "object", @@ -11188,73 +14218,51 @@ }, { "required": [ - "position", - "collapsable", - "exposable_content" + "link_type", + "icon_type", + "position" ], - "$ref": "#/components/schemas/StorySectionPartial" - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "location" - ] - }, - "lat": { - "oneOf": [ - { - "type": "number", - "format": "float" - }, - { - "type": "null" - } - ] - }, - "lng": { - "oneOf": [ - { - "type": "number", - "format": "float" - }, - { - "type": "null" - } - ] - }, - "map_type": { - "type": "string", - "enum": [ - "single_location", - "multiple_locations" - ], - "description": "Map type" - }, - "map_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "directions_enabled": { - "type": "boolean", - "description": "Directions enabled" - }, - "display_address": { - "$ref": "#/components/schemas/TranslatedString" - } - } + "$ref": "#/components/schemas/StorySectionLinkItemPartial" } ] }, - "StorySectionMenu": { + "StorySectionLinkItemInput": { + "$ref": "#/components/schemas/StorySectionLinkItemPartial", + "description": "Request body for a story section link item" + }, + "StorySectionSocialItemPartial": { + "type": "object", + "properties": { + "social_network": { + "type": "string", + "enum": [ + "twitter", + "whatsapp", + "wechat", + "facebook", + "instagram", + "pinterest", + "youtube", + "vimeo", + "linkedin", + "tiktok", + "weibo", + "bluesky" + ] + }, + "position": { + "type": "integer" + }, + "username": { + "$ref": "#/components/schemas/TranslatedString" + }, + "link_text": { + "$ref": "#/components/schemas/TranslatedString" + } + }, + "description": "Writable story section social item fields" + }, + "StorySectionSocialItem": { "allOf": [ { "type": "object", @@ -11277,40 +14285,36 @@ }, { "required": [ - "position", - "collapsable", - "exposable_content" + "social_network", + "position" ], - "$ref": "#/components/schemas/StorySectionPartial" - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "menu" - ] - }, - "collection_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "hide_label": { - "type": "boolean", - "description": "Hide label" - } - } + "$ref": "#/components/schemas/StorySectionSocialItemPartial" } ] }, - "StorySectionQuizQuestion": { + "StorySectionSocialItemInput": { + "$ref": "#/components/schemas/StorySectionSocialItemPartial", + "description": "Request body for a story section social item" + }, + "StorySectionPriceItemPartial": { + "type": "object", + "properties": { + "price_cents": { + "type": "integer" + }, + "price_currency": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "description": { + "$ref": "#/components/schemas/TranslatedString" + } + }, + "description": "Writable story section price item fields" + }, + "StorySectionPriceItem": { "allOf": [ { "type": "object", @@ -11331,42 +14335,36 @@ "updated_at" ] }, - { - "required": [ - "position", - "collapsable", - "exposable_content" - ], - "$ref": "#/components/schemas/StorySectionPartial" - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "quiz_question" - ] - }, - "quiz_question_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "zoomable": { - "type": "boolean", - "description": "Zoomable" - } - } + { + "required": [ + "price_cents", + "price_currency", + "position" + ], + "$ref": "#/components/schemas/StorySectionPriceItemPartial" } ] }, - "StorySectionQuizScore": { + "StorySectionPriceItemInput": { + "$ref": "#/components/schemas/StorySectionPriceItemPartial", + "description": "Request body for a story section price item" + }, + "StorySectionOpeningTimeItemPartial": { + "type": "object", + "properties": { + "position": { + "type": "integer" + }, + "description": { + "$ref": "#/components/schemas/TranslatedString" + }, + "time": { + "$ref": "#/components/schemas/TranslatedString" + } + }, + "description": "Writable story section opening time item fields" + }, + "StorySectionOpeningTimeItem": { "allOf": [ { "type": "object", @@ -11389,36 +14387,46 @@ }, { "required": [ - "position", - "collapsable", - "exposable_content" + "position" ], - "$ref": "#/components/schemas/StorySectionPartial" + "$ref": "#/components/schemas/StorySectionOpeningTimeItemPartial" + } + ] + }, + "StorySectionOpeningTimeItemInput": { + "$ref": "#/components/schemas/StorySectionOpeningTimeItemPartial", + "description": "Request body for a story section opening time item" + }, + "StorySectionBadgeItemPartial": { + "type": "object", + "properties": { + "badge_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "quiz_score" - ] + "badge_group_id": { + "oneOf": [ + { + "type": "integer" }, - "quiz_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + { + "type": "null" } - } + ] + }, + "position": { + "type": "integer" } - ] + }, + "description": "Writable story section badge item fields" }, - "StorySectionForm": { + "StorySectionBadgeItem": { "allOf": [ { "type": "object", @@ -11441,36 +14449,35 @@ }, { "required": [ - "position", - "collapsable", - "exposable_content" + "position" ], - "$ref": "#/components/schemas/StorySectionPartial" - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "form" - ] - }, - "form_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - } - } + "$ref": "#/components/schemas/StorySectionBadgeItemPartial" } ] }, - "StorySectionCustomWidget": { + "StorySectionBadgeItemInput": { + "$ref": "#/components/schemas/StorySectionBadgeItemPartial", + "description": "Request body for a story section badge item" + }, + "QuizPartial": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_language": { + "$ref": "#/components/schemas/ContentLanguage" + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "short_title": { + "$ref": "#/components/schemas/TranslatedString" + } + }, + "description": "Writable quiz fields" + }, + "Quiz": { "allOf": [ { "type": "object", @@ -11478,6 +14485,12 @@ "id": { "type": "integer" }, + "translated_languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentLanguage" + } + }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -11487,46 +14500,138 @@ }, "required": [ "id", + "translated_languages", "created_at", "updated_at" ] }, { "required": [ - "position", - "collapsable", - "exposable_content" + "name", + "primary_language" ], - "$ref": "#/components/schemas/StorySectionPartial" + "$ref": "#/components/schemas/QuizPartial" + } + ] + }, + "CreateQuizInput": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "custom_widget" - ] + "primary_language": { + "$ref": "#/components/schemas/ContentLanguage" + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "short_title": { + "$ref": "#/components/schemas/TranslatedString" + } + }, + "required": [ + "name", + "primary_language" + ], + "description": "Request body for creating a quiz" + }, + "UpdateQuizInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_language": { + "$ref": "#/components/schemas/ContentLanguage" + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "short_title": { + "$ref": "#/components/schemas/TranslatedString" + } + }, + "description": "Request body for updating a quiz" + }, + "QuizQuestionPartial": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "question_type": { + "type": "string", + "enum": [ + "single_text_choice", + "single_image_choice", + "multi_text_choice", + "multi_image_choice", + "free_text" + ] + }, + "max_attempts": { + "type": "integer" + }, + "correct_image_media_item_id": { + "oneOf": [ + { + "type": "integer" }, - "custom_widget_id": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "incorrect_image_media_item_id": { + "oneOf": [ + { + "type": "integer" }, - "content": { - "type": "string", - "description": "Custom widget content JSON" + { + "type": "null" } - } + ] + }, + "correct_audio_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "incorrect_audio_media_item_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "title": { + "$ref": "#/components/schemas/TranslatedString" + }, + "question": { + "$ref": "#/components/schemas/TranslatedString" + }, + "correct_text": { + "$ref": "#/components/schemas/TranslatedString" + }, + "incorrect_text": { + "$ref": "#/components/schemas/TranslatedString" } - ] + }, + "description": "Writable quiz question fields" }, - "StorySectionBadgeGroup": { + "QuizQuestion": { "allOf": [ { "type": "object", @@ -11534,6 +14639,9 @@ "id": { "type": "integer" }, + "quiz_id": { + "type": "integer" + }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -11543,38 +14651,52 @@ }, "required": [ "id", + "quiz_id", "created_at", "updated_at" ] }, { "required": [ + "name", "position", - "collapsable", - "exposable_content" + "question_type", + "max_attempts" ], - "$ref": "#/components/schemas/StorySectionPartial" + "$ref": "#/components/schemas/QuizQuestionPartial" + } + ] + }, + "QuizQuestionInput": { + "$ref": "#/components/schemas/QuizQuestionPartial", + "description": "Request body for a quiz question" + }, + "QuizQuestionAnswerPartial": { + "type": "object", + "properties": { + "position": { + "type": "integer" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "badge_group" - ] + "correct": { + "type": "boolean" + }, + "answer_media_item_id": { + "oneOf": [ + { + "type": "integer" }, - "badge_items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StorySectionBadgeItem" - } + { + "type": "null" } - } + ] + }, + "answer_text": { + "$ref": "#/components/schemas/TranslatedString" } - ] + }, + "description": "Writable quiz question answer fields" }, - "StorySectionPriceGroup": { + "QuizQuestionAnswer": { "allOf": [ { "type": "object", @@ -11582,6 +14704,9 @@ "id": { "type": "integer" }, + "quiz_question_id": { + "type": "integer" + }, "created_at": { "$ref": "#/components/schemas/DateTime" }, @@ -11591,6 +14716,7 @@ }, "required": [ "id", + "quiz_question_id", "created_at", "updated_at" ] @@ -11598,484 +14724,469 @@ { "required": [ "position", - "collapsable", - "exposable_content" + "correct" ], - "$ref": "#/components/schemas/StorySectionPartial" - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "price_group" - ] - }, - "price_items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StorySectionPriceItem" - } - } - } + "$ref": "#/components/schemas/QuizQuestionAnswerPartial" } ] }, - "StorySectionOpeningTimeGroup": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" + "QuizQuestionAnswerInput": { + "$ref": "#/components/schemas/QuizQuestionAnswerPartial", + "description": "Request body for a quiz question answer" + }, + "AppearsInPartial": { + "type": "object", + "properties": { + "screens": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subtype_id": { + "type": "integer" + }, + "subtype_type": { + "type": "string" + } + } + } }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" + } + ] + }, + "collections": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subtype_id": { + "type": "integer" + }, + "subtype_type": { + "type": "string" + } + } + } }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "created_at", - "updated_at" ] }, - { - "required": [ - "position", - "collapsable", - "exposable_content" - ], - "$ref": "#/components/schemas/StorySectionPartial" + "media_items": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subtype_id": { + "type": "integer" + }, + "subtype_type": { + "type": "string" + } + } + } + }, + { + "type": "null" + } + ] }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "opening_time_group" - ] + "project_tabs": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, - "opening_time_items": { + { + "type": "null" + } + ] + }, + "quizzes": { + "oneOf": [ + { "type": "array", "items": { - "$ref": "#/components/schemas/StorySectionOpeningTimeItem" + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } } + }, + { + "type": "null" } - } - } - ] - }, - "StorySectionMediaItemPartial": { - "type": "object", - "properties": { - "media_item_id": { + ] + }, + "quiz_questions": { "oneOf": [ { - "type": "integer" + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, { "type": "null" } ] }, - "start_time": { + "quiz_answers": { "oneOf": [ { - "type": "integer" + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, { "type": "null" } ] }, - "auto_play": { - "type": "boolean" - }, - "position": { - "type": "integer" - } - }, - "description": "Writable story section media item fields" - }, - "StorySectionMediaItem": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" - }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" - } - }, - "required": [ - "id", - "created_at", - "updated_at" - ] - }, - { - "required": [ - "position" - ], - "$ref": "#/components/schemas/StorySectionMediaItemPartial" - } - ] - }, - "StorySectionMediaItemInput": { - "$ref": "#/components/schemas/StorySectionMediaItemPartial", - "description": "Request body for a story section media item" - }, - "StorySectionLinkItemPartial": { - "type": "object", - "properties": { - "link_type": { - "type": "string", - "enum": [ - "twitter", - "whatsapp", - "wechat", - "facebook", - "instagram", - "pinterest", - "youtube", - "vimeo", - "linkedin", - "tiktok", - "weibo", - "bluesky", - "internal", - "url", - "email", - "phone", - "live_tours", - "settings", - "badges", - "favourites", - "download", - "app_rating", - "search" - ] - }, - "icon_type": { - "type": "string", - "enum": [ - "media_item", - "stock_icon", - "clear" - ] - }, - "stock_icon": { - "type": "string" - }, - "item_type": { - "type": "string", - "enum": [ - "Bundle", - "Collection", - "CollectionItem", - "Screen", - "MediaItem", - "CrossRegionLink" - ] - }, - "item_id": { + "app_listings": { "oneOf": [ { - "type": "integer" + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, { "type": "null" } ] }, - "position": { - "type": "integer" - }, - "link_text": { - "$ref": "#/components/schemas/TranslatedString" - }, - "url": { - "$ref": "#/components/schemas/TranslatedString" - }, - "username": { - "$ref": "#/components/schemas/TranslatedString" - } - }, - "description": "Writable story section link item fields" - }, - "StorySectionLinkItem": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + "in_app_purchases": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "created_at", - "updated_at" - ] - }, - { - "required": [ - "link_type", - "icon_type", - "position" - ], - "$ref": "#/components/schemas/StorySectionLinkItemPartial" - } - ] - }, - "StorySectionLinkItemInput": { - "$ref": "#/components/schemas/StorySectionLinkItemPartial", - "description": "Request body for a story section link item" - }, - "StorySectionSocialItemPartial": { - "type": "object", - "properties": { - "social_network": { - "type": "string", - "enum": [ - "twitter", - "whatsapp", - "wechat", - "facebook", - "instagram", - "pinterest", - "youtube", - "vimeo", - "linkedin", - "tiktok", - "weibo", - "bluesky" ] }, - "position": { - "type": "integer" - }, - "username": { - "$ref": "#/components/schemas/TranslatedString" - }, - "link_text": { - "$ref": "#/components/schemas/TranslatedString" - } - }, - "description": "Writable story section social item fields" - }, - "StorySectionSocialItem": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + "products": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "created_at", - "updated_at" ] }, - { - "required": [ - "social_network", - "position" - ], - "$ref": "#/components/schemas/StorySectionSocialItemPartial" - } - ] - }, - "StorySectionSocialItemInput": { - "$ref": "#/components/schemas/StorySectionSocialItemPartial", - "description": "Request body for a story section social item" - }, - "StorySectionPriceItemPartial": { - "type": "object", - "properties": { - "price_cents": { - "type": "integer" - }, - "price_currency": { - "type": "string" - }, - "position": { - "type": "integer" - }, - "description": { - "$ref": "#/components/schemas/TranslatedString" - } - }, - "description": "Writable story section price item fields" - }, - "StorySectionPriceItem": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + "map_layers": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "created_at", - "updated_at" ] }, - { - "required": [ - "price_cents", - "price_currency", - "position" - ], - "$ref": "#/components/schemas/StorySectionPriceItemPartial" - } - ] - }, - "StorySectionPriceItemInput": { - "$ref": "#/components/schemas/StorySectionPriceItemPartial", - "description": "Request body for a story section price item" - }, - "StorySectionOpeningTimeItemPartial": { - "type": "object", - "properties": { - "position": { - "type": "integer" - }, - "description": { - "$ref": "#/components/schemas/TranslatedString" - }, - "time": { - "$ref": "#/components/schemas/TranslatedString" - } - }, - "description": "Writable story section opening time item fields" - }, - "StorySectionOpeningTimeItem": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + "projects": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "created_at", - "updated_at" ] }, - { - "required": [ - "position" - ], - "$ref": "#/components/schemas/StorySectionOpeningTimeItemPartial" - } - ] - }, - "StorySectionOpeningTimeItemInput": { - "$ref": "#/components/schemas/StorySectionOpeningTimeItemPartial", - "description": "Request body for a story section opening time item" - }, - "StorySectionBadgeItemPartial": { - "type": "object", - "properties": { - "badge_id": { + "badges": { "oneOf": [ { - "type": "integer" + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, { "type": "null" } ] }, - "badge_group_id": { + "linked_items": { "oneOf": [ { - "type": "integer" + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, { "type": "null" } ] }, - "position": { - "type": "integer" - } - }, - "description": "Writable story section badge item fields" - }, - "StorySectionBadgeItem": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "$ref": "#/components/schemas/DateTime" + "playlist_items": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } }, - "updated_at": { - "$ref": "#/components/schemas/DateTime" + { + "type": "null" } - }, - "required": [ - "id", - "created_at", - "updated_at" ] - }, - { - "required": [ - "position" - ], - "$ref": "#/components/schemas/StorySectionBadgeItemPartial" } - ] - }, - "StorySectionBadgeItemInput": { - "$ref": "#/components/schemas/StorySectionBadgeItemPartial", - "description": "Request body for a story section badge item" + }, + "description": "Cross-references where this record is referenced. Each slot is an array of references or null when absent — slots that do not apply to the parent resource type always return null. `subtype_type` is the polymorphic subtype class name." } } } diff --git a/internal/api/quizzes.go b/internal/api/quizzes.go new file mode 100644 index 0000000..488e1f9 --- /dev/null +++ b/internal/api/quizzes.go @@ -0,0 +1,275 @@ +package api + +import ( + "fmt" + "strconv" +) + +// ── Quizzes ──────────────────────────────────────────────────────────────────── +// +// Quizzes are a three-level top-level resource: +// +// Quiz +// └── QuizQuestion (POST .../questions, position-ordered) +// └── QuizQuestionAnswer (POST .../questions/:id/answers, position-ordered) +// +// Translated fields are sent as locale maps ({"en": "…"}), the same shape as +// collections / screens; see CreateScreen for why the body is flat. Response +// envelopes mirror the jbuilder views in mytours-web: +// - quizzes: {quizzes:[…], meta} / {quiz:{…}} +// - questions: {questions:[…], meta} / {question:{…}} +// - answers: {answers:[…], meta} / {answer:{…}} + +// ListQuizzes returns a paginated list of quizzes. +func ListQuizzes(c *Client, query map[string]string) ([]map[string]interface{}, *PaginationMeta, error) { + var resp struct { + Quizzes []map[string]interface{} `json:"quizzes"` + Meta *PaginationMeta `json:"meta"` + } + if err := c.Get("/api/public/quizzes", query, &resp); err != nil { + return nil, nil, err + } + return resp.Quizzes, resp.Meta, nil +} + +// GetQuiz returns a single quiz by ID. +func GetQuiz(c *Client, id string) (map[string]interface{}, error) { + var resp map[string]interface{} + if err := c.Get(fmt.Sprintf("/api/public/quizzes/%s", id), nil, &resp); err != nil { + return nil, err + } + if quiz, ok := resp["quiz"].(map[string]interface{}); ok { + return quiz, nil + } + return resp, nil +} + +// CreateQuiz creates a new quiz. Fields are sent flat; see CreateScreen. +func CreateQuiz(c *Client, fields map[string]interface{}) (map[string]interface{}, error) { + var resp map[string]interface{} + if err := c.Post("/api/public/quizzes", fields, &resp); err != nil { + return nil, err + } + if quiz, ok := resp["quiz"].(map[string]interface{}); ok { + return quiz, nil + } + return resp, nil +} + +// UpdateQuiz updates an existing quiz. +func UpdateQuiz(c *Client, id string, fields map[string]interface{}) (map[string]interface{}, error) { + var resp map[string]interface{} + if err := c.Patch(fmt.Sprintf("/api/public/quizzes/%s", id), fields, &resp); err != nil { + return nil, err + } + if quiz, ok := resp["quiz"].(map[string]interface{}); ok { + return quiz, nil + } + return resp, nil +} + +// DeleteQuiz deletes a quiz by ID. Optional query supports the per-locale +// delete via the public API's "language" param — pass {"language": "fr"} to +// drop only the French translation instead of the whole quiz. Same shape as +// DeleteScreen / DeleteCollection. +func DeleteQuiz(c *Client, id string, query map[string]string) error { + return c.Delete(fmt.Sprintf("/api/public/quizzes/%s", id), query) +} + +// QuizAppearsIn returns the cross-resource references for a quiz — the screens +// that reference it via a quiz-score or quiz-question story section. See +// ScreenAppearsIn for the envelope shape. +func QuizAppearsIn(c *Client, id string) (map[string]interface{}, error) { + var resp map[string]interface{} + if err := c.Get(fmt.Sprintf("/api/public/quizzes/%s/appears_in", id), nil, &resp); err != nil { + return nil, err + } + return resp, nil +} + +// ── Quiz Questions ────────────────────────────────────────────────────────────── + +// ListQuizQuestions returns the questions for a quiz, in display order. +func ListQuizQuestions(c *Client, quizID string, query map[string]string) ([]map[string]interface{}, *PaginationMeta, error) { + var resp struct { + Questions []map[string]interface{} `json:"questions"` + Meta *PaginationMeta `json:"meta"` + } + path := fmt.Sprintf("/api/public/quizzes/%s/questions", quizID) + if err := c.Get(path, query, &resp); err != nil { + return nil, nil, err + } + return resp.Questions, resp.Meta, nil +} + +// GetQuizQuestion returns a single question by ID. +func GetQuizQuestion(c *Client, quizID, questionID string) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s", quizID, questionID) + if err := c.Get(path, nil, &resp); err != nil { + return nil, err + } + if q, ok := resp["question"].(map[string]interface{}); ok { + return q, nil + } + return resp, nil +} + +// CreateQuizQuestion creates a new question on a quiz. +func CreateQuizQuestion(c *Client, quizID string, fields map[string]interface{}) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions", quizID) + if err := c.Post(path, fields, &resp); err != nil { + return nil, err + } + if q, ok := resp["question"].(map[string]interface{}); ok { + return q, nil + } + return resp, nil +} + +// UpdateQuizQuestion updates an existing question. +func UpdateQuizQuestion(c *Client, quizID, questionID string, fields map[string]interface{}) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s", quizID, questionID) + if err := c.Patch(path, fields, &resp); err != nil { + return nil, err + } + if q, ok := resp["question"].(map[string]interface{}); ok { + return q, nil + } + return resp, nil +} + +// DeleteQuizQuestion deletes a question. Optional query supports the per-locale +// delete via the "language" param, same as DeleteQuiz. +func DeleteQuizQuestion(c *Client, quizID, questionID string, query map[string]string) error { + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s", quizID, questionID) + return c.Delete(path, query) +} + +// ReorderQuizQuestions sets the order of a quiz's questions via update_positions. +// The API expects {"positions": [{"id": , "position": }, ...]}. +// +// Unlike ReorderCollectionItems (which is 1-based because that endpoint treats +// position 0 as "unset"), the quiz position concern writes literal positions and +// the model uses acts_as_list with top_of_list: 0 — so positions here are +// 0-based: the first id passed lands at position 0 (the top of the list). This +// matches the controller spec, which round-trips a [2,1,0] payload verbatim. +func ReorderQuizQuestions(c *Client, quizID string, questionIDs []string) error { + path := fmt.Sprintf("/api/public/quizzes/%s/questions/update_positions", quizID) + positions, err := zeroBasedPositions(questionIDs, "question") + if err != nil { + return err + } + return c.Post(path, map[string]interface{}{"positions": positions}, nil) +} + +// QuizQuestionAppearsIn returns the cross-resource references for a question — +// the screens that embed it, the parent quiz, and any badges triggered by it. +func QuizQuestionAppearsIn(c *Client, quizID, questionID string) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/appears_in", quizID, questionID) + if err := c.Get(path, nil, &resp); err != nil { + return nil, err + } + return resp, nil +} + +// ── Quiz Question Answers ─────────────────────────────────────────────────────── + +// ListQuizAnswers returns the answers for a question, in display order. +func ListQuizAnswers(c *Client, quizID, questionID string, query map[string]string) ([]map[string]interface{}, *PaginationMeta, error) { + var resp struct { + Answers []map[string]interface{} `json:"answers"` + Meta *PaginationMeta `json:"meta"` + } + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers", quizID, questionID) + if err := c.Get(path, query, &resp); err != nil { + return nil, nil, err + } + return resp.Answers, resp.Meta, nil +} + +// GetQuizAnswer returns a single answer by ID. +func GetQuizAnswer(c *Client, quizID, questionID, answerID string) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers/%s", quizID, questionID, answerID) + if err := c.Get(path, nil, &resp); err != nil { + return nil, err + } + if a, ok := resp["answer"].(map[string]interface{}); ok { + return a, nil + } + return resp, nil +} + +// CreateQuizAnswer creates a new answer on a question. +func CreateQuizAnswer(c *Client, quizID, questionID string, fields map[string]interface{}) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers", quizID, questionID) + if err := c.Post(path, fields, &resp); err != nil { + return nil, err + } + if a, ok := resp["answer"].(map[string]interface{}); ok { + return a, nil + } + return resp, nil +} + +// UpdateQuizAnswer updates an existing answer. +func UpdateQuizAnswer(c *Client, quizID, questionID, answerID string, fields map[string]interface{}) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers/%s", quizID, questionID, answerID) + if err := c.Patch(path, fields, &resp); err != nil { + return nil, err + } + if a, ok := resp["answer"].(map[string]interface{}); ok { + return a, nil + } + return resp, nil +} + +// DeleteQuizAnswer deletes an answer. Optional query supports the per-locale +// delete via the "language" param, same as DeleteQuiz. +func DeleteQuizAnswer(c *Client, quizID, questionID, answerID string, query map[string]string) error { + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers/%s", quizID, questionID, answerID) + return c.Delete(path, query) +} + +// ReorderQuizAnswers sets the order of a question's answers via update_positions. +// Positions are 0-based; see ReorderQuizQuestions for why. +func ReorderQuizAnswers(c *Client, quizID, questionID string, answerIDs []string) error { + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers/update_positions", quizID, questionID) + positions, err := zeroBasedPositions(answerIDs, "answer") + if err != nil { + return err + } + return c.Post(path, map[string]interface{}{"positions": positions}, nil) +} + +// QuizAnswerAppearsIn returns the cross-resource references for an answer. +func QuizAnswerAppearsIn(c *Client, quizID, questionID, answerID string) (map[string]interface{}, error) { + var resp map[string]interface{} + path := fmt.Sprintf("/api/public/quizzes/%s/questions/%s/answers/%s/appears_in", quizID, questionID, answerID) + if err := c.Get(path, nil, &resp); err != nil { + return nil, err + } + return resp, nil +} + +// zeroBasedPositions maps an ordered slice of string IDs to the +// {id, position} payload the quiz update_positions endpoints expect, assigning +// position i (0-based) to the i-th id. label names the resource in the +// non-integer-id error message. +func zeroBasedPositions(ids []string, label string) ([]map[string]interface{}, error) { + positions := make([]map[string]interface{}, 0, len(ids)) + for i, idStr := range ids { + id, err := strconv.Atoi(idStr) + if err != nil { + return nil, fmt.Errorf("invalid %s id %q: must be an integer", label, idStr) + } + positions = append(positions, map[string]interface{}{"id": id, "position": i}) + } + return positions, nil +} diff --git a/internal/api/quizzes_test.go b/internal/api/quizzes_test.go new file mode 100644 index 0000000..d9761d4 --- /dev/null +++ b/internal/api/quizzes_test.go @@ -0,0 +1,400 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListQuizzes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("q") != "caps" { + t.Errorf("expected q=caps, got %s", r.URL.Query().Get("q")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "quizzes": []interface{}{ + map[string]interface{}{"id": 1, "name": "Capitals"}, + map[string]interface{}{"id": 2, "name": "Flags"}, + }, + "meta": map[string]interface{}{"page": 1, "pages": 1, "per_page": 30, "count": 2}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + quizzes, meta, err := ListQuizzes(c, map[string]string{"q": "caps"}) + if err != nil { + t.Fatalf("ListQuizzes: %v", err) + } + if len(quizzes) != 2 { + t.Errorf("expected 2 quizzes, got %d", len(quizzes)) + } + if meta == nil || meta.Count != 2 { + t.Errorf("expected meta.Count=2, got %+v", meta) + } +} + +func TestGetQuiz(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "quiz": map[string]interface{}{"id": 42, "name": "Capitals"}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + quiz, err := GetQuiz(c, "42") + if err != nil { + t.Fatalf("GetQuiz: %v", err) + } + if quiz["name"] != "Capitals" { + t.Errorf("expected name=Capitals, got %v", quiz["name"]) + } +} + +func TestCreateQuiz(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + if body["name"] != "Capitals" { + t.Errorf("expected name=Capitals, got %v", body["name"]) + } + title, ok := body["title"].(map[string]interface{}) + if !ok || title["en"] != "Capitals Quiz" { + t.Errorf("expected title.en=Capitals Quiz, got %v", body["title"]) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{ + "quiz": map[string]interface{}{"id": 7, "name": "Capitals"}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + quiz, err := CreateQuiz(c, map[string]interface{}{ + "name": "Capitals", + "title": map[string]interface{}{"en": "Capitals Quiz"}, + }) + if err != nil { + t.Fatalf("CreateQuiz: %v", err) + } + if quiz["id"].(float64) != 7 { + t.Errorf("expected id=7, got %v", quiz["id"]) + } +} + +func TestUpdateQuiz(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes/42" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "quiz": map[string]interface{}{"id": 42, "name": "Renamed"}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + quiz, err := UpdateQuiz(c, "42", map[string]interface{}{"name": "Renamed"}) + if err != nil { + t.Fatalf("UpdateQuiz: %v", err) + } + if quiz["name"] != "Renamed" { + t.Errorf("expected name=Renamed, got %v", quiz["name"]) + } +} + +func TestDeleteQuizWithLanguage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes/42" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("language") != "fr" { + t.Errorf("expected language=fr, got %s", r.URL.Query().Get("language")) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"quiz": map[string]interface{}{"id": 42}}) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + if err := DeleteQuiz(c, "42", map[string]string{"language": "fr"}); err != nil { + t.Fatalf("DeleteQuiz: %v", err) + } +} + +func TestQuizAppearsIn(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/appears_in" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "screens": []interface{}{map[string]interface{}{"id": 99}}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + result, err := QuizAppearsIn(c, "42") + if err != nil { + t.Fatalf("QuizAppearsIn: %v", err) + } + if _, ok := result["screens"]; !ok { + t.Errorf("expected screens key, got %v", result) + } +} + +func TestListQuizQuestions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "questions": []interface{}{map[string]interface{}{"id": 7, "name": "Q1"}}, + "meta": map[string]interface{}{"page": 1, "pages": 1, "per_page": 30, "count": 1}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + questions, meta, err := ListQuizQuestions(c, "42", nil) + if err != nil { + t.Fatalf("ListQuizQuestions: %v", err) + } + if len(questions) != 1 || questions[0]["name"] != "Q1" { + t.Errorf("unexpected questions: %v", questions) + } + if meta == nil || meta.Count != 1 { + t.Errorf("expected meta.Count=1, got %+v", meta) + } +} + +func TestCreateQuizQuestion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes/42/questions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + if body["question_type"] != "single_text_choice" { + t.Errorf("expected question_type=single_text_choice, got %v", body["question_type"]) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{ + "question": map[string]interface{}{"id": 7, "name": "Q1", "quiz_id": 42}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + question, err := CreateQuizQuestion(c, "42", map[string]interface{}{ + "name": "Q1", + "question_type": "single_text_choice", + }) + if err != nil { + t.Fatalf("CreateQuizQuestion: %v", err) + } + if question["id"].(float64) != 7 { + t.Errorf("expected id=7, got %v", question["id"]) + } +} + +// TestReorderQuizQuestions pins the 0-based position encoding: unlike +// ReorderCollectionItems (1-based), the quiz update_positions endpoint writes +// literal positions, so the first id passed must land at position 0. +func TestReorderQuizQuestions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes/42/questions/update_positions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + positions, ok := body["positions"].([]interface{}) + if !ok || len(positions) != 3 { + t.Fatalf("expected 3 positions, got %T %v", body["positions"], body["positions"]) + } + first := positions[0].(map[string]interface{}) + if first["id"].(float64) != 7 || first["position"].(float64) != 0 { + t.Errorf("expected first {id:7, position:0}, got %v", first) + } + third := positions[2].(map[string]interface{}) + if third["id"].(float64) != 9 || third["position"].(float64) != 2 { + t.Errorf("expected third {id:9, position:2}, got %v", third) + } + w.WriteHeader(204) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + if err := ReorderQuizQuestions(c, "42", []string{"7", "8", "9"}); err != nil { + t.Fatalf("ReorderQuizQuestions: %v", err) + } +} + +func TestReorderQuizQuestionsRejectsNonIntID(t *testing.T) { + c := NewClient("http://example.invalid", "test-token") + if err := ReorderQuizQuestions(c, "42", []string{"7", "abc"}); err == nil { + t.Fatal("expected an error for a non-integer id, got nil") + } +} + +func TestListQuizAnswers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "answers": []interface{}{map[string]interface{}{"id": 13, "correct": true}}, + "meta": map[string]interface{}{"page": 1, "pages": 1, "per_page": 30, "count": 1}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + answers, _, err := ListQuizAnswers(c, "42", "7", nil) + if err != nil { + t.Fatalf("ListQuizAnswers: %v", err) + } + if len(answers) != 1 || answers[0]["correct"] != true { + t.Errorf("unexpected answers: %v", answers) + } +} + +func TestCreateQuizAnswer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + if body["correct"] != true { + t.Errorf("expected correct=true (JSON bool), got %T %v", body["correct"], body["correct"]) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{ + "answer": map[string]interface{}{"id": 13, "correct": true, "quiz_question_id": 7}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + answer, err := CreateQuizAnswer(c, "42", "7", map[string]interface{}{ + "correct": true, + "answer_text": map[string]interface{}{"en": "Paris"}, + }) + if err != nil { + t.Fatalf("CreateQuizAnswer: %v", err) + } + if answer["id"].(float64) != 13 { + t.Errorf("expected id=13, got %v", answer["id"]) + } +} + +func TestReorderQuizAnswers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers/update_positions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + positions := body["positions"].([]interface{}) + first := positions[0].(map[string]interface{}) + if first["id"].(float64) != 13 || first["position"].(float64) != 0 { + t.Errorf("expected first {id:13, position:0}, got %v", first) + } + w.WriteHeader(204) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + if err := ReorderQuizAnswers(c, "42", "7", []string{"13", "14"}); err != nil { + t.Fatalf("ReorderQuizAnswers: %v", err) + } +} + +func TestDeleteQuizAnswer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers/13" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(204) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + if err := DeleteQuizAnswer(c, "42", "7", "13", nil); err != nil { + t.Fatalf("DeleteQuizAnswer: %v", err) + } +} + +func TestQuizAnswerAppearsIn(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers/13/appears_in" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "quizzes": []interface{}{map[string]interface{}{"id": 42}}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + result, err := QuizAnswerAppearsIn(c, "42", "7", "13") + if err != nil { + t.Fatalf("QuizAnswerAppearsIn: %v", err) + } + if _, ok := result["quizzes"]; !ok { + t.Errorf("expected quizzes key, got %v", result) + } +} diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 9cabaa2..183a510 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -142,6 +142,11 @@ func newCompletionRefreshCmd() *cobra.Command { return api.ListProjects(client, map[string]string{"page": strconv.Itoa(page), "per_page": "100"}) }) }}, + {"quizzes", func() ([]completion.CacheEntry, error) { + return fetchAllEntries(func(page int) ([]map[string]interface{}, *api.PaginationMeta, error) { + return api.ListQuizzes(client, map[string]string{"page": strconv.Itoa(page), "per_page": "100"}) + }) + }}, } fmt.Fprintf(cmd.OutOrStdout(), "Refreshed completions for %s:\n", key) @@ -172,7 +177,7 @@ func newCompletionStatusCmd() *cobra.Command { return fmt.Errorf("no account configured; run `stqry config init` in a directory with stqry.yaml") } - resources := []string{"collections", "screens", "media", "projects"} + resources := []string{"collections", "screens", "media", "projects", "quizzes"} w := cmd.OutOrStdout() fmt.Fprintf(w, "%-15s %-8s %-12s %s\n", "Resource", "Items", "Age", "Status") fmt.Fprintf(w, "%-15s %-8s %-12s %s\n", "--------", "-----", "---", "------") diff --git a/internal/cli/completion_helpers.go b/internal/cli/completion_helpers.go index 6f96261..d556fdd 100644 --- a/internal/cli/completion_helpers.go +++ b/internal/cli/completion_helpers.go @@ -87,3 +87,7 @@ func completeMediaIDs(cmd *cobra.Command, args []string, toComplete string) ([]s func completeProjectIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completionResults(resolveAccountKeyForCompletion(), "projects") } + +func completeQuizIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completionResults(resolveAccountKeyForCompletion(), "quizzes") +} diff --git a/internal/cli/quizzes.go b/internal/cli/quizzes.go new file mode 100644 index 0000000..0ab40f7 --- /dev/null +++ b/internal/cli/quizzes.go @@ -0,0 +1,925 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/mytours/stqry-cli/internal/api" + "github.com/mytours/stqry-cli/internal/output" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" +) + +// validQuizQuestionTypes mirrors QuizQuestion::QUESTION_TYPES in mytours-web +// (app/models/quiz_question.rb). single_image_choice / multi_image_choice are +// the "image" question types (answers carry an image media item); the rest are +// "text" question types (answers carry answer_text). Keep in sync if new types +// are added server-side. +var validQuizQuestionTypes = []string{ + "single_text_choice", "single_image_choice", + "multi_text_choice", "multi_image_choice", "free_text", +} + +func validateQuizQuestionType(t string) error { + return validateEnum("question type", t, validQuizQuestionTypes) +} + +func newQuizzesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "quizzes", + Short: "Manage quizzes", + Long: `Manage quizzes, their questions, and each question's answers. + +Quizzes are a three-level hierarchy: + + quiz + └── question (stqry quizzes questions ...) + └── answer (stqry quizzes questions answers ...) + +A quiz is surfaced in an app by a quiz_question / quiz_score story section on a +screen; use 'stqry quizzes appears-in ' to find the screens that embed it.`, + Example: ` # List all quizzes + stqry quizzes list + + # Create a quiz, then add a question and two answers + stqry quizzes create --name "Capitals Quiz" --title "Capitals" + stqry quizzes questions add 42 --name "Q1" --question-type single_text_choice \ + --question "Capital of France?" --correct-text "Correct!" --incorrect-text "Try again" + stqry quizzes questions answers add 42 7 --answer-text "Paris" --correct + stqry quizzes questions answers add 42 7 --answer-text "Lyon"`, + } + + cmd.AddCommand(newQuizzesListCmd()) + cmd.AddCommand(newQuizzesGetCmd()) + cmd.AddCommand(newQuizzesCreateCmd()) + cmd.AddCommand(newQuizzesUpdateCmd()) + cmd.AddCommand(newQuizzesDeleteCmd()) + appearsIn := newAppearsInCmd("quiz", "quizzes", api.QuizAppearsIn) + appearsIn.ValidArgsFunction = completeQuizIDs + cmd.AddCommand(appearsIn) + cmd.AddCommand(newQuizQuestionsCmd()) + + return cmd +} + +// ── quizzes list ──────────────────────────────────────────────────────────────── + +func newQuizzesListCmd() *cobra.Command { + var page, perPage int + var q, sortField, sortDirection string + + cmd := &cobra.Command{ + Use: "list", + Short: "List quizzes", + Example: ` # List all quizzes + stqry quizzes list + + # Search for quizzes by name + stqry quizzes list --q "capitals" + + # Sort by updated time, most recent first + stqry quizzes list --sort-field updated_at --sort-direction desc + + # Filter with built-in jq (no external jq needed) + stqry quizzes list --jq '.[].name'`, + RunE: func(cmd *cobra.Command, args []string) error { + query := map[string]string{} + if page > 0 { + query["page"] = strconv.Itoa(page) + } + if perPage > 0 { + query["per_page"] = strconv.Itoa(perPage) + } + if q != "" { + query["q"] = q + } + if sortField != "" { + query["sort_field"] = sortField + } + if sortDirection != "" { + query["sort_direction"] = strings.ToUpper(sortDirection) + } + + quizzes, meta, err := api.ListQuizzes(activeClient, query) + if err != nil { + return err + } + + var m *output.Meta + if meta != nil { + m = &output.Meta{Page: meta.Page, Pages: meta.Pages, PerPage: meta.PerPage, Count: meta.Count} + } + return printer.PrintList([]string{"id", "name", "title"}, quizzes, m) + }, + } + + cmd.Flags().IntVar(&page, "page", 0, "Page number") + cmd.Flags().IntVar(&perPage, "per-page", 0, "Results per page") + cmd.Flags().StringVar(&q, "q", "", "Search query (matches name)") + cmd.Flags().StringVar(&sortField, "sort-field", "", "Field to sort by (one of: id, name, created_at, updated_at)") + cmd.Flags().StringVar(&sortDirection, "sort-direction", "", "Sort direction (asc / desc)") + + return cmd +} + +// ── quizzes get ───────────────────────────────────────────────────────────────── + +func newQuizzesGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a quiz by ID", + Example: ` # Get a quiz by ID + stqry quizzes get 42 + + # Filter a specific field + stqry quizzes get 42 --jq '.title.en'`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + quiz, err := api.GetQuiz(activeClient, args[0]) + if err != nil { + return err + } + return printer.PrintOne(quiz, nil) + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +// ── quizzes create ────────────────────────────────────────────────────────────── + +func newQuizzesCreateCmd() *cobra.Command { + var name, title, shortTitle, primaryLanguage string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a quiz", + Example: ` # Create a quiz (--name or --title is required) + stqry quizzes create --name "Capitals Quiz" + + # Set the translatable title (defaults to the primary language when --lang is omitted) + stqry quizzes create --name "Capitals Quiz" --title "Capitales" --lang fr + + # Override the short title (used in compact UI views) + stqry quizzes create --name "Grand Capitals Quiz" --short-title "Capitals"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := api.ValidateLanguage(primaryLanguage); err != nil { + return err + } + if name == "" && title == "" { + return fmt.Errorf("either --name or --title is required") + } + lang := resolveContentLanguage() + if primaryLanguage == "" { + primaryLanguage = lang + } + // Default name to title and title to name verbatim. Do not slugify; + // the "name" field is a flat-string display label, not a URL slug. + effectiveName := name + if effectiveName == "" { + effectiveName = title + } + effectiveTitle := title + if effectiveTitle == "" { + effectiveTitle = name + } + fields := map[string]interface{}{ + "name": effectiveName, + "primary_language": primaryLanguage, + "title": map[string]interface{}{lang: effectiveTitle}, + } + // The model requires short_title on the primary language; default + // it to the title when omitted (mirrors collections create). + effectiveShort := shortTitle + if effectiveShort == "" { + effectiveShort = effectiveTitle + } + fields["short_title"] = map[string]interface{}{lang: effectiveShort} + + quiz, err := api.CreateQuiz(activeClient, fields) + if err != nil { + return err + } + return printer.PrintOne(quiz, nil) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Quiz name (defaults to --title if omitted; plain label, not a slug)") + cmd.Flags().StringVar(&title, "title", "", "Quiz title (defaults to --name if omitted)") + cmd.Flags().StringVar(&shortTitle, "short-title", "", "Quiz short title (defaults to --title if omitted)") + cmd.Flags().StringVar(&primaryLanguage, "primary-language", "", "Primary language for the new quiz (defaults to --lang, which itself defaults to the account's 'settings.default_content_language'). Drives which locale required translated fields are validated against on the server.") + + return cmd +} + +// ── quizzes update ────────────────────────────────────────────────────────────── + +func newQuizzesUpdateCmd() *cobra.Command { + var name, title, shortTitle, primaryLanguage string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a quiz", + Example: ` # Rename a quiz + stqry quizzes update 42 --name "New Name" + + # Update the title in French + stqry quizzes update 42 --title "Capitales" --lang fr + + # Change the primary language (the target locale must already have title + + # short_title populated, or the server rejects the flip) + stqry quizzes update 42 --primary-language fr`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := api.ValidateLanguage(primaryLanguage); err != nil { + return err + } + lang := resolveContentLanguage() + fields := map[string]interface{}{} + cmd.Flags().Visit(func(f *flag.Flag) { + switch f.Name { + case "name": + fields["name"] = name + case "title": + fields["title"] = map[string]interface{}{lang: title} + case "short-title": + fields["short_title"] = map[string]interface{}{lang: shortTitle} + case "primary-language": + fields["primary_language"] = primaryLanguage + } + }) + if len(fields) == 0 { + return fmt.Errorf("no fields to update; pass at least one flag (e.g. --name)") + } + + quiz, err := api.UpdateQuiz(activeClient, args[0], fields) + if err != nil { + return err + } + return printer.PrintOne(quiz, nil) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "New quiz name") + cmd.Flags().StringVar(&title, "title", "", "New quiz title") + cmd.Flags().StringVar(&shortTitle, "short-title", "", "New quiz short title") + cmd.Flags().StringVar(&primaryLanguage, "primary-language", "", "Change the primary language (e.g. en, fr; see 'stqry config languages'). Existing translation records are preserved.") + cmd.ValidArgsFunction = completeQuizIDs + + return cmd +} + +// ── quizzes delete ────────────────────────────────────────────────────────────── + +func newQuizzesDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a quiz (or just one of its translation locales)", + Example: ` # Delete a quiz entirely + stqry quizzes delete 42 + + # Delete only the French translations on a quiz (the quiz itself stays) + stqry quizzes delete 42 --lang fr`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Only forward --lang when the user explicitly passed it (the global + // --lang otherwise defaults to the account's primary language via + // resolveContentLanguage, and a bare `quizzes delete 42` must not + // silently drop primary-locale translations). Same defensive shape + // as screens/collections delete. + var query map[string]string + lang := "" + if cmd.Flags().Changed("lang") { + lang = flagLang + } + if lang != "" { + query = map[string]string{"language": lang} + } + if err := api.DeleteQuiz(activeClient, args[0], query); err != nil { + return err + } + if lang != "" { + printHumanConfirmation("Deleted %s translations.", lang) + } else { + printHumanConfirmation("Deleted.") + } + return nil + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +// ── quizzes questions ───────────────────────────────────────────────────────────── + +func newQuizQuestionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "questions", + Short: "Manage a quiz's questions", + Example: ` # List questions for a quiz + stqry quizzes questions list 42 + + # Add a question + stqry quizzes questions add 42 --name "Q1" --question-type single_text_choice \ + --question "Capital of France?" --correct-text "Correct!" --incorrect-text "Try again"`, + } + + cmd.AddCommand(newQuizQuestionsListCmd()) + cmd.AddCommand(newQuizQuestionsGetCmd()) + cmd.AddCommand(newQuizQuestionsAddCmd()) + cmd.AddCommand(newQuizQuestionsUpdateCmd()) + cmd.AddCommand(newQuizQuestionsRemoveCmd()) + cmd.AddCommand(newQuizQuestionsReorderCmd()) + cmd.AddCommand(newQuizQuestionsAppearsInCmd()) + cmd.AddCommand(newQuizAnswersCmd()) + + return cmd +} + +func newQuizQuestionsListCmd() *cobra.Command { + var page, perPage int + var q string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List questions for a quiz", + Example: ` # List all questions for a quiz + stqry quizzes questions list 42 + + # Search by name + stqry quizzes questions list 42 --q "capital"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := map[string]string{} + if page > 0 { + query["page"] = strconv.Itoa(page) + } + if perPage > 0 { + query["per_page"] = strconv.Itoa(perPage) + } + if q != "" { + query["q"] = q + } + questions, meta, err := api.ListQuizQuestions(activeClient, args[0], query) + if err != nil { + return err + } + var m *output.Meta + if meta != nil { + m = &output.Meta{Page: meta.Page, Pages: meta.Pages, PerPage: meta.PerPage, Count: meta.Count} + } + return printer.PrintList([]string{"id", "position", "name", "question_type", "title"}, questions, m) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page number") + cmd.Flags().IntVar(&perPage, "per-page", 0, "Results per page") + cmd.Flags().StringVar(&q, "q", "", "Search query (matches name)") + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizQuestionsGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a single quiz question", + Example: ` # Get a question by ID + stqry quizzes questions get 42 7 + + # Filter a specific field + stqry quizzes questions get 42 7 --jq '.question.en'`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + question, err := api.GetQuizQuestion(activeClient, args[0], args[1]) + if err != nil { + return err + } + return printer.PrintOne(question, nil) + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizQuestionsAddCmd() *cobra.Command { + var name, questionType, title, question, correctText, incorrectText string + var maxAttempts, position int + var correctImageID, incorrectImageID, correctAudioID, incorrectAudioID int + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a question to a quiz", + Long: `Add a question to a quiz. + +The server requires the title, question, correct_text, and incorrect_text +translated fields on the quiz's primary language. --title defaults to --name +when omitted; pass --question / --correct-text / --incorrect-text explicitly (a +create missing any of them is rejected with a 422 listing the blank fields). + +For an image question (--question-type single_image_choice / multi_image_choice) +the per-answer image lives on the answer (--answer-media-item-id). The four +question-level media flags are the optional correct/incorrect feedback +image/audio shown after the question is answered; each must reference a media +item of the matching subtype (image vs audio) or the server returns 422.`, + Example: ` # Add a text-choice question + stqry quizzes questions add 42 --name "Q1" --question-type single_text_choice \ + --question "Capital of France?" --correct-text "Correct!" --incorrect-text "Try again" + + # Add an image-choice question with correct/incorrect feedback images + stqry quizzes questions add 42 --name "Q2" --question-type single_image_choice \ + --question "Pick the Eiffel Tower" --correct-text "Oui" --incorrect-text "Non" \ + --correct-image-media-item-id 101 --incorrect-image-media-item-id 102 + + # Allow up to 3 attempts and insert at the top + stqry quizzes questions add 42 --name "Q3" --question-type free_text \ + --question "Name a French city" --correct-text "Bravo" --incorrect-text "Nope" \ + --max-attempts 3 --position 0`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateQuizQuestionType(questionType); err != nil { + return err + } + lang := resolveContentLanguage() + effectiveTitle := title + if effectiveTitle == "" { + effectiveTitle = name + } + fields := map[string]interface{}{ + "name": name, + "question_type": questionType, + "title": map[string]interface{}{lang: effectiveTitle}, + } + if question != "" { + fields["question"] = map[string]interface{}{lang: question} + } + if correctText != "" { + fields["correct_text"] = map[string]interface{}{lang: correctText} + } + if incorrectText != "" { + fields["incorrect_text"] = map[string]interface{}{lang: incorrectText} + } + // Visit() so position 0 / max-attempts / the media FKs are only sent + // when the user passed them (position 0 is meaningful — top of list). + cmd.Flags().Visit(func(f *flag.Flag) { + switch f.Name { + case "position": + fields["position"] = position + case "max-attempts": + fields["max_attempts"] = maxAttempts + case "correct-image-media-item-id": + fields["correct_image_media_item_id"] = correctImageID + case "incorrect-image-media-item-id": + fields["incorrect_image_media_item_id"] = incorrectImageID + case "correct-audio-media-item-id": + fields["correct_audio_media_item_id"] = correctAudioID + case "incorrect-audio-media-item-id": + fields["incorrect_audio_media_item_id"] = incorrectAudioID + } + }) + + question, err := api.CreateQuizQuestion(activeClient, args[0], fields) + if err != nil { + return err + } + return printer.PrintOne(question, nil) + }, + } + + registerQuizQuestionFlags(cmd, &name, &questionType, &title, &question, &correctText, &incorrectText, + &maxAttempts, &position, &correctImageID, &incorrectImageID, &correctAudioID, &incorrectAudioID) + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("question-type") + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizQuestionsUpdateCmd() *cobra.Command { + var name, questionType, title, question, correctText, incorrectText string + var maxAttempts, position int + var correctImageID, incorrectImageID, correctAudioID, incorrectAudioID int + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a quiz question", + Example: ` # Rename a question and update its prompt + stqry quizzes questions update 42 7 --name "Q1 (revised)" --question "What is the capital of France?" + + # Switch the question type and bump the attempt cap + stqry quizzes questions update 42 7 --question-type multi_text_choice --max-attempts 2 + + # Update the French feedback text + stqry quizzes questions update 42 7 --correct-text "Bravo" --incorrect-text "Essaie encore" --lang fr`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateQuizQuestionType(questionType); err != nil { + return err + } + lang := resolveContentLanguage() + fields := map[string]interface{}{} + cmd.Flags().Visit(func(f *flag.Flag) { + switch f.Name { + case "name": + fields["name"] = name + case "question-type": + fields["question_type"] = questionType + case "title": + fields["title"] = map[string]interface{}{lang: title} + case "question": + fields["question"] = map[string]interface{}{lang: question} + case "correct-text": + fields["correct_text"] = map[string]interface{}{lang: correctText} + case "incorrect-text": + fields["incorrect_text"] = map[string]interface{}{lang: incorrectText} + case "position": + fields["position"] = position + case "max-attempts": + fields["max_attempts"] = maxAttempts + case "correct-image-media-item-id": + fields["correct_image_media_item_id"] = correctImageID + case "incorrect-image-media-item-id": + fields["incorrect_image_media_item_id"] = incorrectImageID + case "correct-audio-media-item-id": + fields["correct_audio_media_item_id"] = correctAudioID + case "incorrect-audio-media-item-id": + fields["incorrect_audio_media_item_id"] = incorrectAudioID + } + }) + if len(fields) == 0 { + return fmt.Errorf("no fields to update; pass at least one flag (e.g. --name)") + } + + question, err := api.UpdateQuizQuestion(activeClient, args[0], args[1], fields) + if err != nil { + return err + } + return printer.PrintOne(question, nil) + }, + } + + registerQuizQuestionFlags(cmd, &name, &questionType, &title, &question, &correctText, &incorrectText, + &maxAttempts, &position, &correctImageID, &incorrectImageID, &correctAudioID, &incorrectAudioID) + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +// registerQuizQuestionFlags wires the shared question flag surface for add and +// update so the two stay symmetric. +func registerQuizQuestionFlags(cmd *cobra.Command, name, questionType, title, question, correctText, incorrectText *string, + maxAttempts, position, correctImageID, incorrectImageID, correctAudioID, incorrectAudioID *int) { + cmd.Flags().StringVar(name, "name", "", "Question name (internal label, not shown to end users)") + cmd.Flags().StringVar(questionType, "question-type", "", fmt.Sprintf("Question type (one of: %s)", strings.Join(validQuizQuestionTypes, ", "))) + cmd.Flags().StringVar(title, "title", "", "Question title (TranslatedString; defaults to --name on add)") + cmd.Flags().StringVar(question, "question", "", "Question prompt text (TranslatedString; required by the server on the primary language)") + cmd.Flags().StringVar(correctText, "correct-text", "", "Feedback shown when answered correctly (TranslatedString; required by the server on the primary language)") + cmd.Flags().StringVar(incorrectText, "incorrect-text", "", "Feedback shown when answered incorrectly (TranslatedString; required by the server on the primary language)") + cmd.Flags().IntVar(maxAttempts, "max-attempts", 1, "Number of attempts allowed (server default: 1)") + cmd.Flags().IntVar(position, "position", 0, "Position within the quiz (0-based; omit to append to the end). Use 'questions reorder' to bulk-reorder.") + cmd.Flags().IntVar(correctImageID, "correct-image-media-item-id", 0, "Image media item shown as correct-answer feedback (must be an image media item)") + cmd.Flags().IntVar(incorrectImageID, "incorrect-image-media-item-id", 0, "Image media item shown as incorrect-answer feedback (must be an image media item)") + cmd.Flags().IntVar(correctAudioID, "correct-audio-media-item-id", 0, "Audio media item played as correct-answer feedback (must be an audio media item)") + cmd.Flags().IntVar(incorrectAudioID, "incorrect-audio-media-item-id", 0, "Audio media item played as incorrect-answer feedback (must be an audio media item)") +} + +func newQuizQuestionsRemoveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a quiz question (or just one of its translation locales)", + Example: ` # Remove a question from a quiz + stqry quizzes questions remove 42 7 + + # Drop only the French translations on a question (the question itself stays) + stqry quizzes questions remove 42 7 --lang fr`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + var query map[string]string + lang := "" + if cmd.Flags().Changed("lang") { + lang = flagLang + } + if lang != "" { + query = map[string]string{"language": lang} + } + if err := api.DeleteQuizQuestion(activeClient, args[0], args[1], query); err != nil { + return err + } + if lang != "" { + printHumanConfirmation("Removed %s translations from question %s.", lang, args[1]) + } else { + printHumanConfirmation("Removed.") + } + return nil + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizQuestionsReorderCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "reorder ...", + Short: "Reorder questions within a quiz", + Example: ` # Reorder questions (IDs in desired order; first becomes position 0) + stqry quizzes questions reorder 42 7 8 9`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := api.ReorderQuizQuestions(activeClient, args[0], args[1:]); err != nil { + return err + } + printHumanConfirmation("Reordered.") + return nil + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizQuestionsAppearsInCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "appears-in ", + Short: "Show every place a quiz question is referenced", + Example: ` # Show all references to a question (screens, parent quiz, badges) + stqry quizzes questions appears-in 42 7 + + # Pull just the linking screens + stqry quizzes questions appears-in 42 7 --jq '.screens'`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + result, err := api.QuizQuestionAppearsIn(activeClient, args[0], args[1]) + if err != nil { + return err + } + return printer.PrintOne(result, nil) + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +// ── quizzes questions answers ─────────────────────────────────────────────────── + +func newQuizAnswersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "answers", + Short: "Manage a quiz question's answers", + Example: ` # List answers for a question + stqry quizzes questions answers list 42 7 + + # Add the correct answer and a distractor + stqry quizzes questions answers add 42 7 --answer-text "Paris" --correct + stqry quizzes questions answers add 42 7 --answer-text "Lyon"`, + } + + cmd.AddCommand(newQuizAnswersListCmd()) + cmd.AddCommand(newQuizAnswersGetCmd()) + cmd.AddCommand(newQuizAnswersAddCmd()) + cmd.AddCommand(newQuizAnswersUpdateCmd()) + cmd.AddCommand(newQuizAnswersRemoveCmd()) + cmd.AddCommand(newQuizAnswersReorderCmd()) + cmd.AddCommand(newQuizAnswersAppearsInCmd()) + + return cmd +} + +func newQuizAnswersListCmd() *cobra.Command { + var page, perPage int + var q string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List answers for a question", + Example: ` # List all answers for a question + stqry quizzes questions answers list 42 7 + + # Search by answer text + stqry quizzes questions answers list 42 7 --q "Paris"`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + query := map[string]string{} + if page > 0 { + query["page"] = strconv.Itoa(page) + } + if perPage > 0 { + query["per_page"] = strconv.Itoa(perPage) + } + if q != "" { + query["q"] = q + } + answers, meta, err := api.ListQuizAnswers(activeClient, args[0], args[1], query) + if err != nil { + return err + } + var m *output.Meta + if meta != nil { + m = &output.Meta{Page: meta.Page, Pages: meta.Pages, PerPage: meta.PerPage, Count: meta.Count} + } + return printer.PrintList([]string{"id", "position", "correct", "answer_text"}, answers, m) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page number") + cmd.Flags().IntVar(&perPage, "per-page", 0, "Results per page") + cmd.Flags().StringVar(&q, "q", "", "Search query (matches answer_text)") + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizAnswersGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a single answer", + Example: ` # Get an answer by ID + stqry quizzes questions answers get 42 7 13 + + # Filter a specific field + stqry quizzes questions answers get 42 7 13 --jq '.correct'`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + answer, err := api.GetQuizAnswer(activeClient, args[0], args[1], args[2]) + if err != nil { + return err + } + return printer.PrintOne(answer, nil) + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizAnswersAddCmd() *cobra.Command { + var answerText string + var position, answerMediaItemID int + var correct bool + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add an answer to a question", + Long: `Add an answer to a question. + +Text questions (single_text_choice, multi_text_choice, free_text) require +--answer-text. Image questions (single_image_choice, multi_image_choice) require +--answer-media-item-id pointing at an image media item. Mark the right answer(s) +with --correct.`, + Example: ` # Add the correct answer to a text question + stqry quizzes questions answers add 42 7 --answer-text "Paris" --correct + + # Add a distractor (incorrect by default) + stqry quizzes questions answers add 42 7 --answer-text "Lyon" + + # Add an image answer (image-choice question) + stqry quizzes questions answers add 42 8 --answer-media-item-id 55 --correct`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + lang := resolveContentLanguage() + // Always send `correct` on create so the answer has a definite value + // (the column is integer-backed; false serialises to 0). + fields := map[string]interface{}{"correct": correct} + if answerText != "" { + fields["answer_text"] = map[string]interface{}{lang: answerText} + } + cmd.Flags().Visit(func(f *flag.Flag) { + switch f.Name { + case "position": + fields["position"] = position + case "answer-media-item-id": + fields["answer_media_item_id"] = answerMediaItemID + } + }) + + answer, err := api.CreateQuizAnswer(activeClient, args[0], args[1], fields) + if err != nil { + return err + } + return printer.PrintOne(answer, nil) + }, + } + + cmd.Flags().StringVar(&answerText, "answer-text", "", "Answer text (TranslatedString; required for text questions)") + cmd.Flags().BoolVar(&correct, "correct", false, "Mark this answer as correct (default: false)") + cmd.Flags().IntVar(&answerMediaItemID, "answer-media-item-id", 0, "Image media item for image-choice answers (must be an image media item)") + cmd.Flags().IntVar(&position, "position", 0, "Position within the question (0-based; omit to append to the end)") + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizAnswersUpdateCmd() *cobra.Command { + var answerText string + var position, answerMediaItemID int + var correct bool + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an answer", + Example: ` # Mark an answer correct + stqry quizzes questions answers update 42 7 13 --correct + + # Mark an answer incorrect + stqry quizzes questions answers update 42 7 13 --correct=false + + # Update the answer text in French + stqry quizzes questions answers update 42 7 13 --answer-text "Paris" --lang fr`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + lang := resolveContentLanguage() + fields := map[string]interface{}{} + cmd.Flags().Visit(func(f *flag.Flag) { + switch f.Name { + case "answer-text": + fields["answer_text"] = map[string]interface{}{lang: answerText} + case "correct": + fields["correct"] = correct + case "position": + fields["position"] = position + case "answer-media-item-id": + fields["answer_media_item_id"] = answerMediaItemID + } + }) + if len(fields) == 0 { + return fmt.Errorf("no fields to update; pass at least one flag (e.g. --correct)") + } + + answer, err := api.UpdateQuizAnswer(activeClient, args[0], args[1], args[2], fields) + if err != nil { + return err + } + return printer.PrintOne(answer, nil) + }, + } + + cmd.Flags().StringVar(&answerText, "answer-text", "", "New answer text (TranslatedString)") + cmd.Flags().BoolVar(&correct, "correct", false, "Whether this answer is correct (pass --correct=false to mark incorrect)") + cmd.Flags().IntVar(&answerMediaItemID, "answer-media-item-id", 0, "Image media item for image-choice answers (must be an image media item)") + cmd.Flags().IntVar(&position, "position", 0, "Position within the question (0-based)") + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizAnswersRemoveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove an answer (or just one of its translation locales)", + Example: ` # Remove an answer from a question + stqry quizzes questions answers remove 42 7 13 + + # Drop only the French translations on an answer (the answer itself stays) + stqry quizzes questions answers remove 42 7 13 --lang fr`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + var query map[string]string + lang := "" + if cmd.Flags().Changed("lang") { + lang = flagLang + } + if lang != "" { + query = map[string]string{"language": lang} + } + if err := api.DeleteQuizAnswer(activeClient, args[0], args[1], args[2], query); err != nil { + return err + } + if lang != "" { + printHumanConfirmation("Removed %s translations from answer %s.", lang, args[2]) + } else { + printHumanConfirmation("Removed.") + } + return nil + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizAnswersReorderCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "reorder ...", + Short: "Reorder answers within a question", + Example: ` # Reorder answers (IDs in desired order; first becomes position 0) + stqry quizzes questions answers reorder 42 7 13 14 15`, + Args: cobra.MinimumNArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + if err := api.ReorderQuizAnswers(activeClient, args[0], args[1], args[2:]); err != nil { + return err + } + printHumanConfirmation("Reordered.") + return nil + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} + +func newQuizAnswersAppearsInCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "appears-in ", + Short: "Show every place an answer is referenced", + Example: ` # Show all references to an answer (parent quiz, screens) + stqry quizzes questions answers appears-in 42 7 13`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + result, err := api.QuizAnswerAppearsIn(activeClient, args[0], args[1], args[2]) + if err != nil { + return err + } + return printer.PrintOne(result, nil) + }, + } + cmd.ValidArgsFunction = completeQuizIDs + return cmd +} diff --git a/internal/cli/quizzes_test.go b/internal/cli/quizzes_test.go new file mode 100644 index 0000000..9b211ff --- /dev/null +++ b/internal/cli/quizzes_test.go @@ -0,0 +1,448 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestQuizzesListCmd(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "quizzes": []interface{}{ + map[string]interface{}{"id": 1, "name": "Capitals"}, + }, + "meta": map[string]interface{}{"page": 1, "pages": 1, "per_page": 30, "count": 1}, + }) + })) + defer server.Close() + setupTestHome(t, server.URL) + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "list"}) + execErr := cmd.Execute() + + w.Close() + os.Stdout = origStdout + buf := make([]byte, 4096) + n, _ := r.Read(buf) + r.Close() + out := string(buf[:n]) + + if execErr != nil { + t.Fatalf("Execute: %v", execErr) + } + if !contains(out, "Capitals") { + t.Errorf("expected output to contain Capitals, got:\n%s", out) + } +} + +// TestQuizzesCreateDefaults asserts the collections-style defaulting: +// --name with no --title defaults title (and short_title) to the name, and +// primary_language defaults to the resolved content language. +func TestQuizzesCreateDefaults(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/api/public/quizzes" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{"quiz": map[string]interface{}{"id": 1, "name": "Capitals"}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "create", "--name=Capitals"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + if captured["name"] != "Capitals" { + t.Errorf("expected name=Capitals, got %v", captured["name"]) + } + if captured["primary_language"] != "en" { + t.Errorf("expected primary_language=en, got %v", captured["primary_language"]) + } + title, ok := captured["title"].(map[string]interface{}) + if !ok || title["en"] != "Capitals" { + t.Errorf("expected title.en=Capitals (defaulted from name), got %v", captured["title"]) + } + short, ok := captured["short_title"].(map[string]interface{}) + if !ok || short["en"] != "Capitals" { + t.Errorf("expected short_title.en=Capitals (defaulted from title), got %v", captured["short_title"]) + } +} + +func TestQuizzesCreateRequiresNameOrTitle(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should not be called; got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "create"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err == nil { + t.Fatal("expected an error when neither --name nor --title is passed") + } +} + +// TestQuizzesUpdateOnlySendsChangedFields asserts the Visit() semantics: a bare +// --name update must not also send title / short_title / primary_language. +func TestQuizzesUpdateOnlySendsChangedFields(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/api/public/quizzes/42" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"quiz": map[string]interface{}{"id": 42, "name": "Renamed"}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "update", "42", "--name=Renamed"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if captured["name"] != "Renamed" { + t.Errorf("expected name=Renamed, got %v", captured["name"]) + } + if _, present := captured["title"]; present { + t.Errorf("expected no title field on a bare --name update, got %v", captured["title"]) + } + if _, present := captured["primary_language"]; present { + t.Errorf("expected no primary_language field on a bare --name update") + } +} + +func TestQuizzesDeleteWithLang(t *testing.T) { + var sawLang string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/api/public/quizzes/42" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + sawLang = r.URL.Query().Get("language") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"quiz": map[string]interface{}{"id": 42}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "delete", "42", "--lang=fr"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if sawLang != "fr" { + t.Errorf("expected language=fr query param, got %q", sawLang) + } +} + +// TestQuizzesDeleteBareDoesNotForwardLang asserts a bare delete (no --lang) does +// NOT inherit the global --lang default and silently drop the primary locale. +func TestQuizzesDeleteBareDoesNotForwardLang(t *testing.T) { + var sawLang string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawLang = r.URL.Query().Get("language") + w.WriteHeader(204) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "delete", "42"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if sawLang != "" { + t.Errorf("expected no language query param on a bare delete, got %q", sawLang) + } +} + +func TestQuizQuestionsAddSendsFields(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/api/public/quizzes/42/questions" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{"question": map[string]interface{}{"id": 7}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "add", "42", + "--name=Q1", "--question-type=single_text_choice", + "--question=Capital of France?", "--correct-text=Yes", "--incorrect-text=No", + "--max-attempts=3"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + if captured["question_type"] != "single_text_choice" { + t.Errorf("expected question_type=single_text_choice, got %v", captured["question_type"]) + } + // max_attempts must be a JSON number, not a string. + if mv, ok := captured["max_attempts"].(float64); !ok || mv != 3 { + t.Errorf("expected max_attempts=3 (number), got %T %v", captured["max_attempts"], captured["max_attempts"]) + } + q, ok := captured["question"].(map[string]interface{}) + if !ok || q["en"] != "Capital of France?" { + t.Errorf("expected question.en wrapped as translated map, got %v", captured["question"]) + } + // --title not passed → defaults to --name. + title, ok := captured["title"].(map[string]interface{}) + if !ok || title["en"] != "Q1" { + t.Errorf("expected title.en defaulted to name Q1, got %v", captured["title"]) + } +} + +func TestQuizQuestionsAddRejectsInvalidType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should not be called for an invalid question type") + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "add", "42", "--name=Q1", "--question-type=bogus"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err == nil { + t.Fatal("expected a client-side error for an invalid question type") + } +} + +func TestQuizQuestionsReorderZeroBased(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/update_positions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(204) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "reorder", "42", "7", "8"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + positions := captured["positions"].([]interface{}) + first := positions[0].(map[string]interface{}) + if first["position"].(float64) != 0 { + t.Errorf("expected first id at position 0 (0-based), got %v", first["position"]) + } +} + +// TestQuizAnswersAddAlwaysSendsCorrect asserts that `add` always serialises the +// boolean `correct` flag (default false) so the answer is created with a +// definite value. +func TestQuizAnswersAddAlwaysSendsCorrect(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{"answer": map[string]interface{}{"id": 13}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "answers", "add", "42", "7", "--answer-text=Lyon"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + correct, present := captured["correct"] + if !present { + t.Fatal("expected `correct` to always be sent on add") + } + if correct != false { + t.Errorf("expected correct=false (default JSON bool), got %T %v", correct, correct) + } + at, ok := captured["answer_text"].(map[string]interface{}) + if !ok || at["en"] != "Lyon" { + t.Errorf("expected answer_text.en=Lyon, got %v", captured["answer_text"]) + } +} + +// TestQuizAnswersUpdateCorrectFalse asserts that --correct=false reaches the wire +// as a literal false (the Visit() path forwards the explicit value). +func TestQuizAnswersUpdateCorrectFalse(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/api/public/quizzes/42/questions/7/answers/13" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"answer": map[string]interface{}{"id": 13, "correct": false}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "answers", "update", "42", "7", "13", "--correct=false"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if v, present := captured["correct"]; !present || v != false { + t.Errorf("expected correct=false on the wire, got present=%v value=%v", present, captured["correct"]) + } +} + +// TestQuizQuestionUpdatePartial asserts a bare `questions update --name` succeeds +// without --question-type (the unconditional validateQuizQuestionType call is a +// no-op for the empty default) and that Visit() sends only the changed field — +// no question_type, no title. +func TestQuizQuestionUpdatePartial(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/api/public/quizzes/42/questions/7" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"question": map[string]interface{}{"id": 7, "name": "Renamed"}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "update", "42", "7", "--name=Renamed"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if captured["name"] != "Renamed" { + t.Errorf("expected name=Renamed, got %v", captured["name"]) + } + if _, present := captured["question_type"]; present { + t.Errorf("expected no question_type on a bare --name update, got %v", captured["question_type"]) + } + if _, present := captured["title"]; present { + t.Errorf("expected no title on a bare --name update, got %v", captured["title"]) + } +} + +// TestQuizQuestionUpdateMediaFKs asserts the question-level media feedback FKs +// reach the wire as JSON numbers when passed. +func TestQuizQuestionUpdateMediaFKs(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/api/public/quizzes/42/questions/7" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"question": map[string]interface{}{"id": 7}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "update", "42", "7", + "--correct-image-media-item-id=101", "--incorrect-audio-media-item-id=202"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if v, ok := captured["correct_image_media_item_id"].(float64); !ok || v != 101 { + t.Errorf("expected correct_image_media_item_id=101 (number), got %T %v", captured["correct_image_media_item_id"], captured["correct_image_media_item_id"]) + } + if v, ok := captured["incorrect_audio_media_item_id"].(float64); !ok || v != 202 { + t.Errorf("expected incorrect_audio_media_item_id=202 (number), got %T %v", captured["incorrect_audio_media_item_id"], captured["incorrect_audio_media_item_id"]) + } + // Flags not passed must not be sent (Visit semantics). + if _, present := captured["incorrect_image_media_item_id"]; present { + t.Errorf("expected unset incorrect_image_media_item_id to be omitted") + } +} + +// TestQuizAnswersReorderZeroBased asserts the CLI answer reorder posts 0-based +// positions (first id → position 0) to the nested update_positions endpoint. +func TestQuizAnswersReorderZeroBased(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/quizzes/42/questions/7/answers/update_positions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(204) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"quizzes", "questions", "answers", "reorder", "42", "7", "13", "14"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + positions := captured["positions"].([]interface{}) + first := positions[0].(map[string]interface{}) + if first["id"].(float64) != 13 || first["position"].(float64) != 0 { + t.Errorf("expected first answer id=13 at position 0, got id=%v position=%v", first["id"], first["position"]) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d66b066..f767272 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -190,6 +190,7 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newConfigCmd()) rootCmd.AddCommand(newCollectionsCmd()) rootCmd.AddCommand(newScreensCmd()) + rootCmd.AddCommand(newQuizzesCmd()) rootCmd.AddCommand(newMediaCmd()) rootCmd.AddCommand(newUploadedFilesCmd()) rootCmd.AddCommand(newProjectsCmd()) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index cc4bb9f..7961c66 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -63,6 +63,24 @@ func jsonResult(v interface{}) (*mcpgo.CallToolResult, error) { return mcpgo.NewToolResultText(string(data)), nil } +// Pagination contract shared by every list tool. The server defaults to 30 +// items per page and hard-caps per_page at 1000 (per_page > 1000 → HTTP 400); +// see the CLI reference. pageParam / perPageParam declare the two parameters in +// one place so their descriptions can't drift apart across the list tools — they +// previously disagreed, several wrongly advertising a 25-item default the server +// never used. +func pageParam() mcpgo.ToolOption { + return mcpgo.WithNumber("page", + mcpgo.Description("Page number (1-based, default: 1)"), + ) +} + +func perPageParam() mcpgo.ToolOption { + return mcpgo.WithNumber("per_page", + mcpgo.Description("Items per page (default: 30, max: 1000)"), + ) +} + // paginationQuery builds a query map from optional page/per_page values. // Returns nil if neither is set (preserves existing nil-passthrough behaviour). func paginationQuery(page, perPage int) map[string]string { @@ -91,6 +109,7 @@ func NewServer() *server.MCPServer { registerProjectTabTools(s, sess) registerCollectionTools(s, sess) registerScreenTools(s, sess) + registerQuizTools(s, sess) registerMediaTools(s, sess) registerUploadedFileTools(s, sess) registerCodeTools(s, sess) diff --git a/internal/mcp/tools_quizzes.go b/internal/mcp/tools_quizzes.go new file mode 100644 index 0000000..2088e98 --- /dev/null +++ b/internal/mcp/tools_quizzes.go @@ -0,0 +1,619 @@ +package mcp + +import ( + "context" + "fmt" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/mytours/stqry-cli/internal/api" +) + +// registerQuizTools registers the quiz / question / answer tool surface. Quizzes +// are a three-level resource (quiz → question → answer); translated fields +// (title, short_title, question, correct_text, incorrect_text, answer_text) are +// passed inside `fields` as locale maps, e.g. {"title": {"en": "Capitals"}}. +func registerQuizTools(s *server.MCPServer, sess *Session) { + registerQuizResourceTools(s, sess) + registerQuizQuestionTools(s, sess) + registerQuizAnswerTools(s, sess) +} + +func registerQuizResourceTools(s *server.MCPServer, sess *Session) { + s.AddTool( + mcpgo.NewTool("list_quizzes", + mcpgo.WithDescription("List all quizzes for the configured STQRY account."), + pageParam(), + perPageParam(), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + items, meta, err := api.ListQuizzes(client, paginationQuery(req.GetInt("page", 0), req.GetInt("per_page", 0))) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("listing quizzes: %v", err)), nil + } + return jsonResult(map[string]interface{}{"quizzes": items, "meta": meta}) + }, + ) + + s.AddTool( + mcpgo.NewTool("get_quiz", + mcpgo.WithDescription("Get a single STQRY quiz by ID."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The quiz ID")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + quiz, err := api.GetQuiz(client, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting quiz: %v", err)), nil + } + return jsonResult(quiz) + }, + ) + + s.AddTool( + mcpgo.NewTool("create_quiz", + mcpgo.WithDescription("Create a new STQRY quiz. Required fields: name and primary_language (e.g. \"en\"). The server also requires title and short_title on the primary language; pass them as locale maps, e.g. {\"name\": \"Capitals\", \"primary_language\": \"en\", \"title\": {\"en\": \"Capitals\"}, \"short_title\": {\"en\": \"Caps\"}}."), + mcpgo.WithObject("fields", mcpgo.Required(), mcpgo.Description("Quiz fields: name (string, required), primary_language (e.g. \"en\", required), title / short_title (locale maps)")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + fields, err := requireFields(req) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + quiz, err := api.CreateQuiz(client, fields) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("creating quiz: %v", err)), nil + } + return jsonResult(quiz) + }, + ) + + s.AddTool( + mcpgo.NewTool("update_quiz", + mcpgo.WithDescription("Update an existing STQRY quiz by ID. Translated fields (title, short_title) are locale maps. Pass primary_language to flip the primary locale (the target locale must already have title + short_title)."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The quiz ID")), + mcpgo.WithObject("fields", mcpgo.Required(), mcpgo.Description("Quiz fields to update")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + fields, err := requireFields(req) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + quiz, err := api.UpdateQuiz(client, id, fields) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("updating quiz: %v", err)), nil + } + return jsonResult(quiz) + }, + ) + + s.AddTool( + mcpgo.NewTool("delete_quiz", + mcpgo.WithDescription("Delete a STQRY quiz by ID. Pass `language` to drop only that locale's translations instead of the whole quiz."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The quiz ID")), + mcpgo.WithString("language", mcpgo.Description("Optional locale (e.g. 'fr') — drops only that translation; the quiz itself stays.")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + var query map[string]string + if lang := req.GetString("language", ""); lang != "" { + query = map[string]string{"language": lang} + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + if err := api.DeleteQuiz(client, id, query); err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("deleting quiz: %v", err)), nil + } + return mcpgo.NewToolResultText(`{"ok":true}`), nil + }, + ) + + s.AddTool( + mcpgo.NewTool("get_quiz_appears_in", + mcpgo.WithDescription("Show every place a quiz is referenced (screens embedding it via a quiz_question / quiz_score story section)."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The quiz ID")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + result, err := api.QuizAppearsIn(client, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting quiz appears_in: %v", err)), nil + } + return jsonResult(result) + }, + ) +} + +func registerQuizQuestionTools(s *server.MCPServer, sess *Session) { + s.AddTool( + mcpgo.NewTool("list_quiz_questions", + mcpgo.WithDescription("List the questions for a STQRY quiz, in display order."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + pageParam(), + perPageParam(), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + items, meta, err := api.ListQuizQuestions(client, quizID, paginationQuery(req.GetInt("page", 0), req.GetInt("per_page", 0))) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("listing quiz questions: %v", err)), nil + } + return jsonResult(map[string]interface{}{"questions": items, "meta": meta}) + }, + ) + + s.AddTool( + mcpgo.NewTool("get_quiz_question", + mcpgo.WithDescription("Get a single STQRY quiz question by ID."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The question ID")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + question, err := api.GetQuizQuestion(client, quizID, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting quiz question: %v", err)), nil + } + return jsonResult(question) + }, + ) + + s.AddTool( + mcpgo.NewTool("create_quiz_question", + mcpgo.WithDescription("Create a question on a STQRY quiz. Required: name, question_type (one of single_text_choice, single_image_choice, multi_text_choice, multi_image_choice, free_text). The server requires title, question, correct_text, incorrect_text on the primary language (locale maps). Optional: position, max_attempts, and correct/incorrect image/audio feedback media item ids."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithObject("fields", mcpgo.Required(), mcpgo.Description("Question fields. Example: {\"name\": \"Q1\", \"question_type\": \"single_text_choice\", \"title\": {\"en\": \"T\"}, \"question\": {\"en\": \"Capital of France?\"}, \"correct_text\": {\"en\": \"Yes\"}, \"incorrect_text\": {\"en\": \"No\"}}")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + fields, err := requireFields(req) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + question, err := api.CreateQuizQuestion(client, quizID, fields) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("creating quiz question: %v", err)), nil + } + return jsonResult(question) + }, + ) + + s.AddTool( + mcpgo.NewTool("update_quiz_question", + mcpgo.WithDescription("Update a STQRY quiz question. Translated fields (title, question, correct_text, incorrect_text) are locale maps."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The question ID")), + mcpgo.WithObject("fields", mcpgo.Required(), mcpgo.Description("Question fields to update")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + fields, err := requireFields(req) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + question, err := api.UpdateQuizQuestion(client, quizID, id, fields) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("updating quiz question: %v", err)), nil + } + return jsonResult(question) + }, + ) + + s.AddTool( + mcpgo.NewTool("delete_quiz_question", + mcpgo.WithDescription("Delete a STQRY quiz question. Pass `language` to drop only that locale's translations instead of the whole question."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The question ID")), + mcpgo.WithString("language", mcpgo.Description("Optional locale (e.g. 'fr') — drops only that translation.")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + var query map[string]string + if lang := req.GetString("language", ""); lang != "" { + query = map[string]string{"language": lang} + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + if err := api.DeleteQuizQuestion(client, quizID, id, query); err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("deleting quiz question: %v", err)), nil + } + return mcpgo.NewToolResultText(`{"ok":true}`), nil + }, + ) + + s.AddTool( + mcpgo.NewTool("reorder_quiz_questions", + mcpgo.WithDescription("Reorder the questions within a STQRY quiz. Pass question IDs in the desired order; positions are 0-based (the first id becomes position 0)."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithArray("question_ids", mcpgo.Required(), mcpgo.Description("Array of question IDs (strings or numbers) in the desired order")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + ids, err := idArrayParam(req, "question_ids") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + if err := api.ReorderQuizQuestions(client, quizID, ids); err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("reordering quiz questions: %v", err)), nil + } + return mcpgo.NewToolResultText(`{"ok":true}`), nil + }, + ) + + s.AddTool( + mcpgo.NewTool("get_quiz_question_appears_in", + mcpgo.WithDescription("Show every place a quiz question is referenced (screens embedding it, the parent quiz, and any badges it triggers)."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The question ID")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + result, err := api.QuizQuestionAppearsIn(client, quizID, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting quiz question appears_in: %v", err)), nil + } + return jsonResult(result) + }, + ) +} + +func registerQuizAnswerTools(s *server.MCPServer, sess *Session) { + s.AddTool( + mcpgo.NewTool("list_quiz_answers", + mcpgo.WithDescription("List the answers for a STQRY quiz question, in display order."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + pageParam(), + perPageParam(), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + items, meta, err := api.ListQuizAnswers(client, quizID, questionID, paginationQuery(req.GetInt("page", 0), req.GetInt("per_page", 0))) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("listing quiz answers: %v", err)), nil + } + return jsonResult(map[string]interface{}{"answers": items, "meta": meta}) + }, + ) + + s.AddTool( + mcpgo.NewTool("get_quiz_answer", + mcpgo.WithDescription("Get a single STQRY quiz answer by ID."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The answer ID")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + answer, err := api.GetQuizAnswer(client, quizID, questionID, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting quiz answer: %v", err)), nil + } + return jsonResult(answer) + }, + ) + + s.AddTool( + mcpgo.NewTool("create_quiz_answer", + mcpgo.WithDescription("Create an answer on a STQRY quiz question. Text questions require answer_text (locale map); image questions require answer_media_item_id (an image media item). Mark the right answer(s) with correct: true. Example: {\"answer_text\": {\"en\": \"Paris\"}, \"correct\": true}."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + mcpgo.WithObject("fields", mcpgo.Required(), mcpgo.Description("Answer fields: correct (bool), answer_text (locale map) or answer_media_item_id (int), position (int)")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + fields, err := requireFields(req) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + answer, err := api.CreateQuizAnswer(client, quizID, questionID, fields) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("creating quiz answer: %v", err)), nil + } + return jsonResult(answer) + }, + ) + + s.AddTool( + mcpgo.NewTool("update_quiz_answer", + mcpgo.WithDescription("Update a STQRY quiz answer. answer_text is a locale map; correct is a bool."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The answer ID")), + mcpgo.WithObject("fields", mcpgo.Required(), mcpgo.Description("Answer fields to update")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + fields, err := requireFields(req) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + answer, err := api.UpdateQuizAnswer(client, quizID, questionID, id, fields) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("updating quiz answer: %v", err)), nil + } + return jsonResult(answer) + }, + ) + + s.AddTool( + mcpgo.NewTool("delete_quiz_answer", + mcpgo.WithDescription("Delete a STQRY quiz answer. Pass `language` to drop only that locale's translations instead of the whole answer."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The answer ID")), + mcpgo.WithString("language", mcpgo.Description("Optional locale (e.g. 'fr') — drops only that translation.")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + var query map[string]string + if lang := req.GetString("language", ""); lang != "" { + query = map[string]string{"language": lang} + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + if err := api.DeleteQuizAnswer(client, quizID, questionID, id, query); err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("deleting quiz answer: %v", err)), nil + } + return mcpgo.NewToolResultText(`{"ok":true}`), nil + }, + ) + + s.AddTool( + mcpgo.NewTool("reorder_quiz_answers", + mcpgo.WithDescription("Reorder the answers within a STQRY quiz question. Pass answer IDs in the desired order; positions are 0-based (the first id becomes position 0)."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + mcpgo.WithArray("answer_ids", mcpgo.Required(), mcpgo.Description("Array of answer IDs (strings or numbers) in the desired order")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + ids, err := idArrayParam(req, "answer_ids") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + if err := api.ReorderQuizAnswers(client, quizID, questionID, ids); err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("reordering quiz answers: %v", err)), nil + } + return mcpgo.NewToolResultText(`{"ok":true}`), nil + }, + ) + + s.AddTool( + mcpgo.NewTool("get_quiz_answer_appears_in", + mcpgo.WithDescription("Show every place a quiz answer is referenced (the parent quiz and the screens that surface its question / score)."), + mcpgo.WithString("quiz_id", mcpgo.Required(), mcpgo.Description("The parent quiz ID")), + mcpgo.WithString("question_id", mcpgo.Required(), mcpgo.Description("The parent question ID")), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The answer ID")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + quizID := req.GetString("quiz_id", "") + if quizID == "" { + return mcpgo.NewToolResultError("quiz_id is required"), nil + } + questionID := req.GetString("question_id", "") + if questionID == "" { + return mcpgo.NewToolResultError("question_id is required"), nil + } + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + result, err := api.QuizAnswerAppearsIn(client, quizID, questionID, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting quiz answer appears_in: %v", err)), nil + } + return jsonResult(result) + }, + ) +} + +// requireFields extracts the `fields` object from a tool request, returning a +// descriptive error when it is missing or not an object. +func requireFields(req mcpgo.CallToolRequest) (map[string]interface{}, error) { + fields, ok := req.GetArguments()["fields"].(map[string]interface{}) + if !ok || fields == nil { + return nil, fmt.Errorf("fields is required and must be an object") + } + return fields, nil +} + +// idArrayParam reads a required array param (e.g. "question_ids") and coerces +// each entry to a string id. Mirrors the reorder sub-item tool's coercion so +// callers can pass either JSON strings or numbers. +func idArrayParam(req mcpgo.CallToolRequest, name string) ([]string, error) { + raw, ok := req.GetArguments()[name].([]interface{}) + if !ok || len(raw) == 0 { + return nil, fmt.Errorf("%s is required and must be a non-empty array", name) + } + ids := make([]string, 0, len(raw)) + for _, r := range raw { + switch v := r.(type) { + case string: + ids = append(ids, v) + case float64: + ids = append(ids, fmt.Sprintf("%d", int(v))) + default: + return nil, fmt.Errorf("%s entries must be strings or numbers, got %T", name, r) + } + } + return ids, nil +}