diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 72ce23c47..570057b98 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -255,8 +255,8 @@ Optional extra settings for some [Question Types](#question-types) | `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload | | `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit | | `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit | -| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) | -| `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) | +| `dateMax` | `date` | string / Integer | - | Maximum allowed date to be chosen (as `YYYY-MM-DD` string, or Unix timestamp) | +| `dateMin` | `date` | string / Integer | - | Minimum allowed date to be chosen (as `YYYY-MM-DD` string, or Unix timestamp) | | `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range | | `timeMax` | `time` | string | - | Maximum allowed time to be chosen (as `HH:mm` string) | | `timeMin` | `time` | string | - | Minimum allowed time to be chosen (as `HH:mm` string) | diff --git a/lib/Constants.php b/lib/Constants.php index 0d53a168a..768a4073c 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -181,8 +181,8 @@ class Constants { ]; public const EXTRA_SETTINGS_DATE = [ - 'dateMax' => ['integer', 'NULL'], - 'dateMin' => ['integer', 'NULL'], + 'dateMax' => ['string', 'integer', 'NULL'], + 'dateMin' => ['string', 'integer', 'NULL'], 'dateRange' => ['boolean', 'NULL'], ]; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 2eed57e39..4d658b910 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -24,8 +24,8 @@ * allowOtherAnswer?: bool, * allowedFileExtensions?: list, * allowedFileTypes?: list, - * dateMax?: int, - * dateMin?: int, + * dateMax?: string|int, + * dateMin?: string|int, * dateRange?: bool, * maxAllowedFilesCount?: int, * maxFileSize?: int, @@ -36,8 +36,8 @@ * optionsLimitMin?: int, * optionsLowest?: 0|1, * shuffleOptions?: bool, - * timeMax?: int, - * timeMin?: int, + * timeMax?: string, + * timeMin?: string, * timeRange?: bool, * validationRegex?: string, * validationType?: string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index c7a73a630..5950ccd0c 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -861,9 +861,40 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType // Validate extraSettings for specific question types if ($questionType === Constants::ANSWER_TYPE_DATE) { + $format = Constants::ANSWER_PHPDATETIME_FORMAT['date']; + $dateMinDate = null; + $dateMaxDate = null; + + // Validate dateMin format + if (isset($extraSettings['dateMin'])) { + if (is_numeric($extraSettings['dateMin'])) { + $dateMinDate = \DateTime::createFromFormat('!Y-m-d', (new \DateTime('@' . (int)$extraSettings['dateMin'])) + ->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d')); + } else { + $dateMinString = $extraSettings['dateMin']; + $dateMinDate = \DateTime::createFromFormat('!' . $format, $dateMinString); + if (!$dateMinDate || $dateMinDate->format($format) !== $dateMinString) { + return false; + } + } + } + + // Validate dateMax format + if (isset($extraSettings['dateMax'])) { + if (is_numeric($extraSettings['dateMax'])) { + $dateMaxDate = \DateTime::createFromFormat('!Y-m-d', (new \DateTime('@' . (int)$extraSettings['dateMax'])) + ->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d')); + } else { + $dateMaxString = $extraSettings['dateMax']; + $dateMaxDate = \DateTime::createFromFormat('!' . $format, $dateMaxString); + if (!$dateMaxDate || $dateMaxDate->format($format) !== $dateMaxString) { + return false; + } + } + } + // Ensure dateMin and dateMax don't overlap - if (isset($extraSettings['dateMin']) && isset($extraSettings['dateMax']) - && $extraSettings['dateMin'] > $extraSettings['dateMax']) { + if ($dateMinDate !== null && $dateMaxDate !== null && $dateMinDate > $dateMaxDate) { return false; } } elseif ($questionType === Constants::ANSWER_TYPE_TIME) { diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 560da02fb..395da3937 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -664,10 +664,33 @@ private function validateDateTime(array $answers, string $format, ?string $text $previousDate = $d; if ($extraSettings) { - if ((isset($extraSettings['dateMin']) && $d < (new DateTime())->setTimestamp($extraSettings['dateMin'])) - || (isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax'])) - || (isset($extraSettings['timeMin']) && $d < DateTime::createFromFormat($format, $extraSettings['timeMin'])) - || (isset($extraSettings['timeMax']) && $d > DateTime::createFromFormat($format, $extraSettings['timeMax'])) + $dateMin = isset($extraSettings['dateMin']) + ? (is_numeric($extraSettings['dateMin']) + ? DateTime::createFromFormat('!Y-m-d', (new DateTime('@' . (int)$extraSettings['dateMin'])) + ->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d')) + : DateTime::createFromFormat('!' . $format, $extraSettings['dateMin'])) + : null; + $dateMax = isset($extraSettings['dateMax']) + ? (is_numeric($extraSettings['dateMax']) + ? DateTime::createFromFormat('!Y-m-d', (new DateTime('@' . (int)$extraSettings['dateMax'])) + ->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d')) + : DateTime::createFromFormat('!' . $format, $extraSettings['dateMax'])) + : null; + $timeMin = isset($extraSettings['timeMin']) + ? DateTime::createFromFormat($format, $extraSettings['timeMin']) + : null; + $timeMax = isset($extraSettings['timeMax']) + ? DateTime::createFromFormat($format, $extraSettings['timeMax']) + : null; + + $compareDate = ($dateMin !== null || $dateMax !== null) + ? (DateTime::createFromFormat('!' . $format, $dateStr) ?: $d) + : $d; + + if (($dateMin instanceof \DateTimeInterface && $compareDate < $dateMin) + || ($dateMax instanceof \DateTimeInterface && $compareDate > $dateMax) + || ($timeMin instanceof \DateTimeInterface && $d < $timeMin) + || ($timeMax instanceof \DateTimeInterface && $d > $timeMax) ) { throw new \InvalidArgumentException(sprintf('Date/time is not in the allowed range for question "%s".', $text)); } diff --git a/openapi.json b/openapi.json index fbaedb4e4..32c6b4356 100644 --- a/openapi.json +++ b/openapi.json @@ -496,12 +496,26 @@ } }, "dateMax": { - "type": "integer", - "format": "int64" + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + } + ] }, "dateMin": { - "type": "integer", - "format": "int64" + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + } + ] }, "dateRange": { "type": "boolean" @@ -555,12 +569,10 @@ "type": "boolean" }, "timeMax": { - "type": "integer", - "format": "int64" + "type": "string" }, "timeMin": { - "type": "integer", - "format": "int64" + "type": "string" }, "timeRange": { "type": "boolean" diff --git a/src/components/Questions/QuestionDate.vue b/src/components/Questions/QuestionDate.vue index 087cf0b02..072d1cf03 100644 --- a/src/components/Questions/QuestionDate.vue +++ b/src/components/Questions/QuestionDate.vue @@ -187,7 +187,10 @@ export default { */ dateMax() { return this.extraSettings?.dateMax - ? moment(this.extraSettings.dateMax, 'X').toDate() + ? moment(this.extraSettings.dateMax, [ + this.answerType.storageFormat, + 'X', + ]).toDate() : null }, @@ -196,7 +199,10 @@ export default { */ dateMin() { return this.extraSettings?.dateMin - ? moment(this.extraSettings.dateMin, 'X').toDate() + ? moment(this.extraSettings.dateMin, [ + this.answerType.storageFormat, + 'X', + ]).toDate() : null }, @@ -275,11 +281,14 @@ export default { * Handles the change event for the maximum date input. * Updates the maximum allowable date based on the provided value. * - * @param {string | Date} value - The new maximum date value. Can be a string or a Date object. + * @param {string | Date | null} value - The new maximum date value. Can be a string or a Date object. */ onDateMaxChange(value) { this.onExtraSettingsChange({ - dateMax: parseInt(moment(value).format('X')), + dateMax: + value === null || value === '' + ? null + : moment(value).format(this.answerType.storageFormat), }) }, @@ -287,11 +296,14 @@ export default { * Handles the change event for the minimum date input. * Updates the minimum allowable date based on the provided value. * - * @param {string | Date} value - The new minimum date value. Can be a string or a Date object. + * @param {string | Date | null} value - The new minimum date value. Can be a string or a Date object. */ onDateMinChange(value) { this.onExtraSettingsChange({ - dateMin: parseInt(moment(value).format('X')), + dateMin: + value === null || value === '' + ? null + : moment(value).format(this.answerType.storageFormat), }) }, @@ -417,11 +429,11 @@ export default { /** * Form expires timestamp to Date of the datepicker * - * @param {number} value the expires timestamp + * @param {number|string} value the expires timestamp or formatted date string * @return {Date} */ parseTimestampToDate(value) { - return moment(value, 'X').toDate() + return moment(value, [this.answerType.storageFormat, 'X']).toDate() }, }, } diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index d321a5668..800e0ef3d 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -1306,6 +1306,20 @@ public function testAreExtraSettingsValid(array $extraSettings, string $question $this->assertEquals($expected, $this->formsService->areExtraSettingsValid($extraSettings, $questionType)); } + public function testAreExtraSettingsValidNormalizesLegacyDateLimitsAsUtcDates(): void { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set('America/Los_Angeles'); + + try { + $this->assertTrue($this->formsService->areExtraSettingsValid([ + 'dateMin' => 1742860800, + 'dateMax' => 1742860800, + ], Constants::ANSWER_TYPE_DATE)); + } finally { + date_default_timezone_set($previousTimezone); + } + } + public static function dataAreExtraSettingsValid() { return [ 'empty-extra-settings' => [ @@ -1444,6 +1458,14 @@ public static function dataAreExtraSettingsValid() { 'questionType' => Constants::ANSWER_TYPE_DATE, 'expected' => true ], + 'valid-date-settings-string' => [ + 'extraSettings' => [ + 'dateMin' => '2026-08-20', + 'dateMax' => '2026-08-25', + ], + 'questionType' => Constants::ANSWER_TYPE_DATE, + 'expected' => true + ], 'invalid-date-settings' => [ 'extraSettings' => [ 'dateMin' => 'today', @@ -1452,6 +1474,14 @@ public static function dataAreExtraSettingsValid() { 'questionType' => Constants::ANSWER_TYPE_DATE, 'expected' => false ], + 'invalid-date-settings-overlap' => [ + 'extraSettings' => [ + 'dateMin' => '2026-08-25', + 'dateMax' => '2026-08-20', + ], + 'questionType' => Constants::ANSWER_TYPE_DATE, + 'expected' => false + ], 'invalid-date-limits' => [ // max < min 'extraSettings' => [ diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php index d1400ec0c..e33f84d3b 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -994,6 +994,42 @@ public static function dataValidateSubmission() { // Expected Result 'Date/time is not in the allowed range for question "q1".', ], + 'date-exact-limits-question-string' => [ + // Questions + [ + ['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => '2026-08-20', 'dateMax' => '2026-08-24']] + ], + // Answers + [ + '1' => ['2026-08-24'] + ], + // Expected Result + null, + ], + 'date-below-min-question-string' => [ + // Questions + [ + ['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => '2026-08-20', 'dateMax' => '2026-08-24']] + ], + // Answers + [ + '1' => ['2026-08-19'] + ], + // Expected Result + 'Date/time is not in the allowed range for question "q1".', + ], + 'date-above-max-question-string' => [ + // Questions + [ + ['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => '2026-08-20', 'dateMax' => '2026-08-24']] + ], + // Answers + [ + '1' => ['2026-08-25'] + ], + // Expected Result + 'Date/time is not in the allowed range for question "q1".', + ], 'valid-date-range' => [ // Questions [ @@ -1365,6 +1401,26 @@ public function testValidateSubmission(array $questions, array $answers, ?string $this->assertTrue(true); } + public function testValidateSubmissionNormalizesLegacyDateLimitAsUtcDate(): void { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set('America/Los_Angeles'); + + try { + $questions = [[ + 'id' => 1, + 'type' => 'date', + 'text' => 'q1', + 'isRequired' => false, + 'extraSettings' => ['dateMin' => 1742860800], + ]]; + + $this->submissionService->validateSubmission($questions, ['1' => ['2025-03-25']], 'admin', 1); + $this->assertTrue(true); + } finally { + date_default_timezone_set($previousTimezone); + } + } + public function testValidateSubmission_rejectsForeignUploadToken(): void { $questions = [[ 'id' => 8,