diff --git a/appinfo/routes.php b/appinfo/routes.php index 0c1fea1a9..28f04cece 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,353 +25,216 @@ * */ +$apiBase = '/api/{apiVersion}/'; +$requirements_v3 = [ + 'apiVersion' => 'v3', + 'formId' => '\d+', + 'questionId' => '\d+', + 'optionId' => '\d+', + 'shareId' => '\d+', + 'submissionId' => '\d+' +]; + return [ 'routes' => [ // Internal AppConfig routes - [ - 'name' => 'config#getAppConfig', - 'url' => '/config', - 'verb' => 'GET' - ], - [ - 'name' => 'config#updateAppConfig', - 'url' => '/config/update', - 'verb' => 'PATCH' - ], + ['name' => 'config#getAppConfig', 'url' => '/config', 'verb' => 'GET'], + ['name' => 'config#updateAppConfig', 'url' => '/config/update', 'verb' => 'PATCH'], // Public Share Link - [ - 'name' => 'page#public_link_view', - 'url' => '/s/{hash}', - 'verb' => 'GET' - ], + ['name' => 'page#public_link_view', 'url' => '/s/{hash}', 'verb' => 'GET'], // Embedded View - [ - 'name' => 'page#embedded_form_view', - 'url' => '/embed/{hash}', - 'verb' => 'GET' - ], + ['name' => 'page#embedded_form_view', 'url' => '/embed/{hash}', 'verb' => 'GET'], // Internal views - [ - 'name' => 'page#views', - 'url' => '/{hash}/{view}', - 'verb' => 'GET' - ], + ['name' => 'page#views', 'url' => '/{hash}/{view}', 'verb' => 'GET'], // Internal Form Link - [ - 'name' => 'page#internal_link_view', - 'url' => '/{hash}', - 'verb' => 'GET' - ], + ['name' => 'page#internal_link_view', 'url' => '/{hash}', 'verb' => 'GET'], // App Root - [ - 'name' => 'page#index', - 'url' => '/', - 'verb' => 'GET' - ], + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], ], 'ocs' => [ // CORS Preflight - [ - 'name' => 'api#preflightedCors', - 'url' => '/api/{apiVersion}/{path}', - 'verb' => 'OPTIONS', - 'requirements' => [ - 'path' => '.+', - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#preflightedCors', 'url' => $apiBase . '{path}', 'verb' => 'OPTIONS', 'requirements' => [ + 'path' => '.+', + 'apiVersion' => 'v2(\.[1-4])?|v3' + ]], + // API routes v3 // Forms - [ - 'name' => 'api#getForms', - 'url' => '/api/{apiVersion}/forms', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#newForm', - 'url' => '/api/{apiVersion}/form', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#getForm', - 'url' => '/api/{apiVersion}/form/{id}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#cloneForm', - 'url' => '/api/{apiVersion}/form/clone/{id}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - // TODO: Remove POST in next API release - [ - 'name' => 'api#updateForm', - 'url' => '/api/{apiVersion}/form/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#updateForm', - 'url' => '/api/{apiVersion}/form/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#transferOwner', - 'url' => '/api/{apiVersion}/form/transfer', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#deleteForm', - 'url' => '/api/{apiVersion}/form/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#getPartialForm', - 'url' => '/api/{apiVersion}/partial_form/{hash}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#getSharedForms', - 'url' => '/api/{apiVersion}/shared_forms', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#getForms', 'url' => $apiBase . 'forms', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newForm', 'url' => $apiBase . 'forms', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#getForm', 'url' => $apiBase . 'forms/{formId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#updateForm', 'url' => $apiBase . 'forms/{formId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteForm', 'url' => $apiBase . 'forms/{formId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + + // Questions + ['name' => 'api#getQuestions', 'url' => $apiBase . 'forms/{formId}/questions', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newQuestion', 'url' => $apiBase . 'forms/{formId}/questions', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#getQuestion', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#updateQuestion', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteQuestion', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + ['name' => 'api#reorderQuestions', 'url' => $apiBase . 'forms/{formId}/questions/reorder', 'verb' => 'PUT', 'requirements' => $requirements_v3], + + // Options + // ['name' => 'api#getOptions', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options', 'verb' => 'POST', 'requirements' => $requirements_v3], + // ['name' => 'api#getOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/{optionId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#updateOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/{optionId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/{optionId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + // ['name' => 'api#reorderOptions', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/reorder', 'verb' => 'PUT', 'requirements' => $requirements_v3], + + // Shares + // ['name' => 'shareApi#getUserShares', 'url' => $apiBase . 'shares', 'verb' => 'GET', 'requirements' => $requirements_v3], + // ['name' => 'shareApi#getShares', 'url' => $apiBase . 'forms/{formId}/shares', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'shareApi#newShare', 'url' => $apiBase . 'forms/{formId}/shares', 'verb' => 'POST', 'requirements' => $requirements_v3], + // ['name' => 'shareApi#getShare', 'url' => $apiBase . 'forms/{formId}/shares/{shareId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'shareApi#updateShare', 'url' => $apiBase . 'forms/{formId}/shares/{shareId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'shareApi#deleteShare', 'url' => $apiBase . 'forms/{formId}/shares/{shareId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + + // Submissions + ['name' => 'api#getSubmissions', 'url' => $apiBase . 'forms/{formId}/submissions', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newSubmission', 'url' => $apiBase . 'forms/{formId}/submissions', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#deleteAllSubmissions', 'url' => $apiBase . 'forms/{formId}/submissions', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + //['name' => 'api#getSubmission', 'url' => $apiBase . 'forms/{formId}/submissions/{submissionId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + //['name' => 'api#updateSubmission', 'url' => $apiBase . 'forms/{formId}/submissions/{submissionId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteSubmission', 'url' => $apiBase . 'forms/{formId}/submissions/{submissionId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + ['name' => 'api#exportSubmissionsToCloud', 'url' => $apiBase . 'forms/{formId}/submissions/export', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#uploadFiles', 'url' => $apiBase . 'forms/{formId}/submissions/files/{questionId}', 'verb' => 'POST', 'requirements' => $requirements_v3], + + // Legacy v2 routes (TODO: remove with Forms v5) + // Forms + ['name' => 'api#getFormsLegacy', 'url' => $apiBase . 'forms', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#newFormLegacy', 'url' => $apiBase . 'form', 'verb' => 'POST', 'requirements' => [ + 'apiVersion_path' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#getFormLegacy', 'url' => $apiBase . 'form/{id}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion_path' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#cloneFormLegacy', 'url' => $apiBase . 'form/clone/{id}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#updateFormLegacy', 'url' => $apiBase . 'form/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#updateFormLegacy', 'url' => $apiBase . 'form/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#transferOwnerLegacy', 'url' => $apiBase . 'form/transfer', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#deleteFormLegacy', 'url' => $apiBase . 'form/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#getPartialFormLegacy', 'url' => $apiBase . 'partial_form/{hash}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'hash' => '[a-zA-Z0-9]{16}' + ]], + ['name' => 'api#getSharedFormsLegacy', 'url' => $apiBase . 'shared_forms', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], // Questions - [ - 'name' => 'api#newQuestion', - 'url' => '/api/{apiVersion}/question', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#newQuestionLegacy', 'url' => $apiBase . 'question', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'api#updateQuestion', - 'url' => '/api/{apiVersion}/question/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#updateQuestion', - 'url' => '/api/{apiVersion}/question/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], + ['name' => 'api#updateQuestionLegacy', 'url' => $apiBase . 'question/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#updateQuestionLegacy', 'url' => $apiBase . 'question/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'api#reorderQuestions', - 'url' => '/api/{apiVersion}/question/reorder', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#reorderQuestions', - 'url' => '/api/{apiVersion}/question/reorder', - 'verb' => 'PUT', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#deleteQuestion', - 'url' => '/api/{apiVersion}/question/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#cloneQuestion', - 'url' => '/api/{apiVersion}/question/clone/{id}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2\.[3-4]' - ] - ], + ['name' => 'api#reorderQuestionsLegacy', 'url' => $apiBase . 'question/reorder', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#reorderQuestionsLegacy', 'url' => $apiBase . 'question/reorder', 'verb' => 'PUT', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#deleteQuestionLegacy', 'url' => $apiBase . 'question/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#cloneQuestionLegacy', 'url' => $apiBase . 'question/clone/{id}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2\.[3-4]', + 'id' => '\d+' + ]], // Options - [ - 'name' => 'api#newOption', - 'url' => '/api/{apiVersion}/option', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#newOptionLegacy', 'url' => $apiBase . 'option', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'api#updateOption', - 'url' => '/api/{apiVersion}/option/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#updateOption', - 'url' => '/api/{apiVersion}/option/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#deleteOption', - 'url' => '/api/{apiVersion}/option/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#updateOptionLegacy', 'url' => $apiBase . 'option/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#updateOptionLegacy', 'url' => $apiBase . 'option/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#deleteOptionLegacy', 'url' => $apiBase . 'option/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], // Shares - [ - 'name' => 'shareApi#newShare', - 'url' => '/api/{apiVersion}/share', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'shareApi#deleteShare', - 'url' => '/api/{apiVersion}/share/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'shareApi#newShareLegacy', 'url' => $apiBase . 'share', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'shareApi#deleteShareLegacy', 'url' => $apiBase . 'share/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'shareApi#updateShare', - 'url' => '/api/{apiVersion}/share/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2\.[1-4]' - ] - ], - [ - 'name' => 'shareApi#updateShare', - 'url' => '/api/{apiVersion}/share/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], + ['name' => 'shareApi#updateShareLegacy', 'url' => $apiBase . 'share/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2\.[1-4]' + ]], + ['name' => 'shareApi#updateShareLegacy', 'url' => $apiBase . 'share/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], // Submissions - [ - 'name' => 'api#getSubmissions', - 'url' => '/api/{apiVersion}/submissions/{hash}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#exportSubmissions', - 'url' => '/api/{apiVersion}/submissions/export/{hash}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#exportSubmissionsToCloud', - 'url' => '/api/{apiVersion}/submissions/export', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#deleteAllSubmissions', - 'url' => '/api/{apiVersion}/submissions/{formId}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#uploadFiles', - 'url' => '/api/{apiVersion}/uploadFiles/{formId}/{questionId}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2.5' - ] - ], - [ - 'name' => 'api#insertSubmission', - 'url' => '/api/{apiVersion}/submission/insert', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#deleteSubmission', - 'url' => '/api/{apiVersion}/submission/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#getSubmissionsLegacy', 'url' => $apiBase . 'submissions/{hash}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'hash' => '[a-zA-Z0-9]{16}' + ]], + ['name' => 'api#exportSubmissionsLegacy', 'url' => $apiBase . 'submissions/export/{hash}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'hash' => '[a-zA-Z0-9]{16}' + ]], + ['name' => 'api#exportSubmissionsToCloudLegacy', 'url' => $apiBase . 'submissions/export', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#deleteAllSubmissionsLegacy', 'url' => $apiBase . 'submissions/{formId}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'formId' => '\d+' + ]], + ['name' => 'api#uploadFilesLegacy', 'url' => $apiBase . 'uploadFiles/{formId}/{questionId}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2.5', + 'formId' => '\d+', + 'questionId' => '\d+' + ]], + ['name' => 'api#insertSubmissionLegacy', 'url' => $apiBase . 'submission/insert', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#deleteSubmissionLegacy', 'url' => $apiBase . 'submission/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], // Submissions linking with file in cloud - [ - 'name' => 'api#linkFile', - 'url' => '/api/{apiVersion}/form/link/{fileFormat}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2.4', - 'fileFormat' => 'csv|ods|xlsx' - ] - ], - [ - 'name' => 'api#unlinkFile', - 'url' => '/api/{apiVersion}/form/unlink', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2.4', - ] - ] + ['name' => 'api#linkFileLegacy', 'url' => $apiBase . 'form/link/{fileFormat}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2.4', + 'fileFormat' => 'csv|ods|xlsx' + ]], + ['name' => 'api#unlinkFileLegacy', 'url' => $apiBase . 'form/unlink', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2.4', + ]] ] ]; diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 0ff651f60..a34fd686c 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -3,6 +3,7 @@ * @copyright Copyright (c) 2017 Vinzenz Rosenkranz * * @author affan98 + * @author Christian Hartmann * @author Ferdinand Thiessen * @author Jan-Christoph Borchardt * @author John Molakvoæ (skjnldsv) @@ -93,6 +94,8 @@ public function __construct( $this->currentUser = $userSession->getUser(); } + // API v3 methods + // Forms /** * @CORS * @NoAdminRequired @@ -101,7 +104,1249 @@ public function __construct( * Return only with necessary information for Listing. * @return DataResponse */ - public function getForms(): DataResponse { + public function getForms(string $type = 'owned'): DataResponse { + if ($type === 'owned') { + $forms = $this->formMapper->findAllByOwnerId($this->currentUser->getUID()); + $result = []; + foreach ($forms as $form) { + $result[] = $this->formsService->getPartialFormArray($form); + } + return new DataResponse($result); + } elseif ($type === 'shared') { + $forms = $this->formsService->getSharedForms($this->currentUser); + $result = array_values(array_map(fn (Form $form): array => $this->formsService->getPartialFormArray($form), $forms)); + return new DataResponse($result); + } else { + throw new OCSBadRequestException(); + } + } + + /** + * @CORS + * @NoAdminRequired + * + * Create a new Form and return the Form to edit. + * Return a cloned Form if the parameter $fromId is set + * + * @param int $fromId (optional) ID of the Form that should be cloned + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newForm(?int $fromId = null): DataResponse { + // Check if user is allowed + if (!$this->configService->canCreateForms()) { + $this->logger->debug('This user is not allowed to create Forms.'); + throw new OCSForbiddenException(); + } + + if($fromId === null) { + // Create Form + $form = new Form(); + $form->setOwnerId($this->currentUser->getUID()); + $form->setCreated(time()); + $form->setHash($this->formsService->generateFormHash()); + $form->setTitle(''); + $form->setDescription(''); + $form->setAccess([ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]); + $form->setSubmitMultiple(false); + $form->setShowExpiration(false); + $form->setExpires(0); + $form->setIsAnonymous(false); + $form->setLastUpdated(time()); + + $this->formMapper->insert($form); + } else { + $oldForm = $this->getFormIfAllowed($fromId); + + // Read Form, set new Form specific data, extend Title. + $formData = $oldForm->read(); + unset($formData['id']); + $formData['created'] = time(); + $formData['lastUpdated'] = time(); + $formData['hash'] = $this->formsService->generateFormHash(); + // TRANSLATORS Appendix to the form Title of a duplicated/copied form. + $formData['title'] .= ' - ' . $this->l10n->t('Copy'); + + $form = Form::fromParams($formData); + $this->formMapper->insert($form); + + // Get Questions, set new formId, reinsert + $questions = $this->questionMapper->findByForm($oldForm->getId()); + foreach ($questions as $oldQuestion) { + $questionData = $oldQuestion->read(); + + unset($questionData['id']); + $questionData['formId'] = $form->getId(); + $newQuestion = Question::fromParams($questionData); + $this->questionMapper->insert($newQuestion); + + // Get Options, set new QuestionId, reinsert + $options = $this->optionMapper->findByQuestion($oldQuestion->getId()); + foreach ($options as $oldOption) { + $optionData = $oldOption->read(); + + unset($optionData['id']); + $optionData['questionId'] = $newQuestion->getId(); + $newOption = Option::fromParams($optionData); + $this->optionMapper->insert($newOption); + } + } + } + return $this->getForm($form->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Read all information to edit a Form (form, questions, options, except submissions/answers). + * + * @param int $formId FormId, pass `0` for using the optional `hash` parameter + * @param string $hash Hash of the Form + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function getForm(int $formId = 0, ?string $hash = null): DataResponse { + if ($formId > 0) { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $formData = $this->formsService->getForm($form); + } elseif ($hash !== null) { + try { + $form = $this->formMapper->findByHash($hash); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $formData = $this->formsService->getPartialFormArray($form); + } else { + throw new OCSBadRequestException(); + } + + return new DataResponse($formData); + } + + /** + * @CORS + * @NoAdminRequired + * + * Writes the given key-value pairs into Database. + * + * @param int $formId FormId of form to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateForm(int $formId, array $keyValuePairs): DataResponse { + $this->logger->debug('Updating form: formId: {formId}, values: {keyValuePairs}', [ + 'formId' => $formId, + 'keyValuePairs' => $keyValuePairs + ]); + + $form = $this->getFormIfAllowed($formId); + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + // Process owner transfer + if (sizeof($keyValuePairs) === 1 && key_exists('ownerId', $keyValuePairs)) { + $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ + 'formId' => $formId, + 'uid' => $keyValuePairs['ownerId'] + ]); + + $form = $this->getFormIfAllowed($formId); + $user = $this->userManager->get($keyValuePairs['ownerId']); + if ($user == null) { + $this->logger->debug('Could not find new form owner'); + throw new OCSBadRequestException('Could not find new form owner'); + } + + // update form owner + $form->setOwnerId($keyValuePairs['ownerId']); + + // Update changed Columns in Db. + $this->formMapper->update($form); + + return new DataResponse($form->getOwnerId()); + } + + // Don't allow to change params id, hash, ownerId, created, lastUpdated, fileId + if (key_exists('id', $keyValuePairs) || key_exists('hash', $keyValuePairs) || + key_exists('ownerId', $keyValuePairs) || key_exists('created', $keyValuePairs) || + isset($keyValuePairs['fileId']) || key_exists('lastUpdated', $keyValuePairs)) { + $this->logger->info('Not allowed to update id, hash, ownerId, created, fileId or lastUpdated'); + throw new OCSForbiddenException(); + } + + // Process file linking + if (isset($keyValuePairs['path']) && isset($keyValuePairs['fileFormat'])) { + $file = $this->submissionService->writeFileToCloud($form, $keyValuePairs['path'], $keyValuePairs['fileFormat']); + + $form->setFileId($file->getId()); + $form->setFileFormat($keyValuePairs['fileFormat']); + } + + // Process file unlinking + if (key_exists('fileId', $keyValuePairs) && key_exists('fileFormat', $keyValuePairs) && !isset($keyValuePairs['fileId']) && !isset($keyValuePairs['fileFormat'])) { + $form->setFileId(null); + $form->setFileFormat(null); + } + + unset($keyValuePairs['path']); + unset($keyValuePairs['fileId']); + unset($keyValuePairs['fileFormat']); + + // Create FormEntity with given Params & Id. + foreach ($keyValuePairs as $key => $value) { + $method = 'set' . ucfirst($key); + $form->$method($value); + } + + // Update changed Columns in Db. + $this->formMapper->update($form); + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($form->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a form + * + * @param int $formId the form id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteForm(int $formId): DataResponse { + $this->logger->debug('Delete Form: {formId}', [ + 'formId' => $formId, + ]); + + $form = $this->getFormIfAllowed($formId); + $this->formMapper->deleteForm($form); + + return new DataResponse($formId); + } + + // Questions + /** + * @CORS + * @NoAdminRequired + * + * Read all questions (including options) + * + * @param int $formId FormId + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function getQuestions(int $formId): DataResponse { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $questionData = $this->formsService->getQuestions($formId); + + return new DataResponse($questionData); + } + + /** + * @CORS + * @NoAdminRequired + * + * Read a specific question (including options) + * + * @param int $formId FormId + * @param int $questionId QuestionId + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function getQuestion(int $formId, int $questionId): DataResponse { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $question = $this->formsService->getQuestion($questionId); + + if ($question['id'] !== $formId) { + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + + return new DataResponse($question); + } + + /** + * @CORS + * @NoAdminRequired + * + * Add a new question + * + * @param int $formId the form id + * @param string $type the new question type + * @param string $text the new question title + * @param int $fromId (optional) id of the question that should be cloned + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse { + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + if($fromId === null) { + $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}', [ + 'formId' => $formId, + 'type' => $type, + 'text' => $text, + ]); + + if (array_search($type, Constants::ANSWER_TYPES) === false) { + $this->logger->debug('Invalid type'); + throw new OCSBadRequestException('Invalid type'); + } + + // Block creation of datetime questions + if ($type === 'datetime') { + $this->logger->debug('Datetime question type no longer supported'); + throw new OCSBadRequestException('Datetime question type no longer supported'); + } + + // Retrieve all active questions sorted by Order. Takes the order of the last array-element and adds one. + $questions = $this->questionMapper->findByForm($formId); + $lastQuestion = array_pop($questions); + if ($lastQuestion) { + $questionOrder = $lastQuestion->getOrder() + 1; + } else { + $questionOrder = 1; + } + + $question = new Question(); + + $question->setFormId($formId); + $question->setOrder($questionOrder); + $question->setType($type); + $question->setText($text); + $question->setDescription(''); + $question->setIsRequired(false); + $question->setExtraSettings([]); + + $question = $this->questionMapper->insert($question); + + $response = $question->read(); + $response['options'] = []; + $response['accept'] = []; + + $this->formsService->setLastUpdatedTimestamp($formId); + } else { + $this->logger->debug('Question to be cloned: {fromId}', [ + 'fromId' => $fromId + ]); + + try { + $sourceQuestion = $this->questionMapper->findById($fromId); + $sourceOptions = $this->optionMapper->findByQuestion($fromId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSNotFoundException('Could not find question'); + } + + $allQuestions = $this->questionMapper->findByForm($formId); + + $questionData = $sourceQuestion->read(); + unset($questionData['id']); + $questionData["order"] = end($allQuestions)->getOrder() + 1; + + $newQuestion = Question::fromParams($questionData); + $this->questionMapper->insert($newQuestion); + + $response = $newQuestion->read(); + $response['options'] = []; + $response['accept'] = []; + + foreach ($sourceOptions as $sourceOption) { + $optionData = $sourceOption->read(); + + unset($optionData['id']); + $optionData['questionId'] = $newQuestion->getId(); + $newOption = Option::fromParams($optionData); + $insertedOption = $this->optionMapper->insert($newOption); + + $response['options'][] = $insertedOption->read(); + } + } + + return new DataResponse($response); + } + + /** + * @CORS + * @NoAdminRequired + * + * Writes the given key-value pairs into Database. + * Key 'order' should only be changed by reorderQuestions() and is not allowed here. + * + * @param int $formId the form id + * @param int $questionId id of question to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateQuestion(int $formId, int $questionId, array $keyValuePairs): DataResponse { + $this->logger->debug('Updating question: formId: {formId}, questionId: {questionId}, values: {keyValuePairs}', [ + 'formId' => $formId, + 'questionId' => $questionId, + 'keyValuePairs' => $keyValuePairs + ]); + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSBadRequestException('Could not find question'); + } + + if ($question->getFormId() !== $formId) { + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + //Don't allow to change id or formId + if (key_exists('id', $keyValuePairs) || key_exists('formId', $keyValuePairs)) { + $this->logger->debug('Not allowed to update \'id\' or \'formId\''); + throw new OCSForbiddenException(); + } + + // Don't allow to reorder here + if (key_exists('order', $keyValuePairs)) { + $this->logger->debug('Key \'order\' is not allowed on updateQuestion. Please use reorderQuestions() to change order.'); + throw new OCSForbiddenException('Please use reorderQuestions() to change order'); + } + + if (key_exists('extraSettings', $keyValuePairs) && !$this->formsService->areExtraSettingsValid($keyValuePairs['extraSettings'], $question->getType())) { + throw new OCSBadRequestException('Invalid extraSettings, will not update.'); + } + + // Create QuestionEntity with given Params & Id. + $question = Question::fromParams($keyValuePairs); + $question->setId($questionId); + + // Update changed Columns in Db. + $this->questionMapper->update($question); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($question->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a question + * + * @param int $formId the form id + * @param int $questionId the question id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteQuestion(int $formId, int $questionId): DataResponse { + $this->logger->debug('Mark question as deleted: {questionId}', [ + 'questionId' => $questionId, + ]); + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSBadRequestException('Could not find question'); + } + + if ($question->getFormId() !== $formId) { + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Store Order of deleted Question + $deletedOrder = $question->getOrder(); + + // Mark question as deleted + $question->setOrder(0); + $this->questionMapper->update($question); + + // Update all question-order > deleted order. + $formQuestions = $this->questionMapper->findByForm($formId); + foreach ($formQuestions as $question) { + $questionOrder = $question->getOrder(); + if ($questionOrder > $deletedOrder) { + $question->setOrder($questionOrder - 1); + $this->questionMapper->update($question); + } + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($questionId); + } + + /** + * @CORS + * @NoAdminRequired + * + * Updates the Order of all Questions of a Form. + * + * @param int $formId Id of the form to reorder + * @param Array $newOrder Array of Question-Ids in new order. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function reorderQuestions(int $formId, array $newOrder): DataResponse { + $this->logger->debug('Reordering Questions on Form {formId} as Question-Ids {newOrder}', [ + 'formId' => $formId, + 'newOrder' => $newOrder + ]); + + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Check if array contains duplicates + if (array_unique($newOrder) !== $newOrder) { + $this->logger->debug('The given array contains duplicates'); + throw new OCSBadRequestException('The given array contains duplicates'); + } + + // Check if all questions are given in Array. + $questions = $this->questionMapper->findByForm($formId); + if (sizeof($questions) !== sizeof($newOrder)) { + $this->logger->debug('The length of the given array does not match the number of stored questions'); + throw new OCSBadRequestException('The length of the given array does not match the number of stored questions'); + } + + $questions = []; // Clear Array of Entities + $response = []; // Array of ['questionId' => ['order' => newOrder]] + + // Store array of Question-Entities and check the Questions FormId & old Order. + foreach ($newOrder as $arrayKey => $questionId) { + try { + $questions[$arrayKey] = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question. Id: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(); + } + + // Abort if a question is not part of the Form. + if ($questions[$arrayKey]->getFormId() !== $formId) { + $this->logger->debug('This Question is not part of the given Form: questionId: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(); + } + + // Abort if a question is already marked as deleted (order==0) + $oldOrder = $questions[$arrayKey]->getOrder(); + if ($oldOrder === 0) { + $this->logger->debug('This Question has already been marked as deleted: Id: {questionId}', [ + 'questionId' => $questions[$arrayKey]->getId() + ]); + throw new OCSBadRequestException(); + } + + // Only set order, if it changed. + if ($oldOrder !== $arrayKey + 1) { + // Set Order. ArrayKey counts from zero, order counts from 1. + $questions[$arrayKey]->setOrder($arrayKey + 1); + } + } + + // Write to Database + foreach ($questions as $question) { + $this->questionMapper->update($question); + + $response[$question->getId()] = [ + 'order' => $question->getOrder() + ]; + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($response); + } + + // Options + + /** + * @NoAdminRequired + * + * Add a new option to a question + * + * @param int $formId id of the form + * @param int $questionId id of the question + * @param array $optionTexts the new option text + * @return DataResponse Returns a DataResponse containing the added options + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newOption(int $formId, int $questionId, array $optionTexts): DataResponse { + $this->logger->debug('Adding new options: formId: {formId}, questionId: {questionId}, text: {text}', [ + 'formId' => $formId, + 'questionId' => $questionId, + 'text' => $optionTexts, + ]); + + try { + $question = $this->questionMapper->findById($questionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form or question'); + throw new OCSBadRequestException('Could not find form or question'); + } + + if ($question->getFormId() !== $formId) { + $this->logger->debug('This Question is not part of the given Form: questionId: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + $addedOptions = []; + foreach ($optionTexts as $text) { + $option = new Option(); + + $option->setQuestionId($questionId); + $option->setText($text); + + try { + $option = $this->optionMapper->insert($option); + // Add the stored option to the collection of added options + $addedOptions[] = $option->read(); + } catch (IMapperException $e) { + $this->logger->error("Failed to add option: {$e->getMessage()}"); + // Optionally handle the error, e.g., by continuing to the next iteration or returning an error response + } + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($addedOptions); + } + + /** + * @CORS + * @NoAdminRequired + * + * Writes the given key-value pairs into Database. + * + * @param int $formId id of form + * @param int $questionId id of question + * @param int $optionId id of option to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateOption(int $formId, int $questionId, int $optionId, array $keyValuePairs): DataResponse { + $this->logger->debug('Updating option: form: {formId}, question: {questionId}, option: {optionId}, values: {keyValuePairs}', [ + 'formId' => $formId, + 'questionId' => $questionId, + 'optionId' => $optionId, + 'keyValuePairs' => $keyValuePairs + ]); + + try { + $option = $this->optionMapper->findById($optionId); + $question = $this->questionMapper->findById($questionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find option, question or form'); + throw new OCSBadRequestException('Could not find option, question or form'); + } + + if ($option->getQuestionId() !== $questionId || $question->getFormId() !== $formId) { + $this->logger->debug('The given option id doesn\'t match the question or form.'); + throw new OCSBadRequestException(); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + //Don't allow to change id or questionId + if (key_exists('id', $keyValuePairs) || key_exists('questionId', $keyValuePairs)) { + $this->logger->debug('Not allowed to update id or questionId'); + throw new OCSForbiddenException(); + } + + // Create OptionEntity with given Params & Id. + $option = Option::fromParams($keyValuePairs); + $option->setId($optionId); + + // Update changed Columns in Db. + $this->optionMapper->update($option); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($option->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete an option + * + * @param int $formId id of form + * @param int $questionId id of question + * @param int $optionId id of option to update + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteOption(int $formId, int $questionId, int $optionId): DataResponse { + $this->logger->debug('Deleting option: {optionId}', [ + 'optionId' => $optionId + ]); + + try { + $option = $this->optionMapper->findById($optionId); + $question = $this->questionMapper->findById($questionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form, question or option'); + throw new OCSBadRequestException('Could not find form, question or option'); + } + + if ($option->getQuestionId() !== $questionId || $question->getFormId() !== $formId) { + $this->logger->debug('The given option id doesn\'t match the question or form.'); + throw new OCSBadRequestException(); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + $this->optionMapper->delete($option); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($optionId); + } + + // Submissions + + /** + * @CORS + * @NoAdminRequired + * + * Get all the submissions of a given form + * + * @param int $formId of the form + * @return DataResponse|DataDownloadResponse + * @throws OCSNotFoundException + * @throws OCSForbiddenException + */ + public function getSubmissions(int $formId, ?string $fileFormat = null): DataResponse|DataDownloadResponse { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSNotFoundException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + if ($fileFormat !== null) { + $submissionsData = $this->submissionService->getSubmissionsData($form, $fileFormat); + $fileName = $this->formsService->getFileName($form, $fileFormat); + + return new DataDownloadResponse($submissionsData, $fileName, Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat]); + } + + // Load submissions and currently active questions + $submissions = $this->submissionService->getSubmissions($formId); + $questions = $this->formsService->getQuestions($formId); + + // Append Display Names + foreach ($submissions as $key => $submission) { + if (substr($submission['userId'], 0, 10) === 'anon-user-') { + // Anonymous User + // TRANSLATORS On Results when listing the single Responses to the form, this text is shown as heading of the Response. + $submissions[$key]['userDisplayName'] = $this->l10n->t('Anonymous response'); + } else { + $userEntity = $this->userManager->get($submission['userId']); + + if ($userEntity instanceof IUser) { + $submissions[$key]['userDisplayName'] = $userEntity->getDisplayName(); + } else { + // Fallback, should not occur regularly. + $submissions[$key]['userDisplayName'] = $submission['userId']; + } + } + } + + $response = [ + 'submissions' => $submissions, + 'questions' => $questions, + ]; + + return new DataResponse($response); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete all submissions of a specified form + * + * @param int $formId the form id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteAllSubmissions(int $formId): DataResponse { + $this->logger->debug('Delete all submissions to form: {formId}', [ + 'formId' => $formId, + ]); + + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + // The current user has permissions to remove submissions + if (!$this->formsService->canDeleteResults($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); + throw new OCSForbiddenException(); + } + + // Delete all submissions (incl. Answers) + $this->submissionMapper->deleteByForm($formId); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($formId); + } + + /** + * @CORS + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * Process a new submission + * + * @param int $formId the form id + * @param array $answers [question_id => arrayOfString] + * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { + $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ + 'formId' => $formId, + 'answers' => $answers, + 'shareHash' => $shareHash, + ]); + + $form = $this->loadFormForSubmission($formId, $shareHash); + + $questions = $this->formsService->getQuestions($formId); + // Is the submission valid + $isSubmissionValid = $this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId()); + if (is_string($isSubmissionValid)) { + throw new OCSBadRequestException($isSubmissionValid); + } + if ($isSubmissionValid === false) { + throw new OCSBadRequestException('At least one submitted answer is not valid'); + } + + // Create Submission + $submission = new Submission(); + $submission->setFormId($formId); + $submission->setTimestamp(time()); + + // If not logged in, anonymous, or embedded use anonID + if (!$this->currentUser || $form->getIsAnonymous()) { + $anonID = "anon-user-". hash('md5', strval(time() + rand())); + $submission->setUserId($anonID); + } else { + $submission->setUserId($this->currentUser->getUID()); + } + + // Does the user have permissions to submit + // This is done right before insert so we minimize race conditions for submitting on unique-submission forms + if (!$this->formsService->canSubmit($form)) { + throw new OCSForbiddenException('Already submitted'); + } + + // Insert new submission + $this->submissionMapper->insert($submission); + + // Ensure the form is unique if needed. + // If we can not submit anymore then the submission must be unique + if (!$this->formsService->canSubmit($form) && $this->submissionMapper->hasMultipleFormSubmissionsByUser($form, $submission->getUserId())) { + $this->submissionMapper->delete($submission); + throw new OCSForbiddenException('Already submitted'); + } + + // Process Answers + foreach ($answers as $questionId => $answerArray) { + // Search corresponding Question, skip processing if not found + $questionIndex = array_search($questionId, array_column($questions, 'id')); + if ($questionIndex === false) { + continue; + } + + $this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray); + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + //Create Activity + $this->formsService->notifyNewSubmission($form, $submission->getUserId()); + + if ($form->getFileId() !== null) { + try { + $filePath = $this->formsService->getFilePath($form); + $fileFormat = $form->getFileFormat(); + $ownerId = $form->getOwnerId(); + + $this->submissionService->writeFileToCloud($form, $filePath, $fileFormat, $ownerId); + } catch (NotFoundException $e) { + $this->logger->notice('Form {formId} linked to a file that doesn\'t exist anymore', [ + 'formId' => $formId + ]); + } + } + + return new DataResponse(); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a specific submission + * + * @param int $formId the form id + * @param int $submissionId the submission id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteSubmission(int $formId, int $submissionId): DataResponse { + $this->logger->debug('Delete Submission: {submissionId}', [ + 'submissionId' => $submissionId, + ]); + + try { + $submission = $this->submissionMapper->findById($submissionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form or submission'); + throw new OCSBadRequestException(); + } + + if ($formId !== $submission->getFormId()) { + $this->logger->debug('Submission doesn\'t belong to given form'); + throw new OCSBadRequestException('Submission doesn\'t belong to given form'); + } + + // The current user has permissions to remove submissions + if (!$this->formsService->canDeleteResults($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); + throw new OCSForbiddenException(); + } + + // Delete submission (incl. Answers) + $this->submissionMapper->deleteById($submissionId); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($submissionId); + } + + /** + * @CORS + * @NoAdminRequired + * + * Export Submissions to the Cloud + * + * @param int $formId of the form + * @param string $path The Cloud-Path to export to + * @param string $fileFormat File format used for export + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function exportSubmissionsToCloud(int $formId, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSNotFoundException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + $file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat); + + return new DataResponse($file->getName()); + } + + /** + * @CORS + * @NoAdminRequired + * @PublicPage + * + * Uploads a temporary files to the server during form filling + * + * @param int $formId id of the form + * @param int $questionId id of the question + * @param string $shareHash hash of the form share + * @return Response + */ + public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): Response { + $this->logger->debug('Uploading files for formId: {formId}, questionId: {questionId}', [ + 'formId' => $formId, + 'questionId' => $questionId + ]); + + $uploadedFiles = []; + foreach ($this->request->getUploadedFile('files') as $key => $files) { + foreach ($files as $i => $value) { + $uploadedFiles[$i][$key] = $value; + } + } + + if (!count($uploadedFiles)) { + throw new OCSBadRequestException('No files provided'); + } + + $form = $this->loadFormForSubmission($formId, $shareHash); + + if (!$this->formsService->canSubmit($form)) { + throw new OCSForbiddenException('Already submitted'); + } + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question with id {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(previous: $e instanceof \Exception ? $e : null); + } + + if ($formId !== $question->getFormId()) { + $this->logger->debug('Question doesn\'t belong to the given form'); + throw new OCSBadRequestException('Question doesn\'t belong to the given form'); + } + + $path = $this->formsService->getTemporaryUploadedFilePath($form, $question); + + $response = []; + foreach ($uploadedFiles as $uploadedFile) { + $error = $uploadedFile['error'] ?? 0; + if ($error !== UPLOAD_ERR_OK) { + $this->logger->error('Failed to get the uploaded file. PHP file upload error code: ' . $error, + ['file_name' => $uploadedFile['name']]); + + throw new OCSBadRequestException(sprintf('Failed to upload the file "%s".', $uploadedFile['name'])); + } + + if (!is_uploaded_file($uploadedFile['tmp_name'])) { + throw new OCSBadRequestException('Invalid file provided'); + } + + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $userFolder->getStorage()->verifyPath($path, $uploadedFile['name']); + + $extraSettings = $question->getExtraSettings(); + if (($extraSettings['maxFileSize'] ?? 0) > 0 && $uploadedFile['size'] > $extraSettings['maxFileSize']) { + throw new OCSBadRequestException(sprintf('File size exceeds the maximum allowed size of %s bytes.', $extraSettings['maxFileSize'])); + } + + if (!empty($extraSettings['allowedFileTypes']) || !empty($extraSettings['allowedFileExtensions'])) { + $mimeType = $this->mimeTypeDetector->detectContent($uploadedFile['tmp_name']); + $aliases = $this->mimeTypeDetector->getAllAliases(); + + $valid = false; + foreach ($extraSettings['allowedFileTypes'] ?? [] as $allowedFileType) { + if (str_starts_with($aliases[$mimeType] ?? '', $allowedFileType)) { + $valid = true; + break; + } + } + + if (!$valid && !empty($extraSettings['allowedFileExtensions'])) { + $mimeTypesPerExtension = method_exists($this->mimeTypeDetector, 'getAllMappings') + ? $this->mimeTypeDetector->getAllMappings() : []; + foreach ($extraSettings['allowedFileExtensions'] as $allowedFileExtension) { + if (isset($mimeTypesPerExtension[$allowedFileExtension]) + && in_array($mimeType, $mimeTypesPerExtension[$allowedFileExtension])) { + $valid = true; + break; + } + } + } + + if (!$valid) { + throw new OCSBadRequestException(sprintf('File type is not allowed. Allowed file types: %s', + implode(', ', array_merge($extraSettings['allowedFileTypes'] ?? [], $extraSettings['allowedFileExtensions'] ?? [])) + )); + } + } + + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + /** @var \OCP\Files\Folder $folder */ + + $fileName = $folder->getNonExistingName($uploadedFile['name']); + $file = $folder->newFile($fileName, file_get_contents($uploadedFile['tmp_name'])); + + $uploadedFileEntity = new UploadedFile(); + $uploadedFileEntity->setFormId($formId); + $uploadedFileEntity->setOriginalFileName($fileName); + $uploadedFileEntity->setFileId($file->getId()); + $uploadedFileEntity->setCreated(time()); + $this->uploadedFileMapper->insert($uploadedFileEntity); + + $response[] = [ + 'uploadedFileId' => $uploadedFileEntity->getId(), + 'fileName' => $fileName, + ]; + } + + return new DataResponse($response); + } + + /* + * + * Legacy API v2 methods (TODO: remove with Forms v5) + * + */ + + /** + * @CORS + * @NoAdminRequired + * + * Read Form-List of owned forms + * Return only with necessary information for Listing. + * @return DataResponse + */ + public function getFormsLegacy(): DataResponse { $forms = $this->formMapper->findAllByOwnerId($this->currentUser->getUID()); $result = []; @@ -120,7 +1365,7 @@ public function getForms(): DataResponse { * Return only with necessary information for Listing. * @return DataResponse */ - public function getSharedForms(): DataResponse { + public function getSharedFormsLegacy(): DataResponse { $forms = $this->formsService->getSharedForms($this->currentUser); $result = array_values(array_map(fn (Form $form): array => $this->formsService->getPartialFormArray($form), $forms)); @@ -137,7 +1382,7 @@ public function getSharedForms(): DataResponse { * @return DataResponse * @throws OCSBadRequestException if forbidden or not found */ - public function getPartialForm(string $hash): DataResponse { + public function getPartialFormLegacy(string $hash): DataResponse { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -164,7 +1409,7 @@ public function getPartialForm(string $hash): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function getForm(int $id): DataResponse { + public function getFormLegacy(int $id): DataResponse { try { $form = $this->formMapper->findById($id); $formData = $this->formsService->getForm($form); @@ -191,7 +1436,7 @@ public function getForm(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function newForm(): DataResponse { + public function newFormLegacy(): DataResponse { // Check if user is allowed if (!$this->configService->canCreateForms()) { $this->logger->debug('This user is not allowed to create Forms.'); @@ -232,7 +1477,7 @@ public function newForm(): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function cloneForm(int $id): DataResponse { + public function cloneFormLegacy(int $id): DataResponse { $this->logger->debug('Cloning Form: {id}', [ 'id' => $id ]); @@ -295,7 +1540,7 @@ public function cloneForm(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateForm(int $id, array $keyValuePairs): DataResponse { + public function updateFormLegacy(int $id, array $keyValuePairs): DataResponse { $this->logger->debug('Updating form: FormId: {id}, values: {keyValuePairs}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs @@ -341,7 +1586,7 @@ public function updateForm(int $id, array $keyValuePairs): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function transferOwner(int $formId, string $uid): DataResponse { + public function transferOwnerLegacy(int $formId, string $uid): DataResponse { $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ 'formId' => $formId, 'uid' => $uid @@ -374,7 +1619,7 @@ public function transferOwner(int $formId, string $uid): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteForm(int $id): DataResponse { + public function deleteFormLegacy(int $id): DataResponse { $this->logger->debug('Delete Form: {id}', [ 'id' => $id, ]); @@ -398,7 +1643,7 @@ public function deleteForm(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function newQuestion(int $formId, string $type, string $text = ''): DataResponse { + public function newQuestionLegacy(int $formId, string $type, string $text = ''): DataResponse { $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}', [ 'formId' => $formId, 'type' => $type, @@ -464,7 +1709,7 @@ public function newQuestion(int $formId, string $type, string $text = ''): DataR * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function reorderQuestions(int $formId, array $newOrder): DataResponse { + public function reorderQuestionsLegacy(int $formId, array $newOrder): DataResponse { $this->logger->debug('Reordering Questions on Form {formId} as Question-Ids {newOrder}', [ 'formId' => $formId, 'newOrder' => $newOrder @@ -554,7 +1799,7 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateQuestion(int $id, array $keyValuePairs): DataResponse { + public function updateQuestionLegacy(int $id, array $keyValuePairs): DataResponse { $this->logger->debug('Updating question: questionId: {id}, values: {keyValuePairs}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs @@ -618,7 +1863,7 @@ public function updateQuestion(int $id, array $keyValuePairs): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteQuestion(int $id): DataResponse { + public function deleteQuestionLegacy(int $id): DataResponse { $this->logger->debug('Mark question as deleted: {id}', [ 'id' => $id, ]); @@ -669,7 +1914,7 @@ public function deleteQuestion(int $id): DataResponse { * @return DataResponse * @throws OCSBadRequestException|OCSForbiddenException */ - public function cloneQuestion(int $id): DataResponse { + public function cloneQuestionLegacy(int $id): DataResponse { $this->logger->debug('Question to be cloned: {id}', [ 'id' => $id ]); @@ -731,7 +1976,7 @@ public function cloneQuestion(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function newOption(int $questionId, string $text): DataResponse { + public function newOptionLegacy(int $questionId, string $text): DataResponse { $this->logger->debug('Adding new option: questionId: {questionId}, text: {text}', [ 'questionId' => $questionId, 'text' => $text, @@ -779,7 +2024,7 @@ public function newOption(int $questionId, string $text): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateOption(int $id, array $keyValuePairs): DataResponse { + public function updateOptionLegacy(int $id, array $keyValuePairs): DataResponse { $this->logger->debug('Updating option: option: {id}, values: {keyValuePairs}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs @@ -839,7 +2084,7 @@ public function updateOption(int $id, array $keyValuePairs): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteOption(int $id): DataResponse { + public function deleteOptionLegacy(int $id): DataResponse { $this->logger->debug('Deleting option: {id}', [ 'id' => $id ]); @@ -881,7 +2126,7 @@ public function deleteOption(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function getSubmissions(string $hash): DataResponse { + public function getSubmissionsLegacy(string $hash): DataResponse { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -924,66 +2169,6 @@ public function getSubmissions(string $hash): DataResponse { return new DataResponse($response); } - /** - * Insert answers for a question - * - * @param Form $form - * @param int $submissionId - * @param array $question - * @param string[]|array $answerArray - */ - private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray) { - foreach ($answerArray as $answer) { - $answerEntity = new Answer(); - $answerEntity->setSubmissionId($submissionId); - $answerEntity->setQuestionId($question['id']); - - $answerText = ''; - $uploadedFile = null; - // Are we using answer ids as values - if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { - // Search corresponding option, skip processing if not found - $optionIndex = array_search($answer, array_column($question['options'], 'id')); - if ($optionIndex !== false) { - $answerText = $question['options'][$optionIndex]['text']; - } elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) { - $answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, "", $answer); - } - } elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) { - $uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']); - $answerEntity->setFileId($uploadedFile->getFileId()); - - $userFolder = $this->storage->getUserFolder($form->getOwnerId()); - $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']); - - if ($userFolder->nodeExists($path)) { - $folder = $userFolder->get($path); - } else { - $folder = $userFolder->newFolder($path); - } - /** @var \OCP\Files\Folder $folder */ - - $file = $userFolder->getById($uploadedFile->getFileId())[0]; - $name = $folder->getNonExistingName($file->getName()); - $file->move($folder->getPath() . '/' . $name); - - $answerText = $name; - } else { - $answerText = $answer; // Not a multiple-question, answerText is given answer - } - - if ($answerText === "") { - continue; - } - - $answerEntity->setText($answerText); - $this->answerMapper->insert($answerEntity); - if ($uploadedFile) { - $this->uploadedFileMapper->delete($uploadedFile); - } - } - } - /** * @CORS * @NoAdminRequired @@ -993,7 +2178,7 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest * * @return Response */ - public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): Response { + public function uploadFilesLegacy(int $formId, int $questionId, string $shareHash = ''): Response { $this->logger->debug('Uploading files for formId: {formId}, questionId: {questionId}', ['formId' => $formId, 'questionId' => $questionId]); @@ -1117,7 +2302,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { + public function insertSubmissionLegacy(int $formId, array $answers, string $shareHash = ''): DataResponse { $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ 'formId' => $formId, 'answers' => $answers, @@ -1207,7 +2392,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteSubmission(int $id): DataResponse { + public function deleteSubmissionLegacy(int $id): DataResponse { $this->logger->debug('Delete Submission: {id}', [ 'id' => $id, ]); @@ -1245,7 +2430,7 @@ public function deleteSubmission(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteAllSubmissions(int $formId): DataResponse { + public function deleteAllSubmissionsLegacy(int $formId): DataResponse { $this->logger->debug('Delete all submissions to form: {formId}', [ 'formId' => $formId, ]); @@ -1283,7 +2468,7 @@ public function deleteAllSubmissions(int $formId): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function exportSubmissions(string $hash, string $fileFormat = Constants::DEFAULT_FILE_FORMAT): DataDownloadResponse { + public function exportSubmissionsLegacy(string $hash, string $fileFormat = Constants::DEFAULT_FILE_FORMAT): DataDownloadResponse { $this->logger->debug('Export submissions for form: {hash}', [ 'hash' => $hash, ]); @@ -1319,7 +2504,7 @@ public function exportSubmissions(string $hash, string $fileFormat = Constants:: * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function exportSubmissionsToCloud(string $hash, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { + public function exportSubmissionsToCloudLegacy(string $hash, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -1342,7 +2527,7 @@ public function exportSubmissionsToCloud(string $hash, string $path, string $fil * * @param string $hash of the form */ - public function unlinkFile(string $hash): DataResponse { + public function unlinkFileLegacy(string $hash): DataResponse { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -1382,7 +2567,7 @@ public function unlinkFile(string $hash): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function linkFile(string $hash, string $path, string $fileFormat): DataResponse { + public function linkFileLegacy(string $hash, string $path, string $fileFormat): DataResponse { $this->logger->debug('Linking form {hash} to file at /{path} in format {fileFormat}', [ 'hash' => $hash, 'path' => $path, @@ -1420,6 +2605,68 @@ public function linkFile(string $hash, string $path, string $fileFormat): DataRe ]); } + // private functions + + /** + * Insert answers for a question + * + * @param Form $form + * @param int $submissionId + * @param array $question + * @param string[]|array $answerArray + */ + private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray) { + foreach ($answerArray as $answer) { + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + + $answerText = ''; + $uploadedFile = null; + // Are we using answer ids as values + if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { + // Search corresponding option, skip processing if not found + $optionIndex = array_search($answer, array_column($question['options'], 'id')); + if ($optionIndex !== false) { + $answerText = $question['options'][$optionIndex]['text']; + } elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) { + $answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, "", $answer); + } + } elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) { + $uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']); + $answerEntity->setFileId($uploadedFile->getFileId()); + + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']); + + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + /** @var \OCP\Files\Folder $folder */ + + $file = $userFolder->getById($uploadedFile->getFileId())[0]; + $name = $folder->getNonExistingName($file->getName()); + $file->move($folder->getPath() . '/' . $name); + + $answerText = $name; + } else { + $answerText = $answer; // Not a multiple-question, answerText is given answer + } + + if ($answerText === "") { + continue; + } + + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + if ($uploadedFile) { + $this->uploadedFileMapper->delete($uploadedFile); + } + } + } + private function loadFormForSubmission(int $formId, string $shareHash): Form { try { $form = $this->formMapper->findById($formId); @@ -1465,13 +2712,13 @@ private function loadFormForSubmission(int $formId, string $shareHash): Form { /** * Helper that retrieves a form if the current user is allowed to edit it * This throws an exception in case either the form is not found or permissions are missing. - * @param int $id The form ID to retrieve + * @param int $formId The form ID to retrieve * @throws OCSNotFoundException If the form was not found * @throws OCSForbiddenException If the current user has no permission to edit */ - private function getFormIfAllowed(int $id): Form { + private function getFormIfAllowed(int $formId): Form { try { - $form = $this->formMapper->findById($id); + $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); throw new OCSNotFoundException(); diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index 07bc5bcde..27719d516 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -203,6 +203,274 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar return new DataResponse($shareData); } + /** + * @CORS + * @NoAdminRequired + * + * Update permissions of a share + * + * @param int $formId of the form + * @param int $shareId of the share to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateShare(int $formId, int $shareId, array $keyValuePairs): DataResponse { + $this->logger->debug('Updating share: {shareId} of form {formId}, permissions: {permissions}', [ + 'formId' => $formId, + 'shareId' => $shareId, + 'keyValuePairs' => $keyValuePairs + ]); + + try { + $formShare = $this->shareMapper->findById($shareId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find share', ['exception' => $e]); + throw new OCSBadRequestException('Could not find share'); + } + + if ($formId !== $formShare->getFormId()) { + $this->logger->debug('This share doesn\'t belong to the given Form'); + throw new OCSBadRequestException('Share doesn\'t belong to given Form'); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + //Don't allow to change other properties than permissions + if (count($keyValuePairs) > 1 || !key_exists('permissions', $keyValuePairs)) { + $this->logger->debug('Not allowed to update other properties than permissions'); + throw new OCSForbiddenException(); + } + + if (!$this->validatePermissions($keyValuePairs['permissions'], $formShare->getShareType())) { + throw new OCSBadRequestException('Invalid permission given'); + } + + $formShare->setPermissions($keyValuePairs['permissions']); + $formShare = $this->shareMapper->update($formShare); + + if (in_array($formShare->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE], true)) { + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $uploadedFilesFolderPath = $this->formsService->getFormUploadedFilesFolderPath($form); + if ($userFolder->nodeExists($uploadedFilesFolderPath)) { + $folder = $userFolder->get($uploadedFilesFolderPath); + } else { + $folder = $userFolder->newFolder($uploadedFilesFolderPath); + } + /** @var \OCP\Files\Folder $folder */ + + if (in_array(Constants::PERMISSION_RESULTS, $keyValuePairs['permissions'], true)) { + $folderShare = $this->shareManager->newShare(); + $folderShare->setShareType($formShare->getShareType()); + $folderShare->setSharedWith($formShare->getShareWith()); + $folderShare->setSharedBy($form->getOwnerId()); + $folderShare->setPermissions(\OCP\Constants::PERMISSION_READ); + $folderShare->setNode($folder); + $folderShare->setShareOwner($form->getOwnerId()); + + $this->shareManager->createShare($folderShare); + } else { + $folderShares = $this->shareManager->getSharesBy($form->getOwnerId(), $formShare->getShareType(), $folder); + foreach ($folderShares as $folderShare) { + if ($folderShare->getSharedWith() === $formShare->getShareWith()) { + $this->shareManager->deleteShare($folderShare); + } + } + } + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($formShare->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a share + * + * @param int $formId of the form + * @param int $shareId of the share to delete + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteShare(int $formId, int $shareId): DataResponse { + $this->logger->debug('Deleting share: {shareId} of form {formId}', [ + 'formId' => $formId, + 'shareId' => $shareId, + ]); + + try { + $share = $this->shareMapper->findById($shareId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find share', ['exception' => $e]); + throw new OCSBadRequestException('Could not find share'); + } + + if ($formId !== $share->getFormId()) { + $this->logger->debug('This share doesn\'t belong to the given Form'); + throw new OCSBadRequestException('Share doesn\'t belong to given Form'); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + $this->shareMapper->deleteById($shareId); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($shareId); + } + + /* + * + * Legacy API v2 methods (TODO: remove with Forms v5) + * + */ + + /** + * @CORS + * @NoAdminRequired + * + * Add a new share + * + * @param int $formId The form to share + * @param int $shareType Nextcloud-ShareType + * @param string $shareWith ID of user/group/... to share with. For Empty shareWith and shareType Link, this will be set as RandomID. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newShareLegacy(int $formId, int $shareType, string $shareWith = '', array $permissions = [Constants::PERMISSION_SUBMIT]): DataResponse { + $this->logger->debug('Adding new share: formId: {formId}, shareType: {shareType}, shareWith: {shareWith}, permissions: {permissions}', [ + 'formId' => $formId, + 'shareType' => $shareType, + 'shareWith' => $shareWith, + 'permissions' => $permissions, + ]); + + // Only accept usable shareTypes + if (array_search($shareType, Constants::SHARE_TYPES_USED) === false) { + $this->logger->debug('Invalid shareType'); + throw new OCSBadRequestException('Invalid shareType'); + } + + // Block LinkShares if not allowed + if ($shareType === IShare::TYPE_LINK && !$this->configService->getAllowPublicLink()) { + $this->logger->debug('Link Share not allowed.'); + throw new OCSForbiddenException('Link Share not allowed.'); + } + + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form', ['exception' => $e]); + throw new OCSBadRequestException('Could not find form'); + } + + // Check for permission to share form + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if (!$this->validatePermissions($permissions, $shareType)) { + throw new OCSBadRequestException('Invalid permission given'); + } + + // Create public-share hash, if necessary. + if ($shareType === IShare::TYPE_LINK) { + $shareWith = $this->secureRandom->generate( + 24, + ISecureRandom::CHAR_HUMAN_READABLE + ); + } + + // Check for valid shareWith, needs to be done separately per shareType + switch ($shareType) { + case IShare::TYPE_USER: + if (!($this->userManager->get($shareWith) instanceof IUser)) { + $this->logger->debug('Invalid user to share with.'); + throw new OCSBadRequestException('Invalid user to share with.'); + } + break; + + case IShare::TYPE_GROUP: + if (!($this->groupManager->get($shareWith) instanceof IGroup)) { + $this->logger->debug('Invalid group to share with.'); + throw new OCSBadRequestException('Invalid group to share with.'); + } + break; + + case IShare::TYPE_LINK: + // Check if hash already exists. (Unfortunately not possible here by unique index on db.) + try { + // Try loading a share to the hash. + $nonex = $this->shareMapper->findPublicShareByHash($shareWith); + + // If we come here, a share has been found --> The share hash already exists, thus aborting. + $this->logger->debug('Share Hash already exists.'); + throw new OCSException('Share Hash exists. Please retry.'); + } catch (DoesNotExistException $e) { + // Just continue, this is what we expect to happen (share hash not existing yet). + } + break; + + case IShare::TYPE_CIRCLE: + if (!$this->circlesService->isCirclesEnabled()) { + $this->logger->debug('Teams app is disabled, sharing to teams not possible.'); + throw new OCSException('Teams app is disabled.'); + } + $circle = $this->circlesService->getCircle($shareWith); + if (is_null($circle)) { + $this->logger->debug('Invalid team to share with.'); + throw new OCSBadRequestException('Invalid team to share with.'); + } + break; + + default: + // This passed the check for used shareTypes, but has not been found here. + $this->logger->warning('Unknown, but used shareType: {shareType}. Please file an issue on GitHub.', [ 'shareType' => $shareType ]); + throw new OCSException('Unknown shareType.'); + } + + $share = new Share(); + $share->setFormId($formId); + $share->setShareType($shareType); + $share->setShareWith($shareWith); + $share->setPermissions($permissions); + + /** @var Share */ + $share = $this->shareMapper->insert($share); + + // Create share-notifications (activity) + $this->formsService->notifyNewShares($form, $share); + + $this->formsService->setLastUpdatedTimestamp($formId); + + // Append displayName for Frontend + $shareData = $share->read(); + $shareData['displayName'] = $this->formsService->getShareDisplayName($shareData); + + return new DataResponse($shareData); + } + /** * @CORS * @NoAdminRequired @@ -214,7 +482,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteShare(int $id): DataResponse { + public function deleteShareLegacy(int $id): DataResponse { $this->logger->debug('Deleting share: {id}', [ 'id' => $id ]); @@ -251,7 +519,7 @@ public function deleteShare(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateShare(int $id, array $keyValuePairs): DataResponse { + public function updateShareLegacy(int $id, array $keyValuePairs): DataResponse { $this->logger->debug('Updating share: {id}, permissions: {permissions}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index ac4cc3595..d32b6dcb6 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -144,6 +144,40 @@ public function getQuestions(int $formId): array { } } + /** + * Load specific question + * + * @param integer $questionId id of the question + * @return array + */ + public function getQuestion(int $questionId): array { + $question = []; + try { + $questionEntity = $this->questionMapper->findById($questionId); + $question = $questionEntity->read(); + $question['options'] = $this->getOptions($question['id']); + $question['accept'] = []; + if ($question['type'] === Constants::ANSWER_TYPE_FILE) { + if ($question['extraSettings']['allowedFileTypes'] ?? null) { + $question['accept'] = array_keys(array_intersect( + $this->mimeTypeDetector->getAllAliases(), + $question['extraSettings']['allowedFileTypes'] + )); + } + + if ($question['extraSettings']['allowedFileExtensions'] ?? null) { + foreach ($question['extraSettings']['allowedFileExtensions'] as $extension) { + $question['accept'][] = '.' . $extension; + } + } + } + } catch (DoesNotExistException $e) { + //handle silently + } finally { + return $question; + } + } + /** * Load shares corresponding to form * diff --git a/src/Forms.vue b/src/Forms.vue index 4aeb241fe..25ae6b073 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -362,7 +362,7 @@ export default { // Load Owned forms try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/forms'), + generateOcsUrl('apps/forms/api/v3/forms'), ) this.forms = OcsResponse2Data(response) } catch (error) { @@ -375,7 +375,7 @@ export default { // Load shared forms try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/shared_forms'), + generateOcsUrl('apps/forms/api/v3/forms?type=shared'), ) this.allSharedForms = OcsResponse2Data(response) } catch (error) { @@ -413,7 +413,7 @@ export default { ) { try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/partial_form/{hash}', { + generateOcsUrl('apps/forms/api/v3/forms/0?hash={hash}', { hash, }), ) @@ -447,7 +447,7 @@ export default { try { // Request a new empty form const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/form'), + generateOcsUrl('apps/forms/api/v3/forms'), ) const newForm = OcsResponse2Data(response) this.forms.unshift(newForm) @@ -467,7 +467,7 @@ export default { async onCloneForm(id) { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/form/clone/{id}', { id }), + generateOcsUrl('apps/forms/api/v3/forms?fromId={id}', { id }), ) const newForm = OcsResponse2Data(response) this.forms.unshift(newForm) diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 6fdd0d3b0..5e92f1e63 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -301,9 +301,10 @@ export default { try { // TODO: add loading status feedback ? await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/form/update'), - { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, + }), + { keyValuePairs: { state: this.isArchived ? FormState.FormClosed @@ -326,7 +327,7 @@ export default { this.loading = true try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/form/{id}', { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, }), ) diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index 6af41b2d6..9c32ff4c4 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -68,6 +68,10 @@ export default { type: Number, required: true, }, + formId: { + type: Number, + required: true, + }, isUnique: { type: Boolean, required: true, @@ -168,17 +172,22 @@ export default { async createAnswer(answer) { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/option'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options', + { + id: this.formId, + questionId: answer.questionId, + }, + ), { - questionId: answer.questionId, - text: answer.text, + optionTexts: [answer.text], }, ) logger.debug('Created answer', { answer }) // Was synced once, this is now up to date with the server delete answer.local - return Object.assign({}, answer, OcsResponse2Data(response)) + return Object.assign({}, answer, OcsResponse2Data(response)[0]) } catch (error) { logger.error('Error while saving answer', { answer, error }) showError(t('forms', 'Error while saving the answer')) @@ -199,9 +208,15 @@ export default { async updateAnswer(answer) { try { await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/option/update'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', + { + id: this.formId, + questionId: answer.questionId, + optionId: answer.id, + }, + ), { - id: this.answer.id, keyValuePairs: { text: answer.text, }, diff --git a/src/components/Questions/QuestionDropdown.vue b/src/components/Questions/QuestionDropdown.vue index 0431dd5ff..3b41df5aa 100644 --- a/src/components/Questions/QuestionDropdown.vue +++ b/src/components/Questions/QuestionDropdown.vue @@ -66,6 +66,7 @@ " ref="input" :answer="answer" + :form-id="formId" :index="index" :is-unique="!isMultiple" :is-dropdown="true" @@ -304,9 +305,14 @@ export default { // let's not await, deleting in background axios .delete( - generateOcsUrl('apps/forms/api/v2.4/option/{id}', { - id: option.id, - }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', + { + id: this.formId, + questionId: this.id, + optionId: option.id, + }, + ), ) .catch((error) => { logger.error('Error while deleting an option', { diff --git a/src/components/Questions/QuestionFile.vue b/src/components/Questions/QuestionFile.vue index f9b253258..2d0c832cf 100644 --- a/src/components/Questions/QuestionFile.vue +++ b/src/components/Questions/QuestionFile.vue @@ -276,9 +276,9 @@ export default { formData.append('shareHash', loadState('forms', 'shareHash', null)) const url = generateOcsUrl( - 'apps/forms/api/v2.5/uploadFiles/{formId}/{questionId}', + 'apps/forms/api/v3/forms/{id}/submissions/files/{questionId}', { - formId: this.formId, + id: this.formId, questionId: this.id, }, ) diff --git a/src/components/Questions/QuestionMultiple.vue b/src/components/Questions/QuestionMultiple.vue index 6728495b2..f8c14404b 100644 --- a/src/components/Questions/QuestionMultiple.vue +++ b/src/components/Questions/QuestionMultiple.vue @@ -144,6 +144,7 @@ " ref="input" :answer="answer" + :form-id="formId" :index="index" :is-unique="isUnique" :is-dropdown="false" @@ -611,9 +612,14 @@ export default { // let's not await, deleting in background axios .delete( - generateOcsUrl('apps/forms/api/v2.4/option/{id}', { - id: option.id, - }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', + { + id: this.formId, + questionId: this.id, + optionId: option.id, + }, + ), ) .catch((error) => { logger.error('Error while deleting an option', { diff --git a/src/components/SidebarTabs/SharingSidebarTab.vue b/src/components/SidebarTabs/SharingSidebarTab.vue index a3559c38c..e4bc95695 100644 --- a/src/components/SidebarTabs/SharingSidebarTab.vue +++ b/src/components/SidebarTabs/SharingSidebarTab.vue @@ -320,9 +320,10 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/share'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares', { + id: this.form.id, + }), { - formId: this.form.id, shareType: newShare.shareType, shareWith: newShare.shareWith, }, @@ -347,9 +348,10 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/share'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares', { + id: this.form.id, + }), { - formId: this.form.id, shareType: this.SHARE_TYPES.SHARE_TYPE_LINK, }, ) @@ -389,9 +391,11 @@ export default { try { const response = await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/share/update'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares/{shareId}', { + id: this.form.id, + shareId: updatedShare.id, + }), { - id: updatedShare.id, keyValuePairs: { permissions: updatedShare.permissions, }, @@ -424,8 +428,9 @@ export default { try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/share/{id}', { - id: share.id, + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares/{shareId}', { + id: this.form.id, + shareId: share.id, }), ) this.$emit('remove-share', share) diff --git a/src/components/SidebarTabs/TransferOwnership.vue b/src/components/SidebarTabs/TransferOwnership.vue index 4895547db..045be19c6 100644 --- a/src/components/SidebarTabs/TransferOwnership.vue +++ b/src/components/SidebarTabs/TransferOwnership.vue @@ -176,11 +176,12 @@ export default { if (this.form.id && this.selected.shareWith) { try { emit('forms:last-updated:set', this.form.id) - await axios.post( - generateOcsUrl('apps/forms/api/v2.4/form/transfer'), + await axios.patch( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: this.form.id, + }), { - formId: this.form.id, - uid: this.selected.shareWith, + ownerId: this.selected.shareWith, }, ) showSuccess( diff --git a/src/mixins/QuestionMixin.js b/src/mixins/QuestionMixin.js index b569b7a85..9c04279bd 100644 --- a/src/mixins/QuestionMixin.js +++ b/src/mixins/QuestionMixin.js @@ -372,9 +372,14 @@ export default { try { // TODO: add loading status feedback ? await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/question/update'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}', + { + id: this.formId, + questionId: this.id, + }, + ), { - id: this.id, keyValuePairs: { [key]: value, }, @@ -393,29 +398,38 @@ export default { * @param {Array} answers - The array of answers for the question. */ async handleMultipleOptions(answers) { - const options = this.options.slice() this.isLoading = true - for (let i = 0; i < answers.length; i++) { + try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2/option'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options', + { + id: this.formId, + questionId: this.id, + }, + ), { - questionId: this.id, - text: answers[i], + optionTexts: answers, // Send the entire array of answers at once }, ) - const newServerOption = OcsResponse2Data(response) - const option = { - id: newServerOption.id, // use the ID from the server - questionId: this.id, - text: answers[i], - local: false, - } - options.push(option) + const newServerOptions = OcsResponse2Data(response) // Assuming this function can handle arrays + const options = this.options.slice() + newServerOptions.forEach((option) => { + options.push({ + id: option.id, // Use the ID from the server + questionId: this.id, + text: option.text, + local: false, + }) + }) + this.updateOptions(options) + this.$nextTick(() => { + this.focusIndex(options.length - 1) + }) + } catch (error) { + logger.error('Error while saving question options', { error }) + showError(t('forms', 'Error while saving question options')) } - this.updateOptions(options) - this.$nextTick(() => { - this.focusIndex(options.length - 1) - }) this.isLoading = false }, diff --git a/src/mixins/ViewsMixin.js b/src/mixins/ViewsMixin.js index c41c0a457..7b416a621 100644 --- a/src/mixins/ViewsMixin.js +++ b/src/mixins/ViewsMixin.js @@ -154,7 +154,7 @@ export default { try { const response = await request( - generateOcsUrl('apps/forms/api/v2.4/form/{id}', { id }), + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id }), ) this.$emit('update:form', OcsResponse2Data(response)) this.isLoadingForm = false @@ -178,9 +178,10 @@ export default { try { // TODO: add loading status feedback ? await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/form/update'), - { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, + }), + { keyValuePairs: { [key]: this.form[key], }, diff --git a/src/views/Create.vue b/src/views/Create.vue index e5cda6c99..eed385362 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -147,7 +147,7 @@ :max-string-lengths="maxStringLengths" v-bind.sync="form.questions[index]" @clone="cloneQuestion(question)" - @delete="deleteQuestion(question)" + @delete="deleteQuestion(question.id)" @move-down="onMoveDown(index)" @move-up="onMoveUp(index)" /> @@ -416,9 +416,10 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/question'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/questions', { + id: this.form.id, + }), { - formId: this.form.id, type, text, }, @@ -458,23 +459,30 @@ export default { /** * Delete a question * - * @param {object} question the question to delete - * @param {number} question.id the question id to delete + * @param {number} questionId the question id to delete */ - async deleteQuestion({ id }) { + async deleteQuestion(questionId) { this.isLoadingQuestions = true try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/question/{id}', { id }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}', + { + id: this.form.id, + questionId, + }, + ), ) const index = this.form.questions.findIndex( - (search) => search.id === id, + (search) => search.id === questionId, ) this.form.questions.splice(index, 1) emit('forms:last-updated:set', this.form.id) } catch (error) { - logger.error(`Error while removing question ${id}`, { error }) + logger.error(`Error while removing question ${questionId}`, { + error, + }) showError( t('forms', 'There was an error while removing the question'), ) @@ -493,9 +501,13 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/question/clone/{id}', { - id, - }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions?fromId={questionId}', + { + id: this.form.id, + questionId: id, + }, + ), ) const question = OcsResponse2Data(response) @@ -530,9 +542,13 @@ export default { try { await axios.put( - generateOcsUrl('apps/forms/api/v2.4/question/reorder'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/reorder', + { + id: this.form.id, + }, + ), { - formId: this.form.id, newOrder, }, ) diff --git a/src/views/Results.vue b/src/views/Results.vue index 2bd819fc4..483d91524 100644 --- a/src/views/Results.vue +++ b/src/views/Results.vue @@ -474,9 +474,17 @@ export default { methods: { async onUnlinkFile() { - await axios.post(generateOcsUrl('apps/forms/api/v2.4/form/unlink'), { - hash: this.form.hash, - }) + await axios.update( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: this.form.id, + }), + { + keyValuePairs: { + fileId: null, + fileFormat: null, + }, + }, + ) this.form.fileFormat = null this.form.fileId = null @@ -489,8 +497,8 @@ export default { try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/submissions/{hash}', { - hash: this.form.hash, + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, }), ) @@ -515,8 +523,8 @@ export default { async onDownloadFile(fileFormat) { const exportUrl = - generateOcsUrl('apps/forms/api/v2.4/submissions/export/{hash}', { - hash: this.form.hash, + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, }) + '?requesttoken=' + encodeURIComponent(getRequestToken()) + @@ -531,14 +539,15 @@ export default { .pick() .then(async (path) => { try { - const response = await axios.post( - generateOcsUrl( - 'apps/forms/api/v2.4/form/link/{fileFormat}', - { fileFormat }, - ), + const response = await axios.patch( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: this.form.id, + }), { - hash: this.form.hash, - path, + keyValuePairs: { + path, + fileFormat, + }, }, ) const responseData = OcsResponse2Data(response) @@ -581,9 +590,15 @@ export default { try { const response = await axios.post( generateOcsUrl( - 'apps/forms/api/v2.4/submissions/export', + 'apps/forms/api/v3/forms/{id}/submissions/export', + { + id: this.form.id, + }, ), - { hash: this.form.hash, path, fileFormat }, + { + path, + fileFormat, + }, ) showSuccess( t('forms', 'Export successful to {file}', { @@ -608,7 +623,7 @@ export default { async fetchLinkedFileInfo() { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/form/{id}', { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, }), ) @@ -628,9 +643,13 @@ export default { } try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/submissions/export'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/submissions/export', + { + id: this.form.id, + }, + ), { - hash: this.form.hash, path: this.form.filePath, fileFormat: this.form.fileFormat, }, @@ -651,7 +670,13 @@ export default { try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/submission/{id}', { id }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/submissions/{submissionId}', + { + id: this.form.id, + submissionId: id, + }, + ), ) showSuccess(t('forms', 'Submission deleted')) const index = this.form.submissions.findIndex( @@ -678,8 +703,8 @@ export default { this.loadingResults = true try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/submissions/{formId}', { - formId: this.form.id, + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, }), ) this.form.submissions = [] diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 21b3c2d6b..ffe6783c1 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -624,9 +624,10 @@ export default { try { await axios.post( - generateOcsUrl('apps/forms/api/v2.4/submission/insert'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, + }), { - formId: this.form.id, answers: this.answers, shareHash: this.shareHash, }, diff --git a/tests/Integration/Api/ApiV2Test.php b/tests/Integration/Api/ApiV2Test.php index f3a49a86f..d5d742634 100644 --- a/tests/Integration/Api/ApiV2Test.php +++ b/tests/Integration/Api/ApiV2Test.php @@ -48,7 +48,7 @@ class ApiV2Test extends IntegrationBase { private function setTestForms() { $this->testForms = [ [ - 'hash' => 'abcdefg', + 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'description' => 'Just a simple form.', 'owner_id' => 'test', @@ -168,7 +168,7 @@ private function setTestForms() { ] ], [ - 'hash' => 'abcdefghij', + 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'description' => '', 'owner_id' => 'someUser', @@ -205,7 +205,7 @@ private function setTestForms() { 'submissions' => [] ], [ - 'hash' => 'third', + 'hash' => 'zyxwvutsrq654321', 'title' => 'Title of a third Form', 'description' => '', 'owner_id' => 'test', @@ -291,7 +291,7 @@ public function dataGetForms() { 'getTestforms' => [ 'expected' => [ [ - 'hash' => 'abcdefg', + 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'expires' => 0, 'state' => 0, @@ -301,7 +301,7 @@ public function dataGetForms() { 'submissionCount' => 3, ], [ - 'hash' => 'third', + 'hash' => 'zyxwvutsrq654321', 'title' => 'Title of a third Form', 'expires' => 0, 'state' => 0, @@ -334,7 +334,7 @@ public function dataGetSharedForms() { 'getTestforms' => [ 'expected' => [ [ - 'hash' => 'abcdefghij', + 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'expires' => 0, 'state' => 0, @@ -367,7 +367,7 @@ public function dataGetPartialForm() { return [ 'getPartialForm' => [ 'expected' => [ - 'hash' => 'abcdefghij', + 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'expires' => 0, 'state' => 0, @@ -458,7 +458,7 @@ public function dataGetFullForm() { return [ 'getFullForm' => [ 'expected' => [ - 'hash' => 'abcdefg', + 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'description' => 'Just a simple form.', 'ownerId' => 'test', diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php new file mode 100644 index 000000000..190071ded --- /dev/null +++ b/tests/Integration/Api/ApiV3Test.php @@ -0,0 +1,1433 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\Forms\Tests\Integration\Api; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\ClientException; + +use OCA\Forms\Constants; +use OCA\Forms\Tests\Integration\IntegrationBase; + +/** + * @group DB + */ +class ApiV3Test extends IntegrationBase { + /** @var GuzzleHttp\Client */ + private $http; + + protected array $users = [ + 'test' => 'Test user', + ]; + + /** + * Store Test Forms Array. + * Necessary as function due to object type-casting. + */ + private function setTestForms() { + $this->testForms = [ + [ + 'hash' => '0123456789abcdef', + 'title' => 'Title of a Form', + 'description' => 'Just a simple form.', + 'owner_id' => 'test', + 'access_enum' => 0, + 'created' => 12345, + 'expires' => 0, + 'state' => 0, + 'is_anonymous' => false, + 'submit_multiple' => false, + 'show_expiration' => false, + 'last_updated' => 123456789, + 'submission_message' => 'Back to website', + 'file_id' => null, + 'file_format' => null, + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'First Question?', + 'description' => 'Please answer this.', + 'isRequired' => true, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'extraSettings' => [] + ], + [ + 'type' => 'multiple_unique', + 'text' => 'Second Question?', + 'description' => '', + 'isRequired' => false, + 'name' => 'city', + 'order' => 2, + 'options' => [ + [ + 'text' => 'Option 1' + ], + [ + 'text' => 'Option 2' + ], + [ + 'text' => '' + ] + ], + 'accept' => [], + 'extraSettings' => [ + 'shuffleOptions' => true + ] + ], + [ + 'type' => 'file', + 'text' => 'File Question?', + 'description' => '', + 'isRequired' => false, + 'name' => 'file', + 'order' => 3, + 'options' => [], + 'accept' => ['.txt'], + 'extraSettings' => [ + 'allowedFileExtensions' => ['txt'], + 'maxAllowedFilesCount' => 1, + 'maxFileSize' => 1024, + ], + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user1', + 'permissions' => ['submit', 'results'], + ], + [ + 'shareType' => 3, + 'shareWith' => 'shareHash', + 'permissions' => ['submit'], + ], + ], + 'submissions' => [ + [ + 'userId' => 'user1', + 'timestamp' => 123456, + 'answers' => [ + [ + 'questionIndex' => 0, + 'text' => 'This is a short answer.' + ], + [ + 'questionIndex' => 1, + 'text' => 'Option 1' + ] + ] + ], + [ + 'userId' => 'user2', + 'timestamp' => 12345, + 'answers' => [ + [ + 'questionIndex' => 0, + 'text' => 'This is another short answer.' + ], + [ + 'questionIndex' => 1, + 'text' => 'Option 2' + ] + ] + ], + [ + 'userId' => 'user3', + 'timestamp' => 1234, + 'answers' => [ + [ + 'questionIndex' => 0, + 'text' => '' + ] + ] + ] + ] + ], + [ + 'hash' => 'abcdefghij123456', + 'title' => 'Title of a second Form', + 'description' => '', + 'owner_id' => 'someUser', + 'access_enum' => 2, + 'created' => 12345, + 'expires' => 0, + 'state' => 0, + 'is_anonymous' => false, + 'submit_multiple' => false, + 'show_expiration' => false, + 'last_updated' => 123456789, + 'submission_message' => '', + 'file_id' => null, + 'file_format' => null, + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'Third Question?', + 'description' => '', + 'isRequired' => false, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'extraSettings' => [] + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user2', + ], + ], + 'submissions' => [] + ], + [ + 'hash' => 'zyxwvutsrq654321', + 'title' => 'Title of a third Form', + 'description' => '', + 'owner_id' => 'test', + 'access_enum' => 2, + 'created' => 12345, + 'expires' => 0, + 'state' => 0, + 'is_anonymous' => false, + 'submit_multiple' => false, + 'show_expiration' => false, + 'last_updated' => 123456789, + 'submission_message' => '', + 'file_id' => 12, + 'file_format' => 'csv', + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'Third Question?', + 'description' => '', + 'isRequired' => false, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'extraSettings' => [] + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user2', + ], + ], + 'submissions' => [] + ], + ]; + } + + /** + * Set up test environment. + * Writing testforms into db, preparing http request + */ + public function setUp(): void { + $this->setTestForms(); + $this->users = [ + 'test' => 'Test Displayname', + 'user1' => 'User No. 1', + ]; + + parent::setUp(); + + // Set up http Client + $this->http = new Client([ + 'base_uri' => 'http://localhost:8080/ocs/v2.php/apps/forms/', + 'auth' => ['test', 'test'], + 'headers' => [ + 'OCS-ApiRequest' => 'true', + 'Accept' => 'application/json' + ], + ]); + } + + public function tearDown(): void { + parent::tearDown(); + } + + // Small Wrapper for OCS-Response + private function OcsResponse2Data($resp) { + $arr = json_decode($resp->getBody()->getContents(), true); + return $arr['ocs']['data']; + } + + // Unset Id, as we can not control it on the tests. + private function arrayUnsetId(array $arr): array { + foreach ($arr as $index => $elem) { + unset($arr[$index]['id']); + } + return $arr; + } + + public function dataGetForms() { + return [ + 'getTestforms' => [ + 'expected' => [ + [ + 'hash' => '0123456789abcdef', + 'title' => 'Title of a Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => Constants::PERMISSION_ALL, + 'partial' => true, + 'submissionCount' => 3, + ], + [ + 'hash' => 'zyxwvutsrq654321', + 'title' => 'Title of a third Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => Constants::PERMISSION_ALL, + 'partial' => true, + 'submissionCount' => 0, + ] + ] + ] + ]; + } + /** + * @dataProvider dataGetForms + * + * @param array $expected + */ + public function testGetForms(array $expected): void { + $resp = $this->http->request('GET', 'api/v3/forms'); + + $data = $this->OcsResponse2Data($resp); + $data = $this->arrayUnsetId($data); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetSharedForms() { + return [ + 'getTestforms' => [ + 'expected' => [ + [ + 'hash' => 'abcdefghij123456', + 'title' => 'Title of a second Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => [ + 'submit' + ], + 'partial' => true + ], + ] + ] + ]; + } + /** + * @dataProvider dataGetSharedForms + * + * @param array $expected + */ + public function testGetSharedForms(array $expected): void { + $resp = $this->http->request('GET', 'api/v3/forms?type=shared'); + + $data = $this->OcsResponse2Data($resp); + $data = $this->arrayUnsetId($data); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetPartialForm() { + return [ + 'getPartialForm' => [ + 'expected' => [ + 'hash' => 'abcdefghij123456', + 'title' => 'Title of a second Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => [ + 'submit' + ], + 'partial' => true + ] + ] + ]; + } + /** + * @dataProvider dataGetPartialForm + * + * @param array $expected + */ + public function testGetPartialForm(array $expected): void { + $resp = $this->http->request('GET', "api/v3/forms/0?hash={$this->testForms[1]['hash']}"); + + $data = $this->OcsResponse2Data($resp); + unset($data['id']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetNewForm() { + return [ + 'getNewForm' => [ + 'expected' => [ + // 'hash' => Some random, cannot be checked. + 'title' => '', + 'description' => '', + 'ownerId' => 'test', + // 'created' => time() can not be checked exactly + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false + ], + 'expires' => 0, + 'state' => 0, + 'isAnonymous' => false, + 'submitMultiple' => false, + 'showExpiration' => false, + // 'lastUpdated' => time() can not be checked exactly + 'canSubmit' => true, + 'permissions' => Constants::PERMISSION_ALL, + 'questions' => [], + 'shares' => [], + 'submissionCount' => 0, + 'submissionMessage' => null, + 'fileId' => null, + 'fileFormat' => null, + ] + ] + ]; + } + /** + * @dataProvider dataGetNewForm + * + * @param array $expected + */ + public function testGetNewForm(array $expected): void { + $resp = $this->http->request('POST', 'api/v3/forms'); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion on tearDown + $this->testForms[] = $data; + + // Cannot control id + unset($data['id']); + // Check general behaviour of hash + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{16}$/', $data['hash']); + unset($data['hash']); + // Check general behaviour of created (Created in the last 10 seconds) + $this->assertEqualsWithDelta(time(), $data['created'], 10); + unset($data['created']); + // Check general behaviour of lastUpdated (Last update in the last 10 seconds) + $this->assertEqualsWithDelta(time(), $data['lastUpdated'], 10); + unset($data['lastUpdated']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetFullForm() { + return [ + 'getFullForm' => [ + 'expected' => [ + 'hash' => '0123456789abcdef', + 'title' => 'Title of a Form', + 'description' => 'Just a simple form.', + 'ownerId' => 'test', + 'created' => 12345, + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false + ], + 'expires' => 0, + 'state' => 0, + 'isAnonymous' => false, + 'submitMultiple' => false, + 'showExpiration' => false, + 'lastUpdated' => 123456789, + 'canSubmit' => true, + 'permissions' => Constants::PERMISSION_ALL, + 'submissionMessage' => 'Back to website', + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'First Question?', + 'isRequired' => true, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'description' => 'Please answer this.', + 'extraSettings' => [] + ], + [ + 'type' => 'multiple_unique', + 'text' => 'Second Question?', + 'isRequired' => false, + 'name' => 'city', + 'order' => 2, + 'options' => [ + [ + 'text' => 'Option 1' + ], + [ + 'text' => 'Option 2' + ], + [ + 'text' => '' + ] + ], + 'accept' => [], + 'description' => '', + 'extraSettings' => [ + 'shuffleOptions' => true, + ] + ], + [ + 'type' => 'file', + 'text' => 'File Question?', + 'isRequired' => false, + 'name' => 'file', + 'order' => 3, + 'options' => [], + 'accept' => ['.txt'], + 'description' => '', + 'extraSettings' => [ + 'allowedFileExtensions' => ['txt'], + 'maxAllowedFilesCount' => 1, + 'maxFileSize' => 1024, + ], + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user1', + 'permissions' => ['submit', 'results'], + 'displayName' => 'User No. 1' + ], + [ + 'shareType' => 3, + 'shareWith' => 'shareHash', + 'permissions' => ['submit'], + 'displayName' => '' + ], + ], + 'submissionCount' => 3, + 'fileId' => null, + 'fileFormat' => null, + ] + ] + ]; + } + /** + * @dataProvider dataGetFullForm + * + * @param array $expected + */ + public function testGetFullForm(array $expected): void { + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + // Cannot control ids, but check general consistency. + foreach ($data['questions'] as $qIndex => $question) { + $this->assertEquals($data['id'], $question['formId']); + unset($data['questions'][$qIndex]['formId']); + + foreach ($question['options'] as $oIndex => $option) { + $this->assertEquals($question['id'], $option['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['id']); + } + unset($data['questions'][$qIndex]['id']); + } + foreach ($data['shares'] as $sIndex => $share) { + $this->assertEquals($data['id'], $share['formId']); + unset($data['shares'][$sIndex]['formId']); + unset($data['shares'][$sIndex]['id']); + } + unset($data['id']); + + // Allow a 10 second diff for lastUpdated between expectation and data + $this->assertEqualsWithDelta($expected['lastUpdated'], $data['lastUpdated'], 10); + unset($data['lastUpdated']); + unset($expected['lastUpdated']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataCloneForm() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + // Compared to full form expected, update changed properties + $fullFormExpected['title'] = 'Title of a Form - Copy'; + $fullFormExpected['shares'] = []; + $fullFormExpected['submissionCount'] = 0; + // Compared to full form expected, unset unpredictable properties. These will be checked logically. + unset($fullFormExpected['id']); + unset($fullFormExpected['hash']); + unset($fullFormExpected['created']); + unset($fullFormExpected['lastUpdated']); + foreach ($fullFormExpected['questions'] as $qIndex => $question) { + unset($fullFormExpected['questions'][$qIndex]['formId']); + } + + return [ + 'updateFormProps' => [ + 'expected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataCloneForm + * + * @param array $expected + */ + public function testCloneForm(array $expected): void { + $resp = $this->http->request('POST', "api/v3/forms?fromId={$this->testForms[0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion on tearDown + $this->testForms[] = $data; + + // Cannot control ids, but check general consistency. + foreach ($data['questions'] as $qIndex => $question) { + $this->assertEquals($data['id'], $question['formId']); + unset($data['questions'][$qIndex]['formId']); + + foreach ($question['options'] as $oIndex => $option) { + $this->assertEquals($question['id'], $option['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['id']); + } + unset($data['questions'][$qIndex]['id']); + } + foreach ($data['shares'] as $sIndex => $share) { + $this->assertEquals($data['id'], $share['formId']); + unset($data['shares'][$sIndex]['formId']); + unset($data['shares'][$sIndex]['id']); + } + // Check not just returning source-form (id must differ). + $this->assertGreaterThan($this->testForms[0]['id'], $data['id']); + unset($data['id']); + + // Check general behaviour of hash + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{16}$/', $data['hash']); + unset($data['hash']); + // Check general behaviour of created (Created in the last 10 seconds) + $this->assertTrue(time() - $data['created'] < 10); + unset($data['created']); + // Check general behaviour of lastUpdated (Last update in the last 10 seconds) + $this->assertTrue(time() - $data['lastUpdated'] < 10); + unset($data['lastUpdated']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataUpdateFormProperties() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['title'] = 'This is my NEW Title!'; + $fullFormExpected['access'] = [ + 'permitAllUsers' => true, + 'showToAllUsers' => true + ]; + return [ + 'updateFormProps' => [ + 'expected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateFormProperties + * + * @param array $expected + */ + public function testUpdateFormProperties(array $expected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'title' => 'This is my NEW Title!', + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => true + ] + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + + $expected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($expected); + } + + public function testDeleteForm() { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + + // Check if not existent anymore. + try { + $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}"); + } catch (ClientException $e) { + $resp = $e->getResponse(); + } + $this->assertEquals(400, $resp->getStatusCode()); + } + + public function dataCreateNewQuestion() { + return [ + 'newQuestion' => [ + 'expected' => [ + // 'formId' => 3, // Checked during test + // 'order' => 3, // Checked during test + 'type' => 'short', + 'isRequired' => false, + 'text' => 'Already some Question?', + 'name' => '', + 'options' => [], + 'accept' => [], + 'description' => '', + 'extraSettings' => [], + ] + ], + 'emptyQuestion' => [ + 'expected' => [ + // 'formId' => 3, // Checked during test + // 'order' => 3, // Checked during test + 'type' => 'short', + 'isRequired' => false, + 'text' => '', + 'name' => '', + 'options' => [], + 'accept' => [], + 'description' => '', + 'extraSettings' => [], + ] + ] + ]; + } + /** + * @dataProvider dataCreateNewQuestion + * + * @param array $expected + */ + public function testCreateNewQuestion(array $expected): void { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => 'short', + 'text' => $expected['text'] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion on tearDown + $this->testForms[0]['questions'][] = $data; + + // Check formId & order + $this->assertEquals($this->testForms[0]['id'], $data['formId']); + unset($data['formId']); + $this->assertEquals(sizeof($this->testForms[0]['questions']), $data['order']); + unset($data['order']); + unset($data['id']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataUpdateQuestionProperties() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['questions'][0]['text'] = 'Still first Question!'; + $fullFormExpected['questions'][0]['isRequired'] = false; + + return [ + 'updateQuestionProps' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateQuestionProperties + * + * @param array $fullFormExpected + */ + public function testUpdateQuestionProperties(array $fullFormExpected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'isRequired' => false, + 'text' => 'Still first Question!' + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($fullFormExpected); + } + + public function dataReorderQuestions() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['questions'][0]['order'] = 2; + $fullFormExpected['questions'][1]['order'] = 1; + + // Exchange questions, as they will be returned in new order. + $tmp = $fullFormExpected['questions'][0]; + $fullFormExpected['questions'][0] = $fullFormExpected['questions'][1]; + $fullFormExpected['questions'][1] = $tmp; + + return [ + 'updateQuestionProps' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataReorderQuestions + * + * @param array $fullFormExpected + */ + public function testReorderQuestions(array $fullFormExpected): void { + $resp = $this->http->request('PUT', "api/v3/forms/{$this->testForms[0]['id']}/questions/reorder", [ + 'json' => [ + 'newOrder' => [ + $this->testForms[0]['questions'][1]['id'], + $this->testForms[0]['questions'][0]['id'], + $this->testForms[0]['questions'][2]['id'], + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals([ + $this->testForms[0]['questions'][0]['id'] => [ 'order' => 2 ], + $this->testForms[0]['questions'][1]['id'] => [ 'order' => 1 ], + $this->testForms[0]['questions'][2]['id'] => [ 'order' => 3 ], + ], $data); + + $fullFormExpected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($fullFormExpected); + } + + public function dataDeleteQuestion() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + array_splice($fullFormExpected['questions'], 0, 1); + $fullFormExpected['questions'][0]['order'] = 1; + $fullFormExpected['questions'][1]['order'] = 2; + + return [ + 'deleteQuestion' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataDeleteQuestion + * + * @param array $fullFormExpected + */ + public function testDeleteQuestion(array $fullFormExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function testCloneQuestion() { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions?fromId=" . $this->testForms[0]['questions'][0]['id']); + $data = $this->OcsResponse2Data($resp); + $this->testForms[0]['questions'][] = $data; + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertNotEquals($data['id'], $this->testForms[0]['questions'][0]['id']); + + $copy = $this->testForms[0]['questions'][0]; + unset($copy['id']); + unset($copy['order']); + foreach ($copy as $key => $value) { + $this->assertEquals($value, $data[$key]); + } + } + + public function dataCreateNewOption() { + return [ + 'newOption' => [ + 'expected' => [ + // 'questionId' => Done dynamically below. + 'text' => 'A new Option.' + ] + ] + ]; + } + /** + * @dataProvider dataCreateNewOption + * + * @param array $expected + */ + public function testCreateNewOption(array $expected): void { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][1]['id']}/options", [ + 'json' => [ + 'optionTexts' => ['A new Option.'] + ] + ]); + $data = $this->OcsResponse2Data($resp)[0]; + + // Store for deletion on tearDown + $this->testForms[0]['questions'][1]['options'][] = $data; + + // Check questionId + $this->assertEquals($this->testForms[0]['questions'][1]['id'], $data['questionId']); + unset($data['questionId']); + unset($data['id']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataUpdateOptionProperties() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['questions'][1]['options'][0]['text'] = 'New option Text.'; + + return [ + 'updateOptionProps' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateOptionProperties + * + * @param array $fullFormExpected + */ + public function testUpdateOptionProperties(array $fullFormExpected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][1]['id']}/options/{$this->testForms[0]['questions'][1]['options'][0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'text' => 'New option Text.' + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][1]['options'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($fullFormExpected); + } + + public function dataDeleteOption() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + array_splice($fullFormExpected['questions'][1]['options'], 0, 1); + + return [ + 'deleteOption' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataDeleteOption + * + * @param array $fullFormExpected + */ + public function testDeleteOption(array $fullFormExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][1]['id']}/options/{$this->testForms[0]['questions'][1]['options'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][1]['options'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function dataAddShare() { + return [ + 'addAShare' => [ + 'expected' => [ + // 'formId' => Checked dynamically + 'shareType' => 0, + 'shareWith' => 'test', + 'permissions' => ['submit'], + 'displayName' => 'Test Displayname' + ] + ] + ]; + } + /** + * @dataProvider dataAddShare + * + * @param array $expected + */ + public function testAddShare(array $expected) { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/shares", [ + 'json' => [ + 'shareType' => 0, + 'shareWith' => 'test', + 'permissions' => ['submit'] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + // Store for cleanup + $this->testForms[0]['shares'][] = $data; + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data['formId']); + unset($data['formId']); + unset($data['id']); + $this->assertEquals($expected, $data); + } + + public function dataUpdateShare() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['shares'][0]['permissions'] = [ Constants::PERMISSION_SUBMIT ]; + + return [ + 'deleteShare' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateShare + * + * @param array $fullFormExpected + */ + public function testUpdateShare(array $fullFormExpected) { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/shares/{$this->testForms[0]['shares'][0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'permissions' => [ Constants::PERMISSION_SUBMIT ], + ], + ], + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['shares'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function dataDeleteShare() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + array_splice($fullFormExpected['shares'], 0, 1); + + return [ + 'deleteShare' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataDeleteShare + * + * @param array $fullFormExpected + */ + public function testDeleteShare(array $fullFormExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/shares/{$this->testForms[0]['shares'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['shares'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function dataGetSubmissions() { + return [ + 'getSubmissions' => [ + 'expected' => [ + 'submissions' => [ + [ + // 'formId' => Checked dynamically + 'userId' => 'user1', + 'userDisplayName' => 'User No. 1', + 'timestamp' => 123456, + 'answers' => [ + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'This is a short answer.', + 'fileId' => null, + ], + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'Option 1', + 'fileId' => null, + ] + ] + ], + [ + // 'formId' => Checked dynamically + 'userId' => 'user2', + 'userDisplayName' => 'user2', + 'timestamp' => 12345, + 'answers' => [ + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'This is another short answer.', + 'fileId' => null, + ], + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'Option 2', + 'fileId' => null, + ] + ] + ], + [ + // 'formId' => Checked dynamically + 'userId' => 'user3', + 'userDisplayName' => 'user3', + 'timestamp' => 1234, + 'answers' => [ + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => '', + 'fileId' => null, + ] + ] + ] + ], + 'questions' => $this->dataGetFullForm()['getFullForm']['expected']['questions'] + ] + ] + ]; + } + /** + * @dataProvider dataGetSubmissions + * + * @param array $expected + */ + public function testGetSubmissions(array $expected) { + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); + $data = $this->OcsResponse2Data($resp); + + // Cannot control ids, but check general consistency. + foreach ($data['submissions'] as $sIndex => $submission) { + $this->assertEquals($this->testForms[0]['id'], $submission['formId']); + unset($data['submissions'][$sIndex]['formId']); + + foreach ($submission['answers'] as $aIndex => $answer) { + $this->assertEquals($submission['id'], $answer['submissionId']); + $this->assertEquals($this->testForms[0]['questions'][ + $this->testForms[0]['submissions'][$sIndex]['answers'][$aIndex]['questionIndex'] + ]['id'], $answer['questionId']); + unset($data['submissions'][$sIndex]['answers'][$aIndex]['submissionId']); + unset($data['submissions'][$sIndex]['answers'][$aIndex]['questionId']); + unset($data['submissions'][$sIndex]['answers'][$aIndex]['id']); + } + unset($data['submissions'][$sIndex]['id']); + } + foreach ($data['questions'] as $qIndex => $question) { + $this->assertEquals($this->testForms[0]['id'], $question['formId']); + unset($data['questions'][$qIndex]['formId']); + + foreach ($question['options'] as $oIndex => $option) { + $this->assertEquals($question['id'], $option['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['id']); + } + unset($data['questions'][$qIndex]['id']); + } + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataExportSubmissions() { + return [ + 'exportSubmissions' => [ + 'expected' => <<<'CSV' + "User ID","User display name","Timestamp","First Question?","Second Question?","File Question?" + "","Anonymous user","1970-01-01T00:20:34+00:00","","","" + "","Anonymous user","1970-01-01T03:25:45+00:00","This is another short answer.","Option 2","" + "user1","User No. 1","1970-01-02T10:17:36+00:00","This is a short answer.","Option 1","" +CSV + ] + ]; + } + /** + * @dataProvider dataExportSubmissions + * + * @param array $expected + */ + public function testExportSubmissions(string $expected) { + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions?fileFormat=csv"); + $data = substr($resp->getBody()->getContents(), 3); // Some strange Character removed at the beginning + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('attachment; filename="Title of a Form (responses).csv"', $resp->getHeaders()['Content-Disposition'][0]); + $this->assertEquals('text/csv;charset=UTF-8', $resp->getHeaders()['Content-type'][0]); + $arr_txt_expected = preg_split('/,/', str_replace(["\t", "\n"], '', $expected)); + $arr_txt_data = preg_split('/,/', str_replace(["\t", "\n"], '', $data)); + $this->assertEquals($arr_txt_expected, $arr_txt_data); + } + + public function testLinkFile() { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'path' => '', + 'fileFormat' => 'csv' + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + } + + public function testUnlinkFile() { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[2]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'fileId' => null, + 'fileFormat' => null + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[2]['id'], $data); + } + + public function testExportToCloud() { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions/export", [ + 'json' => [ + 'path' => '' + ]] + ); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('Title of a Form (responses).csv', $data); + } + + public function dataDeleteSubmissions() { + $submissionsExpected = $this->dataGetSubmissions()['getSubmissions']['expected']; + $submissionsExpected['submissions'] = []; + + return [ + 'deleteSubmissions' => [ + 'submissionsExpected' => $submissionsExpected + ] + ]; + } + /** + * @dataProvider dataDeleteSubmissions + * + * @param array $submissionsExpected + */ + public function testDeleteSubmissions(array $submissionsExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + + $this->testGetSubmissions($submissionsExpected); + } + + public function dataNewSubmission() { + $submissionsExpected = $this->dataGetSubmissions()['getSubmissions']['expected']; + $submissionsExpected['submissions'][] = [ + 'userId' => 'test' + ]; + + return [ + 'insertSubmission' => [ + 'submissionsExpected' => $submissionsExpected + ] + ]; + } + /** + * @dataProvider dataNewSubmission + */ + public function testNewSubmission() { + + $uploadedFileResponse = $this->http->request('POST', + "api/v3/forms/{$this->testForms[0]['id']}/submissions/files/{$this->testForms[0]['questions'][2]['id']}", + [ + 'multipart' => [ + [ + 'name' => 'files[]', + 'contents' => 'hello world', + 'filename' => 'test.txt' + ] + ] + ]); + + $data = $this->OcsResponse2Data($uploadedFileResponse); + $uploadedFileId = $data[0]['uploadedFileId']; + + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions", [ + 'json' => [ + 'answers' => [ + $this->testForms[0]['questions'][0]['id'] => ['ShortAnswer!'], + $this->testForms[0]['questions'][1]['id'] => [ + $this->testForms[0]['questions'][1]['options'][0]['id'] + ], + $this->testForms[0]['questions'][2]['id'] => [['uploadedFileId' => $uploadedFileId]] + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + + // Check stored submissions + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion + $this->testForms[0]['submissions'][] = $data['submissions'][0]; + + // Check Ids + foreach ($data['submissions'][0]['answers'] as $aIndex => $answer) { + $this->assertEquals($data['submissions'][0]['id'], $answer['submissionId']); + unset($data['submissions'][0]['answers'][$aIndex]['id']); + unset($data['submissions'][0]['answers'][$aIndex]['submissionId']); + + if (isset($answer['fileId'])) { + $this->assertIsNumeric($answer['fileId'], 'fileId should be numeric.'); + $this->assertGreaterThan(0, $answer['fileId'], 'fileId should be greater than 0.'); + unset($data['submissions'][0]['answers'][$aIndex]['fileId']); + } + } + unset($data['submissions'][0]['id']); + // Check general behaviour of timestamp (Insert in the last 10 seconds) + $this->assertTrue(time() - $data['submissions'][0]['timestamp'] < 10); + unset($data['submissions'][0]['timestamp']); + + $this->assertEquals([ + 'userId' => 'test', + 'userDisplayName' => 'Test Displayname', + 'formId' => $this->testForms[0]['id'], + 'answers' => [ + [ + 'questionId' => $this->testForms[0]['questions'][0]['id'], + 'text' => 'ShortAnswer!', + 'fileId' => null, + ], + [ + 'questionId' => $this->testForms[0]['questions'][1]['id'], + 'text' => 'Option 1', + 'fileId' => null, + ], + [ + 'questionId' => $this->testForms[0]['questions'][2]['id'], + 'text' => 'test.txt', + ], + ] + ], $data['submissions'][0]); + } + + public function dataDeleteSingleSubmission() { + $submissionsExpected = $this->dataGetSubmissions()['getSubmissions']['expected']; + array_splice($submissionsExpected['submissions'], 0, 1); + + return [ + 'deleteSingleSubmission' => [ + 'submissionsExpected' => $submissionsExpected + ] + ]; + } + /** + * @dataProvider dataDeleteSingleSubmission + * + * @param array $submissionsExpected + */ + public function testDeleteSingleSubmission(array $submissionsExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/submissions/{$this->testForms[0]['submissions'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['submissions'][0]['id'], $data); + + $this->testGetSubmissions($submissionsExpected); + } + + /** + * Test transfer owner endpoint for form + * + * Keep this test at the end as it might break other tests + */ + public function testTransferOwner() { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'ownerId' => 'user1', + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('user1', $data); + } +}; diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index 419cac9b3..22266a68a 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -43,8 +43,6 @@ function time($expected = null) { return $value; } -namespace OCA\Forms\Controller; - /** * mock is_uploaded_file() function used in services * @param string|bool|null $filename the value that should be returned when called @@ -113,6 +111,8 @@ class ApiControllerTest extends TestCase { private $formMapper; /** @var OptionMapper|MockObject */ private $optionMapper; + /** @var Question|MockObject */ + private $question; /** @var QuestionMapper|MockObject */ private $questionMapper; /** @var ShareMapper|MockObject */ @@ -144,6 +144,7 @@ public function setUp(): void { $this->answerMapper = $this->createMock(AnswerMapper::class); $this->formMapper = $this->createMock(FormMapper::class); $this->optionMapper = $this->createMock(OptionMapper::class); + $this->question = $this->createMock(Question::class); $this->questionMapper = $this->createMock(QuestionMapper::class); $this->shareMapper = $this->createMock(ShareMapper::class); $this->submissionMapper = $this->createMock(SubmissionMapper::class); @@ -225,22 +226,21 @@ public function throwMockedException(string $class) { public function testGetSubmissions_invalidForm() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willThrowException($exception); - $this->expectException(OCSBadRequestException::class); - $this->apiController->getSubmissions('hash'); + $this->expectException(OCSNotFoundException::class); + $this->apiController->getSubmissions(1); } public function testGetSubmissions_noPermissions() { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('currentUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -249,7 +249,7 @@ public function testGetSubmissions_noPermissions() { ->willReturn(false); $this->expectException(OCSForbiddenException::class); - $this->apiController->getSubmissions('hash'); + $this->apiController->getSubmissions(1); } public function dataGetSubmissions() { @@ -293,12 +293,11 @@ public function dataGetSubmissions() { public function testGetSubmissions(array $submissions, array $questions, array $expected) { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('otherUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -316,28 +315,27 @@ public function testGetSubmissions(array $submissions, array $questions, array $ ->with(1) ->willReturn($questions); - $this->assertEquals(new DataResponse($expected), $this->apiController->getSubmissions('hash')); + $this->assertEquals(new DataResponse($expected), $this->apiController->getSubmissions(1)); } public function testExportSubmissions_invalidForm() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(99) ->willThrowException($exception); $this->expectException(OCSNotFoundException::class); - $this->apiController->exportSubmissions('hash'); + $this->apiController->getSubmissions(99, 'csv'); } public function testExportSubmissions_noPermissions() { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('currentUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -346,18 +344,17 @@ public function testExportSubmissions_noPermissions() { ->willReturn(false); $this->expectException(OCSForbiddenException::class); - $this->apiController->exportSubmissions('hash'); + $this->apiController->getSubmissions(1, 'csv'); } public function testExportSubmissions() { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('currentUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -377,17 +374,17 @@ public function testExportSubmissions() { ->with($form, 'csv') ->willReturn($fileName); - $this->assertEquals(new DataDownloadResponse($csv, $fileName, 'text/csv'), $this->apiController->exportSubmissions('hash')); + $this->assertEquals(new DataDownloadResponse($csv, $fileName, 'text/csv'), $this->apiController->getSubmissions(1, 'csv')); } public function testExportSubmissionsToCloud_invalidForm() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willThrowException($exception); $this->expectException(OCSNotFoundException::class); - $this->apiController->exportSubmissionsToCloud('hash', ''); + $this->apiController->exportSubmissionsToCloud(1, ''); } public function testUnlinkFile() { @@ -408,7 +405,7 @@ public function testUnlinkFile() { ->with($form) ->willReturn(true); - $this->apiController->unlinkFile('hash'); + $this->apiController->unlinkFileLegacy('hash'); $this->assertNull($form->getFileId()); $this->assertNull($form->getFileFormat()); @@ -537,7 +534,7 @@ public function testCloneForm_exceptions(bool $canCreate, $callback, string $exc ->with(7) ->willReturnCallback($callback); $this->expectException($exception); - $this->apiController->cloneForm(7); + $this->apiController->newForm(7); } public function dataCloneForm() { @@ -641,7 +638,7 @@ public function testCloneForm($old, $new) { ->method('getForm') ->with(14) ->willReturn(new DataResponse('success')); - $this->assertEquals(new DataResponse('success'), $apiController->cloneForm(7)); + $this->assertEquals(new DataResponse('success'), $apiController->newForm(7)); } private function formAccess(bool $hasUserAccess = true, bool $hasFormExpired = false, bool $canSubmit = true) { @@ -661,7 +658,7 @@ private function formAccess(bool $hasUserAccess = true, bool $hasFormExpired = f public function testCloneQuestion_notFound() { $this->questionMapper->method('findById')->with(42)->willThrowException($this->createMock(IMapperException::class)); $this->expectException(OCSNotFoundException::class); - $this->apiController->cloneQuestion(42); + $this->apiController->cloneQuestionLegacy(42); } public function testCloneQuestion_noPermission() { @@ -670,7 +667,7 @@ public function testCloneQuestion_noPermission() { $this->questionMapper->method('findById')->with(42)->willReturn($question); $this->formMapper->method('findById')->with(1)->willReturn($form); $this->expectException(OCSForbiddenException::class); - $this->apiController->cloneQuestion(42); + $this->apiController->cloneQuestionLegacy(42); } public function testUploadFiles() { @@ -678,12 +675,18 @@ public function testUploadFiles() { $form->setId(1); $form->setHash('hash'); $form->setOwnerId('currentUser'); - + $question = Question::fromParams(['formId' => 1]); + $this->formMapper->expects($this->once()) + ->method('findById') + ->with(1) + ->willReturn($form); + + $this->questionMapper->expects($this->once()) ->method('findById') - ->with(1) - ->willReturn($form); - + ->with(10) + ->willReturn($question); + $this->request->expects($this->once()) ->method('getUploadedFile') ->with('files') @@ -739,7 +742,7 @@ public function testUploadFiles() { $this->apiController->uploadFiles(1, 10, ''); } - public function testInsertSubmission_answers() { + public function testNewSubmission_answers() { $form = new Form(); $form->setId(1); $form->setHash('hash'); @@ -876,17 +879,17 @@ public function testInsertSubmission_answers() { ->with('admin') ->willReturn($userFolder); - $this->apiController->insertSubmission(1, $answers, ''); + $this->apiController->newSubmission(1, $answers, ''); } - public function testInsertSubmission_formNotFound() { + public function testNewSubmission_formNotFound() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) ->method('findById') ->with(1) ->willThrowException($exception); $this->expectException(OCSBadRequestException::class); - $this->apiController->insertSubmission(1, [], ''); + $this->apiController->newSubmission(1, [], ''); } /** @@ -903,7 +906,7 @@ public function dataForCheckForbiddenException() { /** * @dataProvider dataForCheckForbiddenException() */ - public function testInsertSubmission_forbiddenException($hasUserAccess, $hasFormExpired, $canSubmit) { + public function testNewSubmission_forbiddenException($hasUserAccess, $hasFormExpired, $canSubmit) { $form = new Form(); $form->setId(1); $form->setOwnerId('admin'); @@ -921,10 +924,10 @@ public function testInsertSubmission_forbiddenException($hasUserAccess, $hasForm $this->expectException(OCSForbiddenException::class); - $this->apiController->insertSubmission(1, [], ''); + $this->apiController->newSubmission(1, [], ''); } - public function testInsertSubmission_validateSubmission() { + public function testNewSubmission_validateSubmission() { $form = new Form(); $form->setId(1); $form->setOwnerId('admin'); @@ -947,7 +950,7 @@ public function testInsertSubmission_validateSubmission() { $this->expectException(OCSBadRequestException::class); - $this->apiController->insertSubmission(1, [], ''); + $this->apiController->newSubmission(1, [], ''); } public function testDeleteSubmissionNotFound() { @@ -960,7 +963,7 @@ public function testDeleteSubmissionNotFound() { ->willThrowException($exception); $this->expectException(OCSBadRequestException::class); - $this->apiController->deleteSubmission(42); + $this->apiController->deleteSubmission(1, 42); } /** @@ -987,7 +990,7 @@ public function testDeleteSubmissionNoPermission($submissionData, $formData) { ->willReturn(false); $this->expectException(OCSForbiddenException::class); - $this->apiController->deleteSubmission(42); + $this->apiController->deleteSubmission(1, 42); } /** @@ -1023,7 +1026,7 @@ public function testDeleteSubmission($submissionData, $formData) { ->method('setLastUpdatedTimestamp') ->with($formData['id']); - $this->assertEquals(new DataResponse(42), $this->apiController->deleteSubmission(42)); + $this->assertEquals(new DataResponse(42), $this->apiController->deleteSubmission(1, 42)); } public function dataTestDeletePermission() { @@ -1063,7 +1066,7 @@ public function testTransferOwnerNotOwner() { ->willReturn($form); $this->expectException(OCSForbiddenException::class); - $this->apiController->transferOwner(1, 'newOwner'); + $this->apiController->transferOwnerLegacy(1, 'newOwner'); } public function testTransferNewOwnerNotFound() { @@ -1083,7 +1086,7 @@ public function testTransferNewOwnerNotFound() { ->willReturn(null); $this->expectException(OCSBadRequestException::class); - $this->apiController->transferOwner(1, 'newOwner'); + $this->apiController->transferOwnerLegacy(1, 'newOwner'); } public function testTransferOwner() { @@ -1103,7 +1106,7 @@ public function testTransferOwner() { ->with('newOwner') ->willReturn($newOwner); - $this->assertEquals(new DataResponse('newOwner'), $this->apiController->transferOwner(1, 'newOwner')); + $this->assertEquals(new DataResponse('newOwner'), $this->apiController->transferOwnerLegacy(1, 'newOwner')); $this->assertEquals('newOwner', $form->getOwnerId()); } } diff --git a/tests/Unit/Controller/ShareApiControllerTest.php b/tests/Unit/Controller/ShareApiControllerTest.php index 50042ae27..005cd5ac7 100644 --- a/tests/Unit/Controller/ShareApiControllerTest.php +++ b/tests/Unit/Controller/ShareApiControllerTest.php @@ -551,7 +551,7 @@ public function testDeleteShare() { ->with('8'); $response = new DataResponse(8); - $this->assertEquals($response, $this->shareApiController->deleteShare(8)); + $this->assertEquals($response, $this->shareApiController->deleteShare(5, 8)); } /** @@ -565,7 +565,7 @@ public function testDeleteUnknownShare() { ; $this->expectException(OCSBadRequestException::class); - $this->shareApiController->deleteShare(8); + $this->shareApiController->deleteShare(1, 8); } /** @@ -589,7 +589,7 @@ public function testDeleteForeignShare() { ->willReturn($form); $this->expectException(OCSForbiddenException::class); - $this->shareApiController->deleteShare(8); + $this->shareApiController->deleteShare(5, 8); } public function dataUpdateShare() { @@ -824,10 +824,10 @@ public function testUpdateShare(array $share, string $formOwner, array $keyValue if ($exception === null) { $expectedResponse = new DataResponse($expected); - $this->assertEquals($expectedResponse, $this->shareApiController->updateShare($share['id'], $keyValuePairs)); + $this->assertEquals($expectedResponse, $this->shareApiController->updateShare($share['formId'], $share['id'], $keyValuePairs)); } else { $this->expectException($exception); - $this->shareApiController->updateShare($share['id'], $keyValuePairs); + $this->shareApiController->updateShare($share['formId'], $share['id'], $keyValuePairs); } } @@ -846,7 +846,7 @@ public function testUpdateShare_NotExistingShare() { ->method('debug'); $this->expectException(OCSBadRequestException::class); - $this->shareApiController->updateShare(1337, [Constants::PERMISSION_SUBMIT]); + $this->shareApiController->updateShare(1, 1337, [Constants::PERMISSION_SUBMIT]); } /** @@ -876,6 +876,6 @@ public function testUpdateShare_NotExistingForm() { ->method('debug'); $this->expectException(OCSBadRequestException::class); - $this->shareApiController->updateShare(1337, [Constants::PERMISSION_SUBMIT]); + $this->shareApiController->updateShare(7331, 1337, [Constants::PERMISSION_SUBMIT]); } }