Skip to content

Commit 0a0539f

Browse files
author
Robert
committed
feat(forms): add owner submission notification emails
Signed-off-by: Robert <robert@local>
1 parent 07759e3 commit 0a0539f

21 files changed

+526
-72
lines changed

docs/API_v3.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ Returns the full-depth object of the requested form (without submissions).
170170
"isAnonymous": false,
171171
"submitMultiple": true,
172172
"allowEditSubmissions": false,
173+
"notifyOwnerOnSubmission": false,
173174
"showExpiration": false,
174175
"canSubmit": true,
175176
"state": 0,
@@ -275,6 +276,7 @@ Update a single or multiple properties of a form-object. Concerns **only** the F
275276
- To transfer the ownership of a form to another user, you must only send a single _keyValuePair_ containing the key `ownerId` and the user id of the new owner.
276277
- To link a file for submissions, the _keyValuePairs_ need to contain the keys `path` and `fileFormat`
277278
- To unlink a file for submissions, the _keyValuePairs_ need to contain the keys `fileId` and `fileFormat` need to contain the value `null`
279+
- `notifyOwnerOnSubmission` must be a boolean.
278280
- Response: **Status-Code OK**, as well as the id of the updated form.
279281

280282
```

docs/DataStructure.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This document describes the Object-Structure, that is used within the Forms App
2121
| description | String | max. 8192 ch. | The Form description |
2222
| ownerId | String | | The nextcloud userId of the form owner |
2323
| 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+
| notifyOwnerOnSubmission | Boolean | | If the form owner should receive an email notification with a response summary for each new submission |
2425
| created | unix timestamp | | When the form has been created |
2526
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
2627
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
@@ -66,7 +67,8 @@ This document describes the Object-Structure, that is used within the Forms App
6667
"shares": []
6768
"submissions": [],
6869
"submissionCount": 0,
69-
"submissionMessage": "string"
70+
"submissionMessage": "string",
71+
"notifyOwnerOnSubmission": false
7072
}
7173
```
7274

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\OwnerNotificationListener;
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, OwnerNotificationListener::class);
4649
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
4750
$context->registerMiddleware(ThrottleFormAccessMiddleware::class);
4851
$context->registerSearchProvider(SearchProvider::class);

lib/Controller/ApiController.php

Lines changed: 11 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;
@@ -178,6 +179,8 @@ public function newForm(?int $fromId = null): DataResponse {
178179
$form->setShowExpiration(false);
179180
$form->setExpires(0);
180181
$form->setIsAnonymous(false);
182+
$form->setNotifyOwnerOnSubmission(false);
183+
$form->setState(Constants::FORM_STATE_ACTIVE);
181184

182185
$this->formMapper->insert($form);
183186
} else {
@@ -206,6 +209,8 @@ public function newForm(?int $fromId = null): DataResponse {
206209
$formData['showExpiration'] = false;
207210
$formData['expires'] = 0;
208211
$formData['isAnonymous'] = false;
212+
$formData['notifyOwnerOnSubmission'] = false;
213+
$formData['state'] = Constants::FORM_STATE_ACTIVE;
209214

210215
$form = Form::fromParams($formData);
211216
$this->formMapper->insert($form);
@@ -316,6 +321,10 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse {
316321
// Do not allow changing showToAllUsers or permitAllUsers if disabled
317322
$this->checkAccessUpdate($keyValuePairs);
318323

324+
if (isset($keyValuePairs['notifyOwnerOnSubmission']) && !is_bool($keyValuePairs['notifyOwnerOnSubmission'])) {
325+
throw new OCSBadRequestException('notifyOwnerOnSubmission must be a boolean');
326+
}
327+
319328
// Process file linking
320329
if (isset($keyValuePairs['path']) && isset($keyValuePairs['fileFormat'])) {
321330
$file = $this->submissionService->writeFileToCloud($form, $keyValuePairs['path'], $keyValuePairs['fileFormat']);
@@ -1405,7 +1414,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
14051414
$this->formMapper->update($form);
14061415

14071416
//Create Activity
1408-
$this->formsService->notifyNewSubmission($form, $submission);
1417+
$this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_CREATED);
14091418

14101419
if ($form->getFileId() !== null) {
14111420
$this->jobList->add(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => $form->getId()]);
@@ -1487,7 +1496,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers)
14871496
}
14881497

14891498
//Create Activity
1490-
$this->formsService->notifyNewSubmission($form, $submission);
1499+
$this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED);
14911500

14921501
return new DataResponse($submissionId);
14931502
}

lib/Db/Form.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
* @method void setLastUpdated(int $value)
4444
* @method string|null getSubmissionMessage()
4545
* @method void setSubmissionMessage(string|null $value)
46+
* @method bool getNotifyOwnerOnSubmission()
47+
* @method void setNotifyOwnerOnSubmission(bool $value)
4648
* @method int getState()
4749
* @psalm-method 0|1|2 getState()
4850
* @method void setState(int|null $value)
@@ -69,6 +71,7 @@ class Form extends Entity {
6971
protected $allowEditSubmissions;
7072
protected $showExpiration;
7173
protected $submissionMessage;
74+
protected $notifyOwnerOnSubmission;
7275
protected $lastUpdated;
7376
protected $state;
7477
protected $lockedBy;
@@ -85,6 +88,7 @@ public function __construct() {
8588
$this->addType('submitMultiple', 'boolean');
8689
$this->addType('allowEditSubmissions', 'boolean');
8790
$this->addType('showExpiration', 'boolean');
91+
$this->addType('notifyOwnerOnSubmission', 'boolean');
8892
$this->addType('lastUpdated', 'integer');
8993
$this->addType('state', 'integer');
9094
$this->addType('lockedBy', 'string');
@@ -160,6 +164,7 @@ public function setAccess(array $access): void {
160164
* showExpiration: bool,
161165
* lastUpdated: int,
162166
* submissionMessage: ?string,
167+
* notifyOwnerOnSubmission: bool,
163168
* state: 0|1|2,
164169
* lockedBy: ?string,
165170
* lockedUntil: ?int,
@@ -184,6 +189,7 @@ public function read() {
184189
'showExpiration' => (bool)$this->getShowExpiration(),
185190
'lastUpdated' => (int)$this->getLastUpdated(),
186191
'submissionMessage' => $this->getSubmissionMessage(),
192+
'notifyOwnerOnSubmission' => (bool)$this->getNotifyOwnerOnSubmission(),
187193
'state' => $this->getState(),
188194
'lockedBy' => $this->getLockedBy(),
189195
'lockedUntil' => $this->getLockedUntil(),

lib/Events/FormSubmittedEvent.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,35 @@
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+
public const TRIGGER_VERIFIED = 'verified';
17+
1418
public function __construct(
1519
Form $form,
1620
private Submission $submission,
21+
private string $trigger = self::TRIGGER_CREATED,
1722
) {
1823
parent::__construct($form);
1924
}
2025

26+
public function getSubmission(): Submission {
27+
return $this->submission;
28+
}
29+
30+
public function getTrigger(): string {
31+
return $this->trigger;
32+
}
33+
34+
public function isNewSubmission(): bool {
35+
return $this->trigger === self::TRIGGER_CREATED;
36+
}
37+
2138
public function getWebhookSerializable(): array {
2239
return [
2340
'form' => $this->form->read(),
2441
'submission' => $this->submission->read(),
42+
'trigger' => $this->trigger,
2543
];
2644
}
2745
}

lib/FormsMigrator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
148148
$form->setSubmitMultiple($formData['submitMultiple']);
149149
$form->setAllowEditSubmissions($formData['allowEditSubmissions']);
150150
$form->setShowExpiration($formData['showExpiration']);
151-
$form->setMaxSubmissions($formData['maxSubmissions'] ?? null);
151+
$form->setNotifyOwnerOnSubmission($formData['notifyOwnerOnSubmission'] ?? false);
152152

153153
$this->formMapper->insert($form);
154154

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 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\OwnerNotificationMailService;
17+
use OCP\AppFramework\Db\DoesNotExistException;
18+
use OCP\EventDispatcher\Event;
19+
use OCP\EventDispatcher\IEventListener;
20+
use OCP\IUserManager;
21+
use Psr\Log\LoggerInterface;
22+
23+
/**
24+
* @implements IEventListener<FormSubmittedEvent>
25+
*/
26+
class OwnerNotificationListener implements IEventListener {
27+
public function __construct(
28+
private OwnerNotificationMailService $ownerNotificationMailService,
29+
private AnswerMapper $answerMapper,
30+
private QuestionMapper $questionMapper,
31+
private IUserManager $userManager,
32+
private LoggerInterface $logger,
33+
) {
34+
}
35+
36+
public function handle(Event $event): void {
37+
if (!($event instanceof FormSubmittedEvent)) {
38+
return;
39+
}
40+
if (!$event->isNewSubmission()) {
41+
return;
42+
}
43+
44+
$form = $event->getForm();
45+
if (!$form->getNotifyOwnerOnSubmission()) {
46+
return;
47+
}
48+
49+
$submission = $event->getSubmission();
50+
$owner = $this->userManager->get($form->getOwnerId());
51+
$ownerMail = trim((string)$owner?->getEMailAddress());
52+
if ($ownerMail === '') {
53+
return;
54+
}
55+
56+
$answerSummaries = [];
57+
try {
58+
$answers = $this->answerMapper->findBySubmission($submission->getId());
59+
} catch (DoesNotExistException $e) {
60+
return;
61+
}
62+
foreach ($answers as $answer) {
63+
try {
64+
$question = $this->questionMapper->findById($answer->getQuestionId());
65+
} catch (DoesNotExistException $e) {
66+
$this->logger->warning('Question missing while preparing owner notification mail', [
67+
'formId' => $form->getId(),
68+
'submissionId' => $submission->getId(),
69+
'questionId' => $answer->getQuestionId(),
70+
]);
71+
continue;
72+
}
73+
74+
$questionType = $question->getType();
75+
$answerText = trim($answer->getText() ?? '');
76+
if (
77+
$answerText !== ''
78+
&& in_array($questionType, [Constants::ANSWER_TYPE_SHORT, Constants::ANSWER_TYPE_LONG], true)
79+
) {
80+
$answerSummaries[] = [
81+
'question' => $question->getText(),
82+
'answer' => $answerText,
83+
];
84+
}
85+
}
86+
87+
$this->ownerNotificationMailService->send(
88+
$form,
89+
$submission,
90+
[$ownerMail],
91+
$answerSummaries,
92+
);
93+
}
94+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 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 Version050300Date20260228170000 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+
30+
$formsTable = $schema->getTable('forms_v2_forms');
31+
if (!$formsTable->hasColumn('notify_owner_on_submission')) {
32+
$formsTable->addColumn('notify_owner_on_submission', Types::BOOLEAN, [
33+
'notnull' => false,
34+
'default' => false,
35+
]);
36+
}
37+
38+
return $schema;
39+
}
40+
}

lib/ResponseDefinitions.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
* shares: list<FormsShare>,
142142
* submissionCount?: int,
143143
* submissionMessage: ?string,
144+
* notifyOwnerOnSubmission: bool,
144145
* }
145146
*
146147
* @psalm-type FormsUploadedFile = array{

0 commit comments

Comments
 (0)