Skip to content

Commit 2275ad9

Browse files
tpokorraChartman123
authored andcommitted
patch for AllowEdit for Forms5
Signed-off-by: Timotheus Pokorra <timotheus.pokorra@solidcharity.com>
1 parent 5021405 commit 2275ad9

19 files changed

Lines changed: 953 additions & 43 deletions

docs/DataStructure.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ This document describes the Object-Structure, that is used within the Forms App
2727
| isAnonymous | Boolean | | If Answers will be stored anonymously |
2828
| state | Integer | [Form state](#form-state) | The state of the form |
2929
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
30+
| allowEdit | Boolean | | If users are allowed to edit or delete their response |
3031
| showExpiration | Boolean | | If the expiration date will be shown on the form |
3132
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
3233
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
@@ -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+
"allowEdit": false,
4951
"showExpiration": false,
5052
"canSubmit": true,
5153
"permissions": [

lib/Controller/ApiController.php

Lines changed: 149 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ public function newForm(?int $fromId = null): DataResponse {
172172
'showToAllUsers' => false,
173173
]);
174174
$form->setSubmitMultiple(false);
175+
$form->setAllowEdit(false);
175176
$form->setShowExpiration(false);
176177
$form->setExpires(0);
177178
$form->setIsAnonymous(false);
@@ -1294,7 +1295,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
12941295
continue;
12951296
}
12961297

1297-
$this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray);
1298+
$this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray, false);
12981299
}
12991300

13001301
$this->formMapper->update($form);
@@ -1309,6 +1310,87 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
13091310
return new DataResponse(null, Http::STATUS_CREATED);
13101311
}
13111312

1313+
/**
1314+
* Update an existing submission
1315+
*
1316+
* @param int $formId the form id
1317+
* @param int $submissionId the submission id
1318+
* @param array<string, list<string>> $answers [question_id => arrayOfString]
1319+
* @param string $shareHash public share-hash -> Necessary to submit on public link-shares.
1320+
* @return DataResponse<Http::STATUS_OK, int, array{}>
1321+
* @throws OCSBadRequestException Can only update submission if AllowEdit is set and the answers are valid
1322+
* @throws OCSForbiddenException Can only update your own submission
1323+
*
1324+
* 200: the id of the updated submission
1325+
*/
1326+
#[CORS()]
1327+
#[NoAdminRequired()]
1328+
#[NoCSRFRequired()]
1329+
#[PublicPage()]
1330+
#[ApiRoute(verb: 'PUT', url: '/api/v3/forms/{formId}/submissions/{submissionId}')]
1331+
public function updateSubmission(int $formId, int $submissionId, array $answers, string $shareHash = ''): DataResponse {
1332+
$this->logger->debug('Updating submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [
1333+
'formId' => $formId,
1334+
'answers' => $answers,
1335+
'shareHash' => $shareHash,
1336+
]);
1337+
1338+
$form = $this->loadFormForSubmission($formId, $shareHash);
1339+
1340+
if (!$form->getAllowEdit()) {
1341+
throw new OCSBadRequestException('Can only update if AllowEdit is set');
1342+
}
1343+
1344+
$questions = $this->formsService->getQuestions($formId);
1345+
// Is the submission valid
1346+
$isSubmissionValid = $this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId());
1347+
if (is_string($isSubmissionValid)) {
1348+
throw new OCSBadRequestException($isSubmissionValid);
1349+
}
1350+
if ($isSubmissionValid === false) {
1351+
throw new OCSBadRequestException('At least one submitted answer is not valid');
1352+
}
1353+
1354+
// get existing submission of this user
1355+
try {
1356+
$submission = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID());
1357+
} catch (DoesNotExistException $e) {
1358+
throw new OCSBadRequestException('Cannot update a non existing submission');
1359+
}
1360+
1361+
if ($submissionId != $submission->getId()) {
1362+
throw new OCSForbiddenException('Can only update your own submissions');
1363+
}
1364+
1365+
$submission->setTimestamp(time());
1366+
$this->submissionMapper->update($submission);
1367+
1368+
if (empty($answers)) {
1369+
// Clear Answers
1370+
foreach ($questions as $question) {
1371+
$this->storeAnswersForQuestion($form, $submission->getId(), $question, [''], true);
1372+
}
1373+
} else {
1374+
// Process Answers
1375+
foreach ($answers as $questionId => $answerArray) {
1376+
// Search corresponding Question, skip processing if not found
1377+
$questionIndex = array_search($questionId, array_column($questions, 'id'));
1378+
if ($questionIndex === false) {
1379+
continue;
1380+
}
1381+
1382+
$question = $questions[$questionIndex];
1383+
1384+
$this->storeAnswersForQuestion($form, $submission->getId(), $question, $answerArray, true);
1385+
}
1386+
}
1387+
1388+
//Create Activity
1389+
$this->formsService->notifyNewSubmission($form, $submission);
1390+
1391+
return new DataResponse($submissionId);
1392+
}
1393+
13121394
/**
13131395
* Delete a specific submission
13141396
*
@@ -1522,14 +1604,23 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
15221604
// private functions
15231605

15241606
/**
1525-
* Insert answers for a question
1607+
* Insert or update answers for a question
15261608
*
15271609
* @param Form $form
15281610
* @param int $submissionId
15291611
* @param array $question
15301612
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
1613+
* @param bool $update
15311614
*/
1532-
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
1615+
private function storeAnswersForQuestion(Form $form, int $submissionId, array $question, array $answerArray, bool $update): void {
1616+
// get stored answers for this question
1617+
$storedAnswers = [];
1618+
if ($update) {
1619+
$storedAnswers = $this->answerMapper->findBySubmissionAndQuestion($submissionId, $question['id']);
1620+
}
1621+
1622+
$newAnswerTexts = [];
1623+
15331624
foreach ($answerArray as $answer) {
15341625
$answerEntity = new Answer();
15351626
$answerEntity->setSubmissionId($submissionId);
@@ -1546,6 +1637,33 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest
15461637
} elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) {
15471638
$answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, '', $answer);
15481639
}
1640+
1641+
if (!array_key_exists($question['id'], $newAnswerTexts)) {
1642+
$newAnswerTexts[$question['id']] = [];
1643+
}
1644+
$newAnswerTexts[$question['id']][] = $answerText;
1645+
1646+
// has this answer already been stored?
1647+
$foundAnswer = false;
1648+
foreach ($storedAnswers as $storedAnswer) {
1649+
if ($storedAnswer->getText() == $answerText) {
1650+
// nothing to be changed
1651+
$foundAnswer = true;
1652+
break;
1653+
}
1654+
}
1655+
if (!$foundAnswer) {
1656+
if ($answerText === '') {
1657+
continue;
1658+
}
1659+
// need to add answer
1660+
$answerEntity = new Answer();
1661+
$answerEntity->setSubmissionId($submissionId);
1662+
$answerEntity->setQuestionId($question['id']);
1663+
$answerEntity->setText($answerText);
1664+
$this->answerMapper->insert($answerEntity);
1665+
}
1666+
15491667
} elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) {
15501668
$uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']);
15511669
$answerEntity->setFileId($uploadedFile->getFileId());
@@ -1565,20 +1683,43 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest
15651683
$file->move($folder->getPath() . '/' . $name);
15661684

15671685
$answerText = $name;
1686+
1687+
$answerEntity->setText($answerText);
1688+
$this->answerMapper->insert($answerEntity);
15681689
} else {
15691690
$answerText = $answer; // Not a multiple-question, answerText is given answer
1570-
}
15711691

1572-
if ($answerText === '') {
1573-
continue;
1692+
if (!empty($storedAnswers)) {
1693+
$answerEntity = $storedAnswers[0];
1694+
$answerEntity->setText($answerText);
1695+
$this->answerMapper->update($answerEntity);
1696+
} else {
1697+
if ($answerText === '') {
1698+
continue;
1699+
}
1700+
$answerEntity = new Answer();
1701+
$answerEntity->setSubmissionId($submissionId);
1702+
$answerEntity->setQuestionId($question['id']);
1703+
$answerEntity->setText($answerText);
1704+
$this->answerMapper->insert($answerEntity);
1705+
}
15741706
}
15751707

1576-
$answerEntity->setText($answerText);
1577-
$this->answerMapper->insert($answerEntity);
15781708
if ($uploadedFile) {
15791709
$this->uploadedFileMapper->delete($uploadedFile);
15801710
}
15811711
}
1712+
1713+
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
1714+
// drop all answers that are not in new set of answers
1715+
foreach ($storedAnswers as $storedAnswer) {
1716+
$questionId = $storedAnswer->getQuestionId();
1717+
1718+
if (empty($newAnswerTexts[$questionId]) || !in_array($storedAnswer->getText(), $newAnswerTexts[$questionId])) {
1719+
$this->answerMapper->delete($storedAnswer);
1720+
}
1721+
}
1722+
}
15821723
}
15831724

15841725
private function loadFormForSubmission(int $formId, string $shareHash): Form {

lib/Db/AnswerMapper.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ public function findBySubmission(int $submissionId): array {
4141
return $this->findEntities($qb);
4242
}
4343

44+
/**
45+
* @param int $submissionId
46+
* @param int $questionId
47+
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
48+
* @return Answer[]
49+
*/
50+
51+
public function findBySubmissionAndQuestion(int $submissionId, int $questionId): array {
52+
$qb = $this->db->getQueryBuilder();
53+
54+
$qb->select('*')
55+
->from($this->getTableName())
56+
->where(
57+
$qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT)),
58+
$qb->expr()->eq('question_id', $qb->createNamedParameter($questionId, IQueryBuilder::PARAM_INT))
59+
);
60+
61+
return $this->findEntities($qb);
62+
}
63+
4464
/**
4565
* @param int $submissionId
4666
*/

lib/Db/Form.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
* @method void setIsAnonymous(bool $value)
3636
* @method int getSubmitMultiple()
3737
* @method void setSubmitMultiple(bool $value)
38+
* @method int getAllowEdit()
39+
* @method void setAllowEdit(bool $value)
3840
* @method int getShowExpiration()
3941
* @method void setShowExpiration(bool $value)
4042
* @method int getLastUpdated()
@@ -58,6 +60,7 @@ class Form extends Entity {
5860
protected $expires;
5961
protected $isAnonymous;
6062
protected $submitMultiple;
63+
protected $allowEdit;
6164
protected $showExpiration;
6265
protected $submissionMessage;
6366
protected $lastUpdated;
@@ -71,6 +74,7 @@ public function __construct() {
7174
$this->addType('expires', 'integer');
7275
$this->addType('isAnonymous', 'boolean');
7376
$this->addType('submitMultiple', 'boolean');
77+
$this->addType('allowEdit', 'boolean');
7478
$this->addType('showExpiration', 'boolean');
7579
$this->addType('lastUpdated', 'integer');
7680
$this->addType('state', 'integer');
@@ -140,6 +144,7 @@ public function setAccess(array $access): void {
140144
* expires: int,
141145
* isAnonymous: bool,
142146
* submitMultiple: bool,
147+
* allowEdit: bool,
143148
* showExpiration: bool,
144149
* lastUpdated: int,
145150
* submissionMessage: ?string,
@@ -160,6 +165,7 @@ public function read() {
160165
'expires' => (int)$this->getExpires(),
161166
'isAnonymous' => (bool)$this->getIsAnonymous(),
162167
'submitMultiple' => (bool)$this->getSubmitMultiple(),
168+
'allowEdit' => (bool)$this->getAllowEdit(),
163169
'showExpiration' => (bool)$this->getShowExpiration(),
164170
'lastUpdated' => (int)$this->getLastUpdated(),
165171
'submissionMessage' => $this->getSubmissionMessage(),

lib/Db/SubmissionMapper.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ public function findByForm(int $formId): array {
4747
return $this->findEntities($qb);
4848
}
4949

50+
/**
51+
* @param int $formId
52+
* @param string $userId
53+
*
54+
* @return Submission
55+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result
56+
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
57+
*/
58+
public function findByFormAndUser(int $formId, string $userId): Submission {
59+
$qb = $this->db->getQueryBuilder();
60+
61+
$qb->select('*')
62+
->from($this->getTableName())
63+
->where(
64+
$qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)),
65+
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
66+
)
67+
//Newest submissions first
68+
->orderBy('timestamp', 'DESC');
69+
70+
return $this->findEntity($qb);
71+
}
72+
5073
/**
5174
* @param int $id
5275
* @return Submission

lib/FormsMigrator.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
146146
$form->setExpires($formData['expires']);
147147
$form->setIsAnonymous($formData['isAnonymous']);
148148
$form->setSubmitMultiple($formData['submitMultiple']);
149+
$form->setAllowEdit($formData['allowEdit']);
149150
$form->setShowExpiration($formData['showExpiration']);
150151

151152
$this->formMapper->insert($form);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
class Version050000Date20250109201500 extends SimpleMigrationStep {
19+
20+
/**
21+
* @param IOutput $output
22+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
23+
* @param array $options
24+
* @return null|ISchemaWrapper
25+
*/
26+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
27+
/** @var ISchemaWrapper $schema */
28+
$schema = $schemaClosure();
29+
$table = $schema->getTable('forms_v2_forms');
30+
31+
if (!$table->hasColumn('allow_edit')) {
32+
$table->addColumn('allow_edit', Types::BOOLEAN, [
33+
'notnull' => false,
34+
'default' => 0,
35+
]);
36+
37+
return $schema;
38+
}
39+
40+
return null;
41+
}
42+
}

lib/ResponseDefinitions.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
* isAnonymous: bool,
115115
* lastUpdated: int,
116116
* submitMultiple: bool,
117+
* allowEdit: bool,
117118
* showExpiration: bool,
118119
* canSubmit: bool,
119120
* permissions: list<FormsPermission>,
@@ -122,6 +123,9 @@
122123
* shares: list<FormsShare>,
123124
* submissionCount?: int,
124125
* submissionMessage: ?string,
126+
* answers?: array<string,mixed>,
127+
* newSubmission?: bool,
128+
* submissionId?: int,
125129
* }
126130
*
127131
* @psalm-type FormsUploadedFile = array{

0 commit comments

Comments
 (0)