From 7a9b46ab1c450231c26d06af36c88f2ddbb760ef Mon Sep 17 00:00:00 2001 From: lafricain79 Date: Fri, 13 Mar 2026 23:08:26 +0100 Subject: [PATCH] feat: add maximum submissions limit for forms Add the ability to limit the number of responses a form can receive. When the limit is reached, the form is automatically closed and displays a dedicated message instead of accepting new submissions. - Add max_submissions column to forms_v2_forms table (migration) - Add maxSubmissions property to Form entity - Check submission limit in FormsService::canSubmit() - Add limit enforcement in ApiController::newSubmission() - Add isMaxSubmissionsReached flag in form API response - Update FormsForm psalm type in ResponseDefinitions - Add limit settings UI in SettingsSidebarTab - Display dedicated 'Form is full' message in Submit view - Update openapi.json - Update unit and integration tests Closes #596 Signed-off-by: lafricain79 Signed-off-by: Christian Hartmann --- lib/Controller/ApiController.php | 6 ++ lib/Db/Form.php | 6 ++ lib/FormsMigrator.php | 1 + .../Version050300Date20260303000000.php | 41 ++++++++++++++ lib/ResponseDefinitions.php | 3 + lib/Service/FormsService.php | 4 ++ openapi.json | 18 +++++- .../SidebarTabs/SettingsSidebarTab.vue | 55 +++++++++++++++++++ src/views/Submit.vue | 20 ++++++- tests/Integration/Api/ApiV3Test.php | 4 ++ .../Api/RespectAdminSettingsTest.php | 2 + tests/Unit/Controller/ApiControllerTest.php | 1 + tests/Unit/FormsMigratorTest.php | 3 +- tests/Unit/Service/FormsServiceTest.php | 4 ++ 14 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 lib/Migration/Version050300Date20260303000000.php 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..f5b421c3a 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'])) { 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/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index a32b7590f..1b31dd14e 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,16 @@ export default { ) }, + onMaxSubmissionsChange(checked) { + this.$emit('update:form-prop', 'maxSubmissions', checked ? 1 : null) + }, + + onMaxSubmissionsValueChange(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 6f6f2c9f4..d26209d38 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, ]] ]; }