Skip to content

Commit 2e93e84

Browse files
author
lafricain79
committed
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 <lafricain79@gmail.com>
1 parent b68b1b6 commit 2e93e84

16 files changed

Lines changed: 270 additions & 22 deletions

lib/Controller/ApiController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,12 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
13751375
throw new OCSForbiddenException('Already submitted');
13761376
}
13771377

1378+
// Check if max submissions limit is reached
1379+
$maxSubmissions = $form->getMaxSubmissions();
1380+
if ($maxSubmissions > 0 && $this->submissionMapper->countSubmissions($formId) >= $maxSubmissions) {
1381+
throw new OCSForbiddenException('Maximum number of submissions reached');
1382+
}
1383+
13781384
// Insert new submission
13791385
$this->submissionMapper->insert($submission);
13801386

lib/Db/Form.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
* @method string getLockedBy()
5151
* @method void setLockedBy(string|null $value)
5252
* @method int getLockedUntil()
53+
* @method int|null getMaxSubmissions()
54+
* @method void setMaxSubmissions(int|null $value)
5355
* @method void setLockedUntil(int|null $value)
5456
*/
5557
class Form extends Entity {
@@ -71,6 +73,7 @@ class Form extends Entity {
7173
protected $state;
7274
protected $lockedBy;
7375
protected $lockedUntil;
76+
protected $maxSubmissions;
7477

7578
/**
7679
* Form constructor.
@@ -86,6 +89,7 @@ public function __construct() {
8689
$this->addType('state', 'integer');
8790
$this->addType('lockedBy', 'string');
8891
$this->addType('lockedUntil', 'integer');
92+
$this->addType('maxSubmissions', 'integer');
8993
}
9094

9195
// JSON-Decoding of access-column.
@@ -159,6 +163,7 @@ public function setAccess(array $access): void {
159163
* state: 0|1|2,
160164
* lockedBy: ?string,
161165
* lockedUntil: ?int,
166+
* maxSubmissions: ?int,
162167
* }
163168
*/
164169
public function read() {
@@ -182,6 +187,7 @@ public function read() {
182187
'state' => $this->getState(),
183188
'lockedBy' => $this->getLockedBy(),
184189
'lockedUntil' => $this->getLockedUntil(),
190+
'maxSubmissions' => $this->getMaxSubmissions(),
185191
];
186192
}
187193
}

lib/FormsMigrator.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
148148
$form->setSubmitMultiple($formData['submitMultiple']);
149149
$form->setAllowEditSubmissions($formData['allowEditSubmissions']);
150150
$form->setShowExpiration($formData['showExpiration']);
151+
$form->setMaxSubmissions($formData['maxSubmissions'] ?? null);
151152

152153
$this->formMapper->insert($form);
153154

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
class Version050300Date20260303000000 extends SimpleMigrationStep {
19+
20+
/**
21+
* @param IOutput $output
22+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
23+
* @param array $options
24+
* @return null|ISchemaWrapper
25+
*/
26+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
27+
/** @var ISchemaWrapper $schema */
28+
$schema = $schemaClosure();
29+
$table = $schema->getTable('forms_v2_forms');
30+
31+
if (!$table->hasColumn('max_submissions')) {
32+
$table->addColumn('max_submissions', Types::INTEGER, [
33+
'notnull' => false,
34+
'default' => null,
35+
'comment' => 'Maximum number of submissions, null means unlimited',
36+
]);
37+
}
38+
39+
return $schema;
40+
}
41+
}

lib/ResponseDefinitions.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
* state: int,
111111
* lockedBy: ?string,
112112
* lockedUntil: ?int,
113+
* maxSubmissions: ?int,
113114
* }
114115
*
115116
* @psalm-type FormsForm = array{
@@ -125,6 +126,7 @@
125126
* fileId: ?int,
126127
* filePath?: ?string,
127128
* isAnonymous: bool,
129+
* isMaxSubmissionsReached: bool,
128130
* lastUpdated: int,
129131
* submitMultiple: bool,
130132
* allowEditSubmissions: bool,
@@ -135,6 +137,7 @@
135137
* state: 0|1|2,
136138
* lockedBy: ?string,
137139
* lockedUntil: ?int,
140+
* maxSubmissions: ?int,
138141
* shares: list<FormsShare>,
139142
* submissionCount?: int,
140143
* submissionMessage: ?string,

lib/Service/FormsService.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ public function getForm(Form $form): array {
204204
$result['permissions'] = $this->getPermissions($form);
205205
// Append canSubmit, to be able to show proper EmptyContent on internal view.
206206
$result['canSubmit'] = $this->canSubmit($form);
207+
// Append isMaxSubmissionsReached to show proper message on submit view.
208+
$maxSubmissions = $form->getMaxSubmissions();
209+
$result['isMaxSubmissionsReached'] = $maxSubmissions !== null
210+
&& $this->submissionMapper->countSubmissions($form->getId()) >= $maxSubmissions;
207211

208212
// Append submissionCount if currentUser has permissions to see results
209213
if (in_array(Constants::PERMISSION_RESULTS, $result['permissions'])) {
@@ -484,6 +488,12 @@ public function canDeleteResults(Form $form): bool {
484488
* @return boolean
485489
*/
486490
public function canSubmit(Form $form): bool {
491+
// Check if max submissions limit is reached
492+
$maxSubmissions = $form->getMaxSubmissions();
493+
if ($maxSubmissions !== null && $this->submissionMapper->countSubmissions($form->getId()) >= $maxSubmissions) {
494+
return false;
495+
}
496+
487497
// We cannot control how many time users can submit if public link available
488498
if ($this->hasPublicLink($form)) {
489499
return true;

lib/Service/SubmissionService.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,9 @@ public function validateSubmission(array $questions, array $answers, string $for
517517

518518
// Check if all answers are within the possible options
519519
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']['allowOtherAnswer'])) {
520+
// Normalize option IDs once for consistent comparison (DB may return ints, request may send strings)
521+
$optionIds = array_map('intval', array_column($question['options'] ?? [], 'id'));
522+
520523
foreach ($answers[$questionId] as $answer) {
521524
// Handle linear scale questions
522525
if ($question['type'] === Constants::ANSWER_TYPE_LINEARSCALE) {
@@ -527,8 +530,18 @@ public function validateSubmission(array $questions, array $answers, string $for
527530
}
528531
}
529532
// Search corresponding option, return false if non-existent
530-
elseif (!in_array($answer, array_column($question['options'], 'id'))) {
531-
throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', $answer, $question['text']));
533+
else {
534+
// Accept numeric strings like "46" from JSON payloads reliably (e.g. with hardening extensions enabled)
535+
$answerId = is_int($answer) ? $answer : (is_string($answer) ? intval(trim($answer)) : null);
536+
537+
// Reject non-numeric / malformed values early
538+
if ($answerId === null || (string)$answerId !== (string)intval($answerId)) {
539+
throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', is_scalar($answer) ? (string)$answer : gettype($answer), $question['text']));
540+
}
541+
542+
if (!in_array($answerId, $optionIds, true)) {
543+
throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', $answer, $question['text']));
544+
}
532545
}
533546
}
534547
}

openapi.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
"fileFormat",
107107
"fileId",
108108
"isAnonymous",
109+
"isMaxSubmissionsReached",
109110
"lastUpdated",
110111
"submitMultiple",
111112
"allowEditSubmissions",
@@ -116,6 +117,7 @@
116117
"state",
117118
"lockedBy",
118119
"lockedUntil",
120+
"maxSubmissions",
119121
"shares",
120122
"submissionMessage"
121123
],
@@ -163,6 +165,9 @@
163165
"isAnonymous": {
164166
"type": "boolean"
165167
},
168+
"isMaxSubmissionsReached": {
169+
"type": "boolean"
170+
},
166171
"lastUpdated": {
167172
"type": "integer",
168173
"format": "int64"
@@ -209,6 +214,11 @@
209214
"format": "int64",
210215
"nullable": true
211216
},
217+
"maxSubmissions": {
218+
"type": "integer",
219+
"format": "int64",
220+
"nullable": true
221+
},
212222
"shares": {
213223
"type": "array",
214224
"items": {
@@ -307,7 +317,8 @@
307317
"partial",
308318
"state",
309319
"lockedBy",
310-
"lockedUntil"
320+
"lockedUntil",
321+
"maxSubmissions"
311322
],
312323
"properties": {
313324
"id": {
@@ -348,6 +359,11 @@
348359
"type": "integer",
349360
"format": "int64",
350361
"nullable": true
362+
},
363+
"maxSubmissions": {
364+
"type": "integer",
365+
"format": "int64",
366+
"nullable": true
351367
}
352368
}
353369
},

0 commit comments

Comments
 (0)