Skip to content

Commit 9745ea2

Browse files
authored
Merge pull request #2715 from nextcloud/feat/allowEditSubmissions
feat: allow editing of submission by the user
2 parents 6d539be + de7971b commit 9745ea2

30 files changed

+1539
-66
lines changed

docs/API_v3.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,43 @@ Upload a file to an answer before form submission
822822
"data": {"uploadedFileId": integer, "fileName": "string"}
823823
```
824824

825+
### Get a specific submission
826+
827+
Get all Submissions to a Form
828+
829+
- Endpoint: `/api/v3/forms/{formId}/submissions/{submissionId}`
830+
- Method: `GET`
831+
- Url-Parameters:
832+
| Parameter | Type | Description |
833+
|-----------|---------|-------------|
834+
| _formId_ | Integer | ID of the form to get the submissions for |
835+
| _submissionId_ | Integer | ID of the submission to get |
836+
- Response: The submission
837+
838+
```
839+
"data": {
840+
"id": 6,
841+
"formId": 3,
842+
"userId": "jonas",
843+
"timestamp": 1611274453,
844+
"answers": [
845+
{
846+
"id": 8,
847+
"submissionId": 6,
848+
"questionId": 1,
849+
"text": "Option 3"
850+
},
851+
{
852+
"id": 9,
853+
"submissionId": 6,
854+
"questionId": 2,
855+
"text": "One more."
856+
},
857+
],
858+
"userDisplayName": "jonas"
859+
}
860+
```
861+
825862
### Insert a Submission
826863

827864
Store Submission to Database

docs/DataStructure.md

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,27 @@ This document describes the Object-Structure, that is used within the Forms App
1313

1414
### Form
1515

16-
| Property | Type | Restrictions | Description |
17-
| ----------------- | ------------------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
18-
| id | Integer | unique | An instance-wide unique id of the form |
19-
| hash | 16-char String | unique | An instance-wide unique hash |
20-
| title | String | max. 256 ch. | The form title |
21-
| description | String | max. 8192 ch. | The Form description |
22-
| ownerId | String | | The nextcloud userId of the form owner |
23-
| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) |
24-
| created | unix timestamp | | When the form has been created |
25-
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
26-
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
27-
| isAnonymous | Boolean | | If Answers will be stored anonymously |
28-
| state | Integer | [Form state](#form-state) | The state of the form |
29-
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
30-
| showExpiration | Boolean | | If the expiration date will be shown on the form |
31-
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
32-
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
33-
| questions | Array of [Questions](#question) | | Array of questions belonging to the form |
34-
| shares | Array of [Shares](#share) | | Array of shares of the form |
35-
| submissions | Array of [Submissions](#submission) | | Array of submissions belonging to the form |
16+
| Property | Type | Restrictions | Description |
17+
| -------------------- | ------------------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
18+
| id | Integer | unique | An instance-wide unique id of the form |
19+
| hash | 16-char String | unique | An instance-wide unique hash |
20+
| title | String | max. 256 ch. | The form title |
21+
| description | String | max. 8192 ch. | The Form description |
22+
| ownerId | String | | The nextcloud userId of the form owner |
23+
| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) |
24+
| created | unix timestamp | | When the form has been created |
25+
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
26+
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
27+
| isAnonymous | Boolean | | If Answers will be stored anonymously |
28+
| state | Integer | [Form state](#form-state) | The state of the form |
29+
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
30+
| allowEditSubmissions | Boolean | | If users are allowed to edit or delete their response |
31+
| showExpiration | Boolean | | If the expiration date will be shown on the form |
32+
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
33+
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
34+
| questions | Array of [Questions](#question) | | Array of questions belonging to the form |
35+
| shares | Array of [Shares](#share) | | Array of shares of the form |
36+
| submissions | Array of [Submissions](#submission) | | Array of submissions belonging to the form |
3637

3738
```
3839
{
@@ -46,6 +47,7 @@ This document describes the Object-Structure, that is used within the Forms App
4647
"expires": 0,
4748
"isAnonymous": false,
4849
"submitMultiple": true,
50+
"allowEditSubmissions": false,
4951
"showExpiration": false,
5052
"canSubmit": true,
5153
"permissions": [

lib/Controller/ApiController.php

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
* @psalm-import-type FormsPartialForm from ResponseDefinitions
6363
* @psalm-import-type FormsQuestion from ResponseDefinitions
6464
* @psalm-import-type FormsQuestionType from ResponseDefinitions
65+
* @psalm-import-type FormsSubmission from ResponseDefinitions
6566
* @psalm-import-type FormsSubmissions from ResponseDefinitions
6667
* @psalm-import-type FormsUploadedFile from ResponseDefinitions
6768
*/
@@ -172,6 +173,7 @@ public function newForm(?int $fromId = null): DataResponse {
172173
'showToAllUsers' => false,
173174
]);
174175
$form->setSubmitMultiple(false);
176+
$form->setAllowEditSubmissions(false);
175177
$form->setShowExpiration(false);
176178
$form->setExpires(0);
177179
$form->setIsAnonymous(false);
@@ -1158,7 +1160,11 @@ public function getSubmissions(int $formId, ?string $fileFormat = null): DataRes
11581160
}
11591161

11601162
// Load submissions and currently active questions
1161-
$submissions = $this->submissionService->getSubmissions($formId);
1163+
if (in_array(Constants::PERMISSION_RESULTS, $this->formsService->getPermissions($form))) {
1164+
$submissions = $this->submissionService->getSubmissions($formId);
1165+
} else {
1166+
$submissions = $this->submissionService->getSubmissions($formId, $this->currentUser->getUID());
1167+
}
11621168
$questions = $this->formsService->getQuestions($formId);
11631169

11641170
// Append Display Names
@@ -1195,6 +1201,54 @@ public function getSubmissions(int $formId, ?string $fileFormat = null): DataRes
11951201
return new DataResponse($response);
11961202
}
11971203

1204+
/**
1205+
* Get a specific submission
1206+
*
1207+
* @param int $formId of the form
1208+
* @param int $submissionId of the submission
1209+
* @return DataResponse<Http::STATUS_OK, FormsSubmission, array{}>
1210+
* @throws OCSBadRequestException Submission doesn't belong to given form
1211+
* @throws OCSNotFoundException Could not find form
1212+
* @throws OCSNotFoundException Submission doesn't exist
1213+
* @throws OCSForbiddenException The current user has no permission to get this submission
1214+
*
1215+
* 200: the submissions of the form
1216+
*/
1217+
#[CORS()]
1218+
#[NoAdminRequired()]
1219+
#[BruteForceProtection(action: 'form')]
1220+
#[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}/submissions/{submissionId}')]
1221+
public function getSubmission(int $formId, int $submissionId): DataResponse|DataDownloadResponse {
1222+
$form = $this->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS);
1223+
1224+
$submission = $this->submissionService->getSubmission($submissionId);
1225+
if ($submission === null) {
1226+
throw new OCSNotFoundException('Submission doesn\'t exist');
1227+
}
1228+
1229+
if ($submission['formId'] !== $formId) {
1230+
throw new OCSBadRequestException('Submission doesn\'t belong to given form');
1231+
}
1232+
1233+
// Append Display Names
1234+
if (substr($submission['userId'], 0, 10) === 'anon-user-') {
1235+
// Anonymous User
1236+
// TRANSLATORS On Results when listing the single Responses to the form, this text is shown as heading of the Response.
1237+
$submission['userDisplayName'] = $this->l10n->t('Anonymous response');
1238+
} else {
1239+
$userEntity = $this->userManager->get($submission['userId']);
1240+
1241+
if ($userEntity instanceof IUser) {
1242+
$submission['userDisplayName'] = $userEntity->getDisplayName();
1243+
} else {
1244+
// Fallback, should not occur regularly.
1245+
$submission['userDisplayName'] = $submission['userId'];
1246+
}
1247+
}
1248+
1249+
return new DataResponse($submission);
1250+
}
1251+
11981252
/**
11991253
* Delete all submissions of a specified form
12001254
*
@@ -1309,6 +1363,84 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
13091363
return new DataResponse(null, Http::STATUS_CREATED);
13101364
}
13111365

1366+
/**
1367+
* Update an existing submission
1368+
*
1369+
* @param int $formId the form id
1370+
* @param int $submissionId the submission id
1371+
* @param array<string, list<string>> $answers [question_id => arrayOfString]
1372+
* @return DataResponse<Http::STATUS_OK, int, array{}>
1373+
* @throws OCSBadRequestException Can only update submission if allowEditSubmissions is set and the answers are valid
1374+
* @throws OCSForbiddenException Can only update your own submission
1375+
*
1376+
* 200: the id of the updated submission
1377+
*/
1378+
#[CORS()]
1379+
#[NoAdminRequired()]
1380+
#[NoCSRFRequired()]
1381+
#[PublicPage()]
1382+
#[ApiRoute(verb: 'PUT', url: '/api/v3/forms/{formId}/submissions/{submissionId}')]
1383+
public function updateSubmission(int $formId, int $submissionId, array $answers): DataResponse {
1384+
$this->logger->debug('Updating submission: formId: {formId}, answers: {answers}', [
1385+
'formId' => $formId,
1386+
'answers' => $answers,
1387+
]);
1388+
1389+
// submissions can't be updated on public shares, so passing empty shareHash
1390+
$form = $this->loadFormForSubmission($formId, '');
1391+
1392+
if (!$form->getAllowEditSubmissions()) {
1393+
throw new OCSBadRequestException('Can only update if allowEditSubmissions is set');
1394+
}
1395+
1396+
$questions = $this->formsService->getQuestions($formId);
1397+
try {
1398+
// Is the submission valid
1399+
$this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId());
1400+
} catch (\InvalidArgumentException $e) {
1401+
throw new OCSBadRequestException($e->getMessage());
1402+
}
1403+
1404+
// get existing submission of this user
1405+
try {
1406+
$submission = $this->submissionMapper->findById($submissionId);
1407+
} catch (DoesNotExistException $e) {
1408+
throw new OCSBadRequestException('Submission doesn\'t exist');
1409+
}
1410+
1411+
if ($formId !== $submission->getFormId()) {
1412+
throw new OCSBadRequestException('Submission doesn\'t belong to given form');
1413+
}
1414+
1415+
if ($this->currentUser->getUID() !== $submission->getUserId()) {
1416+
throw new OCSForbiddenException('Can only update your own submissions');
1417+
}
1418+
1419+
$submission->setTimestamp(time());
1420+
$this->submissionMapper->update($submission);
1421+
1422+
// Delete current answers
1423+
$this->answerMapper->deleteBySubmission($submissionId);
1424+
1425+
// Process Answers
1426+
foreach ($answers as $questionId => $answerArray) {
1427+
// Search corresponding Question, skip processing if not found
1428+
$questionIndex = array_search($questionId, array_column($questions, 'id'));
1429+
if ($questionIndex === false) {
1430+
continue;
1431+
}
1432+
1433+
$question = $questions[$questionIndex];
1434+
1435+
$this->storeAnswersForQuestion($form, $submission->getId(), $question, $answerArray);
1436+
}
1437+
1438+
//Create Activity
1439+
$this->formsService->notifyNewSubmission($form, $submission);
1440+
1441+
return new DataResponse($submissionId);
1442+
}
1443+
13121444
/**
13131445
* Delete a specific submission
13141446
*
@@ -1343,6 +1475,13 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse {
13431475
throw new OCSBadRequestException('Submission doesn\'t belong to given form');
13441476
}
13451477

1478+
if (
1479+
!in_array(Constants::PERMISSION_RESULTS_DELETE, $this->formsService->getPermissions($form))
1480+
&& $this->currentUser->getUID() !== $submission->getUserId()
1481+
) {
1482+
throw new OCSForbiddenException('Can only delete your own submissions');
1483+
}
1484+
13461485
// Delete submission (incl. Answers)
13471486
$this->submissionMapper->deleteById($submissionId);
13481487
$this->formMapper->update($form);

lib/Controller/PageController.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OCA\Forms\Db\Form;
1212
use OCA\Forms\Db\FormMapper;
1313
use OCA\Forms\Db\ShareMapper;
14+
use OCA\Forms\Db\SubmissionMapper;
1415
use OCA\Forms\Service\ConfigService;
1516
use OCA\Forms\Service\FormsService;
1617

@@ -45,6 +46,7 @@ public function __construct(
4546
IRequest $request,
4647
private FormMapper $formMapper,
4748
private ShareMapper $shareMapper,
49+
private SubmissionMapper $submissionMapper,
4850
private ConfigService $configService,
4951
private FormsService $formsService,
5052
private IAccountManager $accountManager,
@@ -63,7 +65,7 @@ public function __construct(
6365
#[NoAdminRequired()]
6466
#[NoCSRFRequired()]
6567
#[FrontpageRoute(verb: 'GET', url: '/')]
66-
public function index(?string $hash = null): TemplateResponse {
68+
public function index(?string $hash = null, ?int $submissionId = null): TemplateResponse {
6769
Util::addScript($this->appName, 'forms-main');
6870
Util::addStyle($this->appName, 'forms');
6971
Util::addStyle($this->appName, 'forms-style');
@@ -81,6 +83,15 @@ public function index(?string $hash = null): TemplateResponse {
8183
}
8284
}
8385

86+
if (isset($submissionId)) {
87+
try {
88+
$submission = $this->submissionMapper->findById($submissionId);
89+
$this->initialState->provideInitialState('submissionId', $submission->id);
90+
} catch (DoesNotExistException $e) {
91+
// Ignore exception and just don't set the initialState value
92+
}
93+
}
94+
8495
return new TemplateResponse($this->appName, self::TEMPLATE_MAIN, [
8596
'id-app-content' => '#app-content-vue',
8697
'id-app-navigation' => '#app-navigation-vue',
@@ -97,6 +108,16 @@ public function views(string $hash): TemplateResponse {
97108
return $this->index($hash);
98109
}
99110

111+
/**
112+
* @return TemplateResponse
113+
*/
114+
#[NoAdminRequired()]
115+
#[NoCSRFRequired()]
116+
#[FrontpageRoute(verb: 'GET', url: '/{hash}/submit/{submissionId}', requirements: ['hash' => '[a-zA-Z0-9]{16,}', 'submissionId' => '\d+'])]
117+
public function submitViewWithSubmission(string $hash, int $submissionId): TemplateResponse {
118+
return $this->formMapper->findByHash($hash)->getAllowEditSubmissions() ? $this->index($hash, $submissionId) : $this->index($hash);
119+
}
120+
100121
/**
101122
* @param string $hash
102123
* @return RedirectResponse|TemplateResponse Redirect to login or internal view.

0 commit comments

Comments
 (0)