diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 72ce23c47..4cea906ae 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -1,5 +1,5 @@ @@ -238,6 +238,7 @@ Currently supported Question-Types are: | `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion | | `color` | A color answer, hex string representation (e. g. `#123456`) | | `ranking` | Using pre-defined options, the user ranks them from most to least preferred. Needs at least one option available. Answers are stored in ranked order (one answer row per option). | +| `conditional` | A conditional branching question with a trigger question and multiple branches containing subquestions | ## Extra Settings @@ -280,3 +281,189 @@ Supported option types and their intended usage. | `column` | Represents a column header when rendering options in a grid-style question. | `row` and `column` option types are primarily used together with `grid` question types to build a two-dimensional selection matrix. `choice` is the default for normal option lists. +| `triggerType` | `conditional` | string | [See trigger types](#conditional-trigger-types) | The type of trigger question (dropdown, multiple_unique, etc.) | +| `branches` | `conditional` | Array | Array of [Branch objects](#branch-object) | The branches with conditions and subquestions | + +## Conditional Questions + +Conditional questions enable branching logic in forms. A trigger question determines which branch of subquestions appears based on the respondent's answer. + +### Question Properties for Subquestions + +Subquestions (questions belonging to a conditional question's branch) have additional properties: + +| Property | Type | Description | +| ---------------- | ------- | -------------------------------------------------------- | +| parentQuestionId | Integer | The ID of the parent conditional question (null for regular questions) | +| branchId | String | The ID of the branch this subquestion belongs to | + +### Conditional Trigger Types + +Supported trigger types for conditional questions: + +| Trigger Type | Condition Type | Description | +| ----------------- | -------------------- | ------------------------------------------------ | +| `multiple_unique` | `option_selected` | Radio buttons - single option selection | +| `dropdown` | `option_selected` | Dropdown - single option selection | +| `multiple` | `options_combination`| Checkboxes - all specified options must be selected | +| `short` | `string_equals`, `string_contains`, `regex` | Short text with string/regex matching | +| `long` | `string_contains`, `regex` | Long text with string/regex matching | +| `linearscale` | `value_equals`, `value_range` | Linear scale with value matching | +| `date` | `date_range` | Date with date range matching (YYYY-MM-DD) | +| `time` | `time_range` | Time with time range matching (HH:mm) | +| `color` | `value_equals` | Color with exact value matching | +| `file` | `file_uploaded` | File with upload status matching | + +### Branch Object + +A branch defines conditions and subquestions that appear when those conditions are met. + +| Property | Type | Description | +| ------------ | ------------------------------------------- | --------------------------------------------- | +| id | String | Unique identifier for the branch | +| conditions | Array of [Conditions](#condition-object) | Conditions that must be met to show the branch| +| subQuestions | Array of [Questions](#question) | Questions shown when conditions are met | + +```json +{ + "id": "branch-1705587600000", + "conditions": [ + { "type": "option_selected", "optionId": 42 } + ], + "subQuestions": [ + { + "id": 101, + "formId": 3, + "order": 1, + "type": "short", + "text": "Please provide details", + "parentQuestionId": 100, + "branchId": "branch-1705587600000" + } + ] +} +``` + +### Condition Object + +Conditions determine when a branch is activated. The structure depends on the trigger type. + +#### option_selected (for dropdown, multiple_unique) + +```json +{ "type": "option_selected", "optionId": 42 } +``` + +#### options_combination (for multiple/checkboxes) + +```json +{ "type": "options_combination", "optionIds": [42, 43] } +``` +All options in `optionIds` must be selected for the branch to activate (AND logic). + +#### string_equals (for short text) + +```json +{ "type": "string_equals", "value": "yes" } +``` + +#### string_contains (for short, long text) + +```json +{ "type": "string_contains", "value": "keyword" } +``` + +#### regex (for short, long text) + +```json +{ "type": "regex", "value": "^yes.*" } +``` + +#### value_equals (for color) + +```json +{ "type": "value_equals", "value": "#ff0000" } +``` + +#### value_range (for linearscale, time) + +```json +{ "type": "value_range", "min": 3, "max": 5 } +``` + +#### date_range (for date) + +```json +{ "type": "date_range", "min": "2024-01-01", "max": "2024-12-31" } +``` + +#### time_range (for time) + +```json +{ "type": "time_range", "min": "09:00", "max": "17:00" } +``` + +#### file_uploaded (for file) + +```json +{ "type": "file_uploaded", "fileUploaded": true } +``` + +### Conditional Question Example + +A complete conditional question structure: + +```json +{ + "id": 100, + "formId": 3, + "order": 1, + "type": "conditional", + "isRequired": true, + "text": "Do you have any dietary restrictions?", + "options": [ + { "id": 42, "questionId": 100, "order": 1, "text": "Yes" }, + { "id": 43, "questionId": 100, "order": 2, "text": "No" } + ], + "extraSettings": { + "triggerType": "dropdown", + "branches": [ + { + "id": "branch-yes", + "conditions": [{ "type": "option_selected", "optionId": 42 }], + "subQuestions": [ + { + "id": 101, + "formId": 3, + "order": 1, + "type": "long", + "text": "Please describe your dietary restrictions", + "parentQuestionId": 100, + "branchId": "branch-yes" + } + ] + } + ] + } +} +``` + +### Conditional Answer Structure + +When submitting or storing conditional question answers, the structure differs from regular questions: + +```json +{ + "100": { + "trigger": ["42"], + "subQuestions": { + "101": ["Vegetarian, no nuts"] + } + } +} +``` + +| Property | Type | Description | +| ------------ | --------------------------- | ----------------------------------------------------- | +| trigger | Array of strings | Answer values for the trigger question | +| subQuestions | Object (questionId → Array) | Map of subquestion IDs to their answer value arrays | diff --git a/lib/Constants.php b/lib/Constants.php index 684278976..b95fad598 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -1,7 +1,7 @@ ['string'], + 'branches' => ['array'], + ]; + + /** + * Condition types for conditional questions + */ + public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected'; + public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination'; + public const CONDITION_TYPE_STRING_EQUALS = 'string_equals'; + public const CONDITION_TYPE_STRING_CONTAINS = 'string_contains'; + public const CONDITION_TYPE_REGEX = 'regex'; + public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals'; + public const CONDITION_TYPE_VALUE_RANGE = 'value_range'; + public const CONDITION_TYPE_DATE_RANGE = 'date_range'; + public const CONDITION_TYPE_FILE_UPLOADED = 'file_uploaded'; + + public const CONDITION_TYPES = [ + self::CONDITION_TYPE_OPTION_SELECTED, + self::CONDITION_TYPE_OPTIONS_COMBINATION, + self::CONDITION_TYPE_STRING_EQUALS, + self::CONDITION_TYPE_STRING_CONTAINS, + self::CONDITION_TYPE_REGEX, + self::CONDITION_TYPE_VALUE_EQUALS, + self::CONDITION_TYPE_VALUE_RANGE, + self::CONDITION_TYPE_DATE_RANGE, + self::CONDITION_TYPE_FILE_UPLOADED, + ]; + + /** + * Trigger types allowed for conditional questions + * Maps each trigger type to its supported condition types + */ + public const CONDITIONAL_TRIGGER_TYPES = [ + self::ANSWER_TYPE_MULTIPLEUNIQUE => [self::CONDITION_TYPE_OPTION_SELECTED], + self::ANSWER_TYPE_DROPDOWN => [self::CONDITION_TYPE_OPTION_SELECTED], + self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION], + self::ANSWER_TYPE_SHORT => [self::CONDITION_TYPE_STRING_EQUALS, self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], + self::ANSWER_TYPE_LONG => [self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], + self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS, self::CONDITION_TYPE_VALUE_RANGE], + self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE], + self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE], + self::ANSWER_TYPE_TIME => [self::CONDITION_TYPE_VALUE_RANGE], + self::ANSWER_TYPE_COLOR => [self::CONDITION_TYPE_VALUE_EQUALS], + self::ANSWER_TYPE_FILE => [self::CONDITION_TYPE_FILE_UPLOADED], + ]; + public const FILENAME_INVALID_CHARS = [ "\n", '/', diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index edbed741d..576b1cb6b 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1,7 +1,7 @@ * @throws OCSBadRequestException Invalid type * @throws OCSBadRequestException Datetime question type no longer supported @@ -499,7 +501,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse { #[NoAdminRequired()] #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')] - public function newQuestion(int $formId, ?string $type = null, ?string $subtype = null, string $text = '', ?int $fromId = null): DataResponse { + public function newQuestion(int $formId, ?string $type = null, ?string $subtype = null, string $text = '', ?int $fromId = null, ?int $parentQuestionId = null, ?string $branchId = null): DataResponse { $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); $this->formsService->obtainFormLock($form); @@ -509,10 +511,12 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype } if ($fromId === null) { - $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}', [ + $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}, parentQuestionId: {parentQuestionId}, branchId: {branchId}', [ 'formId' => $formId, 'type' => $type, 'text' => $text, + 'parentQuestionId' => $parentQuestionId, + 'branchId' => $branchId, ]); if (array_search($type, Constants::ANSWER_TYPES) === false) { @@ -526,8 +530,47 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype throw new OCSBadRequestException('Datetime question type no longer supported'); } + // Block creation of nested conditional questions (conditional within conditional) + if ($type === Constants::ANSWER_TYPE_CONDITIONAL && $parentQuestionId !== null) { + $this->logger->debug('Nested conditional questions are not supported'); + throw new OCSBadRequestException('Nested conditional questions are not supported'); + } + + // Validate parent question if creating a subquestion + if ($parentQuestionId !== null) { + try { + $parentQuestion = $this->questionMapper->findById($parentQuestionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find parent question'); + throw new OCSNotFoundException('Could not find parent question'); + } + + // Parent must be a conditional question + if ($parentQuestion->getType() !== Constants::ANSWER_TYPE_CONDITIONAL) { + $this->logger->debug('Parent question must be a conditional question'); + throw new OCSBadRequestException('Parent question must be a conditional question'); + } + + // Parent must belong to the same form + if ($parentQuestion->getFormId() !== $formId) { + $this->logger->debug('Parent question does not belong to this form'); + throw new OCSBadRequestException('Parent question does not belong to this form'); + } + + // branchId is required when parentQuestionId is set + if (empty($branchId)) { + $this->logger->debug('branchId is required when creating a subquestion'); + throw new OCSBadRequestException('branchId is required when creating a subquestion'); + } + } + // Retrieve all active questions sorted by Order. Takes the order of the last array-element and adds one. - $questions = $this->questionMapper->findByForm($formId); + // For subquestions, get order within the branch + if ($parentQuestionId !== null) { + $questions = $this->questionMapper->findByBranch($parentQuestionId, $branchId); + } else { + $questions = $this->questionMapper->findByForm($formId); + } $lastQuestion = array_pop($questions); if ($lastQuestion) { $questionOrder = $lastQuestion->getOrder() + 1; @@ -545,6 +588,12 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype $question->setIsRequired(false); $question->setExtraSettings($subtype ? ['questionType' => $subtype] : []); + // Set parent question and branch for subquestions + if ($parentQuestionId !== null) { + $question->setParentQuestionId($parentQuestionId); + $question->setBranchId($branchId); + } + $question = $this->questionMapper->insert($question); $response = $this->formsService->getQuestion($question->getId()); @@ -739,6 +788,15 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { // Store Order of deleted Question $deletedOrder = $question->getOrder(); + // If this is a conditional question, also soft-delete its subquestions + if ($question->getType() === Constants::ANSWER_TYPE_CONDITIONAL) { + $subQuestions = $this->questionMapper->findByParentQuestion($questionId); + foreach ($subQuestions as $subQuestion) { + $subQuestion->setOrder(0); + $this->questionMapper->update($subQuestion); + } + } + // Mark question as deleted $question->setOrder(0); $this->questionMapper->update($question); @@ -862,6 +920,125 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { return new DataResponse($response); } + /** + * Updates the Order of all Questions in a Branch of a conditional + * + * @param int $formId Id of the form to reorder + * @param string $branchId Id of the branch. + * @param int $parentQuestionId Id of the parent trigger question. + * @param list $newOrder Array of Question-Ids in new order. + * @return DataResponse, array{}> + * @throws OCSBadRequestException The given array contains duplicates + * @throws OCSBadRequestException The length of the given array does not match the number of stored questions + * @throws OCSBadRequestException Question doesn't belong to given Form + * @throws OCSBadRequestException One question has already been marked as deleted + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 200: the question ids of the given form in the new order + */ + #[CORS()] + #[NoAdminRequired()] + #[BruteForceProtection(action: 'form')] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/subquestions')] + public function reorderSubQuestions(int $formId, string $branchId, int $parentQuestionId, array $newOrder): DataResponse { + $this->logger->debug('Reordering Sub-Questions on Form {formId}, Parent-Question {parentQuestionId}, Branch {branchId} as Question-Ids {newOrder}', [ + 'formId' => $formId, + 'parentQuestionId' => $parentQuestionId, + 'branchId' => $branchId, + 'newOrder' => $newOrder + ]); + + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException('This form is archived and can not be modified'); + } + + // Check if array contains duplicates + if (array_unique($newOrder) !== $newOrder) { + $this->logger->debug('The given array contains duplicates'); + throw new OCSBadRequestException('The given array contains duplicates'); + } + + // Check if all questions are given in Array. + $questions = $this->questionMapper->findByBranch($parentQuestionId, $branchId); + if (sizeof($questions) !== sizeof($newOrder)) { + $this->logger->debug('The length of the given array does not match the number of stored questions'); + throw new OCSBadRequestException('The length of the given array does not match the number of stored questions'); + } + + $questions = []; // Clear Array of Entities + $response = []; // Array of ['questionId' => ['order' => newOrder]] + + // Store array of Question-Entities and check the Questions FormId & old Order. + foreach ($newOrder as $arrayKey => $questionId) { + try { + $questions[$arrayKey] = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSNotFoundException('Could not find question'); + } + + // Abort if a question is not part of the Form. + if ($questions[$arrayKey]->getFormId() !== $formId) { + $this->logger->debug('This Question is not part of the given form: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + // Abort if a question is not part of the Parent Question. + if ($questions[$arrayKey]->getParentQuestionId() !== $parentQuestionId) { + $this->logger->debug('This Question is not part of the given parent question: {parentQuestionId}', [ + 'parentQuestionId' => $parentQuestionId + ]); + throw new OCSBadRequestException('Question doesn\'t belong to given Parent Question'); + } + + // Abort if a question is not part of the Branch. + if ($questions[$arrayKey]->getBranchId() !== $branchId) { + $this->logger->debug('This Question is not part of the given branch: {branchId}', [ + 'branchId' => $branchId + ]); + throw new OCSBadRequestException('Question doesn\'t belong to given Branch'); + } + + // Abort if a question is already marked as deleted (order==0) + $oldOrder = $questions[$arrayKey]->getOrder(); + if ($oldOrder === 0) { + $this->logger->debug('This question has already been marked as deleted: Id: {questionId}', [ + 'questionId' => $questions[$arrayKey]->getId() + ]); + throw new OCSBadRequestException('One question has already been marked as deleted'); + } + + // Only set order, if it changed. + if ($oldOrder !== $arrayKey + 1) { + // Set Order. ArrayKey counts from zero, order counts from 1. + $questions[$arrayKey]->setOrder($arrayKey + 1); + } + } + + // Write to Database + foreach ($questions as $question) { + $this->questionMapper->update($question); + + $response[(string)$question->getId()] = [ + 'order' => $question->getOrder() + ]; + } + + $this->formMapper->update($form); + + return new DataResponse($response); + } + // Options /** @@ -1799,13 +1976,18 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest return; } + // Special handling for conditional questions + if ($question['type'] === Constants::ANSWER_TYPE_CONDITIONAL) { + $this->storeConditionalAnswer($form, $submissionId, $question, $answerArray); + return; + } + foreach ($answerArray as $answer) { $answerEntity = new Answer(); $answerEntity->setSubmissionId($submissionId); $answerEntity->setQuestionId($question['id']); $answerText = ''; - $uploadedFile = null; // Are we using answer ids as values if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && $question['type'] !== Constants::ANSWER_TYPE_LINEARSCALE) { // Search corresponding option, skip processing if not found @@ -1816,24 +1998,7 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest $answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, '', $answer); } } elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) { - $uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']); - $answerEntity->setFileId($uploadedFile->getFileId()); - - $userFolder = $this->rootFolder->getUserFolder($form->getOwnerId()); - $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']); - - if ($userFolder->nodeExists($path)) { - $folder = $userFolder->get($path); - } else { - $folder = $userFolder->newFolder($path); - } - /** @var \OCP\Files\Folder $folder */ - - $file = $userFolder->getById($uploadedFile->getFileId())[0]; - $name = $folder->getNonExistingName($file->getName()); - $file->move($folder->getPath() . '/' . $name); - - $answerText = $name; + $answerText = $this->storeFileAnswer($form, $submissionId, $question, $answer['uploadedFileId'], $answerEntity); } else { $answerText = $answer; // Not a multiple-question, answerText is given answer } @@ -1844,8 +2009,154 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest $answerEntity->setText($answerText); $this->answerMapper->insert($answerEntity); - if ($uploadedFile) { - $this->uploadedFileMapper->delete($uploadedFile); + } + } + + /** + * Store a file answer by moving the uploaded file to the submsision folder + * + * + * @param Form $form + * @param int $submissionId + * @param array $question The conditional question + * @param array $answerData The conditional answer data + */ + private function storeFileAnswer(Form $form, int $submissionId, array $question, string $uploadedFileId, $answerEntity) { + $uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($uploadedFileId); + $answerEntity->setFileId($uploadedFile->getFileId()); + + $userFolder = $this->rootFolder->getUserFolder($form->getOwnerId()); + $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']); + + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + /** @var \OCP\Files\Folder $folder */ + + $files = $userFolder->getById($uploadedFile->getFileId()); + if (empty($files)) { + throw new OCSBadRequestException('Uploaded file not found in storage.'); + } + $file = $files[0]; + $name = $folder->getNonExistingName($file->getName()); + $file->move($folder->getPath() . '/' . $name); + $this->uploadedFileMapper->delete($uploadedFile); + + return $name; + } + + /** + * Store answers for a conditional question + * + * Conditional answers have the structure: + * - trigger: array of trigger answer values (option IDs for predefined types, or text) + * - subQuestions: array of subquestion answers keyed by subquestion ID + * + * @param Form $form + * @param int $submissionId + * @param array $question The conditional question + * @param array $answerData The conditional answer data + */ + private function storeConditionalAnswer(Form $form, int $submissionId, array $question, array $answerData): void { + $extraSettings = $question['extraSettings'] ?? []; + $triggerType = $extraSettings['triggerType'] ?? null; + $branches = $extraSettings['branches'] ?? []; + + // Handle the structure: could be {trigger: [...], subQuestions: {...}} or just trigger array + $triggerAnswers = $answerData['trigger'] ?? $answerData; + $subQuestionAnswers = $answerData['subQuestions'] ?? []; + + // Ensure triggerAnswers is an array + if (!is_array($triggerAnswers)) { + $triggerAnswers = [$triggerAnswers]; + } + + // Store trigger answers + foreach ($triggerAnswers as $answer) { + if ($answer === '' || $answer === null) { + continue; + } + + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + + $answerText = ''; + + // For predefined trigger types, convert option ID to text + if (in_array($triggerType, [Constants::ANSWER_TYPE_MULTIPLE, Constants::ANSWER_TYPE_MULTIPLEUNIQUE, Constants::ANSWER_TYPE_DROPDOWN])) { + $optionIndex = array_search($answer, array_column($question['options'] ?? [], 'id')); + if ($optionIndex !== false) { + $answerText = $question['options'][$optionIndex]['text']; + } else { + // Could be an "other" answer or direct text + $answerText = is_string($answer) ? $answer : ''; + } + } else { + // For text-based triggers, use the answer directly + $answerText = is_string($answer) ? $answer : ''; + } + + if ($answerText !== '') { + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + } + } + + // Store subquestion answers + // Find the active branch to get subquestion definitions + foreach ($branches as $branch) { + $branchSubQuestions = $branch['subQuestions'] ?? []; + foreach ($branchSubQuestions as $subQuestion) { + $subQuestionId = $subQuestion['id'] ?? null; + if ($subQuestionId === null) { + continue; + } + + $subAnswers = $subQuestionAnswers[$subQuestionId] ?? []; + if (empty($subAnswers)) { + continue; + } + + // Ensure subAnswers is an array + if (!is_array($subAnswers)) { + $subAnswers = [$subAnswers]; + } + + // Store each subquestion answer + foreach ($subAnswers as $subAnswer) { + if ($subAnswer === '' || $subAnswer === null) { + continue; + } + + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($subQuestionId); + + $answerText = ''; + $subQuestionType = $subQuestion['type'] ?? Constants::ANSWER_TYPE_SHORT; + + // For predefined types, convert option ID to text + if (in_array($subQuestionType, Constants::ANSWER_TYPES_PREDEFINED) && $subQuestionType !== Constants::ANSWER_TYPE_LINEARSCALE) { + $optionIndex = array_search($subAnswer, array_column($subQuestion['options'] ?? [], 'id')); + if ($optionIndex !== false) { + $answerText = $subQuestion['options'][$optionIndex]['text']; + } else { + $answerText = is_string($subAnswer) ? $subAnswer : ''; + } + } elseif ($subQuestionType === Constants::ANSWER_TYPE_FILE) { + $answerText = $this->storeFileAnswer($form, $submissionId, $subQuestion, $subAnswer['uploadedFileId'], $answerEntity); + } else { + $answerText = is_string($subAnswer) ? $subAnswer : ''; + } + + if ($answerText !== '') { + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + } + } } } } diff --git a/lib/Db/Question.php b/lib/Db/Question.php index 05355b95f..da99ce3df 100644 --- a/lib/Db/Question.php +++ b/lib/Db/Question.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2019-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -21,9 +21,9 @@ * @method void setOrder(integer $value) * @psalm-method FormsQuestionType getType() * @method string getType() - * @psalm-method 'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' getType() + * @psalm-method 'color'|'conditional'|'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' getType() * @method void setType(string $value) - * @psalm-method void setType('date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' $value) + * @psalm-method void setType('color'|'conditional'|'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' $value) * @method bool getIsRequired() * @method void setIsRequired(bool $value) * @method string getText() @@ -32,6 +32,10 @@ * @method void setDescription(string $value) * @method string getName() * @method void setName(string $value) + * @method ?int getParentQuestionId() + * @method void setParentQuestionId(?int $value) + * @method ?string getBranchId() + * @method void setBranchId(?string $value) */ class Question extends Entity { protected $formId; @@ -42,6 +46,8 @@ class Question extends Entity { protected $name; protected $description; protected $extraSettingsJson; + protected $parentQuestionId; + protected $branchId; public function __construct() { $this->addType('formId', 'integer'); @@ -51,6 +57,8 @@ public function __construct() { $this->addType('text', 'string'); $this->addType('description', 'string'); $this->addType('name', 'string'); + $this->addType('parentQuestionId', 'integer'); + $this->addType('branchId', 'string'); } /** @@ -85,6 +93,8 @@ public function setExtraSettings(array $extraSettings): void { * name: string, * description: string, * extraSettings: FormsQuestionExtraSettings, + * parentQuestionId: ?int, + * branchId: ?string, * } */ public function read(): array { @@ -98,6 +108,8 @@ public function read(): array { 'name' => (string)$this->getName(), 'description' => (string)$this->getDescription(), 'extraSettings' => $this->getExtraSettings(), + 'parentQuestionId' => $this->getParentQuestionId(), + 'branchId' => $this->getBranchId(), ]; } diff --git a/lib/Db/QuestionMapper.php b/lib/Db/QuestionMapper.php index fefd9fd25..67e3db0cb 100644 --- a/lib/Db/QuestionMapper.php +++ b/lib/Db/QuestionMapper.php @@ -1,7 +1,7 @@ db->getQueryBuilder(); $qb->select('*') @@ -37,6 +37,13 @@ public function findByForm(int $formId, bool $loadDeleted = false): array { $qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)) ); + if (!$loadSubquestions) { + // Only load top-level questions (not subquestions of conditional questions) + $qb->andWhere( + $qb->expr()->isNull('parent_question_id') + ); + } + if (!$loadDeleted) { // Don't load questions, that are marked as deleted (marked by order==0). $qb->andWhere( @@ -50,6 +57,81 @@ public function findByForm(int $formId, bool $loadDeleted = false): array { return $this->findEntities($qb); } + /** + * Find subquestions belonging to a parent conditional question + * + * @param int $parentQuestionId The ID of the parent conditional question + * @param bool $loadDeleted Whether to include soft-deleted questions + * @return Question[] + */ + public function findByParentQuestion(int $parentQuestionId, bool $loadDeleted = false): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('parent_question_id', $qb->createNamedParameter($parentQuestionId, IQueryBuilder::PARAM_INT)) + ); + + if (!$loadDeleted) { + $qb->andWhere( + $qb->expr()->neq('order', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ); + } + + // Sort by order within the conditional question + $qb->orderBy('order'); + + return $this->findEntities($qb); + } + + /** + * Find subquestions belonging to a specific branch of a conditional question + * + * @param int $parentQuestionId The ID of the parent conditional question + * @param string $branchId The branch identifier + * @return Question[] + */ + public function findByBranch(int $parentQuestionId, string $branchId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('parent_question_id', $qb->createNamedParameter($parentQuestionId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('branch_id', $qb->createNamedParameter($branchId)) + ) + ->andWhere( + $qb->expr()->neq('order', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ) + ->orderBy('order'); + + return $this->findEntities($qb); + } + + /** + * Delete all subquestions of a parent conditional question + * + * @param int $parentQuestionId The ID of the parent conditional question + */ + public function deleteByParentQuestion(int $parentQuestionId): void { + // First delete options for all subquestions + $subQuestions = $this->findByParentQuestion($parentQuestionId, true); + foreach ($subQuestions as $subQuestion) { + $this->optionMapper->deleteByQuestion($subQuestion->getId()); + } + + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('parent_question_id', $qb->createNamedParameter($parentQuestionId, IQueryBuilder::PARAM_INT)) + ); + + $qb->executeStatement(); + } + /** * @param int $formId */ diff --git a/lib/Migration/Version050300Date20260118000000.php b/lib/Migration/Version050300Date20260118000000.php new file mode 100644 index 000000000..fd562c211 --- /dev/null +++ b/lib/Migration/Version050300Date20260118000000.php @@ -0,0 +1,60 @@ +getTable('forms_v2_questions'); + + // Add parent_question_id column - references parent conditional question + if (!$table->hasColumn('parent_question_id')) { + $table->addColumn('parent_question_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + 'comment' => 'ID of parent conditional question, null for top-level questions', + ]); + } + + // Add branch_id column - identifies which branch this subquestion belongs to + if (!$table->hasColumn('branch_id')) { + $table->addColumn('branch_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => null, + 'comment' => 'Branch identifier within parent conditional question', + ]); + } + + // Add index for efficient lookup of subquestions by parent + if (!$table->hasIndex('forms_v2_q_parent_idx')) { + $table->addIndex(['parent_question_id'], 'forms_v2_q_parent_idx'); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 439e196b5..b791964f0 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -44,7 +44,7 @@ * questionType?: string, * } * - * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid" + * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"|"color"|"conditional" * @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio" * * @psalm-type FormsQuestion = array{ @@ -59,6 +59,8 @@ * extraSettings: FormsQuestionExtraSettings|\stdClass, * options: list, * accept: list, + * parentQuestionId?: ?int, + * branchId?: ?string, * } * * @psalm-type FormsAnswer = array{ diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 239ee94d9..7eeaf20cc 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -1,7 +1,7 @@ loadConditionalSubQuestions($question); + } + $questionList[] = $question; } } catch (DoesNotExistException $e) { @@ -144,6 +149,57 @@ public function getQuestions(int $formId): array { } } + /** + * Load subquestions for a conditional question and attach them to branches + * + * @param array $question The conditional question data + * @return array The question with subquestions attached to branches + */ + private function loadConditionalSubQuestions(array $question): array { + $subQuestionEntities = $this->questionMapper->findByParentQuestion($question['id']); + + // Group subquestions by branchId + $subQuestionsByBranch = []; + foreach ($subQuestionEntities as $subQuestionEntity) { + $subQuestion = $subQuestionEntity->read(); + $subQuestion['options'] = $this->getOptions($subQuestion['id']); + $subQuestion['accept'] = []; + + // Handle file type accept for subquestions + if ($subQuestion['type'] === Constants::ANSWER_TYPE_FILE) { + if ($subQuestion['extraSettings']['allowedFileTypes'] ?? null) { + $subQuestion['accept'] = array_map(function (string $fileType) { + return str_contains($fileType, '/') ? $fileType : $fileType . '/*'; + }, $subQuestion['extraSettings']['allowedFileTypes']); + } + + if ($subQuestion['extraSettings']['allowedFileExtensions'] ?? null) { + foreach ($subQuestion['extraSettings']['allowedFileExtensions'] as $extension) { + $subQuestion['accept'][] = '.' . $extension; + } + } + } + + $branchId = $subQuestion['branchId'] ?? null; + if ($branchId !== null) { + if (!isset($subQuestionsByBranch[$branchId])) { + $subQuestionsByBranch[$branchId] = []; + } + $subQuestionsByBranch[$branchId][] = $subQuestion; + } + } + + // Attach subquestions to their respective branches in extraSettings + if (isset($question['extraSettings']['branches']) && is_array($question['extraSettings']['branches'])) { + foreach ($question['extraSettings']['branches'] as $index => $branch) { + $branchId = $branch['id'] ?? null; + $question['extraSettings']['branches'][$index]['subQuestions'] = $subQuestionsByBranch[$branchId] ?? []; + } + } + + return $question; + } + /** * Load specific question * @@ -169,6 +225,12 @@ public function getQuestion(int $questionId): ?array { } } } + + // Load subquestions for conditional questions + if ($question['type'] === Constants::ANSWER_TYPE_CONDITIONAL) { + $question = $this->loadConditionalSubQuestions($question); + } + return $question; } catch (DoesNotExistException $e) { return null; @@ -836,39 +898,28 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType return true; } - // Ensure only allowed keys are set - switch ($questionType) { - case Constants::ANSWER_TYPE_DROPDOWN: - $allowed = Constants::EXTRA_SETTINGS_DROPDOWN; - break; - case Constants::ANSWER_TYPE_MULTIPLE: - case Constants::ANSWER_TYPE_MULTIPLEUNIQUE: - $allowed = Constants::EXTRA_SETTINGS_MULTIPLE; - break; - case Constants::ANSWER_TYPE_SHORT: - $allowed = Constants::EXTRA_SETTINGS_SHORT; - break; - case Constants::ANSWER_TYPE_FILE: - $allowed = Constants::EXTRA_SETTINGS_FILE; - break; - case Constants::ANSWER_TYPE_DATE: - $allowed = Constants::EXTRA_SETTINGS_DATE; - break; - case Constants::ANSWER_TYPE_GRID: - $allowed = Constants::EXTRA_SETTINGS_GRID; - break; - case Constants::ANSWER_TYPE_RANKING: - $allowed = Constants::EXTRA_SETTINGS_RANKING; - break; - case Constants::ANSWER_TYPE_TIME: - $allowed = Constants::EXTRA_SETTINGS_TIME; - break; - case Constants::ANSWER_TYPE_LINEARSCALE: - $allowed = Constants::EXTRA_SETTINGS_LINEARSCALE; - break; - default: - $allowed = []; + // Map allowed options to answer type + $extraSettingsMap = [ + Constants::ANSWER_TYPE_DROPDOWN => Constants::EXTRA_SETTINGS_DROPDOWN, + Constants::ANSWER_TYPE_MULTIPLE => Constants::EXTRA_SETTINGS_MULTIPLE, + Constants::ANSWER_TYPE_MULTIPLEUNIQUE => Constants::EXTRA_SETTINGS_MULTIPLE, + Constants::ANSWER_TYPE_SHORT => Constants::EXTRA_SETTINGS_SHORT, + Constants::ANSWER_TYPE_FILE => Constants::EXTRA_SETTINGS_FILE, + Constants::ANSWER_TYPE_DATE => Constants::EXTRA_SETTINGS_DATE, + Constants::ANSWER_TYPE_GRID => Constants::EXTRA_SETTINGS_GRID, + Constants::ANSWER_TYPE_TIME => Constants::EXTRA_SETTINGS_TIME, + Constants::ANSWER_TYPE_LINEARSCALE => Constants::EXTRA_SETTINGS_LINEARSCALE, + Constants::ANSWER_TYPE_RANKING => Constants::EXTRA_SETTINGS_RANKING, + Constants::ANSWER_TYPE_CONDITIONAL => Constants::EXTRA_SETTINGS_CONDITIONAL, + ]; + + // Get triggertype if is conditional + $triggerType = $extraSettings['triggerType'] ?? null; + if (is_string($triggerType) && isset($extraSettingsMap[$triggerType])) { + $extraSettingsMap[Constants::ANSWER_TYPE_CONDITIONAL] += $extraSettingsMap[$triggerType]; } + // Ensure only allowed keys are set + $allowed = $extraSettingsMap[$questionType] ?? []; // Number of keys in extraSettings but not in allowed (but not the other way round) $diff = array_diff(array_keys($extraSettings), array_keys($allowed)); if (count($diff) > 0) { @@ -976,6 +1027,46 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType || isset($extraSettings['optionsHighest']) && ($extraSettings['optionsHighest'] < 2 || $extraSettings['optionsHighest'] > 10)) { return false; } + } elseif ($questionType === Constants::ANSWER_TYPE_CONDITIONAL) { + // Validate conditional question settings + if (!isset($extraSettings['triggerType']) || !is_string($extraSettings['triggerType'])) { + return false; + } + + // Validate trigger type is a valid question type (and not nested conditional) + if (!array_key_exists($extraSettings['triggerType'], Constants::CONDITIONAL_TRIGGER_TYPES)) { + return false; + } + + // Branches are required for conditional questions + if (!isset($extraSettings['branches']) || !is_array($extraSettings['branches'])) { + return false; + } + + // Validate branches structure + foreach ($extraSettings['branches'] as $branch) { + // Each branch must have an id + if (!isset($branch['id']) || !is_string($branch['id'])) { + return false; + } + + // Branches must have conditions array + if (!isset($branch['conditions']) || !is_array($branch['conditions'])) { + return false; + } + } + + $diff = array_diff(array_keys($extraSettings), array_keys(Constants::EXTRA_SETTINGS_CONDITIONAL)); + // Handle options of triggerQuestion + if (count($diff) > 0 && isset($triggerType) && $triggerType !== '') { + $diffDict = []; + foreach ($diff as $k) { + $diffDict[$k] = $extraSettings[$k]; + } + $valid = self::areExtraSettingsValid($diffDict, $triggerType); + return $valid; + } + } return true; } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index a7873b12c..9aff5ef24 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -1,7 +1,7 @@ questionMapper->findByForm($form->getId()); + $questions = $this->questionMapper->findByForm($form->getId(), false, true); $defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC'); if (!$this->currentUser) { @@ -480,6 +480,20 @@ public function validateSubmission(array $questions, array $answers, string $for $questionId = $question['id']; $questionAnswered = array_key_exists($questionId, $answers); + // Special handling for conditional questions + if ($question['type'] === Constants::ANSWER_TYPE_CONDITIONAL) { + // Check if required conditional has any answer + if ($question['isRequired'] && !$questionAnswered) { + throw new \InvalidArgumentException(sprintf('Question "%s" is required.', $question['text'])); + } + + // If answered, validate the conditional structure + if ($questionAnswered) { + $this->validateConditionalQuestion($question, $answers[$questionId], $formOwnerId); + } + continue; + } + // Check if all required questions have an answer if ($question['isRequired'] && (!$questionAnswered @@ -708,4 +722,251 @@ private function setCellValue(Worksheet $activeWorksheet, int $column, int $row, $activeWorksheet->setCellValue([$column, $row], $value); } } + + /** + * Validate conditional question answers + * + * Conditional questions have a special answer structure: + * - trigger: array of trigger answer values + * - subQuestions: array of subquestion answers keyed by subquestion ID + * + * @param array $question The conditional question + * @param array $answerData The answer data for the conditional question + * @param string $formOwnerId Owner of the form + * @throws \InvalidArgumentException if validation failed + */ + private function validateConditionalQuestion(array $question, array $answerData, string $formOwnerId): void { + // Answer structure should have 'trigger' key + // For conditional questions, the answerData may be structured differently + // Check if this is a structured conditional answer or a flat array + $triggerAnswer = $answerData['trigger'] ?? $answerData; + $subQuestionAnswers = $answerData['subQuestions'] ?? []; + + $extraSettings = $question['extraSettings'] ?? []; + $triggerType = $extraSettings['triggerType'] ?? null; + $branches = $extraSettings['branches'] ?? []; + + if (!$triggerType) { + throw new \InvalidArgumentException(sprintf('Conditional question "%s" is missing trigger type configuration.', $question['text'])); + } + + // Find the active branch based on trigger answer + $activeBranch = $this->findActiveBranch($triggerType, $triggerAnswer, $branches, $question['options'] ?? []); + + if ($activeBranch === null && !empty($branches)) { + // No branch matched but branches are defined - this might be okay if trigger has no value yet + // Only throw if trigger has a value that doesn't match any branch + if (!empty($triggerAnswer)) { + $this->logger->warning('No branch matched for conditional question', [ + 'questionId' => $question['id'], + 'triggerAnswer' => $triggerAnswer, + ]); + } + return; + } + + // If we have an active branch, validate its subquestions + if ($activeBranch !== null && isset($activeBranch['subQuestions'])) { + $subQuestions = $activeBranch['subQuestions']; + + // Build a questions array from subquestions for validation + foreach ($subQuestions as $subQuestion) { + $subQuestionId = $subQuestion['id']; + $subQuestionAnswered = isset($subQuestionAnswers[$subQuestionId]); + + // Check if required subquestions have an answer + if ($subQuestion['isRequired'] ?? false) { + if (!$subQuestionAnswered || empty($subQuestionAnswers[$subQuestionId])) { + throw new \InvalidArgumentException(sprintf('Subquestion "%s" in conditional question "%s" is required.', $subQuestion['text'] ?? 'Unknown', $question['text'])); + } + } + } + } + } + + /** + * Find the active branch based on trigger answer + * + * @param string $triggerType The type of the trigger question + * @param array $triggerAnswer The trigger answer values + * @param array $branches The available branches + * @param array $options The options for the trigger question + * @return array|null The active branch or null if none matches + */ + private function findActiveBranch(string $triggerType, array $triggerAnswer, array $branches, array $options): ?array { + foreach ($branches as $branch) { + $conditions = $branch['conditions'] ?? []; + + if (empty($conditions)) { + continue; + } + + $matches = $this->evaluateBranchConditions($triggerType, $triggerAnswer, $conditions); + + if ($matches) { + return $branch; + } + } + + return null; + } + + /** + * Evaluate if branch conditions match the trigger answer + * + * @param string $triggerType The type of the trigger question + * @param array $triggerAnswer The trigger answer values + * @param array $conditions The conditions to evaluate + * @return bool True if conditions match + */ + private function evaluateBranchConditions(string $triggerType, array $triggerAnswer, array $conditions): bool { + switch ($triggerType) { + case Constants::ANSWER_TYPE_MULTIPLEUNIQUE: + case Constants::ANSWER_TYPE_DROPDOWN: + // Single select: check if selected option matches any condition + foreach ($conditions as $condition) { + $optionId = $condition['optionId'] ?? null; + if ($optionId !== null && in_array((string)$optionId, $triggerAnswer, true)) { + return true; + } + } + return false; + case Constants::ANSWER_TYPE_MULTIPLE: + // Multi-select: all condition option IDs must be selected + foreach ($conditions as $condition) { + $optionIds = $condition['optionIds'] ?? []; + if (empty($optionIds) || !is_array($optionIds)) { + continue; + } + foreach ($optionIds as $optionId) { + if (!in_array((string)$optionId, $triggerAnswer, true)) { + return false; + } + } + return true; + } + return false; + case Constants::ANSWER_TYPE_SHORT: + case Constants::ANSWER_TYPE_LONG: + // Text-based: evaluate regex/string conditions + $text = $triggerAnswer[0] ?? ''; + foreach ($conditions as $condition) { + $type = $condition['type'] ?? 'string_contains'; + $value = $condition['value'] ?? ''; + + switch ($type) { + case 'string_equals': + if ($text === $value) { + return true; + } + break; + case 'string_contains': + if (str_contains($text, $value)) { + return true; + } + break; + case 'regex': + if ($this->safeRegexMatch($value, $text)) { + return true; + } + break; + } + } + return false; + case Constants::ANSWER_TYPE_LINEARSCALE: + $numValue = (float)($triggerAnswer[0] ?? 0); + foreach ($conditions as $condition) { + $type = $condition['type'] ?? 'value_equals'; + if ($type === 'value_equals') { + if ($numValue == (float)($condition['value'] ?? 0)) { + return true; + } + } elseif ($type === 'value_range') { + $min = $condition['min'] ?? PHP_FLOAT_MIN; + $max = $condition['max'] ?? PHP_FLOAT_MAX; + if ($numValue >= $min && $numValue <= $max) { + return true; + } + } + } + return false; + case Constants::ANSWER_TYPE_COLOR: + $colorValue = $triggerAnswer[0] ?? ''; + foreach ($conditions as $condition) { + if (strcasecmp($colorValue, $condition['value'] ?? '') === 0) { + return true; + } + } + return false; + case Constants::ANSWER_TYPE_FILE: + $hasFile = !empty($triggerAnswer); + foreach ($conditions as $condition) { + if (($condition['fileUploaded'] ?? true) === $hasFile) { + return true; + } + } + return false; + case Constants::ANSWER_TYPE_DATE: + case Constants::ANSWER_TYPE_DATETIME: + case Constants::ANSWER_TYPE_TIME: + // Date range conditions + $dateValue = $triggerAnswer[0] ?? ''; + if (empty($dateValue)) { + return false; + } + $format = Constants::ANSWER_PHPDATETIME_FORMAT[$triggerType] ?? 'Y-m-d'; + $date = \DateTime::createFromFormat($format, $dateValue); + if (!$date) { + return false; + } + + foreach ($conditions as $condition) { + $min = isset($condition['min']) ? \DateTime::createFromFormat($format, $condition['min']) : null; + $max = isset($condition['max']) ? \DateTime::createFromFormat($format, $condition['max']) : null; + + $inRange = true; + if ($min && $date < $min) { + $inRange = false; + } + if ($max && $date > $max) { + $inRange = false; + } + if ($inRange) { + return true; + } + } + return false; + default: + return false; + } + } + + /** + * Safely execute a regex match with validation to prevent ReDoS attacks + * + * @param string $pattern The regex pattern to match + * @param string $subject The string to match against + * @return bool True if the pattern matches, false otherwise + */ + private function safeRegexMatch(string $pattern, string $subject): bool { + if (empty($pattern) || strlen($subject) > 10000) { + return false; + } + + // Validate regex syntax + if (@preg_match($pattern, '') === false) { + return false; + } + + // Set backtrack limit to prevent catastrophic backtracking + $previousLimit = ini_get('pcre.backtrack_limit'); + ini_set('pcre.backtrack_limit', '10000'); + + try { + $result = @preg_match($pattern, $subject); + return $result === 1; + } finally { + ini_set('pcre.backtrack_limit', $previousLimit); + } + } } diff --git a/openapi.json b/openapi.json index a5f5a7db0..e88f27fe6 100644 --- a/openapi.json +++ b/openapi.json @@ -474,6 +474,15 @@ "items": { "type": "string" } + }, + "parentQuestionId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "branchId": { + "type": "string", + "nullable": true } } }, @@ -596,7 +605,9 @@ "long", "file", "datetime", - "grid" + "grid", + "color", + "conditional" ] }, "Share": { @@ -1686,6 +1697,19 @@ "nullable": true, "default": null, "description": "(optional) id of the question that should be cloned" + }, + "parentQuestionId": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "(optional) id of the parent conditional question (for subquestions)" + }, + "branchId": { + "type": "string", + "nullable": true, + "default": null, + "description": "(optional) branch id within the parent conditional question" } } } @@ -2658,6 +2682,224 @@ } } }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/subquestions": { + "patch": { + "operationId": "api-reorder-sub-questions", + "summary": "Updates the Order of all Questions in a Branch of a conditional", + "description": "This endpoint allows CORS requests", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "branchId", + "parentQuestionId", + "newOrder" + ], + "properties": { + "branchId": { + "type": "string", + "description": "Id of the branch." + }, + "parentQuestionId": { + "type": "integer", + "format": "int64", + "description": "Id of the parent trigger question." + }, + "newOrder": { + "type": "array", + "description": "Array of Question-Ids in new order.", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "Id of the form to reorder", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the question ids of the given form in the new order", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Order" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "One question has already been marked as deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/questions/{questionId}/options": { "post": { "operationId": "api-new-option", diff --git a/src/components/Questions/BranchConditionEditor.vue b/src/components/Questions/BranchConditionEditor.vue new file mode 100644 index 000000000..e579a5ca1 --- /dev/null +++ b/src/components/Questions/BranchConditionEditor.vue @@ -0,0 +1,511 @@ + + + + + + + diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue index 5c442380e..6d9525492 100644 --- a/src/components/Questions/Question.vue +++ b/src/components/Questions/Question.vue @@ -13,7 +13,7 @@
+

{{ computedText }}

+
{{ t('forms', 'Technical name') }} - + @@ -123,7 +127,7 @@