diff --git a/docs/API_v3.md b/docs/API_v3.md index 40c61d54c..d18f381a9 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -170,6 +170,9 @@ Returns the full-depth object of the requested form (without submissions). "isAnonymous": false, "submitMultiple": true, "allowEditSubmissions": false, + "notifyOwnerOnSubmission": false, + "attachSubmissionPdf": false, + "notificationRecipients": ["team@example.com"], "showExpiration": false, "canSubmit": true, "state": 0, @@ -275,6 +278,9 @@ Update a single or multiple properties of a form-object. Concerns **only** the F - 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. - To link a file for submissions, the _keyValuePairs_ need to contain the keys `path` and `fileFormat` - To unlink a file for submissions, the _keyValuePairs_ need to contain the keys `fileId` and `fileFormat` need to contain the value `null` + - `notifyOwnerOnSubmission` must be a boolean. + - `attachSubmissionPdf` must be a boolean. + - `notificationRecipients` must be an array of valid email addresses. - Response: **Status-Code OK**, as well as the id of the updated form. ``` diff --git a/docs/DataStructure.md b/docs/DataStructure.md index a4dc03c64..f1b34c2fe 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -21,6 +21,9 @@ This document describes the Object-Structure, that is used within the Forms App | description | String | max. 8192 ch. | The Form description | | ownerId | String | | The nextcloud userId of the form owner | | 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) | +| notifyOwnerOnSubmission | Boolean | | If the form owner should receive an email notification with a response summary for each new submission | +| attachSubmissionPdf | Boolean | | If notification emails should include a PDF attachment with the submitted responses | +| notificationRecipients | Array of Strings | | Additional email recipients to notify for each new submission, independent of `notifyOwnerOnSubmission` | | created | unix timestamp | | When the form has been created | | access | [Access-Object](#access-object) | | Describing access-settings of the form | | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | @@ -66,7 +69,10 @@ This document describes the Object-Structure, that is used within the Forms App "shares": [] "submissions": [], "submissionCount": 0, - "submissionMessage": "string" + "submissionMessage": "string", + "notifyOwnerOnSubmission": false, + "attachSubmissionPdf": false, + "notificationRecipients": ["team@example.com"] } ``` diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 82c17f12f..f73f01469 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,8 +11,10 @@ use OCA\Analytics\Datasource\DatasourceEvent; use OCA\Forms\Capabilities; +use OCA\Forms\Events\FormSubmittedEvent; use OCA\Forms\FormsMigrator; use OCA\Forms\Listener\AnalyticsDatasourceListener; +use OCA\Forms\Listener\OwnerNotificationListener; use OCA\Forms\Listener\UserDeletedListener; use OCA\Forms\Middleware\ThrottleFormAccessMiddleware; use OCA\Forms\Search\SearchProvider; @@ -43,6 +45,7 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + $context->registerEventListener(FormSubmittedEvent::class, OwnerNotificationListener::class); $context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class); $context->registerMiddleware(ThrottleFormAccessMiddleware::class); $context->registerSearchProvider(SearchProvider::class); diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 0cbe6a73e..9d724c67c 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -22,6 +22,7 @@ use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Db\UploadedFile; use OCA\Forms\Db\UploadedFileMapper; +use OCA\Forms\Events\FormSubmittedEvent; use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\ConfigService; @@ -52,6 +53,7 @@ use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Mail\IMailer; use Psr\Log\LoggerInterface; @@ -68,6 +70,8 @@ * @psalm-import-type FormsUploadedFile from ResponseDefinitions */ class ApiController extends OCSController { + private const MAX_NOTIFICATION_RECIPIENTS = 20; + private ?IUser $currentUser; public function __construct( @@ -90,6 +94,7 @@ public function __construct( private UploadedFileMapper $uploadedFileMapper, private IMimeTypeDetector $mimeTypeDetector, private IJobList $jobList, + private IMailer $mailer, ) { parent::__construct($appName, $request); $this->currentUser = $userSession->getUser(); @@ -178,6 +183,10 @@ public function newForm(?int $fromId = null): DataResponse { $form->setShowExpiration(false); $form->setExpires(0); $form->setIsAnonymous(false); + $form->setNotifyOwnerOnSubmission(false); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setAttachSubmissionPdf(false); + $form->setNotificationRecipients([]); $this->formMapper->insert($form); } else { @@ -206,6 +215,10 @@ public function newForm(?int $fromId = null): DataResponse { $formData['showExpiration'] = false; $formData['expires'] = 0; $formData['isAnonymous'] = false; + $formData['notifyOwnerOnSubmission'] = false; + $formData['state'] = Constants::FORM_STATE_ACTIVE; + $formData['attachSubmissionPdf'] = false; + $formData['notificationRecipients'] = []; $form = Form::fromParams($formData); $this->formMapper->insert($form); @@ -316,6 +329,18 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { // Do not allow changing showToAllUsers or permitAllUsers if disabled $this->checkAccessUpdate($keyValuePairs); + if (isset($keyValuePairs['notifyOwnerOnSubmission']) && !is_bool($keyValuePairs['notifyOwnerOnSubmission'])) { + throw new OCSBadRequestException('notifyOwnerOnSubmission must be a boolean'); + } + + if (isset($keyValuePairs['attachSubmissionPdf']) && !is_bool($keyValuePairs['attachSubmissionPdf'])) { + throw new OCSBadRequestException('attachSubmissionPdf must be a boolean'); + } + + if (array_key_exists('notificationRecipients', $keyValuePairs)) { + $keyValuePairs['notificationRecipients'] = $this->normalizeNotificationRecipients($keyValuePairs['notificationRecipients']); + } + // Process file linking if (isset($keyValuePairs['path']) && isset($keyValuePairs['fileFormat'])) { $file = $this->submissionService->writeFileToCloud($form, $keyValuePairs['path'], $keyValuePairs['fileFormat']); @@ -1405,7 +1430,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' $this->formMapper->update($form); //Create Activity - $this->formsService->notifyNewSubmission($form, $submission); + $this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_CREATED); if ($form->getFileId() !== null) { $this->jobList->add(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => $form->getId()]); @@ -1487,7 +1512,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers) } //Create Activity - $this->formsService->notifyNewSubmission($form, $submission); + $this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED); return new DataResponse($submissionId); } @@ -1827,6 +1852,42 @@ private function checkAccessUpdate(array $keyValuePairs): void { } } + /** + * @param mixed $notificationRecipients + * @return list + */ + private function normalizeNotificationRecipients(mixed $notificationRecipients): array { + if (!is_array($notificationRecipients)) { + throw new OCSBadRequestException('notificationRecipients must be an array'); + } + + $normalizedRecipients = []; + foreach ($notificationRecipients as $recipient) { + if (!is_string($recipient)) { + throw new OCSBadRequestException('notificationRecipients must be an array of strings'); + } + + $trimmedRecipient = trim($recipient); + if ($trimmedRecipient === '') { + continue; + } + + if (!$this->mailer->validateMailAddress($trimmedRecipient)) { + throw new OCSBadRequestException('notificationRecipients contains an invalid email address'); + } + + $recipientKey = strtolower($trimmedRecipient); + if (!isset($normalizedRecipients[$recipientKey])) { + $normalizedRecipients[$recipientKey] = $trimmedRecipient; + } + } + + if (count($normalizedRecipients) > self::MAX_NOTIFICATION_RECIPIENTS) { + throw new OCSBadRequestException('Too many notificationRecipients'); + } + + return array_values($normalizedRecipients); + } /** * Checks if the current user is allowed to archive/unarchive the form */ diff --git a/lib/Db/Form.php b/lib/Db/Form.php index d3c9999df..fbacd65d1 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -43,6 +43,12 @@ * @method void setLastUpdated(int $value) * @method string|null getSubmissionMessage() * @method void setSubmissionMessage(string|null $value) + * @method bool getNotifyOwnerOnSubmission() + * @method void setNotifyOwnerOnSubmission(bool $value) + * @method bool getAttachSubmissionPdf() + * @method void setAttachSubmissionPdf(bool $value) + * @method string|null getNotificationRecipientsJson() + * @method void setNotificationRecipientsJson(?string $value) * @method int getState() * @psalm-method 0|1|2 getState() * @method void setState(int|null $value) @@ -69,6 +75,9 @@ class Form extends Entity { protected $allowEditSubmissions; protected $showExpiration; protected $submissionMessage; + protected $notifyOwnerOnSubmission; + protected $attachSubmissionPdf; + protected $notificationRecipientsJson; protected $lastUpdated; protected $state; protected $lockedBy; @@ -85,6 +94,8 @@ public function __construct() { $this->addType('submitMultiple', 'boolean'); $this->addType('allowEditSubmissions', 'boolean'); $this->addType('showExpiration', 'boolean'); + $this->addType('notifyOwnerOnSubmission', 'boolean'); + $this->addType('attachSubmissionPdf', 'boolean'); $this->addType('lastUpdated', 'integer'); $this->addType('state', 'integer'); $this->addType('lockedBy', 'string'); @@ -142,6 +153,37 @@ public function setAccess(array $access): void { $this->setAccessEnum($value); } + /** + * @return list + */ + public function getNotificationRecipients(): array { + $encodedRecipients = $this->getNotificationRecipientsJson(); + if ($encodedRecipients === null || $encodedRecipients === '') { + return []; + } + + $decodedRecipients = json_decode($encodedRecipients, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decodedRecipients)) { + return []; + } + + return array_values(array_filter(array_map(static fn (mixed $recipient): string => trim((string)$recipient), $decodedRecipients), static fn (string $recipient): bool => $recipient !== '')); + } + + /** + * @param list $recipients + */ + public function setNotificationRecipients(array $recipients): void { + $normalizedRecipients = array_values(array_filter(array_map(static fn (string $recipient): string => trim($recipient), $recipients), static fn (string $recipient): bool => $recipient !== '')); + + if ($normalizedRecipients === []) { + $this->setNotificationRecipientsJson(null); + return; + } + + $this->setNotificationRecipientsJson(json_encode($normalizedRecipients, JSON_THROW_ON_ERROR)); + } + /** * @return array{ * id: int, @@ -160,6 +202,9 @@ public function setAccess(array $access): void { * showExpiration: bool, * lastUpdated: int, * submissionMessage: ?string, + * notifyOwnerOnSubmission: bool, + * attachSubmissionPdf: bool, + * notificationRecipients: list, * state: 0|1|2, * lockedBy: ?string, * lockedUntil: ?int, @@ -184,6 +229,9 @@ public function read() { 'showExpiration' => (bool)$this->getShowExpiration(), 'lastUpdated' => (int)$this->getLastUpdated(), 'submissionMessage' => $this->getSubmissionMessage(), + 'notifyOwnerOnSubmission' => (bool)$this->getNotifyOwnerOnSubmission(), + 'attachSubmissionPdf' => (bool)$this->getAttachSubmissionPdf(), + 'notificationRecipients' => $this->getNotificationRecipients(), 'state' => $this->getState(), 'lockedBy' => $this->getLockedBy(), 'lockedUntil' => $this->getLockedUntil(), diff --git a/lib/Events/FormSubmittedEvent.php b/lib/Events/FormSubmittedEvent.php index dde7ec158..7f2923473 100644 --- a/lib/Events/FormSubmittedEvent.php +++ b/lib/Events/FormSubmittedEvent.php @@ -11,17 +11,35 @@ use OCA\Forms\Db\Submission; class FormSubmittedEvent extends AbstractFormEvent { + public const TRIGGER_CREATED = 'created'; + public const TRIGGER_UPDATED = 'updated'; + public const TRIGGER_VERIFIED = 'verified'; + public function __construct( Form $form, private Submission $submission, + private string $trigger = self::TRIGGER_CREATED, ) { parent::__construct($form); } + public function getSubmission(): Submission { + return $this->submission; + } + + public function getTrigger(): string { + return $this->trigger; + } + + public function isNewSubmission(): bool { + return $this->trigger === self::TRIGGER_CREATED; + } + public function getWebhookSerializable(): array { return [ 'form' => $this->form->read(), 'submission' => $this->submission->read(), + 'trigger' => $this->trigger, ]; } } diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 5a74db027..d38c8c22f 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -148,7 +148,9 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setSubmitMultiple($formData['submitMultiple']); $form->setAllowEditSubmissions($formData['allowEditSubmissions']); $form->setShowExpiration($formData['showExpiration']); - $form->setMaxSubmissions($formData['maxSubmissions'] ?? null); + $form->setNotifyOwnerOnSubmission($formData['notifyOwnerOnSubmission'] ?? false); + $form->setAttachSubmissionPdf($formData['attachSubmissionPdf'] ?? false); + $form->setNotificationRecipients($formData['notificationRecipients'] ?? []); $this->formMapper->insert($form); diff --git a/lib/Listener/OwnerNotificationListener.php b/lib/Listener/OwnerNotificationListener.php new file mode 100644 index 000000000..386cb2bd1 --- /dev/null +++ b/lib/Listener/OwnerNotificationListener.php @@ -0,0 +1,116 @@ + + */ +class OwnerNotificationListener implements IEventListener { + public function __construct( + private OwnerNotificationMailService $ownerNotificationMailService, + private AnswerMapper $answerMapper, + private QuestionMapper $questionMapper, + private IUserManager $userManager, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof FormSubmittedEvent)) { + return; + } + if (!$event->isNewSubmission()) { + return; + } + + $form = $event->getForm(); + $submission = $event->getSubmission(); + $recipients = $form->getNotificationRecipients(); + + if ($form->getNotifyOwnerOnSubmission()) { + $owner = $this->userManager->get($form->getOwnerId()); + $ownerMail = $owner?->getEMailAddress(); + if (is_string($ownerMail) && trim($ownerMail) !== '') { + $recipients[] = trim($ownerMail); + } + } + + $normalizedRecipients = []; + foreach ($recipients as $recipient) { + $trimmedRecipient = trim($recipient); + if ($trimmedRecipient === '') { + continue; + } + + $normalizedRecipients[strtolower($trimmedRecipient)] = $trimmedRecipient; + } + + if ($normalizedRecipients === []) { + return; + } + + $answerSummaries = []; + $pdfAnswerEntries = []; + try { + $answers = $this->answerMapper->findBySubmission($submission->getId()); + } catch (DoesNotExistException $e) { + return; + } + foreach ($answers as $answer) { + try { + $question = $this->questionMapper->findById($answer->getQuestionId()); + } catch (DoesNotExistException $e) { + $this->logger->warning('Question missing while preparing owner notification mail', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'questionId' => $answer->getQuestionId(), + ]); + continue; + } + + $questionType = $question->getType(); + $answerText = trim($answer->getText() ?? ''); + if ($answerText !== '') { + $pdfAnswerEntries[] = [ + 'question' => $question->getText(), + 'answer' => $answerText, + ]; + } + if ( + $answerText !== '' + && in_array($questionType, [Constants::ANSWER_TYPE_SHORT, Constants::ANSWER_TYPE_LONG], true) + ) { + $answerSummaries[] = [ + 'question' => $question->getText(), + 'answer' => $answerText, + ]; + } + } + + $this->ownerNotificationMailService->send( + $form, + $submission, + array_values($normalizedRecipients), + $answerSummaries, + $pdfAnswerEntries, + ); + } +} diff --git a/lib/Migration/Version050300Date20260228170000.php b/lib/Migration/Version050300Date20260228170000.php new file mode 100644 index 000000000..6f02adfbf --- /dev/null +++ b/lib/Migration/Version050300Date20260228170000.php @@ -0,0 +1,47 @@ +getTable('forms_v2_forms'); + if (!$formsTable->hasColumn('notify_owner_on_submission')) { + $formsTable->addColumn('notify_owner_on_submission', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + + if (!$formsTable->hasColumn('notification_recipients_json')) { + $formsTable->addColumn('notification_recipients_json', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/Migration/Version050300Date20260228190000.php b/lib/Migration/Version050300Date20260228190000.php new file mode 100644 index 000000000..67567a5d6 --- /dev/null +++ b/lib/Migration/Version050300Date20260228190000.php @@ -0,0 +1,40 @@ +getTable('forms_v2_forms'); + if (!$formsTable->hasColumn('attach_submission_pdf')) { + $formsTable->addColumn('attach_submission_pdf', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 27fea25bb..7326113e3 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -141,6 +141,9 @@ * shares: list, * submissionCount?: int, * submissionMessage: ?string, + * notifyOwnerOnSubmission: bool, + * attachSubmissionPdf: bool, + * notificationRecipients: list, * } * * @psalm-type FormsUploadedFile = array{ diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index f5b421c3a..92d8da52b 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -284,6 +284,8 @@ public function getPublicForm(Form $form): array { unset($formData['fileId']); unset($formData['filePath']); unset($formData['fileFormat']); + unset($formData['notifyOwnerOnSubmission']); + unset($formData['notificationRecipients']); return $formData; } @@ -709,7 +711,7 @@ public function notifyNewShares(Form $form, Share $share): void { * @param Form $form Related Form * @param string $submitter The ID of the user who submitted the form. Can also be our 'anon-user-'-ID */ - public function notifyNewSubmission(Form $form, Submission $submission): void { + public function notifyNewSubmission(Form $form, Submission $submission, string $trigger = FormSubmittedEvent::TRIGGER_CREATED): void { $shares = $this->getShares($form->getId()); try { $this->activityManager->publishNewSubmission($form, $submission->getUserId()); @@ -738,7 +740,7 @@ public function notifyNewSubmission(Form $form, Submission $submission): void { } } - $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission)); + $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission, $trigger)); } /** diff --git a/lib/Service/OwnerNotificationMailService.php b/lib/Service/OwnerNotificationMailService.php new file mode 100644 index 000000000..0d141cff4 --- /dev/null +++ b/lib/Service/OwnerNotificationMailService.php @@ -0,0 +1,102 @@ + $recipients + * @param array $answerSummaries + * @param array $pdfAnswerEntries + */ + public function send(Form $form, Submission $submission, array $recipients, array $answerSummaries = [], array $pdfAnswerEntries = []): void { + $validRecipients = array_values(array_unique(array_filter($recipients, fn (string $recipient): bool => $this->mailer->validateMailAddress($recipient)))); + if ($validRecipients === []) { + return; + } + + $formTitle = $form->getTitle(); + $subject = $this->l10n->t('New response to %s', [$formTitle]); + $resultsUrl = $this->urlGenerator->linkToRouteAbsolute('forms.page.views', [ + 'hash' => $form->getHash(), + 'view' => 'results', + ]); + + try { + $emailTemplate = $this->mailer->createEMailTemplate('forms.OwnerNotificationEmail', [ + 'formTitle' => $formTitle, + ]); + $emailTemplate->setSubject($subject); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($this->l10n->t('New form response received')); + $emailTemplate->addBodyText( + $this->l10n->t('A new response was submitted to the form %s.', [$formTitle]) + ); + + if ($answerSummaries !== []) { + $emailTemplate->addBodyText($this->l10n->t('Submission summary:')); + foreach ($answerSummaries as $summary) { + $emailTemplate->addBodyListItem( + $summary['answer'], + $summary['question'], + '', + '', + '' + ); + } + } + + $emailTemplate->addBodyButton($this->l10n->t('Open form results'), $resultsUrl); + $emailTemplate->addFooter( + $this->l10n->t('This message was sent automatically by %s.', [$this->l10n->t('Forms')]) + ); + + $message = $this->mailer->createMessage(); + $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED); + $message->setSubject($subject); + $message->setTo($validRecipients); + $message->useTemplate($emailTemplate); + if ($form->getAttachSubmissionPdf()) { + $entriesForPdf = $pdfAnswerEntries !== [] ? $pdfAnswerEntries : $answerSummaries; + $message->attach( + $this->mailer->createAttachment( + $this->submissionPdfService->createPdf($form, $submission, $entriesForPdf), + $this->submissionPdfService->createFilename($form, $submission), + 'application/pdf', + ), + ); + } + + $this->mailer->send($message); + } catch (\Throwable $e) { + $this->logger->error('Failed to send owner notification email for submission', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Service/SubmissionPdfService.php b/lib/Service/SubmissionPdfService.php new file mode 100644 index 000000000..6d6b76625 --- /dev/null +++ b/lib/Service/SubmissionPdfService.php @@ -0,0 +1,217 @@ + $answerEntries + */ + public function createPdf(Form $form, Submission $submission, array $answerEntries = []): string { + $submissionTimestamp = max(0, $submission->getTimestamp()); + $headerLines = [ + $this->l10n->t('Nextcloud Forms submission'), + $this->l10n->t('Form: %s', [$form->getTitle()]), + $this->l10n->t('Submission ID: %s', [(string)$submission->getId()]), + $this->l10n->t('Submitted at (UTC): %s', [gmdate('Y-m-d H:i:s', $submissionTimestamp)]), + '', + $this->l10n->t('Responses:'), + ]; + + $responseLines = []; + if ($answerEntries === []) { + $responseLines[] = '- ' . $this->l10n->t('No responses captured'); + } else { + foreach ($answerEntries as $entry) { + $responseLines[] = '- ' . $entry['question'] . ':'; + + $normalizedAnswer = str_replace(["\r\n", "\r"], "\n", $entry['answer']); + foreach (explode("\n", $normalizedAnswer) as $answerLine) { + $responseLines[] = ' ' . ($answerLine === '' ? '[empty line]' : $answerLine); + } + } + } + + $lines = array_merge($headerLines, $responseLines); + $pdfLines = $this->normalizePdfLines($lines); + $contentStreams = array_map( + fn (array $pageLines): string => $this->createContentStream($pageLines), + $this->paginatePdfLines($pdfLines), + ); + + return $this->assemblePdf($contentStreams); + } + + public function createFilename(Form $form, Submission $submission): string { + $title = trim($form->getTitle()); + $base = $title === '' ? 'form' : $title; + $base = preg_replace('/[^\p{L}\p{N}\-_. ]+/u', '_', $base) ?? 'form'; + $base = preg_replace('/\s+/', '_', trim($base)) ?? 'form'; + $base = trim($base, '._-'); + + if ($base === '') { + $base = 'form'; + } + + return sprintf('%s-submission-%d.pdf', $base, $submission->getId()); + } + + /** + * @param list $lines + * @return list + */ + private function normalizePdfLines(array $lines): array { + $normalizedLines = []; + foreach ($lines as $line) { + $encodedLine = $this->encodeLine($line); + foreach ($this->wrapLine($encodedLine, 96) as $wrappedLine) { + $normalizedLines[] = $wrappedLine; + } + } + + return $normalizedLines; + } + + private function encodeLine(string $line): string { + $encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $line); + if ($encoded === false) { + return ''; + } + + return $encoded; + } + + /** + * @return list + */ + private function wrapLine(string $line, int $limit): array { + if ($line === '') { + return ['']; + } + + $words = preg_split('/\s+/', $line) ?: []; + $current = ''; + $result = []; + + foreach ($words as $word) { + if ($word === '') { + continue; + } + + $candidate = $current === '' ? $word : $current . ' ' . $word; + if (strlen($candidate) <= $limit) { + $current = $candidate; + continue; + } + + if ($current !== '') { + $result[] = $current; + $current = ''; + } + + while (strlen($word) > $limit) { + $result[] = substr($word, 0, $limit); + $word = substr($word, $limit); + } + + $current = $word; + } + + if ($current !== '') { + $result[] = $current; + } + + return $result === [] ? [''] : $result; + } + + /** + * @param list $pdfLines + * @return list> + */ + private function paginatePdfLines(array $pdfLines): array { + $pages = array_chunk($pdfLines, self::MAX_LINES_PER_PAGE); + return $pages === [] ? [['']] : $pages; + } + + /** + * @param list $pdfLines + */ + private function createContentStream(array $pdfLines): string { + $content = "BT\n/F1 11 Tf\n14 TL\n50 792 Td\n"; + foreach ($pdfLines as $line) { + $escapedLine = str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $line); + $content .= '(' . $escapedLine . ") Tj\nT*\n"; + } + $content .= 'ET'; + + return $content; + } + + /** + * @param list $contentStreams + */ + private function assemblePdf(array $contentStreams): string { + $pageCount = count($contentStreams); + $fontObjectId = 3 + ($pageCount * 2); + $pageObjectIds = []; + + $objects = [ + 1 => '<< /Type /Catalog /Pages 2 0 R >>', + 2 => '', + ]; + foreach ($contentStreams as $index => $contentStream) { + $pageObjectId = 3 + ($index * 2); + $contentObjectId = $pageObjectId + 1; + $pageObjectIds[] = $pageObjectId; + + $objects[$pageObjectId] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 ' + . $fontObjectId . ' 0 R >> >> /Contents ' . $contentObjectId . ' 0 R >>'; + $objects[$contentObjectId] = '<< /Length ' . strlen($contentStream) . " >>\nstream\n" . $contentStream . "\nendstream"; + } + $objects[2] = '<< /Type /Pages /Kids [' . implode(' ', array_map( + static fn (int $pageObjectId): string => $pageObjectId . ' 0 R', + $pageObjectIds, + )) . '] /Count ' . $pageCount . ' >>'; + $objects[$fontObjectId] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'; + + ksort($objects); + + $pdf = "%PDF-1.4\n"; + $offsets = [0 => 0]; + + foreach ($objects as $id => $object) { + $offsets[$id] = strlen($pdf); + $pdf .= $id . " 0 obj\n" . $object . "\nendobj\n"; + } + + $startXref = strlen($pdf); + $objectCount = max(array_keys($objects)) + 1; + $pdf .= "xref\n0 " . $objectCount . "\n"; + $pdf .= sprintf("%010d 65535 f \n", 0); + for ($i = 1; $i < $objectCount; $i++) { + $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]); + } + + $pdf .= "trailer\n<< /Size " . $objectCount . " /Root 1 0 R >>\n"; + $pdf .= "startxref\n" . $startXref . "\n%%EOF"; + + return $pdf; + } +} diff --git a/openapi.json b/openapi.json index 9d80a1151..b71f05333 100644 --- a/openapi.json +++ b/openapi.json @@ -119,7 +119,10 @@ "lockedUntil", "maxSubmissions", "shares", - "submissionMessage" + "submissionMessage", + "notifyOwnerOnSubmission", + "attachSubmissionPdf", + "notificationRecipients" ], "properties": { "id": { @@ -232,6 +235,18 @@ "submissionMessage": { "type": "string", "nullable": true + }, + "notifyOwnerOnSubmission": { + "type": "boolean" + }, + "attachSubmissionPdf": { + "type": "boolean" + }, + "notificationRecipients": { + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index 1b31dd14e..cabb179a9 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -49,6 +49,48 @@ @update:model-value="onAllowEditSubmissionsChange"> {{ t('forms', 'Allow editing own responses') }} + + {{ + t( + 'forms', + 'Send email notifications for new responses to the form owner', + ) + }} + + + {{ t('forms', 'Attach each submission as PDF to notification emails') }} + +
+ +

+ {{ + t( + 'forms', + 'Additional recipients are notified for each new response, independent of the owner notification switch.', + ) + }} +

+
- - {{ t('forms', 'Limit number of responses') }} - -
- -

- {{ - t( - 'forms', - 'Form will be closed automatically when the limit is reached.', - ) - }} -

-

- {{ t('forms', 'Closed forms do not accept new responses.') }} + {{ t('forms', 'Closed forms do not accept new submissions.') }}

@@ -210,8 +226,8 @@ import NcButton from '@nextcloud/vue/components/NcButton' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' -import NcInputField from '@nextcloud/vue/components/NcInputField' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' import TransferOwnership from './TransferOwnership.vue' import svgLockOpen from '../../../img/lock_open.svg?raw' import ShareTypes from '../../mixins/ShareTypes.js' @@ -220,11 +236,11 @@ import { FormState } from '../../models/Constants.ts' export default { components: { NcButton, - NcInputField, NcCheckboxRadioSwitch, NcDateTimePicker, NcIconSvgWrapper, NcNoteCard, + NcTextArea, TransferOwnership, }, @@ -265,6 +281,7 @@ export default { maxStringLengths: loadState('forms', 'maxStringLengths'), /** If custom submission message is shown as input or rendered markdown */ editMessage: false, + notificationRecipientsInput: '', svgLockOpen, } }, @@ -330,23 +347,6 @@ export default { return this.form.state !== FormState.FormActive }, - hasMaxSubmissions() { - return ( - this.form.maxSubmissions !== null - && this.form.maxSubmissions !== undefined - ) - }, - - maxSubmissionsValue: { - get() { - return this.form.maxSubmissions ?? 1 - }, - - set(value) { - this.$emit('update:form-prop', 'maxSubmissions', value) - }, - }, - isExpired() { return this.form.expires && moment().unix() > this.form.expires }, @@ -363,6 +363,20 @@ export default { }, }, + watch: { + 'form.notificationRecipients': { + handler(value) { + this.updateNotificationRecipientsInput(value) + }, + + deep: true, + }, + }, + + created() { + this.updateNotificationRecipientsInput(this.form.notificationRecipients) + }, + methods: { /** * Save Form-Properties @@ -381,6 +395,29 @@ export default { this.$emit('update:form-prop', 'allowEditSubmissions', checked) }, + onNotifyOwnerOnSubmissionChange(checked) { + this.$emit('update:form-prop', 'notifyOwnerOnSubmission', checked) + }, + + onAttachSubmissionPdfChange(checked) { + this.$emit('update:form-prop', 'attachSubmissionPdf', checked) + }, + + onNotificationRecipientsChange(payload) { + const value = + typeof payload === 'string' + ? payload + : (payload?.target?.value ?? this.notificationRecipientsInput) + + const recipients = value + .split(/[\r\n,]+/g) + .map((recipient) => recipient.trim()) + .filter((recipient) => recipient.length > 0) + + this.notificationRecipientsInput = recipients.join('\n') + this.$emit('update:form-prop', 'notificationRecipients', recipients) + }, + onFormExpiresChange(checked) { if (checked) { this.$emit( @@ -410,16 +447,6 @@ export default { ) }, - onMaxSubmissionsChange(checked) { - this.$emit('update:form-prop', 'maxSubmissions', checked ? 1 : null) - }, - - onMaxSubmissionsValueChange(value) { - if (value > 0) { - this.$emit('update:form-prop', 'maxSubmissions', value) - } - }, - onFormClosedChange(isClosed) { this.$emit( 'update:form-prop', @@ -500,6 +527,15 @@ export default { notBeforeNow(datetime) { return datetime < moment().toDate() }, + + updateNotificationRecipientsInput(notificationRecipients) { + if (!Array.isArray(notificationRecipients)) { + this.notificationRecipientsInput = '' + return + } + + this.notificationRecipientsInput = notificationRecipients.join('\n') + }, }, } @@ -513,6 +549,10 @@ export default { margin-inline-start: 40px; } +.settings-div--separate { + margin-block: 4px; +} + .settings-hint { color: var(--color-text-maxcontrast); padding-inline-start: 16px; diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index 9cfe3394f..0eb393b74 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -255,7 +255,7 @@ public function setUp(): void { // Set up http Client $this->http = new Client([ 'base_uri' => 'http://localhost:8080/ocs/v2.php/apps/forms/', - 'auth' => ['test', 'test'], + 'auth' => ['test', self::TEST_USER_PASSWORD], 'headers' => [ 'OCS-ApiRequest' => 'true', 'Accept' => 'application/json' @@ -396,6 +396,9 @@ public function dataGetNewForm() { 'fileFormat' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'notifyOwnerOnSubmission' => false, + 'attachSubmissionPdf' => false, + 'notificationRecipients' => [], ] ] ]; @@ -529,6 +532,9 @@ public function dataGetFullForm() { 'fileFormat' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'notifyOwnerOnSubmission' => false, + 'attachSubmissionPdf' => false, + 'notificationRecipients' => [], ] ] ]; diff --git a/tests/Integration/Api/RespectAdminSettingsTest.php b/tests/Integration/Api/RespectAdminSettingsTest.php index 09d8ff7d1..f43d50258 100644 --- a/tests/Integration/Api/RespectAdminSettingsTest.php +++ b/tests/Integration/Api/RespectAdminSettingsTest.php @@ -134,6 +134,9 @@ private static function sharedTestForms(): array { 'allowEditSubmissions' => false, 'showExpiration' => false, 'submissionMessage' => '', + 'notifyOwnerOnSubmission' => false, + 'attachSubmissionPdf' => false, + 'notificationRecipients' => [], 'permissions' => [ 'edit', 'embed', @@ -165,7 +168,7 @@ public function setUp(): void { // Set up http Client $this->http = new Client([ 'base_uri' => 'http://localhost:8080/ocs/v2.php/apps/forms/', - 'auth' => ['test', 'test'], + 'auth' => ['test', self::TEST_USER_PASSWORD], 'headers' => [ 'OCS-ApiRequest' => 'true', 'Accept' => 'application/json' diff --git a/tests/Integration/IntegrationBase.php b/tests/Integration/IntegrationBase.php index 7b2fc271d..76187a4be 100644 --- a/tests/Integration/IntegrationBase.php +++ b/tests/Integration/IntegrationBase.php @@ -19,6 +19,7 @@ * @group DB */ class IntegrationBase extends TestCase { + protected const TEST_USER_PASSWORD = 'Forms-Test-Password-2026!'; /** @var Array */ protected $testForms; @@ -44,7 +45,7 @@ public function setUp(): void { foreach ($this->users as $userId => $displayName) { $user = $userManager->get($userId); if ($user === null) { - $user = $userManager->createUser($userId, $userId); + $user = $userManager->createUser($userId, self::TEST_USER_PASSWORD); } $user->setDisplayName($displayName); } diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index bb75417a3..8dd4f39b8 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -43,6 +43,7 @@ function is_uploaded_file(string|bool|null $filename) { use OCA\Forms\Db\Submission; use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Db\UploadedFileMapper; +use OCA\Forms\Events\FormSubmittedEvent; use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\Service\ConfigService; use OCA\Forms\Service\FormsService; @@ -65,6 +66,7 @@ function is_uploaded_file(string|bool|null $filename) { use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Mail\IMailer; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -107,6 +109,8 @@ class ApiControllerTest extends TestCase { private $mimeTypeDetector; /** @var IJobList|MockObject */ private $jobList; + /** @var IMailer|MockObject */ + private $mailer; public function setUp(): void { $this->answerMapper = $this->createMock(AnswerMapper::class); @@ -131,6 +135,7 @@ public function setUp(): void { $this->uploadedFileMapper = $this->createMock(UploadedFileMapper::class); $this->mimeTypeDetector = $this->createMock(IMimeTypeDetector::class); $this->jobList = $this->createMock(IJobList::class); + $this->mailer = $this->createMock(IMailer::class); $this->apiController = new ApiController( 'forms', @@ -152,6 +157,7 @@ public function setUp(): void { $this->uploadedFileMapper, $this->mimeTypeDetector, $this->jobList, + $this->mailer, ); } @@ -179,6 +185,9 @@ public static function createFormValidator(array $expected) { self::assertInstanceOf(Form::class, $form); $read = $form->read(); unset($read['created']); + unset($read['notifyOwnerOnSubmission']); + unset($read['attachSubmissionPdf']); + unset($read['notificationRecipients']); self::assertEquals($expected, $read); return true; }; @@ -430,9 +439,7 @@ public function testCreateNewForm($expectedForm) { ->willReturn('formHash'); $expected = $expectedForm; $expected['id'] = null; - // TODO fix test, currently unset because behaviour has changed - $expected['state'] = null; - $expected['lastUpdated'] = null; + $expected['lastUpdated'] = 0; $this->formMapper->expects($this->once()) ->method('insert') ->with(self::callback(self::createFormValidator($expected))) @@ -995,6 +1002,135 @@ public function testTransferOwner() { $this->assertEquals('newOwner', $form->getOwnerId()); } + public function testUpdateFormNormalizesNotificationRecipients(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setLockedBy(null); + $form->setLockedUntil(null); + $form->setNotificationRecipients([]); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->mailer->expects($this->exactly(3)) + ->method('validateMailAddress') + ->willReturn(true); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (Form $updated): bool { + return $updated->getNotificationRecipients() === ['ext@example.com', 'owner@example.com']; + })); + + $response = $this->apiController->updateForm(1, [ + 'notificationRecipients' => [ + ' ext@example.com ', + 'EXT@example.com', + 'owner@example.com', + '', + ], + ]); + + $this->assertEquals(new DataResponse(1), $response); + } + + public function testUpdateFormRejectsInvalidNotificationRecipientsType(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setLockedBy(null); + $form->setLockedUntil(null); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->expectException(OCSBadRequestException::class); + $this->apiController->updateForm(1, ['notificationRecipients' => 'user@example.com']); + } + + public function testUpdateFormRejectsInvalidNotificationRecipientAddress(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setLockedBy(null); + $form->setLockedUntil(null); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('invalid') + ->willReturn(false); + + $this->expectException(OCSBadRequestException::class); + $this->apiController->updateForm(1, ['notificationRecipients' => ['invalid']]); + } + + public function testUpdateFormRejectsNonBooleanNotifyOwnerOnSubmission(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setLockedBy(null); + $form->setLockedUntil(null); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->expectException(OCSBadRequestException::class); + $this->apiController->updateForm(1, ['notifyOwnerOnSubmission' => 'yes']); + } + + public function testUpdateFormRejectsNonBooleanAttachSubmissionPdf(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setLockedBy(null); + $form->setLockedUntil(null); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->expectException(OCSBadRequestException::class); + $this->apiController->updateForm(1, ['attachSubmissionPdf' => 'yes']); + } + public function testGetSubmission_invalidForm() { $exception = $this->createMock(NoSuchFormException::class); $this->formsService->expects($this->once()) @@ -1217,7 +1353,7 @@ public function testUpdateSubmission_success() { $this->formsService->expects($this->once()) ->method('notifyNewSubmission') - ->with($form, $submission); + ->with($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED); $response = $this->apiController->updateSubmission($formId, $submissionId, $answers); $this->assertEquals(new DataResponse($submissionId), $response); diff --git a/tests/Unit/FormsMigratorTest.php b/tests/Unit/FormsMigratorTest.php index 454f6d2ef..763aa9474 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -111,6 +111,9 @@ public function dataExport() { "showExpiration": false, "lastUpdated": 123456789, "submissionMessage": "Back to website", + "notifyOwnerOnSubmission": false, + "attachSubmissionPdf": false, + "notificationRecipients": [], "questions": [ { "id": 14, @@ -254,7 +257,7 @@ public function testExport(string $expectedJson) { public function dataImport() { return [ 'exactlyOneOfEach' => [ - '$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' + '$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' ] ]; } diff --git a/tests/Unit/Listener/OwnerNotificationListenerTest.php b/tests/Unit/Listener/OwnerNotificationListenerTest.php new file mode 100644 index 000000000..01ab10f32 --- /dev/null +++ b/tests/Unit/Listener/OwnerNotificationListenerTest.php @@ -0,0 +1,258 @@ +mailService = $this->createMock(OwnerNotificationMailService::class); + $this->answerMapper = $this->createMock(AnswerMapper::class); + $this->questionMapper = $this->createMock(QuestionMapper::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->listener = new OwnerNotificationListener( + $this->mailService, + $this->answerMapper, + $this->questionMapper, + $this->userManager, + $this->logger, + ); + } + + public function testHandleSendsOwnerAndExternalNotifications(): void { + $form = $this->createForm(); + $form->setNotifyOwnerOnSubmission(true); + $form->setNotificationRecipients(['external@example.com']); + + $submission = $this->createSubmission(11, $form->getId()); + $event = new FormSubmittedEvent($form, $submission); + + $owner = $this->createMock(IUser::class); + $owner->expects($this->once()) + ->method('getEMailAddress') + ->willReturn('owner@example.com'); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('owner') + ->willReturn($owner); + + $answer = new Answer(); + $answer->setQuestionId(22); + $answer->setSubmissionId(11); + $answer->setText('Short text answer'); + + $question = new Question(); + $question->setId(22); + $question->setFormId($form->getId()); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setText('Question text'); + $question->setDescription(''); + $question->setName(''); + $question->setOrder(1); + $question->setIsRequired(false); + $question->setExtraSettings([]); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(11) + ->willReturn([$answer]); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with(22) + ->willReturn($question); + + $this->mailService->expects($this->once()) + ->method('send') + ->with( + $this->identicalTo($form), + $this->identicalTo($submission), + $this->callback(function (array $recipients): bool { + sort($recipients); + return $recipients === ['external@example.com', 'owner@example.com']; + }), + $this->callback(function (array $summaries): bool { + return count($summaries) === 1 + && $summaries[0]['question'] === 'Question text' + && $summaries[0]['answer'] === 'Short text answer'; + }), + $this->callback(function (array $pdfEntries): bool { + return count($pdfEntries) === 1 + && $pdfEntries[0]['question'] === 'Question text' + && $pdfEntries[0]['answer'] === 'Short text answer'; + }) + ); + + $this->listener->handle($event); + } + + public function testHandleSkipsUpdatedSubmissions(): void { + $form = $this->createForm(); + $submission = $this->createSubmission(11, $form->getId()); + $event = new FormSubmittedEvent($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED); + + $this->answerMapper->expects($this->never()) + ->method('findBySubmission'); + $this->mailService->expects($this->never()) + ->method('send'); + + $this->listener->handle($event); + } + + public function testHandleSendsExternalNotificationsWithoutOwnerNotification(): void { + $form = $this->createForm(); + $form->setNotificationRecipients(['external@example.com']); + + $submission = $this->createSubmission(11, $form->getId()); + $event = new FormSubmittedEvent($form, $submission); + + $this->userManager->expects($this->never()) + ->method('get'); + + $answer = new Answer(); + $answer->setQuestionId(22); + $answer->setSubmissionId(11); + $answer->setText('Short text answer'); + + $question = new Question(); + $question->setId(22); + $question->setFormId($form->getId()); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setText('Question text'); + $question->setDescription(''); + $question->setName(''); + $question->setOrder(1); + $question->setIsRequired(false); + $question->setExtraSettings([]); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(11) + ->willReturn([$answer]); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with(22) + ->willReturn($question); + + $this->mailService->expects($this->once()) + ->method('send') + ->with( + $this->identicalTo($form), + $this->identicalTo($submission), + ['external@example.com'], + $this->callback(function (array $summaries): bool { + return count($summaries) === 1 + && $summaries[0]['question'] === 'Question text' + && $summaries[0]['answer'] === 'Short text answer'; + }), + $this->callback(function (array $pdfEntries): bool { + return count($pdfEntries) === 1 + && $pdfEntries[0]['question'] === 'Question text' + && $pdfEntries[0]['answer'] === 'Short text answer'; + }) + ); + + $this->listener->handle($event); + } + + public function testHandleSkipsWhenOwnerHasNoMailAddressAndNoExternalRecipients(): void { + $form = $this->createForm(); + $form->setNotifyOwnerOnSubmission(true); + + $submission = $this->createSubmission(11, $form->getId()); + $event = new FormSubmittedEvent($form, $submission); + + $owner = $this->createMock(IUser::class); + $owner->expects($this->once()) + ->method('getEMailAddress') + ->willReturn(''); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('owner') + ->willReturn($owner); + + $this->answerMapper->expects($this->never()) + ->method('findBySubmission'); + $this->mailService->expects($this->never()) + ->method('send'); + + $this->listener->handle($event); + } + + private function createForm(): Form { + $form = new Form(); + $form->setId(1); + $form->setTitle('Test form'); + $form->setOwnerId('owner'); + $form->setDescription(''); + $form->setFileId(null); + $form->setFileFormat(null); + $form->setCreated(0); + $form->setExpires(0); + $form->setIsAnonymous(false); + $form->setSubmitMultiple(false); + $form->setAllowEditSubmissions(false); + $form->setShowExpiration(false); + $form->setLastUpdated(0); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setLockedBy(null); + $form->setLockedUntil(null); + $form->setSubmissionMessage(null); + $form->setNotifyOwnerOnSubmission(false); + $form->setAttachSubmissionPdf(false); + $form->setNotificationRecipients([]); + + return $form; + } + + private function createSubmission(int $id, int $formId): Submission { + $submission = new Submission(); + $submission->setId($id); + $submission->setFormId($formId); + $submission->setUserId('submitter'); + $submission->setTimestamp(time()); + + return $submission; + } +} diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 15b0021b9..6555ff505 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -257,6 +257,9 @@ public function dataGetForm() { 'lockedUntil' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'notifyOwnerOnSubmission' => false, + 'attachSubmissionPdf' => false, + 'notificationRecipients' => [], ]] ]; } @@ -478,6 +481,7 @@ public function dataGetPublicForm() { 'lockedUntil' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'attachSubmissionPdf' => false, ]] ]; } diff --git a/tests/Unit/Service/SubmissionPdfServiceTest.php b/tests/Unit/Service/SubmissionPdfServiceTest.php new file mode 100644 index 000000000..f0ac7152c --- /dev/null +++ b/tests/Unit/Service/SubmissionPdfServiceTest.php @@ -0,0 +1,112 @@ +createService(); + $form = new Form(); + $form->setTitle('Customer Survey'); + $submission = new Submission(); + $submission->setId(99); + $submission->setTimestamp(1700000000); + + $pdf = $service->createPdf($form, $submission, [ + [ + 'question' => 'Email', + 'answer' => 'user@example.com', + ], + ]); + + $this->assertTrue(str_starts_with($pdf, '%PDF-1.4')); + $this->assertStringContainsString('Nextcloud Forms submission', $pdf); + $this->assertStringContainsString('Form: Customer Survey', $pdf); + $this->assertStringContainsString('Submission ID: 99', $pdf); + $this->assertStringContainsString('Email', $pdf); + $this->assertStringContainsString('user@example.com', $pdf); + } + + public function testCreatePdfUsesFallbackTextForMissingResponses(): void { + $service = $this->createService(); + $form = new Form(); + $form->setTitle('Customer Survey'); + $submission = new Submission(); + $submission->setId(100); + $submission->setTimestamp(1700000000); + + $pdf = $service->createPdf($form, $submission); + + $this->assertStringContainsString('- No responses captured', $pdf); + } + + public function testCreatePdfSpansMultiplePagesWithoutTruncation(): void { + $service = $this->createService(); + $form = new Form(); + $form->setTitle('Customer Survey'); + $submission = new Submission(); + $submission->setId(101); + $submission->setTimestamp(1700000000); + + $entries = []; + for ($i = 1; $i <= 30; $i++) { + $entries[] = [ + 'question' => 'Question ' . $i, + 'answer' => 'answer-' . $i, + ]; + } + + $pdf = $service->createPdf($form, $submission, $entries); + + $this->assertStringContainsString('/Count 2', $pdf); + $this->assertStringContainsString('answer-30', $pdf); + } + + public function testCreateFilenameSanitizesFormTitle(): void { + $service = $this->createService(); + $form = new Form(); + $form->setTitle(' Customer Survey: 2026 / Berlin? '); + $submission = new Submission(); + $submission->setId(123); + + $filename = $service->createFilename($form, $submission); + + $this->assertSame('Customer_Survey__2026___Berlin-submission-123.pdf', $filename); + } + + public function testCreateFilenameUsesDefaultForEmptyTitle(): void { + $service = $this->createService(); + $form = new Form(); + $form->setTitle(' '); + $submission = new Submission(); + $submission->setId(7); + + $filename = $service->createFilename($form, $submission); + + $this->assertSame('form-submission-7.pdf', $filename); + } + + private function createService(): SubmissionPdfService { + $l10n = $this->createMock(IL10N::class); + $l10n->expects($this->any()) + ->method('t') + ->willReturnCallback(static function (string $text, ...$params): string { + $replace = (isset($params[0]) && is_array($params[0])) ? $params[0] : []; + return $replace === [] ? $text : vsprintf($text, $replace); + }); + + return new SubmissionPdfService($l10n); + } +}