Skip to content

Commit 06294e7

Browse files
lafricain79Chartman123
authored andcommitted
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 ae9e069 commit 06294e7

13 files changed

Lines changed: 168 additions & 3 deletions

File tree

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
}
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;

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
},

src/components/SidebarTabs/SettingsSidebarTab.vue

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,32 @@
7878
{{ t('forms', 'Show expiration date on form') }}
7979
</NcCheckboxRadioSwitch>
8080
</div>
81+
<NcCheckboxRadioSwitch
82+
:model-value="hasMaxSubmissions"
83+
:disabled="formArchived || locked"
84+
type="switch"
85+
@update:model-value="onMaxSubmissionsChange">
86+
{{ t('forms', 'Limit number of responses') }}
87+
</NcCheckboxRadioSwitch>
88+
<div
89+
v-show="hasMaxSubmissions && !formArchived"
90+
class="settings-div--indent">
91+
<NcInputField
92+
v-model="maxSubmissionsValue"
93+
type="number"
94+
:min="1"
95+
:disabled="locked"
96+
:label="t('forms', 'Maximum number of responses')"
97+
@update:model-value="onMaxSubmissionsValueChange" />
98+
<p class="settings-hint">
99+
{{
100+
t(
101+
'forms',
102+
'Form will be closed automatically when the limit is reached.',
103+
)
104+
}}
105+
</p>
106+
</div>
81107
<NcCheckboxRadioSwitch
82108
:model-value="formClosed"
83109
:disabled="formArchived || locked"
@@ -184,6 +210,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
184210
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
185211
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
186212
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
213+
import NcInputField from '@nextcloud/vue/components/NcInputField'
187214
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
188215
import TransferOwnership from './TransferOwnership.vue'
189216
import svgLockOpen from '../../../img/lock_open.svg?raw'
@@ -193,6 +220,7 @@ import { FormState } from '../../models/Constants.ts'
193220
export default {
194221
components: {
195222
NcButton,
223+
NcInputField,
196224
NcCheckboxRadioSwitch,
197225
NcDateTimePicker,
198226
NcIconSvgWrapper,
@@ -302,6 +330,23 @@ export default {
302330
return this.form.state !== FormState.FormActive
303331
},
304332
333+
hasMaxSubmissions() {
334+
return (
335+
this.form.maxSubmissions !== null
336+
&& this.form.maxSubmissions !== undefined
337+
)
338+
},
339+
340+
maxSubmissionsValue: {
341+
get() {
342+
return this.form.maxSubmissions ?? 1
343+
},
344+
345+
set(value) {
346+
this.$emit('update:form-prop', 'maxSubmissions', value)
347+
},
348+
},
349+
305350
isExpired() {
306351
return this.form.expires && moment().unix() > this.form.expires
307352
},
@@ -365,6 +410,17 @@ export default {
365410
)
366411
},
367412
413+
onMaxSubmissionsChange(checked) {
414+
this.$emit('update:form-prop', 'maxSubmissions', checked ? 1 : null)
415+
},
416+
417+
onMaxSubmissionsValueChange(event) {
418+
const value = parseInt(event.target.value)
419+
if (value > 0) {
420+
this.$emit('update:form-prop', 'maxSubmissions', value)
421+
}
422+
},
423+
368424
onFormClosedChange(isClosed) {
369425
this.$emit(
370426
'update:form-prop',

src/views/Submit.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
</template>
5959
</NcEmptyContent>
6060
<NcEmptyContent
61-
v-else-if="success || !form.canSubmit"
61+
v-else-if="success || (!form.canSubmit && !isMaxSubmissionsReached)"
6262
class="forms-emptycontent"
6363
:name="
6464
form.submissionMessage
@@ -74,6 +74,17 @@
7474
<p class="submission-message" v-html="submissionMessageHTML" />
7575
</template>
7676
</NcEmptyContent>
77+
<NcEmptyContent
78+
v-else-if="isMaxSubmissionsReached"
79+
class="forms-emptycontent"
80+
:name="t('forms', 'Form is full')"
81+
:description="
82+
t('forms', 'This form has reached the maximum number of answers')
83+
">
84+
<template #icon>
85+
<NcIconSvgWrapper :svg="IconCheckSvg" size="64" />
86+
</template>
87+
</NcEmptyContent>
7788
<NcEmptyContent
7889
v-else-if="isExpired"
7990
class="forms-emptycontent"
@@ -360,6 +371,10 @@ export default {
360371
return this.form.state === FormState.FormClosed
361372
},
362373
374+
isMaxSubmissionsReached() {
375+
return this.form.isMaxSubmissionsReached === true
376+
},
377+
363378
/**
364379
* Checks if the current state is active.
365380
*

tests/Integration/Api/ApiV3Test.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ public function dataGetNewForm() {
394394
'submissionMessage' => null,
395395
'fileId' => null,
396396
'fileFormat' => null,
397+
'maxSubmissions' => null,
398+
'isMaxSubmissionsReached' => false,
397399
]
398400
]
399401
];
@@ -525,6 +527,8 @@ public function dataGetFullForm() {
525527
'submissionCount' => 3,
526528
'fileId' => null,
527529
'fileFormat' => null,
530+
'maxSubmissions' => null,
531+
'isMaxSubmissionsReached' => false,
528532
]
529533
]
530534
];

tests/Integration/Api/RespectAdminSettingsTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ private static function sharedTestForms(): array {
143143
],
144144
'canSubmit' => true,
145145
'submissionCount' => 0,
146+
'maxSubmissions' => null,
147+
'isMaxSubmissionsReached' => false,
146148
],
147149
];
148150
}

0 commit comments

Comments
 (0)