Skip to content

Commit c89d2d5

Browse files
committed
feat(forms): add respondent confirmation emails
Signed-off-by: Robert <61327381+QuiteBitter@users.noreply.github.com>
1 parent 07759e3 commit c89d2d5

18 files changed

+635
-32
lines changed

docs/API_v3.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,9 @@ Update a single or multiple properties of a question-object.
436436
| Parameter | Type | Description |
437437
|-----------|---------|-------------|
438438
| _keyValuePairs_ | Array | Array of key-value pairs to update |
439-
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
439+
- Restrictions:
440+
- It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
441+
- `extraSettings.confirmationRecipient` can only be enabled for short questions with `extraSettings.validationType` set to `email`.
440442
- Response: **Status-Code OK**, as well as the id of the updated question.
441443

442444
```
@@ -901,6 +903,7 @@ Store Submission to Database
901903
- An **array** of values as value --> Even for short Text Answers, wrapped into Array.
902904
- For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs.
903905
- For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint).
906+
- To send a respondent confirmation email, set the corresponding short question to `validationType = email` and `confirmationRecipient = true`.
904907

905908
```
906909
{

docs/DataStructure.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ Optional extra settings for some [Question Types](#question-types)
240240
| `optionsLimitMin` | `multiple` | Integer | - | Minimum number of options that must be selected |
241241
| `validationType` | `short` | string | `null, 'phone', 'email', 'regex', 'number'` | Custom validation for checking a submission |
242242
| `validationRegex` | `short` | string | regular expression | if `validationType` is 'regex' this defines the regular expression to apply |
243+
| `confirmationRecipient` | `short` | Boolean | `true/false` | Marks an email question as recipient for respondent confirmation emails |
243244
| `allowedFileTypes` | `file` | Array of strings | `'image', 'x-office/document'` | Allowed file types for file upload |
244245
| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
245246
| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |

lib/AppInfo/Application.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
use OCA\Analytics\Datasource\DatasourceEvent;
1313
use OCA\Forms\Capabilities;
14+
use OCA\Forms\Events\FormSubmittedEvent;
1415
use OCA\Forms\FormsMigrator;
1516
use OCA\Forms\Listener\AnalyticsDatasourceListener;
17+
use OCA\Forms\Listener\ConfirmationEmailListener;
1618
use OCA\Forms\Listener\UserDeletedListener;
1719
use OCA\Forms\Middleware\ThrottleFormAccessMiddleware;
1820
use OCA\Forms\Search\SearchProvider;
@@ -43,6 +45,7 @@ public function register(IRegistrationContext $context): void {
4345

4446
$context->registerCapability(Capabilities::class);
4547
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
48+
$context->registerEventListener(FormSubmittedEvent::class, ConfirmationEmailListener::class);
4649
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
4750
$context->registerMiddleware(ThrottleFormAccessMiddleware::class);
4851
$context->registerSearchProvider(SearchProvider::class);

lib/Constants.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ class Constants {
149149
public const EXTRA_SETTINGS_SHORT = [
150150
'validationType' => ['string'],
151151
'validationRegex' => ['string'],
152+
'confirmationRecipient' => ['boolean'],
152153
];
153154

154155
public const EXTRA_SETTINGS_FILE = [

lib/Controller/ApiController.php

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use OCA\Forms\Db\SubmissionMapper;
2323
use OCA\Forms\Db\UploadedFile;
2424
use OCA\Forms\Db\UploadedFileMapper;
25+
use OCA\Forms\Events\FormSubmittedEvent;
2526
use OCA\Forms\Exception\NoSuchFormException;
2627
use OCA\Forms\ResponseDefinitions;
2728
use OCA\Forms\Service\ConfigService;
@@ -546,6 +547,10 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype
546547
$questionData = $sourceQuestion->read();
547548
unset($questionData['id']);
548549
$questionData['order'] = end($allQuestions)->getOrder() + 1;
550+
if (is_array($questionData['extraSettings'] ?? null)
551+
&& ($questionData['extraSettings']['confirmationRecipient'] ?? false) === true) {
552+
$questionData['extraSettings']['confirmationRecipient'] = false;
553+
}
549554

550555
$newQuestion = Question::fromParams($questionData);
551556
$this->questionMapper->insert($newQuestion);
@@ -648,6 +653,12 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair
648653
if (key_exists('extraSettings', $keyValuePairs) && !$this->formsService->areExtraSettingsValid($keyValuePairs['extraSettings'], $question->getType())) {
649654
throw new OCSBadRequestException('Invalid extraSettings, will not update.');
650655
}
656+
$this->assertSingleConfirmationRecipientQuestion(
657+
$formId,
658+
$questionId,
659+
$question->getType(),
660+
is_array($keyValuePairs['extraSettings'] ?? null) ? $keyValuePairs['extraSettings'] : null,
661+
);
651662

652663
// Create QuestionEntity with given Params & Id.
653664
$question = Question::fromParams($keyValuePairs);
@@ -1405,7 +1416,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
14051416
$this->formMapper->update($form);
14061417

14071418
//Create Activity
1408-
$this->formsService->notifyNewSubmission($form, $submission);
1419+
$this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_CREATED);
14091420

14101421
if ($form->getFileId() !== null) {
14111422
$this->jobList->add(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => $form->getId()]);
@@ -1487,7 +1498,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers)
14871498
}
14881499

14891500
//Create Activity
1490-
$this->formsService->notifyNewSubmission($form, $submission);
1501+
$this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED);
14911502

14921503
return new DataResponse($submissionId);
14931504
}
@@ -1827,6 +1838,32 @@ private function checkAccessUpdate(array $keyValuePairs): void {
18271838
}
18281839
}
18291840

1841+
/**
1842+
* Ensure only one short-email question can be used as confirmation recipient in a form.
1843+
*
1844+
* @param array<string, mixed>|null $extraSettings
1845+
*/
1846+
private function assertSingleConfirmationRecipientQuestion(int $formId, int $questionId, string $questionType, ?array $extraSettings): void {
1847+
if ($questionType !== Constants::ANSWER_TYPE_SHORT || !is_array($extraSettings) || ($extraSettings['confirmationRecipient'] ?? false) !== true) {
1848+
return;
1849+
}
1850+
1851+
$formQuestions = $this->questionMapper->findByForm($formId);
1852+
foreach ($formQuestions as $formQuestion) {
1853+
if ($formQuestion->getId() === $questionId) {
1854+
continue;
1855+
}
1856+
1857+
$existingSettings = $formQuestion->getExtraSettings();
1858+
$isExistingConfirmationRecipient = $formQuestion->getType() === Constants::ANSWER_TYPE_SHORT
1859+
&& ($existingSettings['validationType'] ?? null) === 'email'
1860+
&& ($existingSettings['confirmationRecipient'] ?? false) === true;
1861+
if ($isExistingConfirmationRecipient) {
1862+
throw new OCSBadRequestException('Only one confirmation recipient question is allowed per form');
1863+
}
1864+
}
1865+
}
1866+
18301867
/**
18311868
* Checks if the current user is allowed to archive/unarchive the form
18321869
*/

lib/Events/FormSubmittedEvent.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,34 @@
1111
use OCA\Forms\Db\Submission;
1212

1313
class FormSubmittedEvent extends AbstractFormEvent {
14+
public const TRIGGER_CREATED = 'created';
15+
public const TRIGGER_UPDATED = 'updated';
16+
1417
public function __construct(
1518
Form $form,
1619
private Submission $submission,
20+
private string $trigger = self::TRIGGER_CREATED,
1721
) {
1822
parent::__construct($form);
1923
}
2024

25+
public function getSubmission(): Submission {
26+
return $this->submission;
27+
}
28+
29+
public function getTrigger(): string {
30+
return $this->trigger;
31+
}
32+
33+
public function isNewSubmission(): bool {
34+
return $this->trigger === self::TRIGGER_CREATED;
35+
}
36+
2137
public function getWebhookSerializable(): array {
2238
return [
2339
'form' => $this->form->read(),
2440
'submission' => $this->submission->read(),
41+
'trigger' => $this->trigger,
2542
];
2643
}
2744
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Listener;
11+
12+
use OCA\Forms\Constants;
13+
use OCA\Forms\Db\AnswerMapper;
14+
use OCA\Forms\Db\QuestionMapper;
15+
use OCA\Forms\Events\FormSubmittedEvent;
16+
use OCA\Forms\Service\ConfirmationMailService;
17+
use OCP\AppFramework\Db\DoesNotExistException;
18+
use OCP\EventDispatcher\Event;
19+
use OCP\EventDispatcher\IEventListener;
20+
use Psr\Log\LoggerInterface;
21+
22+
/**
23+
* @implements IEventListener<FormSubmittedEvent>
24+
*/
25+
class ConfirmationEmailListener implements IEventListener {
26+
public function __construct(
27+
private ConfirmationMailService $confirmationMailService,
28+
private AnswerMapper $answerMapper,
29+
private QuestionMapper $questionMapper,
30+
private LoggerInterface $logger,
31+
) {
32+
}
33+
34+
public function handle(Event $event): void {
35+
if (!($event instanceof FormSubmittedEvent)) {
36+
return;
37+
}
38+
if (!$event->isNewSubmission()) {
39+
return;
40+
}
41+
42+
$submission = $event->getSubmission();
43+
$form = $event->getForm();
44+
45+
$emailAddress = null;
46+
$answerSummaries = [];
47+
try {
48+
$answers = $this->answerMapper->findBySubmission($submission->getId());
49+
} catch (DoesNotExistException $e) {
50+
return;
51+
}
52+
$hasAmbiguousRecipients = false;
53+
54+
foreach ($answers as $answer) {
55+
try {
56+
$question = $this->questionMapper->findById($answer->getQuestionId());
57+
} catch (DoesNotExistException $e) {
58+
$this->logger->warning('Question missing while preparing confirmation mail', [
59+
'formId' => $form->getId(),
60+
'submissionId' => $submission->getId(),
61+
'questionId' => $answer->getQuestionId(),
62+
]);
63+
continue;
64+
}
65+
66+
$questionType = $question->getType();
67+
$answerText = trim($answer->getText() ?? '');
68+
69+
$extraSettings = $question->getExtraSettings();
70+
$isEmailQuestion = $questionType === Constants::ANSWER_TYPE_SHORT
71+
&& (($extraSettings['validationType'] ?? null) === 'email');
72+
$isConfirmationRecipient = ($extraSettings['confirmationRecipient'] ?? false) === true;
73+
74+
if ($answerText !== '' && $isEmailQuestion && $isConfirmationRecipient) {
75+
if ($emailAddress !== null && !hash_equals($emailAddress, $answerText)) {
76+
$hasAmbiguousRecipients = true;
77+
break;
78+
}
79+
$emailAddress = $answerText;
80+
}
81+
82+
if (
83+
$answerText !== ''
84+
&& in_array($questionType, [Constants::ANSWER_TYPE_SHORT, Constants::ANSWER_TYPE_LONG], true)
85+
) {
86+
$answerSummaries[] = [
87+
'question' => $question->getText(),
88+
'answer' => $answerText,
89+
];
90+
}
91+
}
92+
if ($hasAmbiguousRecipients) {
93+
$this->logger->warning('Skipping confirmation mail because multiple confirmation recipient questions were answered', [
94+
'formId' => $form->getId(),
95+
'submissionId' => $submission->getId(),
96+
]);
97+
return;
98+
}
99+
100+
if ($emailAddress === null) {
101+
return;
102+
}
103+
104+
$this->confirmationMailService->send($form, $submission, $emailAddress, $answerSummaries);
105+
}
106+
}

lib/ResponseDefinitions.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
* timeRange?: bool,
4141
* validationRegex?: string,
4242
* validationType?: string,
43+
* confirmationRecipient?: bool,
4344
* questionType?: string,
4445
* }
4546
*
@@ -110,7 +111,6 @@
110111
* state: int,
111112
* lockedBy: ?string,
112113
* lockedUntil: ?int,
113-
* maxSubmissions: ?int,
114114
* }
115115
*
116116
* @psalm-type FormsForm = array{
@@ -126,7 +126,6 @@
126126
* fileId: ?int,
127127
* filePath?: ?string,
128128
* isAnonymous: bool,
129-
* isMaxSubmissionsReached: bool,
130129
* lastUpdated: int,
131130
* submitMultiple: bool,
132131
* allowEditSubmissions: bool,
@@ -137,7 +136,6 @@
137136
* state: 0|1|2,
138137
* lockedBy: ?string,
139138
* lockedUntil: ?int,
140-
* maxSubmissions: ?int,
141139
* shares: list<FormsShare>,
142140
* submissionCount?: int,
143141
* submissionMessage: ?string,

0 commit comments

Comments
 (0)