diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index f832705bc..0cbe6a73e 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1375,6 +1375,12 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' throw new OCSForbiddenException('Already submitted'); } + // Check if max submissions limit is reached + $maxSubmissions = $form->getMaxSubmissions(); + if ($maxSubmissions > 0 && $this->submissionMapper->countSubmissions($formId) >= $maxSubmissions) { + throw new OCSForbiddenException('Maximum number of submissions reached'); + } + // Insert new submission $this->submissionMapper->insert($submission); diff --git a/lib/Db/Form.php b/lib/Db/Form.php index fe1637eda..d3c9999df 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -50,6 +50,8 @@ * @method string getLockedBy() * @method void setLockedBy(string|null $value) * @method int getLockedUntil() + * @method int|null getMaxSubmissions() + * @method void setMaxSubmissions(int|null $value) * @method void setLockedUntil(int|null $value) */ class Form extends Entity { @@ -71,6 +73,7 @@ class Form extends Entity { protected $state; protected $lockedBy; protected $lockedUntil; + protected $maxSubmissions; /** * Form constructor. @@ -86,6 +89,7 @@ public function __construct() { $this->addType('state', 'integer'); $this->addType('lockedBy', 'string'); $this->addType('lockedUntil', 'integer'); + $this->addType('maxSubmissions', 'integer'); } // JSON-Decoding of access-column. @@ -159,6 +163,7 @@ public function setAccess(array $access): void { * state: 0|1|2, * lockedBy: ?string, * lockedUntil: ?int, + * maxSubmissions: ?int, * } */ public function read() { @@ -182,6 +187,7 @@ public function read() { 'state' => $this->getState(), 'lockedBy' => $this->getLockedBy(), 'lockedUntil' => $this->getLockedUntil(), + 'maxSubmissions' => $this->getMaxSubmissions(), ]; } } diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 3dca54c37..5a74db027 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -148,6 +148,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setSubmitMultiple($formData['submitMultiple']); $form->setAllowEditSubmissions($formData['allowEditSubmissions']); $form->setShowExpiration($formData['showExpiration']); + $form->setMaxSubmissions($formData['maxSubmissions'] ?? null); $this->formMapper->insert($form); diff --git a/lib/Migration/Version050300Date20260303000000.php b/lib/Migration/Version050300Date20260303000000.php new file mode 100644 index 000000000..1461ef5bd --- /dev/null +++ b/lib/Migration/Version050300Date20260303000000.php @@ -0,0 +1,41 @@ +getTable('forms_v2_forms'); + + if (!$table->hasColumn('max_submissions')) { + $table->addColumn('max_submissions', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + 'comment' => 'Maximum number of submissions, null means unlimited', + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index af44f96cb..27fea25bb 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -110,6 +110,7 @@ * state: int, * lockedBy: ?string, * lockedUntil: ?int, + * maxSubmissions: ?int, * } * * @psalm-type FormsForm = array{ @@ -125,6 +126,7 @@ * fileId: ?int, * filePath?: ?string, * isAnonymous: bool, + * isMaxSubmissionsReached: bool, * lastUpdated: int, * submitMultiple: bool, * allowEditSubmissions: bool, @@ -135,6 +137,7 @@ * state: 0|1|2, * lockedBy: ?string, * lockedUntil: ?int, + * maxSubmissions: ?int, * shares: list, * submissionCount?: int, * submissionMessage: ?string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 5a7492329..e2ea51ec9 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -204,6 +204,10 @@ public function getForm(Form $form): array { $result['permissions'] = $this->getPermissions($form); // Append canSubmit, to be able to show proper EmptyContent on internal view. $result['canSubmit'] = $this->canSubmit($form); + // Append isMaxSubmissionsReached to show proper message on submit view. + $maxSubmissions = $form->getMaxSubmissions(); + $result['isMaxSubmissionsReached'] = $maxSubmissions !== null + && $this->submissionMapper->countSubmissions($form->getId()) >= $maxSubmissions; // Append submissionCount if currentUser has permissions to see results if (in_array(Constants::PERMISSION_RESULTS, $result['permissions'])) { @@ -484,6 +488,12 @@ public function canDeleteResults(Form $form): bool { * @return boolean */ public function canSubmit(Form $form): bool { + // Check if max submissions limit is reached + $maxSubmissions = $form->getMaxSubmissions(); + if ($maxSubmissions !== null && $this->submissionMapper->countSubmissions($form->getId()) >= $maxSubmissions) { + return false; + } + // We cannot control how many time users can submit if public link available if ($this->hasPublicLink($form)) { return true; diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 6f3d54162..c171219bc 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -517,6 +517,9 @@ public function validateSubmission(array $questions, array $answers, string $for // Check if all answers are within the possible options if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']['allowOtherAnswer'])) { + // Normalize option IDs once for consistent comparison (DB may return ints, request may send strings) + $optionIds = array_map('intval', array_column($question['options'] ?? [], 'id')); + foreach ($answers[$questionId] as $answer) { // Handle linear scale questions if ($question['type'] === Constants::ANSWER_TYPE_LINEARSCALE) { @@ -527,8 +530,18 @@ public function validateSubmission(array $questions, array $answers, string $for } } // Search corresponding option, return false if non-existent - elseif (!in_array($answer, array_column($question['options'], 'id'))) { - throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', $answer, $question['text'])); + else { + // Accept numeric strings like "46" from JSON payloads reliably (e.g. with hardening extensions enabled) + $answerId = is_int($answer) ? $answer : (is_string($answer) ? intval(trim($answer)) : null); + + // Reject non-numeric / malformed values early + if ($answerId === null || (string)$answerId !== (string)intval($answerId)) { + throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', is_scalar($answer) ? (string)$answer : gettype($answer), $question['text'])); + } + + if (!in_array($answerId, $optionIds, true)) { + throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', $answer, $question['text'])); + } } } } diff --git a/openapi.json b/openapi.json index 4a5e7a3fd..9d80a1151 100644 --- a/openapi.json +++ b/openapi.json @@ -106,6 +106,7 @@ "fileFormat", "fileId", "isAnonymous", + "isMaxSubmissionsReached", "lastUpdated", "submitMultiple", "allowEditSubmissions", @@ -116,6 +117,7 @@ "state", "lockedBy", "lockedUntil", + "maxSubmissions", "shares", "submissionMessage" ], @@ -163,6 +165,9 @@ "isAnonymous": { "type": "boolean" }, + "isMaxSubmissionsReached": { + "type": "boolean" + }, "lastUpdated": { "type": "integer", "format": "int64" @@ -209,6 +214,11 @@ "format": "int64", "nullable": true }, + "maxSubmissions": { + "type": "integer", + "format": "int64", + "nullable": true + }, "shares": { "type": "array", "items": { @@ -307,7 +317,8 @@ "partial", "state", "lockedBy", - "lockedUntil" + "lockedUntil", + "maxSubmissions" ], "properties": { "id": { @@ -348,6 +359,11 @@ "type": "integer", "format": "int64", "nullable": true + }, + "maxSubmissions": { + "type": "integer", + "format": "int64", + "nullable": true } } }, diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index 426536ba3..74dd39ef2 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -11,9 +11,9 @@ class="question__item__pseudoInput" /> @@ -64,6 +64,17 @@ +
+ + + +
@@ -98,6 +109,7 @@ export default { IconCheckboxBlankOutline, IconDelete, IconDragIndicator, + IconPlus, IconRadioboxBlank, IconTableColumn, IconTableRow, @@ -163,10 +175,18 @@ export default { queue: null, debounceOnInput: null, isIMEComposing: false, + localText: this.answer?.text ?? '', } }, computed: { + canCreateLocalAnswer() { + if (this.answer.local) { + return !!this.localText?.trim() + } + return !!this.answer.text?.trim() + }, + ariaLabel() { if (this.answer.local) { if (this.optionType === OptionType.Column) { @@ -239,6 +259,17 @@ export default { }, }, + watch: { + // Keep localText in sync when the parent replaces/updates the answer prop + answer: { + handler(newVal) { + this.localText = newVal?.text ?? '' + }, + + deep: true, + }, + }, + created() { this.queue = new PQueue({ concurrency: 1 }) @@ -266,34 +297,72 @@ export default { * @param {InputEvent} event The input event that triggered adding a new entry */ async onInput({ target, isComposing }) { + if (this.answer.local) { + this.localText = target.value + return + } + if (!isComposing && !this.isIMEComposing && target.value !== '') { // clone answer const answer = { ...this.answer } answer.text = this.$refs.input.value - if (this.answer.local) { - // Dispatched for creation. Marked as synced - this.$set(this.answer, 'local', false) - const newAnswer = await this.createAnswer(answer) + await this.updateAnswer(answer) - // Forward changes, but use current answer.text to avoid erasing - // any in-between changes while creating the answer - newAnswer.text = this.$refs.input.value + // Forward changes, but use current answer.text to avoid erasing + // any in-between changes while updating the answer + answer.text = this.$refs.input.value + this.$emit('update:answer', this.index, answer) + } + }, - this.$emit('create-answer', this.index, newAnswer) - } else { - await this.updateAnswer(answer) + /** + * Handle Enter key: create local answer or move focus + * + * @param {KeyboardEvent} e the keydown event + */ + onEnter(e) { + if (this.answer.local) { + this.createLocalAnswer(e) + return + } + this.focusNextInput(e) + }, - // Forward changes, but use current answer.text to avoid erasing - // any in-between changes while updating the answer - answer.text = this.$refs.input.value - this.$emit('update:answer', this.index, answer) - } + /** + * Create a new local answer option from the current input + * + * @param {Event} e the triggering event + */ + async createLocalAnswer(e) { + if (this.isIMEComposing || e?.isComposing) { + return + } + + const value = this.localText ?? '' + if (!value.trim()) { + return } + + const answer = { ...this.answer } + answer.text = value + + // Dispatched for creation. Marked as synced + this.$set(this.answer, 'local', false) + const newAnswer = await this.createAnswer(answer) + + // Forward changes, but use current answer.text to avoid erasing + // any in-between changes while creating the answer + newAnswer.text = this.$refs.input.value + this.localText = '' + + this.$emit('create-answer', this.index, newAnswer) }, /** * Request a new answer + * + * @param {Event} e the triggering event */ focusNextInput(e) { if (this.isIMEComposing || e?.isComposing) { diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index 15bcea0d6..cd746276f 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -78,6 +78,32 @@ {{ t('forms', 'Show expiration date on form') }} + + {{ t('forms', 'Limit number of responses') }} + +
+ +

+ {{ + t( + 'forms', + 'Form will be closed automatically when the limit is reached.', + ) + }} +

+
this.form.expires }, @@ -365,6 +410,17 @@ export default { ) }, + onMaxSubmissionsChange(checked) { + this.$emit('update:form-prop', 'maxSubmissions', checked ? 1 : null) + }, + + onMaxSubmissionsValueChange(event) { + const value = parseInt(event.target.value) + if (value > 0) { + this.$emit('update:form-prop', 'maxSubmissions', value) + } + }, + onFormClosedChange(isClosed) { this.$emit( 'update:form-prop', diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 5b52df938..b5e1424ee 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -58,7 +58,7 @@ + + + null, 'fileId' => null, 'fileFormat' => null, + 'maxSubmissions' => null, + 'isMaxSubmissionsReached' => false, ] ] ]; @@ -525,6 +527,8 @@ public function dataGetFullForm() { 'submissionCount' => 3, 'fileId' => null, 'fileFormat' => null, + 'maxSubmissions' => null, + 'isMaxSubmissionsReached' => false, ] ] ]; diff --git a/tests/Integration/Api/RespectAdminSettingsTest.php b/tests/Integration/Api/RespectAdminSettingsTest.php index 48235ba09..09d8ff7d1 100644 --- a/tests/Integration/Api/RespectAdminSettingsTest.php +++ b/tests/Integration/Api/RespectAdminSettingsTest.php @@ -143,6 +143,8 @@ private static function sharedTestForms(): array { ], 'canSubmit' => true, 'submissionCount' => 0, + 'maxSubmissions' => null, + 'isMaxSubmissionsReached' => false, ], ]; } diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index c7ad3fdbb..bb75417a3 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -413,6 +413,7 @@ public function dataTestCreateNewForm() { 'allowEditSubmissions' => false, 'lockedBy' => null, 'lockedUntil' => null, + 'maxSubmissions' => null, ]] ]; } diff --git a/tests/Unit/FormsMigratorTest.php b/tests/Unit/FormsMigratorTest.php index 1e7fd97b5..454f6d2ef 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -104,6 +104,7 @@ public function dataExport() { "state": 0, "lockedBy": null, "lockedUntil": null, + "maxSubmissions": null, "isAnonymous": false, "submitMultiple": false, "allowEditSubmissions": false, @@ -253,7 +254,7 @@ public function testExport(string $expectedJson) { public function dataImport() { return [ 'exactlyOneOfEach' => [ - '$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' + '$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' ] ]; } diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index c2690e3b0..15b0021b9 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -255,6 +255,8 @@ public function dataGetForm() { 'allowEditSubmissions' => false, 'lockedBy' => null, 'lockedUntil' => null, + 'maxSubmissions' => null, + 'isMaxSubmissionsReached' => false, ]] ]; } @@ -474,6 +476,8 @@ public function dataGetPublicForm() { 'allowEditSubmissions' => false, 'lockedBy' => null, 'lockedUntil' => null, + 'maxSubmissions' => null, + 'isMaxSubmissionsReached' => false, ]] ]; }