diff --git a/docs/API_v3.md b/docs/API_v3.md index 9ce0f136a..511a6a30f 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -88,6 +88,7 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "hash": "yWeMwcwCwoqRs8T2", "title": "Form 2", "expires": 0, + "opens": 0, "permissions": [ "edit", "results", @@ -103,6 +104,7 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "hash": "em4djk8B9BpXnkYG", "title": "Form 1", "expires": 0, + "opens": 0, "permissions": [ "edit", "results", @@ -166,6 +168,7 @@ Returns the full-depth object of the requested form (without submissions). "showToAllUsers": false }, "expires": 0, + "opens": 0, "fileFormat": "csv", "fileId": 157, "filePath": "foo/bar", @@ -281,6 +284,7 @@ Update a single or multiple properties of a form-object. Concerns **only** the F - To transfer the ownership of a form to another user, you must only send a single _keyValuePair_ containing the key `ownerId` and the user id of the new owner. - To link a file for submissions, the _keyValuePairs_ need to contain the keys `path` and `fileFormat` - To unlink a file for submissions, the _keyValuePairs_ need to contain the keys `fileId` and `fileFormat` need to contain the value `null` + - If both `opens` and `expires` are set (not `0`), `opens` must be before `expires` - Response: **Status-Code OK**, as well as the id of the updated form. ``` diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 570057b98..d0e9acfa3 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -28,6 +28,7 @@ This document describes the Object-Structure, that is used within the Forms App | created | unix timestamp | | When the form has been created | | access | [Access-Object](#access-object) | | Describing access-settings of the form | | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | +| opens | unix-timestamp | | From when the form accepts responses. Timestamp `0` indicates _immediately_ | | isAnonymous | Boolean | | If Answers will be stored anonymously | | state | Integer | [Form state](#form-state) | The state of the form | | lockedBy | String | | The user ID for who has exclusive edit access at the moment | @@ -57,6 +58,7 @@ This document describes the Object-Structure, that is used within the Forms App "created": 1611240961, "access": {}, "expires": 0, + "opens": 0, "isAnonymous": false, "submitMultiple": true, "allowEditSubmissions": false, diff --git a/lib/Constants.php b/lib/Constants.php index 21bed641a..3e465cd0c 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -275,6 +275,7 @@ class Constants { */ public const EMPTY_EXPIRED = 'expired'; public const EMPTY_NOTFOUND = 'notfound'; + public const EMPTY_NOTYETOPEN = 'notyetopen'; /** * Constants related to extra settings for questions diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index c6a4e9107..752a03b89 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -178,6 +178,7 @@ public function newForm(?int $fromId = null): DataResponse { $form->setAllowEditSubmissions(false); $form->setShowExpiration(false); $form->setExpires(0); + $form->setOpens(0); $form->setIsAnonymous(false); $this->formMapper->insert($form); @@ -212,6 +213,7 @@ public function newForm(?int $fromId = null): DataResponse { $formData['allowEditSubmissions'] = false; $formData['showExpiration'] = false; $formData['expires'] = 0; + $formData['opens'] = 0; $formData['isAnonymous'] = false; $form = Form::fromParams($formData); @@ -282,6 +284,7 @@ public function getForm(int $formId): DataResponse { * @param int $formId FormId of form to update * @param array $keyValuePairs Array of key=>value pairs to update. * @return DataResponse + * @throws OCSBadRequestException Invalid opening date or opening date not before expiration date * @throws OCSBadRequestException Could not find new form owner * @throws OCSForbiddenException Empty keyValuePairs provided * @throws OCSForbiddenException Not allowed to update id, hash, created, fileId or lastUpdated. OwnerId only allowed if no other key provided. @@ -336,6 +339,8 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { // Do not allow changing showToAllUsers or permitAllUsers if disabled $this->checkAccessUpdate($keyValuePairs); + $this->checkOpeningDateUpdate($form, $keyValuePairs); + $previousAccess = $form->getAccess(); $previousState = $form->getState(); $previousSettings = $this->getResponseSettings($form); @@ -1950,6 +1955,28 @@ private function checkForbiddenKeys(array $keyValuePairs): void { } } + /** + * Throws if the opening date is invalid or not before the expiration date + */ + private function checkOpeningDateUpdate(Form $form, array $keyValuePairs): void { + if (!array_key_exists('opens', $keyValuePairs) && !array_key_exists('expires', $keyValuePairs)) { + return; + } + + if (array_key_exists('opens', $keyValuePairs) + && (!is_int($keyValuePairs['opens']) || $keyValuePairs['opens'] < 0)) { + $this->logger->info('Invalid opening date'); + throw new OCSBadRequestException('Invalid opening date'); + } + + $opens = $keyValuePairs['opens'] ?? $form->getOpens(); + $expires = $keyValuePairs['expires'] ?? $form->getExpires(); + if ($opens > 0 && $expires > 0 && $opens >= $expires) { + $this->logger->info('Opening date must be before expiration date'); + throw new OCSBadRequestException('Opening date must be before expiration date'); + } + } + /** * Throws if fileId is present in update */ @@ -2103,6 +2130,7 @@ private function getResponseSettings(Form $form): array { 'submitMultiple' => $form->getSubmitMultiple(), 'allowEditSubmissions' => $form->getAllowEditSubmissions(), 'expires' => $form->getExpires(), + 'opens' => $form->getOpens(), 'maxSubmissions' => $form->getMaxSubmissions(), ]; } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 457cb9b3a..ef0b54e5b 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -203,6 +203,11 @@ protected function createPublicSubmitView(Form $form, string $hash): TemplateRes return $this->provideEmptyContent(Constants::EMPTY_EXPIRED, $form); } + if (!$this->formsService->hasFormOpened($form)) { + $this->initialState->provideInitialState('opens', $form->getOpens()); + return $this->provideEmptyContent(Constants::EMPTY_NOTYETOPEN, $form); + } + $this->insertHeaderOnIos(); // Inject style on all templates diff --git a/lib/Db/Form.php b/lib/Db/Form.php index bd071e03d..0bd26232a 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -31,6 +31,8 @@ * @method void setCreated(int $value) * @method int getExpires() * @method void setExpires(int $value) + * @method int getOpens() + * @method void setOpens(int $value) * @method int getIsAnonymous() * @method void setIsAnonymous(bool $value) * @method int getSubmitMultiple() @@ -74,6 +76,7 @@ class Form extends Entity { protected $accessEnum; protected $created; protected $expires; + protected $opens; protected $isAnonymous; protected $submitMultiple; protected $allowEditSubmissions; @@ -96,6 +99,7 @@ class Form extends Entity { public function __construct() { $this->addType('created', 'integer'); $this->addType('expires', 'integer'); + $this->addType('opens', 'integer'); $this->addType('isAnonymous', 'boolean'); $this->addType('submitMultiple', 'boolean'); $this->addType('allowEditSubmissions', 'boolean'); @@ -172,6 +176,7 @@ public function setAccess(array $access): void { * created: int, * access: FormsAccess, * expires: int, + * opens: int, * isAnonymous: bool, * submitMultiple: bool, * allowEditSubmissions: bool, @@ -201,6 +206,7 @@ public function read() { 'created' => $this->getCreated(), 'access' => $this->getAccess(), 'expires' => (int)$this->getExpires(), + 'opens' => (int)$this->getOpens(), 'isAnonymous' => (bool)$this->getIsAnonymous(), 'submitMultiple' => (bool)$this->getSubmitMultiple(), 'allowEditSubmissions' => (bool)$this->getAllowEditSubmissions(), diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 91b4ad64b..45fe1c836 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -144,6 +144,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setOwnerId($user->getUID()); $form->setAccess($formData['access']); $form->setExpires($formData['expires']); + $form->setOpens($formData['opens'] ?? 0); $form->setIsAnonymous($formData['isAnonymous']); $form->setSubmitMultiple($formData['submitMultiple']); $form->setAllowEditSubmissions($formData['allowEditSubmissions']); diff --git a/lib/Migration/Version050500Date20261030000000.php b/lib/Migration/Version050500Date20261030000000.php new file mode 100644 index 000000000..f3c1ae304 --- /dev/null +++ b/lib/Migration/Version050500Date20261030000000.php @@ -0,0 +1,41 @@ +getTable('forms_v2_forms'); + + if ($table->hasColumn('opens')) { + return null; + } + + $table->addColumn('opens', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + 'comment' => 'Timestamp from which the form accepts responses, 0 means immediately', + ]); + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4d658b910..15ed97324 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -106,6 +106,7 @@ * hash: string, * title: string, * expires: int, + * opens: int, * permissions: list, * partial: true, * state: int, @@ -123,6 +124,7 @@ * created: int, * access: FormsAccess, * expires: int, + * opens: int, * fileFormat: ?string, * fileId: ?int, * filePath?: ?string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 65505267e..d67fc625b 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -266,6 +266,7 @@ public function getPartialFormArrays(array $forms): array { 'hash' => $form->getHash(), 'title' => $form->getTitle(), 'expires' => $form->getExpires(), + 'opens' => $form->getOpens(), 'lastUpdated' => $form->getLastUpdated(), 'permissions' => $permissions, 'partial' => true, @@ -407,6 +408,10 @@ public function loadFormForSubmission(int $formId, string $shareHash): Form { throw new OCSForbiddenException('This form is no longer taking answers'); } + if (!$this->hasFormOpened($form)) { + throw new OCSForbiddenException('This form is not yet taking answers'); + } + return $form; } @@ -671,6 +676,13 @@ public function hasFormExpired(Form $form): bool { return ($form->getExpires() !== 0 && $form->getExpires() < time()); } + /** + * Check if the opening date of the form has been reached. + */ + public function hasFormOpened(Form $form): bool { + return $form->getOpens() <= time(); + } + /** * Get DisplayNames to Shares * diff --git a/openapi.json b/openapi.json index 694617425..4336a8c92 100644 --- a/openapi.json +++ b/openapi.json @@ -103,6 +103,7 @@ "created", "access", "expires", + "opens", "fileFormat", "fileId", "isAnonymous", @@ -154,6 +155,10 @@ "type": "integer", "format": "int64" }, + "opens": { + "type": "integer", + "format": "int64" + }, "fileFormat": { "type": "string", "nullable": true @@ -345,6 +350,7 @@ "hash", "title", "expires", + "opens", "permissions", "partial", "state", @@ -367,6 +373,10 @@ "type": "integer", "format": "int64" }, + "opens": { + "type": "integer", + "format": "int64" + }, "permissions": { "type": "array", "items": { diff --git a/playwright/e2e/form-settings.spec.ts b/playwright/e2e/form-settings.spec.ts index 50d92b41b..708e71c7c 100644 --- a/playwright/e2e/form-settings.spec.ts +++ b/playwright/e2e/form-settings.spec.ts @@ -77,6 +77,50 @@ test.describe('Form settings', () => { await expect(submitView.submitButton).toBeVisible() }) + test('Opening date in the future blocks submissions', async ({ + page, + topBar, + appNavigation, + }) => { + const saved = waitForApiResponse(page, 'PATCH') + await page + .getByRole('switch', { name: /Set opening date/ }) + .click({ force: true }) + await saved + + await expect(appNavigation.getOwnForm(/Settings test form/)).toContainText( + /Opens in/, + ) + + await topBar.toggleView(FormsView.View) + + const main = page.getByRole('main') + await expect(main.getByText('Form not yet open')).toBeVisible() + await expect(main.getByText(/This form will open on/)).toBeVisible() + }) + + test('Removing the opening date allows submissions', async ({ + page, + topBar, + submitView, + }) => { + const set = waitForApiResponse(page, 'PATCH') + await page + .getByRole('switch', { name: /Set opening date/ }) + .click({ force: true }) + await set + + const removed = waitForApiResponse(page, 'PATCH') + await page + .getByRole('switch', { name: /Set opening date/ }) + .click({ force: true }) + await removed + + await topBar.toggleView(FormsView.View) + + await expect(submitView.submitButton).toBeVisible() + }) + test('Anonymous mode shows anonymous message on submit view', async ({ page, topBar, diff --git a/src/FormsEmptyContent.vue b/src/FormsEmptyContent.vue index d9dd1e32a..17bd0fcc0 100644 --- a/src/FormsEmptyContent.vue +++ b/src/FormsEmptyContent.vue @@ -19,8 +19,10 @@