From d70cbef76976f895673eedf97a4c4edfd8065bde Mon Sep 17 00:00:00 2001 From: lafricain79 Date: Tue, 3 Mar 2026 14:35:19 +0100 Subject: [PATCH 01/14] 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 - Add limit settings UI in SettingsSidebarTab - Display dedicated 'Form is full' message in Submit view - Add French translations for new strings Closes #596 --- l10n/fr.js | 2 + l10n/fr.json | 2 + lib/Controller/ApiController.php | 5 ++ lib/Db/Form.php | 6 +++ .../Version050300Date20250914000000.php | 49 +++++-------------- lib/Service/FormsService.php | 9 ++++ .../SidebarTabs/SettingsSidebarTab.vue | 46 +++++++++++++++++ src/views/Submit.vue | 16 +++++- 8 files changed, 96 insertions(+), 39 deletions(-) diff --git a/l10n/fr.js b/l10n/fr.js index 1a3c3d62c..2f7742b63 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -45,6 +45,8 @@ OC.L10N.register( "Create new form" : "Créer un nouveau formulaire", "This form does not exist" : "Ce formulaire n’existe pas", "Form expired" : "Formulaire expiré", + "Form is full" : "Formulaire complet", + "This form has reached the maximum number of answers" : "Ce formulaire a atteint le nombre maximum de réponses", "This form has expired and is no longer taking answers" : "Ce formulaire a expiré et n'accepte plus de réponses", "Error while saving configuration" : "Erreur lors de l'enregistrement de la configuration", "Error while reloading config" : "Erreur lors du rechargement de la configuration", diff --git a/l10n/fr.json b/l10n/fr.json index ff63c6fe6..3880b45b1 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -43,6 +43,8 @@ "Create new form" : "Créer un nouveau formulaire", "This form does not exist" : "Ce formulaire n’existe pas", "Form expired" : "Formulaire expiré", + "Form is full" : "Formulaire complet", + "This form has reached the maximum number of answers" : "Ce formulaire a atteint le nombre maximum de réponses", "This form has expired and is no longer taking answers" : "Ce formulaire a expiré et n'accepte plus de réponses", "Error while saving configuration" : "Erreur lors de l'enregistrement de la configuration", "Error while reloading config" : "Erreur lors du rechargement de la configuration", diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index f832705bc..bb70119ff 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1375,6 +1375,11 @@ 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 !== null && $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/Migration/Version050300Date20250914000000.php b/lib/Migration/Version050300Date20250914000000.php index b875b35d2..cbd54f1cc 100644 --- a/lib/Migration/Version050300Date20250914000000.php +++ b/lib/Migration/Version050300Date20250914000000.php @@ -1,60 +1,35 @@ getTable('forms_v2_options'); - - if (!$table->hascolumn('option_type')) { - $table->addColumn('option_type', Types::STRING, [ + $table = $schema->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', + ]); + } + $tableOptions = $schema->getTable('forms_v2_options'); + if (!$tableOptions->hasColumn('option_type')) { + $tableOptions->addColumn('option_type', Types::STRING, [ 'notnull' => false, 'default' => null, + 'length' => 64, ]); } - return $schema; } - - /** - * @param IOutput $output - * @param Closure(): ISchemaWrapper $schemaClosure - * @param array $options - */ - public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { - $qbUpdate = $this->db->getQueryBuilder(); - - $qbUpdate->update('forms_v2_options') - ->set('option_type', $qbUpdate->createNamedParameter('choice')) - ->where($qbUpdate->expr()->isNull('option_type')) - ->executeStatement(); - } } diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 5a7492329..5ab18e823 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,11 @@ 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/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index 15bcea0d6..94c19acae 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -78,6 +78,26 @@ {{ 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 +396,15 @@ export default { ) }, + onMaxSubmissionsChange(checked) { + this.$emit('update:form-prop', 'maxSubmissions', checked ? 100 : 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', @@ -495,4 +535,10 @@ export default { @include markdown-output; } } +.max-submissions { + &__input { + width: calc(100% - var(--default-clickable-area)); + margin-block-start: 4px; + } +} diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 5b52df938..cf0421d01 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -28,7 +28,7 @@ + + + Date: Tue, 3 Mar 2026 19:03:01 +0100 Subject: [PATCH 02/14] Update src/components/SidebarTabs/SettingsSidebarTab.vue Co-authored-by: Christian Hartmann Signed-off-by: L'Africain --- src/components/SidebarTabs/SettingsSidebarTab.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index 94c19acae..fdb171457 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -325,6 +325,7 @@ export default { hasMaxSubmissions() { return this.form.maxSubmissions !== null && this.form.maxSubmissions !== undefined }, + maxSubmissionsValue: { get() { return this.form.maxSubmissions ?? 1 @@ -333,6 +334,7 @@ export default { this.$emit('update:form-prop', 'maxSubmissions', value) }, }, + isExpired() { return this.form.expires && moment().unix() > this.form.expires }, From 028cc875867faf20eb001823a87311fe32572b08 Mon Sep 17 00:00:00 2001 From: L'Africain Date: Tue, 3 Mar 2026 19:05:04 +0100 Subject: [PATCH 03/14] Update src/components/SidebarTabs/SettingsSidebarTab.vue Co-authored-by: Christian Hartmann Signed-off-by: L'Africain --- src/components/SidebarTabs/SettingsSidebarTab.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index fdb171457..459168387 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -401,12 +401,14 @@ export default { onMaxSubmissionsChange(checked) { this.$emit('update:form-prop', 'maxSubmissions', checked ? 100 : 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', From 3a6e955b6459812bf23b69b41f06494f68f63d0f Mon Sep 17 00:00:00 2001 From: L'Africain Date: Tue, 3 Mar 2026 19:05:26 +0100 Subject: [PATCH 04/14] Update src/views/Submit.vue Co-authored-by: Christian Hartmann Signed-off-by: L'Africain --- src/views/Submit.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Submit.vue b/src/views/Submit.vue index cf0421d01..88f285f82 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -28,7 +28,7 @@