From 78b1c27ffbc3d0cc27b66dc5ca98bdc57caa92ab Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:06:54 +0800 Subject: [PATCH 01/22] feat(email): Allow bcc to users --- src/Entity/EmailDto/EmailDto.php | 33 ++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Entity/EmailDto/EmailDto.php b/src/Entity/EmailDto/EmailDto.php index 1ec9640..506db14 100644 --- a/src/Entity/EmailDto/EmailDto.php +++ b/src/Entity/EmailDto/EmailDto.php @@ -12,6 +12,10 @@ class EmailDto { private Address $toAddress; + /** + * @var Address[] the address to BCC (密件副本) + */ + private array $bcc = []; private string $subject; private EmailKind $kind; private string $text; @@ -31,9 +35,33 @@ public function getToAddress(): Address return $this->toAddress; } - public function setToAddress(Address $toAddress): self + public function setToAddress(string|Address $toAddress): self { - $this->toAddress = $toAddress; + if (\is_string($toAddress)) { + $this->toAddress = new Address($toAddress); + } else { + $this->toAddress = $toAddress; + } + + return $this; + } + + /** + * @return Address[] + */ + public function getBcc(): array + { + return $this->bcc; + } + + /** + * @param Address[] $bcc + * + * @return $this + */ + public function setBcc(array $bcc): self + { + $this->bcc = $bcc; return $this; } @@ -90,6 +118,7 @@ public function toEmail(): Email { $email = (new Email()) ->to($this->getToAddress()) + ->bcc(...$this->getBcc()) ->subject($this->getSubject()) ->text($this->getText()) ->html($this->getHtml()); From 7d0f72975e20b415d8c97d9e6201018a7b35cc1b Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:07:14 +0800 Subject: [PATCH 02/22] refactor(email): Make EmailService readonly --- src/Service/EmailService.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index 5b52637..0b47f2e 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -10,13 +10,13 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; -class EmailService +readonly class EmailService { private Address $fromAddress; public function __construct( - private readonly MailerInterface $mailer, - private readonly string $serverMail, + private MailerInterface $mailer, + private string $serverMail, ) { $this->fromAddress = new Address( address: $this->serverMail, From efc9fc1ce34d9603a31c047790993d48b9b5378f Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:07:36 +0800 Subject: [PATCH 03/22] feat(email): Email template service --- src/Service/EmailTemplateService.php | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/Service/EmailTemplateService.php diff --git a/src/Service/EmailTemplateService.php b/src/Service/EmailTemplateService.php new file mode 100644 index 0000000..67c224b --- /dev/null +++ b/src/Service/EmailTemplateService.php @@ -0,0 +1,69 @@ +twigEnvironment->render('email/mjml/remember-to-login.mjml.twig'); + } catch (\Throwable $e) { + throw new \Exception('Failed to render the email content.', previous: $e); + } + + $userAddresses = array_map( + fn (User $user) => new Address( + address: $user->getEmail(), + name: $user->getName() ?? '', + ), + $bccUsers + ); + + return (new EmailDto()) + ->setToAddress($this->serverMail) + ->setBcc($userAddresses) + ->setSubject('[資料庫練功房] 登入次數警告') + ->setText($textContent) + ->setHtml($htmlContent) + ->setKind(EmailKind::Transactional); + } +} From a9c9ffd6916a92f95a23ea3989f68ab22c5187ad Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:11:42 +0800 Subject: [PATCH 04/22] feat(email): EmailDeliveryEvent to DTO --- src/Entity/EmailDeliveryEvent.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Entity/EmailDeliveryEvent.php b/src/Entity/EmailDeliveryEvent.php index ec6aa73..f1af189 100644 --- a/src/Entity/EmailDeliveryEvent.php +++ b/src/Entity/EmailDeliveryEvent.php @@ -4,6 +4,7 @@ namespace App\Entity; +use App\Entity\EmailDto\EmailDto; use App\Repository\EmailDeliveryEventRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -35,7 +36,7 @@ public function setToUser(?User $toUser): static return $this; } - public function getToAddress(): ?string + public function getToAddress(): string { return $this->toAddress; } @@ -58,4 +59,14 @@ public function setEmail(Email $email): static return $this; } + + public function toEmailDto(): EmailDto + { + return (new EmailDto()) + ->setToAddress($this->getToAddress()) + ->setSubject($this->getEmail()->getSubject()) + ->setText($this->getEmail()->getTextContent()) + ->setHtml($this->getEmail()->getHtmlContent()) + ->setKind($this->getEmail()->getKind()); + } } From 218fa6c8212daeadeb717154fb5f634b9ade397d Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:34:16 +0800 Subject: [PATCH 05/22] feat(email): Add a SentEmailDto with sendAt --- src/Entity/EmailDto/SentEmailDto.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/Entity/EmailDto/SentEmailDto.php diff --git a/src/Entity/EmailDto/SentEmailDto.php b/src/Entity/EmailDto/SentEmailDto.php new file mode 100644 index 0000000..adece63 --- /dev/null +++ b/src/Entity/EmailDto/SentEmailDto.php @@ -0,0 +1,22 @@ +sentAt; + } + + public function setSentAt(\DateTimeInterface $sentAt): self + { + $this->sentAt = $sentAt; + + return $this; + } +} From 330dc6c8f5f489bb4a2fbca7b0f0178724e75d01 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:41:20 +0800 Subject: [PATCH 06/22] refactor(email): Return a SentEmailDto from DeliveryEvent Fix: Due to inheritance, we use "static" as the return type. --- src/Entity/EmailDeliveryEvent.php | 10 ++++++---- src/Entity/EmailDto/EmailDto.php | 12 ++++++------ src/Entity/EmailDto/SentEmailDto.php | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Entity/EmailDeliveryEvent.php b/src/Entity/EmailDeliveryEvent.php index f1af189..832726e 100644 --- a/src/Entity/EmailDeliveryEvent.php +++ b/src/Entity/EmailDeliveryEvent.php @@ -4,7 +4,7 @@ namespace App\Entity; -use App\Entity\EmailDto\EmailDto; +use App\Entity\EmailDto\SentEmailDto; use App\Repository\EmailDeliveryEventRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -60,13 +60,15 @@ public function setEmail(Email $email): static return $this; } - public function toEmailDto(): EmailDto + public function toEmailDto(): SentEmailDto { - return (new EmailDto()) + return (new SentEmailDto()) ->setToAddress($this->getToAddress()) ->setSubject($this->getEmail()->getSubject()) ->setText($this->getEmail()->getTextContent()) ->setHtml($this->getEmail()->getHtmlContent()) - ->setKind($this->getEmail()->getKind()); + ->setKind($this->getEmail()->getKind()) + ->setSentAt($this->getCreatedAt()) + ; } } diff --git a/src/Entity/EmailDto/EmailDto.php b/src/Entity/EmailDto/EmailDto.php index 506db14..ad1fe4a 100644 --- a/src/Entity/EmailDto/EmailDto.php +++ b/src/Entity/EmailDto/EmailDto.php @@ -35,7 +35,7 @@ public function getToAddress(): Address return $this->toAddress; } - public function setToAddress(string|Address $toAddress): self + public function setToAddress(string|Address $toAddress): static { if (\is_string($toAddress)) { $this->toAddress = new Address($toAddress); @@ -59,7 +59,7 @@ public function getBcc(): array * * @return $this */ - public function setBcc(array $bcc): self + public function setBcc(array $bcc): static { $this->bcc = $bcc; @@ -71,7 +71,7 @@ public function getSubject(): string return $this->subject; } - public function setSubject(string $subject): self + public function setSubject(string $subject): static { $this->subject = $subject; @@ -83,7 +83,7 @@ public function getKind(): EmailKind return $this->kind; } - public function setKind(EmailKind $kind): self + public function setKind(EmailKind $kind): static { $this->kind = $kind; @@ -95,7 +95,7 @@ public function getText(): string return $this->text; } - public function setText(string $text): self + public function setText(string $text): static { $this->text = $text; @@ -107,7 +107,7 @@ public function getHtml(): string return $this->html; } - public function setHtml(string $html): self + public function setHtml(string $html): static { $this->html = $html; diff --git a/src/Entity/EmailDto/SentEmailDto.php b/src/Entity/EmailDto/SentEmailDto.php index adece63..d1c6388 100644 --- a/src/Entity/EmailDto/SentEmailDto.php +++ b/src/Entity/EmailDto/SentEmailDto.php @@ -13,7 +13,7 @@ public function getSentAt(): \DateTimeInterface return $this->sentAt; } - public function setSentAt(\DateTimeInterface $sentAt): self + public function setSentAt(\DateTimeInterface $sentAt): static { $this->sentAt = $sentAt; From 02aede49aea265973b3375601152f6cc2a8c5790 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 13 Dec 2024 11:42:16 +0800 Subject: [PATCH 07/22] refactor(email): Separate email preview to a Twig component --- src/Controller/EmailController.php | 4 +- src/Twig/Components/Email/Preview.php | 14 ++++ templates/components/Email/Preview.html.twig | 69 +++++++++++++++++++ templates/email/preview.html.twig | 70 +------------------- 4 files changed, 87 insertions(+), 70 deletions(-) create mode 100644 src/Twig/Components/Email/Preview.php create mode 100644 templates/components/Email/Preview.html.twig diff --git a/src/Controller/EmailController.php b/src/Controller/EmailController.php index dace31f..b6f7969 100644 --- a/src/Controller/EmailController.php +++ b/src/Controller/EmailController.php @@ -22,8 +22,10 @@ public function details(#[CurrentUser] User $user, EmailDeliveryEvent $event): R throw $this->createAccessDeniedException('You are not authorized to access this email.'); } + $emailDto = $event->toEmailDto(); + return $this->render('email/preview.html.twig', [ - 'emailDeliveryEvent' => $event, + 'emailDto' => $emailDto, ]); } } diff --git a/src/Twig/Components/Email/Preview.php b/src/Twig/Components/Email/Preview.php new file mode 100644 index 0000000..58cd848 --- /dev/null +++ b/src/Twig/Components/Email/Preview.php @@ -0,0 +1,14 @@ + 0, htmlContent|length > 0 %} + +
+
+
+

+ + {{ emailDto.subject }} +

+ + + +
+ {% if hasHtml %} +
+ +
+ {% endif %} + {% if hasText %} +
+
{{ textContent }}
+
+ {% endif %} +
+
+ +
+
diff --git a/templates/email/preview.html.twig b/templates/email/preview.html.twig index d3d0246..0bba12b 100644 --- a/templates/email/preview.html.twig +++ b/templates/email/preview.html.twig @@ -5,73 +5,5 @@ {% block title %}信件預覽{% endblock %} {% block app %} - {% set textContent = emailDeliveryEvent.email.textContent %} - {% set htmlContent = emailDeliveryEvent.email.htmlContent %} - {% set hasText, hasHtml = textContent|length > 0, htmlContent|length > 0 %} - -
-
-
-

- - {{ emailDeliveryEvent.email.subject }} -

- - - -
- {% if hasHtml %} -
- -
- {% endif %} - {% if hasText %} -
-
{{ emailDeliveryEvent.email.textContent }}
-
- {% endif %} -
-
- -
-
+ {% endblock %} From c71316a86ec72e740785c895220979c6786be696 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sun, 15 Dec 2024 23:51:06 +0800 Subject: [PATCH 08/22] refactor(email): Merge SentEmailDto to EmailDto "sendAt" can be also a future value. --- src/Entity/EmailDeliveryEvent.php | 6 +++--- src/Entity/EmailDto/EmailDto.php | 22 ++++++++++++++++++++++ src/Entity/EmailDto/SentEmailDto.php | 22 ---------------------- src/Twig/Components/Email/Preview.php | 4 ++-- 4 files changed, 27 insertions(+), 27 deletions(-) delete mode 100644 src/Entity/EmailDto/SentEmailDto.php diff --git a/src/Entity/EmailDeliveryEvent.php b/src/Entity/EmailDeliveryEvent.php index 832726e..f0a5005 100644 --- a/src/Entity/EmailDeliveryEvent.php +++ b/src/Entity/EmailDeliveryEvent.php @@ -4,7 +4,7 @@ namespace App\Entity; -use App\Entity\EmailDto\SentEmailDto; +use App\Entity\EmailDto\EmailDto; use App\Repository\EmailDeliveryEventRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -60,9 +60,9 @@ public function setEmail(Email $email): static return $this; } - public function toEmailDto(): SentEmailDto + public function toEmailDto(): EmailDto { - return (new SentEmailDto()) + return (new EmailDto()) ->setToAddress($this->getToAddress()) ->setSubject($this->getEmail()->getSubject()) ->setText($this->getEmail()->getTextContent()) diff --git a/src/Entity/EmailDto/EmailDto.php b/src/Entity/EmailDto/EmailDto.php index ad1fe4a..16b1ca2 100644 --- a/src/Entity/EmailDto/EmailDto.php +++ b/src/Entity/EmailDto/EmailDto.php @@ -20,6 +20,7 @@ class EmailDto private EmailKind $kind; private string $text; private string $html; + private ?\DateTimeInterface $sentAt = null; public static function fromUser(User $user): self { @@ -114,6 +115,23 @@ public function setHtml(string $html): static return $this; } + public function getSentAt(): ?\DateTimeInterface + { + return $this->sentAt; + } + + /** + * Specify when to send this mail. If this is null, the mail will be sent immediately. + * + * @return $this + */ + public function setSentAt(?\DateTimeInterface $sentAt): static + { + $this->sentAt = $sentAt; + + return $this; + } + public function toEmail(): Email { $email = (new Email()) @@ -123,6 +141,10 @@ public function toEmail(): Email ->text($this->getText()) ->html($this->getHtml()); + if (null !== $this->getSentAt()) { + $email = $email->date($this->getSentAt()); + } + $headers = $this->getKind()->addToEmailHeader($email->getHeaders()); return $email->setHeaders($headers); diff --git a/src/Entity/EmailDto/SentEmailDto.php b/src/Entity/EmailDto/SentEmailDto.php deleted file mode 100644 index d1c6388..0000000 --- a/src/Entity/EmailDto/SentEmailDto.php +++ /dev/null @@ -1,22 +0,0 @@ -sentAt; - } - - public function setSentAt(\DateTimeInterface $sentAt): static - { - $this->sentAt = $sentAt; - - return $this; - } -} diff --git a/src/Twig/Components/Email/Preview.php b/src/Twig/Components/Email/Preview.php index 58cd848..f02fdab 100644 --- a/src/Twig/Components/Email/Preview.php +++ b/src/Twig/Components/Email/Preview.php @@ -4,11 +4,11 @@ namespace App\Twig\Components\Email; -use App\Entity\EmailDto\SentEmailDto; +use App\Entity\EmailDto\EmailDto; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] class Preview { - public SentEmailDto $emailDto; + public EmailDto $emailDto; } From 64e38e561057d59cb3da732706fd2a3cd9e7f3a1 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sun, 15 Dec 2024 23:51:24 +0800 Subject: [PATCH 09/22] fix(email): Inject serverMail to EmailTemplateService --- config/services.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/services.php b/config/services.php index ff7f650..7687149 100644 --- a/config/services.php +++ b/config/services.php @@ -4,6 +4,7 @@ use App\Controller\Admin\EmailTemplateController; use App\Service\EmailService; +use App\Service\EmailTemplateService; use App\Service\PromptService; use App\Service\SqlRunnerService; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -46,6 +47,9 @@ $services->set(EmailService::class) ->arg('$serverMail', param('app.server-mail')); + $services->set(EmailTemplateService::class) + ->arg('$serverMail', param('app.server-mail')); + $services->set(EmailTemplateController::class) ->arg('$projectDir', param('kernel.project_dir')); }; From f429fafcbf3ec14b554e80d0e85e41946ba667c6 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sun, 15 Dec 2024 23:52:08 +0800 Subject: [PATCH 10/22] refactor(email): More advanced preview in admin --- .../Admin/EmailTemplateController.php | 21 ++++++++----------- .../admin/email_template/details.html.twig | 6 ++---- .../admin/email_template/index.html.twig | 6 ++---- .../admin/email_template/preview.html.twig | 9 ++++++++ 4 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 templates/admin/email_template/preview.html.twig diff --git a/src/Controller/Admin/EmailTemplateController.php b/src/Controller/Admin/EmailTemplateController.php index 515d80f..9329653 100644 --- a/src/Controller/Admin/EmailTemplateController.php +++ b/src/Controller/Admin/EmailTemplateController.php @@ -4,8 +4,8 @@ namespace App\Controller\Admin; +use App\Service\EmailTemplateService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -16,6 +16,7 @@ class EmailTemplateController extends AbstractController private readonly string $templateDir; public function __construct( + private readonly EmailTemplateService $emailTemplateService, private readonly string $projectDir, ) { $this->templateDir = $this->projectDir.'/templates/email/mjml'; @@ -39,18 +40,15 @@ public function index(): Response ]); } - #[Route('/admin/email-template/{name}', name: 'admin_emailtemplate_details')] - public function details(string $name, Request $request): Response + #[Route('/admin/email-template/login-reminder', name: 'admin_emailtemplate_loginreminder')] + public function loginReminder(): Response { - $parametersJSON = $request->query->get('parameters', '{}'); - $parameters = json_decode($parametersJSON, true); - - if (!\is_array($parameters)) { - throw new \InvalidArgumentException('The parameters must be a valid JSON object.'); - } + $emailDto = $this->emailTemplateService->createLoginReminderDto([]); try { - $content = $this->renderView("email/mjml/$name.mjml.twig", $parameters); + $content = $this->renderView('admin/email_template/preview.html.twig', [ + 'emailDto' => $emailDto, + ]); $error = null; } catch (\Throwable $e) { $content = null; @@ -58,8 +56,7 @@ public function details(string $name, Request $request): Response } return $this->render('admin/email_template/details.html.twig', [ - 'name' => $name, - 'parameters' => $parameters, + 'name' => '登入提醒', 'content' => $content, 'error' => $error, ]); diff --git a/templates/admin/email_template/details.html.twig b/templates/admin/email_template/details.html.twig index adb07d8..fc8d18a 100644 --- a/templates/admin/email_template/details.html.twig +++ b/templates/admin/email_template/details.html.twig @@ -1,16 +1,14 @@ {% extends '@EasyAdmin/page/content.html.twig' %} -{% block content_title %}郵件範本 – 預覽 {{ name }}{% endblock %} +{% block content_title %}郵件範本 – 預覽{{ name }}{% endblock %} {% block page_actions %} 回到範本列表 {% endblock %} {% block main %} -

參數:{{ parameters|json_encode }}(可以在 query string 傳入 parameters={"key": "value"} 參數)

- {% if error is not null %}
發生錯誤:{{ error }}
{% else %} - + {% endif %} {% endblock %} diff --git a/templates/admin/email_template/index.html.twig b/templates/admin/email_template/index.html.twig index b0b00e7..d09f2c0 100644 --- a/templates/admin/email_template/index.html.twig +++ b/templates/admin/email_template/index.html.twig @@ -13,12 +13,10 @@ - {% for template in templates %} - {{ template }} - 預覽 + 登入提醒 + 預覽 - {% endfor %} {% endblock %} diff --git a/templates/admin/email_template/preview.html.twig b/templates/admin/email_template/preview.html.twig new file mode 100644 index 0000000..15a1c57 --- /dev/null +++ b/templates/admin/email_template/preview.html.twig @@ -0,0 +1,9 @@ +{% extends 'base.html.twig' %} + +{% block title %}信件預覽{% endblock %} + +{% block body %} +
+ +
+{% endblock %} From 899aef67bd09ee24a2694402dd281e4bfde7ef25 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sun, 15 Dec 2024 23:55:30 +0800 Subject: [PATCH 11/22] chore: Update dependencies --- composer.lock | 322 ++++++++++++++++++++++++------------------------- devenv.lock | 12 +- package.json | 6 +- pnpm-lock.yaml | 82 ++++++------- 4 files changed, 211 insertions(+), 211 deletions(-) diff --git a/composer.lock b/composer.lock index 73bca12..1ee5088 100644 --- a/composer.lock +++ b/composer.lock @@ -81,12 +81,12 @@ "source": { "type": "git", "url": "https://github.com/async-aws/ses.git", - "reference": "974b35581d495974eed4c4bbdb31b70ea0061bf8" + "reference": "129c625806aefc86e38c6125356a9f68363387ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/async-aws/ses/zipball/974b35581d495974eed4c4bbdb31b70ea0061bf8", - "reference": "974b35581d495974eed4c4bbdb31b70ea0061bf8", + "url": "https://api.github.com/repos/async-aws/ses/zipball/129c625806aefc86e38c6125356a9f68363387ce", + "reference": "129c625806aefc86e38c6125356a9f68363387ce", "shasum": "" }, "require": { @@ -98,7 +98,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.10-dev" } }, "autoload": { @@ -131,7 +131,7 @@ "type": "github" } ], - "time": "2024-11-24T17:57:34+00:00" + "time": "2024-12-12T09:50:39+00:00" }, { "name": "composer/semver", @@ -587,16 +587,16 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.x-dev", + "version": "1.2.x-dev", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "e48ead528b87dcbb0bcc0d67d99265cb4b108b5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/e48ead528b87dcbb0bcc0d67d99265cb4b108b5c", + "reference": "e48ead528b87dcbb0bcc0d67d99265cb4b108b5c", "shasum": "" }, "require": { @@ -612,7 +612,6 @@ "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -627,9 +626,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.x" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2024-12-14T23:24:50+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -1522,12 +1521,12 @@ "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "ecf581d1a124f1ab97f1f4dd10c7a4bc633a67e4" + "reference": "81e468dbe9690103328d069b43655183f3c0cb1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/ecf581d1a124f1ab97f1f4dd10c7a4bc633a67e4", - "reference": "ecf581d1a124f1ab97f1f4dd10c7a4bc633a67e4", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/81e468dbe9690103328d069b43655183f3c0cb1f", + "reference": "81e468dbe9690103328d069b43655183f3c0cb1f", "shasum": "" }, "require": { @@ -1613,7 +1612,7 @@ "type": "github" } ], - "time": "2024-12-09T19:05:00+00:00" + "time": "2024-12-15T11:07:04+00:00" }, { "name": "egulias/email-validator", @@ -2264,12 +2263,12 @@ "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "2d4c11ff471904323354e3ea3ae62ff7397d4267" + "reference": "2b48b24abf3b59c3699ca99e144deb5c0020208f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/2d4c11ff471904323354e3ea3ae62ff7397d4267", - "reference": "2d4c11ff471904323354e3ea3ae62ff7397d4267", + "url": "https://api.github.com/repos/nette/utils/zipball/2b48b24abf3b59c3699ca99e144deb5c0020208f", + "reference": "2b48b24abf3b59c3699ca99e144deb5c0020208f", "shasum": "" }, "require": { @@ -2343,7 +2342,7 @@ "issues": "https://github.com/nette/utils/issues", "source": "https://github.com/nette/utils/tree/master" }, - "time": "2024-12-11T14:09:36+00:00" + "time": "2024-12-12T10:02:15+00:00" }, { "name": "notfloran/mjml-bundle", @@ -3724,12 +3723,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/asset-mapper.git", - "reference": "777908c1b580a51b1452ab95dc90ef9896572fa5" + "reference": "df9a4f9cd7d6d78ad38b515c08478f6da7bbce9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/777908c1b580a51b1452ab95dc90ef9896572fa5", - "reference": "777908c1b580a51b1452ab95dc90ef9896572fa5", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/df9a4f9cd7d6d78ad38b515c08478f6da7bbce9d", + "reference": "df9a4f9cd7d6d78ad38b515c08478f6da7bbce9d", "shasum": "" }, "require": { @@ -3796,7 +3795,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T15:27:34+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfony/cache", @@ -3804,12 +3803,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "e7e983596b744c4539f31e79b0350a6cf5878a20" + "reference": "916032b874b9881c6b91cf9ab05c2cb60459156d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/e7e983596b744c4539f31e79b0350a6cf5878a20", - "reference": "e7e983596b744c4539f31e79b0350a6cf5878a20", + "url": "https://api.github.com/repos/symfony/cache/zipball/916032b874b9881c6b91cf9ab05c2cb60459156d", + "reference": "916032b874b9881c6b91cf9ab05c2cb60459156d", "shasum": "" }, "require": { @@ -3878,7 +3877,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/7.2" + "source": "https://github.com/symfony/cache/tree/7.3" }, "funding": [ { @@ -3894,7 +3893,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:08:50+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/cache-contracts", @@ -4053,12 +4052,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "bcd3c4adf0144dee5011bb35454728c38adec055" + "reference": "a8ba7d486804a6619376ec460c54eedd791845ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/bcd3c4adf0144dee5011bb35454728c38adec055", - "reference": "bcd3c4adf0144dee5011bb35454728c38adec055", + "url": "https://api.github.com/repos/symfony/config/zipball/a8ba7d486804a6619376ec460c54eedd791845ff", + "reference": "a8ba7d486804a6619376ec460c54eedd791845ff", "shasum": "" }, "require": { @@ -4104,7 +4103,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.2.0" + "source": "https://github.com/symfony/config/tree/7.3" }, "funding": [ { @@ -4120,7 +4119,7 @@ "type": "tidelift" } ], - "time": "2024-11-04T11:36:24+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/console", @@ -4128,12 +4127,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "13d074bbfd0ecb91f0a7a7ed4e60561ea6d36abc" + "reference": "28d6a7c0d8389c1d1b8b6ade38c79a50754313b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/13d074bbfd0ecb91f0a7a7ed4e60561ea6d36abc", - "reference": "13d074bbfd0ecb91f0a7a7ed4e60561ea6d36abc", + "url": "https://api.github.com/repos/symfony/console/zipball/28d6a7c0d8389c1d1b8b6ade38c79a50754313b9", + "reference": "28d6a7c0d8389c1d1b8b6ade38c79a50754313b9", "shasum": "" }, "require": { @@ -4213,7 +4212,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T15:35:08+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfony/dependency-injection", @@ -4221,12 +4220,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "a475747af1a1c98272a5471abc35f3da81197c5d" + "reference": "09a04f7055c94e91750643905db0d06229524e6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a475747af1a1c98272a5471abc35f3da81197c5d", - "reference": "a475747af1a1c98272a5471abc35f3da81197c5d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/09a04f7055c94e91750643905db0d06229524e6f", + "reference": "09a04f7055c94e91750643905db0d06229524e6f", "shasum": "" }, "require": { @@ -4277,7 +4276,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.2.0" + "source": "https://github.com/symfony/dependency-injection/tree/7.3" }, "funding": [ { @@ -4293,7 +4292,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T15:45:00+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4369,12 +4368,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "b492be51eb703723d682851a0c9fb39b9d1a7bfb" + "reference": "5f8c317a0f3539b7e2f12d50248fff7db93443f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/b492be51eb703723d682851a0c9fb39b9d1a7bfb", - "reference": "b492be51eb703723d682851a0c9fb39b9d1a7bfb", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/5f8c317a0f3539b7e2f12d50248fff7db93443f2", + "reference": "5f8c317a0f3539b7e2f12d50248fff7db93443f2", "shasum": "" }, "require": { @@ -4454,7 +4453,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/7.2" + "source": "https://github.com/symfony/doctrine-bridge/tree/7.3" }, "funding": [ { @@ -4470,7 +4469,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:50:44+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/doctrine-messenger", @@ -4478,12 +4477,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "533e664a37b4208c5a26f1f7894f212690e806f5" + "reference": "3a3730519d0b76d572bee10b7ce2eb8a10d819c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/533e664a37b4208c5a26f1f7894f212690e806f5", - "reference": "533e664a37b4208c5a26f1f7894f212690e806f5", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/3a3730519d0b76d572bee10b7ce2eb8a10d819c6", + "reference": "3a3730519d0b76d572bee10b7ce2eb8a10d819c6", "shasum": "" }, "require": { @@ -4526,7 +4525,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.2.0" + "source": "https://github.com/symfony/doctrine-messenger/tree/7.3" }, "funding": [ { @@ -4542,7 +4541,7 @@ "type": "tidelift" } ], - "time": "2024-10-18T09:50:33+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/dotenv", @@ -4922,12 +4921,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" + "reference": "86dd50b11455bdeee991048f19efe26a18d6081b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", + "url": "https://api.github.com/repos/symfony/finder/zipball/86dd50b11455bdeee991048f19efe26a18d6081b", + "reference": "86dd50b11455bdeee991048f19efe26a18d6081b", "shasum": "" }, "require": { @@ -4962,7 +4961,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.0" + "source": "https://github.com/symfony/finder/tree/7.3" }, "funding": [ { @@ -4978,7 +4977,7 @@ "type": "tidelift" } ], - "time": "2024-10-23T06:56:12+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/flex", @@ -5152,12 +5151,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "4bf82367c2efedec338d102ebb1b4e6938b040de" + "reference": "06e1348872bae94fff34a6f231822b3cf697f492" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/4bf82367c2efedec338d102ebb1b4e6938b040de", - "reference": "4bf82367c2efedec338d102ebb1b4e6938b040de", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/06e1348872bae94fff34a6f231822b3cf697f492", + "reference": "06e1348872bae94fff34a6f231822b3cf697f492", "shasum": "" }, "require": { @@ -5189,6 +5188,7 @@ "symfony/dotenv": "<6.4", "symfony/form": "<6.4", "symfony/http-client": "<6.4", + "symfony/json-encoder": ">=7.4", "symfony/lock": "<6.4", "symfony/mailer": "<6.4", "symfony/messenger": "<6.4", @@ -5201,7 +5201,7 @@ "symfony/security-csrf": "<7.2", "symfony/serializer": "<7.1", "symfony/stopwatch": "<6.4", - "symfony/translation": "<6.4", + "symfony/translation": "<6.4.3", "symfony/twig-bridge": "<6.4", "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4", @@ -5226,7 +5226,7 @@ "symfony/form": "^6.4|^7.0", "symfony/html-sanitizer": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", - "symfony/json-encoder": "^7.3", + "symfony/json-encoder": "7.3.*", "symfony/lock": "^6.4|^7.0", "symfony/mailer": "^6.4|^7.0", "symfony/messenger": "^6.4|^7.0", @@ -5242,7 +5242,7 @@ "symfony/serializer": "^7.1", "symfony/stopwatch": "^6.4|^7.0", "symfony/string": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/translation": "^6.4.3|^7.0", "symfony/twig-bundle": "^6.4|^7.0", "symfony/type-info": "^7.1", "symfony/uid": "^6.4|^7.0", @@ -5295,7 +5295,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T13:18:41+00:00" + "time": "2024-12-12T15:16:25+00:00" }, { "name": "symfony/http-client", @@ -5303,12 +5303,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "543229901754af59075299ad42ab6baffef8cff8" + "reference": "1897eaecde8f4508698040335fef9e9076fbf64e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/543229901754af59075299ad42ab6baffef8cff8", - "reference": "543229901754af59075299ad42ab6baffef8cff8", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1897eaecde8f4508698040335fef9e9076fbf64e", + "reference": "1897eaecde8f4508698040335fef9e9076fbf64e", "shasum": "" }, "require": { @@ -5374,7 +5374,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/7.2" + "source": "https://github.com/symfony/http-client/tree/7.3" }, "funding": [ { @@ -5390,7 +5390,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T15:34:14+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/http-client-contracts", @@ -5398,12 +5398,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "c5d993f8c4ed8c1773ce8c0e92de39a90fae6ac3" + "reference": "d8d46d0f605337624f8b28fc4acf346c5156533b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/c5d993f8c4ed8c1773ce8c0e92de39a90fae6ac3", - "reference": "c5d993f8c4ed8c1773ce8c0e92de39a90fae6ac3", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/d8d46d0f605337624f8b28fc4acf346c5156533b", + "reference": "d8d46d0f605337624f8b28fc4acf346c5156533b", "shasum": "" }, "require": { @@ -5469,7 +5469,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:50:44+00:00" + "time": "2024-12-11T15:34:14+00:00" }, { "name": "symfony/http-foundation", @@ -5477,12 +5477,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "069924c165fb05f1d0860f21addff0f1d0778e44" + "reference": "c2508e48b252f02b1364980ff79fb168a074e199" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/069924c165fb05f1d0860f21addff0f1d0778e44", - "reference": "069924c165fb05f1d0860f21addff0f1d0778e44", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c2508e48b252f02b1364980ff79fb168a074e199", + "reference": "c2508e48b252f02b1364980ff79fb168a074e199", "shasum": "" }, "require": { @@ -5547,7 +5547,7 @@ "type": "tidelift" } ], - "time": "2024-12-10T12:42:37+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/http-kernel", @@ -5555,12 +5555,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "db324fa06352711d0e027bd3871853b97b11f0bf" + "reference": "406c453966dc1420d8b19ff45007bac8a51d401c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/db324fa06352711d0e027bd3871853b97b11f0bf", - "reference": "db324fa06352711d0e027bd3871853b97b11f0bf", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/406c453966dc1420d8b19ff45007bac8a51d401c", + "reference": "406c453966dc1420d8b19ff45007bac8a51d401c", "shasum": "" }, "require": { @@ -5661,7 +5661,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T15:35:08+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/intl", @@ -5904,12 +5904,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc" + "reference": "6d88891dfa20cad6bf84224005d3d6de3f3cad85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e4d358702fb66e4c8a2af08e90e7271a62de39cc", - "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6d88891dfa20cad6bf84224005d3d6de3f3cad85", + "reference": "6d88891dfa20cad6bf84224005d3d6de3f3cad85", "shasum": "" }, "require": { @@ -5960,7 +5960,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.0" + "source": "https://github.com/symfony/mailer/tree/7.3" }, "funding": [ { @@ -5976,7 +5976,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T15:21:05+00:00" + "time": "2024-12-12T10:02:58+00:00" }, { "name": "symfony/messenger", @@ -6071,12 +6071,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "7f9617fcf15cb61be30f8b252695ed5e2bfac283" + "reference": "0df3f7f08ba2980eb9ef0a51fb6b9d3e98ae07c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/7f9617fcf15cb61be30f8b252695ed5e2bfac283", - "reference": "7f9617fcf15cb61be30f8b252695ed5e2bfac283", + "url": "https://api.github.com/repos/symfony/mime/zipball/0df3f7f08ba2980eb9ef0a51fb6b9d3e98ae07c6", + "reference": "0df3f7f08ba2980eb9ef0a51fb6b9d3e98ae07c6", "shasum": "" }, "require": { @@ -6131,7 +6131,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/7.2" + "source": "https://github.com/symfony/mime/tree/7.3" }, "funding": [ { @@ -6147,7 +6147,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:50:44+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/monolog-bridge", @@ -7100,12 +7100,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" + "reference": "724e6c68cc3cb7c618860a824e5cbda91815e374" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "url": "https://api.github.com/repos/symfony/process/zipball/724e6c68cc3cb7c618860a824e5cbda91815e374", + "reference": "724e6c68cc3cb7c618860a824e5cbda91815e374", "shasum": "" }, "require": { @@ -7137,7 +7137,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.0" + "source": "https://github.com/symfony/process/tree/7.3" }, "funding": [ { @@ -7153,7 +7153,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/property-access", @@ -7237,18 +7237,18 @@ "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "8c05131be5df764fe48ae4d0cbd01342b9a61662" + "reference": "0f06ce4cbb81ea8b184e8d645d936eb1a2fccd18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/8c05131be5df764fe48ae4d0cbd01342b9a61662", - "reference": "8c05131be5df764fe48ae4d0cbd01342b9a61662", + "url": "https://api.github.com/repos/symfony/property-info/zipball/0f06ce4cbb81ea8b184e8d645d936eb1a2fccd18", + "reference": "0f06ce4cbb81ea8b184e8d645d936eb1a2fccd18", "shasum": "" }, "require": { "php": ">=8.2", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "^7.1" + "symfony/type-info": "~7.1.9|^7.2.2" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", @@ -7296,7 +7296,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/7.2" + "source": "https://github.com/symfony/property-info/tree/7.3" }, "funding": [ { @@ -7312,7 +7312,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T15:34:14+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfony/redis-messenger", @@ -7387,12 +7387,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e" + "reference": "656c16cd1c61d2437b434d99b8b938742d9afe95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e10a2450fa957af6c448b9b93c9010a4e4c0725e", - "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e", + "url": "https://api.github.com/repos/symfony/routing/zipball/656c16cd1c61d2437b434d99b8b938742d9afe95", + "reference": "656c16cd1c61d2437b434d99b8b938742d9afe95", "shasum": "" }, "require": { @@ -7444,7 +7444,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.2.0" + "source": "https://github.com/symfony/routing/tree/7.3" }, "funding": [ { @@ -7460,7 +7460,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T11:08:51+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfony/runtime", @@ -7898,12 +7898,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0" + "reference": "bb7b4c20560d5f0bea3a035eb3adb4f6ae08ea00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0", - "reference": "3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0", + "url": "https://api.github.com/repos/symfony/serializer/zipball/bb7b4c20560d5f0bea3a035eb3adb4f6ae08ea00", + "reference": "bb7b4c20560d5f0bea3a035eb3adb4f6ae08ea00", "shasum": "" }, "require": { @@ -7972,7 +7972,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.2.0" + "source": "https://github.com/symfony/serializer/tree/7.3" }, "funding": [ { @@ -7988,7 +7988,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T15:21:05+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfony/service-contracts", @@ -9091,12 +9091,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "9b347f6ca2d9e18cee630787f0a6aa453982bf18" + "reference": "e91b59107feb9fd297623bec5f353021ebc45c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/9b347f6ca2d9e18cee630787f0a6aa453982bf18", - "reference": "9b347f6ca2d9e18cee630787f0a6aa453982bf18", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/e91b59107feb9fd297623bec5f353021ebc45c2e", + "reference": "e91b59107feb9fd297623bec5f353021ebc45c2e", "shasum": "" }, "require": { @@ -9151,7 +9151,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v2.22.1" + "source": "https://github.com/symfony/ux-twig-component/tree/2.x" }, "funding": [ { @@ -9167,7 +9167,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T18:05:50+00:00" + "time": "2024-12-11T00:55:08+00:00" }, { "name": "symfony/validator", @@ -9175,12 +9175,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "ddad20aa8cf7a45a9d6300e5776b8d252dc3524b" + "reference": "6036f24941aa2f5bc6d2cf274f04521273485dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/ddad20aa8cf7a45a9d6300e5776b8d252dc3524b", - "reference": "ddad20aa8cf7a45a9d6300e5776b8d252dc3524b", + "url": "https://api.github.com/repos/symfony/validator/zipball/6036f24941aa2f5bc6d2cf274f04521273485dec", + "reference": "6036f24941aa2f5bc6d2cf274f04521273485dec", "shasum": "" }, "require": { @@ -9248,7 +9248,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.2.0" + "source": "https://github.com/symfony/validator/tree/7.3" }, "funding": [ { @@ -9264,7 +9264,7 @@ "type": "tidelift" } ], - "time": "2024-11-27T09:50:52+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/var-dumper", @@ -9272,12 +9272,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "de6124d690069ee8d4cd21b00050aa231c0434e3" + "reference": "ee43df6370a35da55d191bf2e066cf8c89f9a7d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/de6124d690069ee8d4cd21b00050aa231c0434e3", - "reference": "de6124d690069ee8d4cd21b00050aa231c0434e3", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ee43df6370a35da55d191bf2e066cf8c89f9a7d0", + "reference": "ee43df6370a35da55d191bf2e066cf8c89f9a7d0", "shasum": "" }, "require": { @@ -9347,7 +9347,7 @@ "type": "tidelift" } ], - "time": "2024-11-28T16:26:37+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfony/var-exporter", @@ -9514,12 +9514,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "099581e99f557e9f16b43c5916c26380b54abb22" + "reference": "7a9c072bdb56cd0c3e95993f67abc3dc923dc267" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/099581e99f557e9f16b43c5916c26380b54abb22", - "reference": "099581e99f557e9f16b43c5916c26380b54abb22", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7a9c072bdb56cd0c3e95993f67abc3dc923dc267", + "reference": "7a9c072bdb56cd0c3e95993f67abc3dc923dc267", "shasum": "" }, "require": { @@ -9562,7 +9562,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.0" + "source": "https://github.com/symfony/yaml/tree/7.3" }, "funding": [ { @@ -9578,7 +9578,7 @@ "type": "tidelift" } ], - "time": "2024-10-23T06:56:12+00:00" + "time": "2024-12-14T16:08:18+00:00" }, { "name": "symfonycasts/sass-bundle", @@ -9909,12 +9909,12 @@ "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "47f527ef8447640a5ff9d75ee4cf7cfff29fa3be" + "reference": "918f52e818f7c56e90ec462ae90b230da244a7ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/47f527ef8447640a5ff9d75ee4cf7cfff29fa3be", - "reference": "47f527ef8447640a5ff9d75ee4cf7cfff29fa3be", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/918f52e818f7c56e90ec462ae90b230da244a7ed", + "reference": "918f52e818f7c56e90ec462ae90b230da244a7ed", "shasum": "" }, "require": { @@ -9982,7 +9982,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T10:04:38+00:00" + "time": "2024-12-14T19:06:36+00:00" }, { "name": "webmozart/assert", @@ -10828,12 +10828,12 @@ "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "6539b805982292a6c7307c5da48d6e9e3bd5c4d0" + "reference": "de0553c00bf5268f59590dc6d33cfeff2e1d490a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6539b805982292a6c7307c5da48d6e9e3bd5c4d0", - "reference": "6539b805982292a6c7307c5da48d6e9e3bd5c4d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/de0553c00bf5268f59590dc6d33cfeff2e1d490a", + "reference": "de0553c00bf5268f59590dc6d33cfeff2e1d490a", "shasum": "" }, "require": { @@ -10879,7 +10879,7 @@ "type": "github" } ], - "time": "2024-12-11T15:24:03+00:00" + "time": "2024-12-15T13:10:46+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -11011,17 +11011,17 @@ "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "a4a6a08bd4a461e516b9c3b8fdbf0f1883b34158" + "reference": "ed6fea0ad4ad9c7e25f3ad2e7c4d420cf1e67fe3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/a4a6a08bd4a461e516b9c3b8fdbf0f1883b34158", - "reference": "a4a6a08bd4a461e516b9c3b8fdbf0f1883b34158", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/ed6fea0ad4ad9c7e25f3ad2e7c4d420cf1e67fe3", + "reference": "ed6fea0ad4ad9c7e25f3ad2e7c4d420cf1e67fe3", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0.4" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", @@ -11050,9 +11050,9 @@ "description": "Extra strict and opinionated rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.0" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.x" }, - "time": "2024-10-26T16:04:33+00:00" + "time": "2024-12-12T20:21:10+00:00" }, { "name": "phpstan/phpstan-symfony", @@ -11455,12 +11455,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4e89eff200b801db58f3d580ad7426431949eaa9" + "reference": "54ae58f62c551a1c73498b053bd11697aac16abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4e89eff200b801db58f3d580ad7426431949eaa9", - "reference": "4e89eff200b801db58f3d580ad7426431949eaa9", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/54ae58f62c551a1c73498b053bd11697aac16abe", + "reference": "54ae58f62c551a1c73498b053bd11697aac16abe", "shasum": "" }, "require": { @@ -11532,7 +11532,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.39" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5" }, "funding": [ { @@ -11548,7 +11548,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T10:51:07+00:00" + "time": "2024-12-12T06:30:58+00:00" }, { "name": "react/cache", @@ -13379,12 +13379,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "2bbde92ab25a0e2c88160857af7be9db5da0d145" + "reference": "2d32721e963dcd3fc1846feb556e8b4aebe65e0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/2bbde92ab25a0e2c88160857af7be9db5da0d145", - "reference": "2bbde92ab25a0e2c88160857af7be9db5da0d145", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/2d32721e963dcd3fc1846feb556e8b4aebe65e0e", + "reference": "2d32721e963dcd3fc1846feb556e8b4aebe65e0e", "shasum": "" }, "require": { @@ -13437,7 +13437,7 @@ "description": "Provides utilities for PHPUnit, especially user deprecation notices management", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.2.0" + "source": "https://github.com/symfony/phpunit-bridge/tree/7.3" }, "funding": [ { @@ -13453,7 +13453,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T16:15:23+00:00" + "time": "2024-12-12T06:45:37+00:00" }, { "name": "symfony/web-profiler-bundle", @@ -13593,12 +13593,12 @@ "source": { "type": "git", "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", - "reference": "a193004602d7a9b1c17408eb8b8a1632532a28a7" + "reference": "f81af33e48c384be7e0e3689f02e6e712fa68beb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/a193004602d7a9b1c17408eb8b8a1632532a28a7", - "reference": "a193004602d7a9b1c17408eb8b8a1632532a28a7", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/f81af33e48c384be7e0e3689f02e6e712fa68beb", + "reference": "f81af33e48c384be7e0e3689f02e6e712fa68beb", "shasum": "" }, "require": { @@ -13658,7 +13658,7 @@ "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", "support": { "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", - "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.4.0" + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.5.0" }, "funding": [ { @@ -13666,7 +13666,7 @@ "type": "github" } ], - "time": "2024-12-04T18:37:59+00:00" + "time": "2024-12-13T16:55:11+00:00" } ], "aliases": [], diff --git a/devenv.lock b/devenv.lock index 8a1ec3d..6f47658 100644 --- a/devenv.lock +++ b/devenv.lock @@ -53,10 +53,10 @@ }, "nixpkgs": { "locked": { - "lastModified": 1733749988, + "lastModified": 1734126203, "owner": "nixos", "repo": "nixpkgs", - "rev": "bc27f0fde01ce4e1bfec1ab122d72b7380278e68", + "rev": "71a6392e367b08525ee710a93af2e80083b5b3e2", "type": "github" }, "original": { @@ -68,10 +68,10 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1733730953, + "lastModified": 1734017764, "owner": "NixOS", "repo": "nixpkgs", - "rev": "7109b680d161993918b0a126f38bc39763e5a709", + "rev": "64e9404f308e0f0a0d8cdd7c358f74e34802494b", "type": "github" }, "original": { @@ -91,10 +91,10 @@ "nixpkgs-stable": "nixpkgs-stable" }, "locked": { - "lastModified": 1733665616, + "lastModified": 1734261738, "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "d8c02f0ffef0ef39f6063731fc539d8c71eb463a", + "rev": "4c8e75efbbdcc6f9203f64b1f21f8a55d2285264", "type": "github" }, "original": { diff --git a/package.json b/package.json index 8a8b088..34546cb 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,18 @@ "@swc/core": "^1.10.1", "mjml": "^4.15.3" }, - "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab", + "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c", "devDependencies": { "@codemirror/lang-sql": "^6.8.0", "@codemirror/state": "^6.5.0", - "@eslint/js": "^9.16.0", + "@eslint/js": "^9.17.0", "@hotwired/stimulus": "^3.2.2", "@symfony/stimulus-bridge": "^3.2.2", "@types/bootstrap": "^5.2.10", "bootstrap": "^5.3.3", "codemirror": "^6.0.1", "dprint": "^0.47.6", - "eslint": "^9.16.0", + "eslint": "^9.17.0", "globals": "^15.13.0", "typescript": "^5.7.2", "typescript-eslint": "^8.18.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e97b18..17eb800 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^6.5.0 version: 6.5.0 "@eslint/js": - specifier: ^9.16.0 - version: 9.16.0 + specifier: ^9.17.0 + version: 9.17.0 "@hotwired/stimulus": specifier: ^3.2.2 version: 3.2.2 @@ -45,8 +45,8 @@ importers: specifier: ^0.47.6 version: 0.47.6 eslint: - specifier: ^9.16.0 - version: 9.16.0 + specifier: ^9.17.0 + version: 9.17.0 globals: specifier: ^15.13.0 version: 15.13.0 @@ -55,7 +55,7 @@ importers: version: 5.7.2 typescript-eslint: specifier: ^8.18.0 - version: 8.18.0(eslint@9.16.0)(typescript@5.7.2) + version: 8.18.0(eslint@9.17.0)(typescript@5.7.2) packages: "@babel/runtime@7.26.0": @@ -204,9 +204,9 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - "@eslint/js@9.16.0": + "@eslint/js@9.17.0": resolution: { - integrity: sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==, + integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } @@ -1207,9 +1207,9 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - eslint@9.16.0: + eslint@9.17.0: resolution: { - integrity: sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==, + integrity: sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } hasBin: true @@ -2254,9 +2254,9 @@ packages: } engines: { node: ">= 8" } - streamx@2.21.0: + streamx@2.21.1: resolution: { - integrity: sha512-Qz6MsDZXJ6ur9u+b+4xCG18TluU7PGlRfXVAAjNiGsFrBUt/ioyLkxbFaKJygoPs+/kW4VyBj0bSj89Qu0IGyg==, + integrity: sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==, } string-width@4.2.3: @@ -2322,9 +2322,9 @@ packages: integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==, } - text-decoder@1.2.2: + text-decoder@1.2.3: resolution: { - integrity: sha512-/MDslo7ZyWTA2vnk1j7XoDVfXsGk3tp+zFEJHJGm0UjIlQifonVFwlVbQDFh8KJzTBnT8ie115TYqir6bclddA==, + integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==, } through@2.3.8: @@ -2577,9 +2577,9 @@ snapshots: "@dprint/win32-x64@0.47.6": optional: true - "@eslint-community/eslint-utils@4.4.1(eslint@9.16.0)": + "@eslint-community/eslint-utils@4.4.1(eslint@9.17.0)": dependencies: - eslint: 9.16.0 + eslint: 9.17.0 eslint-visitor-keys: 3.4.3 "@eslint-community/regexpp@4.12.1": {} @@ -2610,7 +2610,7 @@ snapshots: transitivePeerDependencies: - supports-color - "@eslint/js@9.16.0": {} + "@eslint/js@9.17.0": {} "@eslint/object-schema@2.1.5": {} @@ -2843,15 +2843,15 @@ snapshots: "@types/webpack-env@1.18.5": {} - "@typescript-eslint/eslint-plugin@8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2)": + "@typescript-eslint/eslint-plugin@8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.17.0)(typescript@5.7.2))(eslint@9.17.0)(typescript@5.7.2)": dependencies: "@eslint-community/regexpp": 4.12.1 - "@typescript-eslint/parser": 8.18.0(eslint@9.16.0)(typescript@5.7.2) + "@typescript-eslint/parser": 8.18.0(eslint@9.17.0)(typescript@5.7.2) "@typescript-eslint/scope-manager": 8.18.0 - "@typescript-eslint/type-utils": 8.18.0(eslint@9.16.0)(typescript@5.7.2) - "@typescript-eslint/utils": 8.18.0(eslint@9.16.0)(typescript@5.7.2) + "@typescript-eslint/type-utils": 8.18.0(eslint@9.17.0)(typescript@5.7.2) + "@typescript-eslint/utils": 8.18.0(eslint@9.17.0)(typescript@5.7.2) "@typescript-eslint/visitor-keys": 8.18.0 - eslint: 9.16.0 + eslint: 9.17.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -2860,14 +2860,14 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.7.2)": + "@typescript-eslint/parser@8.18.0(eslint@9.17.0)(typescript@5.7.2)": dependencies: "@typescript-eslint/scope-manager": 8.18.0 "@typescript-eslint/types": 8.18.0 "@typescript-eslint/typescript-estree": 8.18.0(typescript@5.7.2) "@typescript-eslint/visitor-keys": 8.18.0 debug: 4.4.0 - eslint: 9.16.0 + eslint: 9.17.0 typescript: 5.7.2 transitivePeerDependencies: - supports-color @@ -2877,12 +2877,12 @@ snapshots: "@typescript-eslint/types": 8.18.0 "@typescript-eslint/visitor-keys": 8.18.0 - "@typescript-eslint/type-utils@8.18.0(eslint@9.16.0)(typescript@5.7.2)": + "@typescript-eslint/type-utils@8.18.0(eslint@9.17.0)(typescript@5.7.2)": dependencies: "@typescript-eslint/typescript-estree": 8.18.0(typescript@5.7.2) - "@typescript-eslint/utils": 8.18.0(eslint@9.16.0)(typescript@5.7.2) + "@typescript-eslint/utils": 8.18.0(eslint@9.17.0)(typescript@5.7.2) debug: 4.4.0 - eslint: 9.16.0 + eslint: 9.17.0 ts-api-utils: 1.4.3(typescript@5.7.2) typescript: 5.7.2 transitivePeerDependencies: @@ -2904,13 +2904,13 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/utils@8.18.0(eslint@9.16.0)(typescript@5.7.2)": + "@typescript-eslint/utils@8.18.0(eslint@9.17.0)(typescript@5.7.2)": dependencies: - "@eslint-community/eslint-utils": 4.4.1(eslint@9.16.0) + "@eslint-community/eslint-utils": 4.4.1(eslint@9.17.0) "@typescript-eslint/scope-manager": 8.18.0 "@typescript-eslint/types": 8.18.0 "@typescript-eslint/typescript-estree": 8.18.0(typescript@5.7.2) - eslint: 9.16.0 + eslint: 9.17.0 typescript: 5.7.2 transitivePeerDependencies: - supports-color @@ -3298,14 +3298,14 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.16.0: + eslint@9.17.0: dependencies: - "@eslint-community/eslint-utils": 4.4.1(eslint@9.16.0) + "@eslint-community/eslint-utils": 4.4.1(eslint@9.17.0) "@eslint-community/regexpp": 4.12.1 "@eslint/config-array": 0.19.1 "@eslint/core": 0.9.1 "@eslint/eslintrc": 3.2.0 - "@eslint/js": 9.16.0 + "@eslint/js": 9.17.0 "@eslint/plugin-kit": 0.2.4 "@humanfs/node": 0.16.6 "@humanwhocodes/module-importer": 1.0.1 @@ -4150,11 +4150,11 @@ snapshots: source-map@0.7.4: {} - streamx@2.21.0: + streamx@2.21.1: dependencies: fast-fifo: 1.3.2 queue-tick: 1.0.1 - text-decoder: 1.2.2 + text-decoder: 1.2.3 optionalDependencies: bare-events: 2.5.0 @@ -4202,9 +4202,9 @@ snapshots: dependencies: b4a: 1.6.7 fast-fifo: 1.3.2 - streamx: 2.21.0 + streamx: 2.21.1 - text-decoder@1.2.2: + text-decoder@1.2.3: dependencies: b4a: 1.6.7 @@ -4229,12 +4229,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.18.0(eslint@9.16.0)(typescript@5.7.2): + typescript-eslint@8.18.0(eslint@9.17.0)(typescript@5.7.2): dependencies: - "@typescript-eslint/eslint-plugin": 8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2) - "@typescript-eslint/parser": 8.18.0(eslint@9.16.0)(typescript@5.7.2) - "@typescript-eslint/utils": 8.18.0(eslint@9.16.0)(typescript@5.7.2) - eslint: 9.16.0 + "@typescript-eslint/eslint-plugin": 8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.17.0)(typescript@5.7.2))(eslint@9.17.0)(typescript@5.7.2) + "@typescript-eslint/parser": 8.18.0(eslint@9.17.0)(typescript@5.7.2) + "@typescript-eslint/utils": 8.18.0(eslint@9.17.0)(typescript@5.7.2) + eslint: 9.17.0 typescript: 5.7.2 transitivePeerDependencies: - supports-color From c5aadec1e5e5504aa8e244157b9c2f98dece8b70 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sun, 15 Dec 2024 23:57:30 +0800 Subject: [PATCH 12/22] chore: Tweak style --- templates/admin/email_template/index.html.twig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/templates/admin/email_template/index.html.twig b/templates/admin/email_template/index.html.twig index d09f2c0..ab5f166 100644 --- a/templates/admin/email_template/index.html.twig +++ b/templates/admin/email_template/index.html.twig @@ -13,10 +13,13 @@ - - 登入提醒 - 預覽 - + + 登入提醒 + + 預覽 + + {% endblock %} From 7ea9460e90fb618600173a268fa8ffc533f4acfe Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 00:28:44 +0800 Subject: [PATCH 13/22] refactor(statistics): Separate statistics to a service --- src/Controller/Admin/StatisticController.php | 57 +++------------- src/Entity/StatisticsDto/LastLoginDto.php | 48 +++++++++++++ src/Service/StatisticsService.php | 68 +++++++++++++++++++ .../admin/statistics/last_login_at.html.twig | 6 +- 4 files changed, 128 insertions(+), 51 deletions(-) create mode 100644 src/Entity/StatisticsDto/LastLoginDto.php create mode 100644 src/Service/StatisticsService.php diff --git a/src/Controller/Admin/StatisticController.php b/src/Controller/Admin/StatisticController.php index be479c4..4ef1651 100644 --- a/src/Controller/Admin/StatisticController.php +++ b/src/Controller/Admin/StatisticController.php @@ -8,64 +8,25 @@ use App\Repository\QuestionRepository; use App\Repository\UserRepository; use App\Service\PointCalculationService; +use App\Service\StatisticsService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class StatisticController extends AbstractController { + public function __construct( + private readonly StatisticsService $statisticsService, + ) { + } + #[Route('/admin/statistic/last-login-at', name: 'admin_statistic_last_login_at')] - public function lastLoginAt(UserRepository $userRepository): Response + public function lastLoginAt(): Response { - /** - * @var list $results - */ - $results = $userRepository->createQueryBuilder('user') - ->leftJoin('user.loginEvents', 'loginEvent') - ->select( - 'user.id', - 'user.email', - 'MAX(loginEvent.createdAt) AS last_login_at', - ) - ->groupBy('user.id', 'user.email') - ->orderBy('last_login_at', 'DESC') - ->getQuery() - ->getResult(); - - /** - * @var list $resultsWithRecency - */ - $resultsWithRecency = []; - /** - * @var list $resultsThatNeverLogin - */ - $resultsThatNeverLogin = []; - - foreach ($results as $result) { - $lastLoginAt = ($lastLoginAt = $result['last_login_at']) !== null - ? new \DateTime($lastLoginAt) - : null; - - if (null !== $lastLoginAt) { - $lastLoginAtString = $lastLoginAt->format('Y-m-d H:i:s'); - $recency = $lastLoginAt->diff(new \DateTime())->format('%a 天'); - - $resultsWithRecency[] = [ - 'id' => $result['id'], - 'email' => $result['email'], - 'last_login_at' => $lastLoginAtString, - 'recency' => $recency, - ]; - } else { - $resultsThatNeverLogin[] = [ - 'id' => $result['id'], - 'email' => $result['email'], - ]; - } - } + $results = $this->statisticsService->lastLoginAt(); return $this->render('admin/statistics/last_login_at.html.twig', [ - 'results' => $resultsWithRecency + $resultsThatNeverLogin, + 'results' => $results, ]); } diff --git a/src/Entity/StatisticsDto/LastLoginDto.php b/src/Entity/StatisticsDto/LastLoginDto.php new file mode 100644 index 0000000..ce69eb9 --- /dev/null +++ b/src/Entity/StatisticsDto/LastLoginDto.php @@ -0,0 +1,48 @@ +user; + } + + public function setUser(User $user): static + { + $this->user = $user; + + return $this; + } + + /** + * Get the last login time. + */ + public function getLastLoginAt(): ?\DateTimeImmutable + { + return $this->lastLoginAt; + } + + public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): static + { + $this->lastLoginAt = $lastLoginAt; + + return $this; + } + + /** + * Get the interval of the last login time and now. + */ + public function getRecency(): ?\DateInterval + { + return $this->lastLoginAt?->diff(new \DateTimeImmutable()); + } +} diff --git a/src/Service/StatisticsService.php b/src/Service/StatisticsService.php new file mode 100644 index 0000000..1591301 --- /dev/null +++ b/src/Service/StatisticsService.php @@ -0,0 +1,68 @@ + $results + */ + $results = $this->userRepository->createQueryBuilder('user') + ->leftJoin('user.loginEvents', 'loginEvent') + ->select( + 'user AS u', + 'MAX(loginEvent.createdAt) AS last_login_at', + ) + ->groupBy('user.id') + ->orderBy('last_login_at', 'DESC') + ->getQuery() + ->getResult(); + + /** + * @var list $resultsWithRecency + */ + $resultsWithRecency = []; + + /** + * @var list $resultsThatNeverLogin + */ + $resultsThatNeverLogin = []; + + foreach ($results as $result) { + $lastLoginAt = ($lastLoginAt = $result['last_login_at']) !== null + ? new \DateTimeImmutable($lastLoginAt) + : null; + $lastLoginDto = (new LastLoginDto()) + ->setUser($result['u']) + ->setLastLoginAt($lastLoginAt); + + if (null !== $lastLoginAt) { + $resultsWithRecency[] = $lastLoginDto; + } else { + $resultsThatNeverLogin[] = $lastLoginDto; + } + } + + return $resultsWithRecency + $resultsThatNeverLogin; + } +} diff --git a/templates/admin/statistics/last_login_at.html.twig b/templates/admin/statistics/last_login_at.html.twig index c10bed6..c4b8415 100644 --- a/templates/admin/statistics/last_login_at.html.twig +++ b/templates/admin/statistics/last_login_at.html.twig @@ -17,9 +17,9 @@ {{ result.email }} - {{ result.last_login_at ?? '沒登入過' }} - {{ result.recency ?? 'N/A' }} + .setEntityId(result.user.id) }}">{{ result.user.email }} + {{ result.lastLoginAt is not null ? result.lastLoginAt.format('Y-m-d H:m:s') : '沒登入過' }} + {{ result.recency is not null ? result.recency.format('%a 天') : 'N/A' }} {% endfor %} From d324ac771b331d5739bce97ebe842d2340244417 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 00:29:43 +0800 Subject: [PATCH 14/22] refactor(service): Make all service "final readonly" --- src/Service/EmailService.php | 2 +- src/Service/EmailTemplateService.php | 2 +- src/Service/SqlRunnerComparer.php | 2 +- src/Service/StatisticsService.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index 0b47f2e..3af40f6 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -10,7 +10,7 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; -readonly class EmailService +final readonly class EmailService { private Address $fromAddress; diff --git a/src/Service/EmailTemplateService.php b/src/Service/EmailTemplateService.php index 67c224b..3af8100 100644 --- a/src/Service/EmailTemplateService.php +++ b/src/Service/EmailTemplateService.php @@ -9,7 +9,7 @@ use App\Entity\User; use Symfony\Component\Mime\Address; -readonly class EmailTemplateService +final readonly class EmailTemplateService { public function __construct( private \Twig\Environment $twigEnvironment, diff --git a/src/Service/SqlRunnerComparer.php b/src/Service/SqlRunnerComparer.php index e0da2fb..4b12dc5 100644 --- a/src/Service/SqlRunnerComparer.php +++ b/src/Service/SqlRunnerComparer.php @@ -7,7 +7,7 @@ use App\Entity\ChallengeDto\CompareResult; use App\Entity\SqlRunnerDto\SqlRunnerResult; -readonly class SqlRunnerComparer +final readonly class SqlRunnerComparer { /** * Compare this answer with user response and return the detailed information. diff --git a/src/Service/StatisticsService.php b/src/Service/StatisticsService.php index 1591301..c7acab2 100644 --- a/src/Service/StatisticsService.php +++ b/src/Service/StatisticsService.php @@ -8,7 +8,7 @@ use App\Entity\User; use App\Repository\UserRepository; -readonly class StatisticsService +final readonly class StatisticsService { public function __construct( private UserRepository $userRepository, From 30575ed3ab38272239cfcdfd2b207f128e8ef188 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 01:22:56 +0800 Subject: [PATCH 15/22] fix(email): Send at most 30 bcc each time --- src/Service/EmailService.php | 19 ++- tests/Service/EmailServiceTest.php | 219 +++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 tests/Service/EmailServiceTest.php diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index 3af40f6..f3a0d3b 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -31,12 +31,19 @@ public function __construct( * * @throws TransportExceptionInterface */ - public function send(EmailDto $emailDto): Email + public function send(EmailDto $emailDto): void { - $email = $emailDto->toEmail()->from($this->fromAddress); - - $this->mailer->send($email); - - return $email; + $recipients = $emailDto->getBcc(); + + if (\count($recipients) > 0) { + $chunks = array_chunk($recipients, 30); + foreach ($chunks as $chunk) { + $email = $emailDto->toEmail()->from($this->fromAddress)->bcc(...$chunk); + $this->mailer->send($email); + } + } else { + $email = $emailDto->toEmail()->from($this->fromAddress); + $this->mailer->send($email); + } } } diff --git a/tests/Service/EmailServiceTest.php b/tests/Service/EmailServiceTest.php new file mode 100644 index 0000000..0b294a0 --- /dev/null +++ b/tests/Service/EmailServiceTest.php @@ -0,0 +1,219 @@ +createMock(MailerInterface::class); + $mailer->expects(self::once()) + ->method('send') + ->willReturnCallback(function (Email $email): void { + self::assertSame('Test', $email->getSubject()); + self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); + self::assertSame('test2@gmail.com', $email->getTo()[0]->getAddress()); + self::assertSame('Test TEXT', $email->getTextBody()); + self::assertSame('Test HTML', $email->getHtmlBody()); + }); + + $emailService = new EmailService($mailer, 'test@example.com'); + $emailDto = (new EmailDto()) + ->setSubject('Test') + ->setToAddress('test2@gmail.com') + ->setKind(EmailKind::Test) + ->setText('Test TEXT') + ->setHtml('Test HTML') + ; + + $emailService->send($emailDto); + } + + public function testSendWithBcc(): void + { + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::once()) + ->method('send') + ->willReturnCallback(function (Email $email): void { + self::assertSame('Test', $email->getSubject()); + self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); + self::assertSame('test2@gmail.com', $email->getTo()[0]->getAddress()); + self::assertSame('me@pan93.com', $email->getBcc()[0]->getAddress()); + self::assertSame('Test TEXT', $email->getTextBody()); + self::assertSame('Test HTML', $email->getHtmlBody()); + }); + + $emailService = new EmailService($mailer, 'test@example.com'); + $emailDto = (new EmailDto()) + ->setSubject('Test') + ->setToAddress('test2@gmail.com') + ->setBcc([ + new Address('me@pan93.com'), + ]) + ->setKind(EmailKind::Test) + ->setText('Test TEXT') + ->setHtml('Test HTML') + ; + + $emailService->send($emailDto); + } + + public function testSendWith29Bcc(): void + { + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::once()) + ->method('send') + ->willReturnCallback(function (Email $email): void { + self::assertSame('Test', $email->getSubject()); + self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); + self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); + self::assertCount(29, $email->getBcc()); + self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); + self::assertSame('bcc29@example.com', $email->getBcc()[28]->getAddress()); + self::assertSame('Test TEXT', $email->getTextBody()); + self::assertSame('Test HTML', $email->getHtmlBody()); + }); + + $emailService = new EmailService($mailer, 'test@example.com'); + $emailDto = (new EmailDto()) + ->setSubject('Test') + ->setToAddress('test@example.com') + ->setBcc(array_map( + fn (int $i) => new Address("bcc$i@example.com"), + range(1, 29) + )) + ->setKind(EmailKind::Test) + ->setText('Test TEXT') + ->setHtml('Test HTML'); + + $emailService->send($emailDto); + } + + public function testSendWith30Bcc(): void + { + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::once()) + ->method('send') + ->willReturnCallback(function (Email $email): void { + self::assertSame('Test', $email->getSubject()); + self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); + self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); + self::assertCount(30, $email->getBcc()); + self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); + self::assertSame('bcc30@example.com', $email->getBcc()[29]->getAddress()); + self::assertSame('Test TEXT', $email->getTextBody()); + self::assertSame('Test HTML', $email->getHtmlBody()); + }); + + $emailService = new EmailService($mailer, 'test@example.com'); + $emailDto = (new EmailDto()) + ->setSubject('Test') + ->setToAddress('test@example.com') + ->setBcc(array_map( + fn (int $i) => new Address("bcc$i@example.com"), + range(1, 30) + )) + ->setKind(EmailKind::Test) + ->setText('Test TEXT') + ->setHtml('Test HTML'); + + $emailService->send($emailDto); + } + + public function testSendWith31Bcc(): void + { + $invokedCount = self::exactly(2); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects($invokedCount) + ->method('send') + ->willReturnCallback(function (Email $email) use (&$invokedCount): void { + self::assertSame('Test', $email->getSubject()); + self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); + self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); + self::assertSame('Test TEXT', $email->getTextBody()); + self::assertSame('Test HTML', $email->getHtmlBody()); + + switch ($invokedCount->numberOfInvocations()) { + case 1: + self::assertCount(30, $email->getBcc()); + self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); + self::assertSame('bcc30@example.com', $email->getBcc()[29]->getAddress()); + break; + case 2: + self::assertCount(1, $email->getBcc()); + self::assertSame('bcc31@example.com', $email->getBcc()[0]->getAddress()); + } + }); + + $emailService = new EmailService($mailer, 'test@example.com'); + $emailDto = (new EmailDto()) + ->setSubject('Test') + ->setToAddress('test@example.com') + ->setBcc(array_map( + fn (int $i) => new Address("bcc$i@example.com"), + range(1, 31) + )) + ->setKind(EmailKind::Test) + ->setText('Test TEXT') + ->setHtml('Test HTML'); + + $emailService->send($emailDto); + } + + public function testSendWith61Bcc(): void + { + $invokedCount = self::exactly(3); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects($invokedCount) + ->method('send') + ->willReturnCallback(function (Email $email) use (&$invokedCount): void { + self::assertSame('Test', $email->getSubject()); + self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); + self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); + self::assertSame('Test TEXT', $email->getTextBody()); + self::assertSame('Test HTML', $email->getHtmlBody()); + + switch ($invokedCount->numberOfInvocations()) { + case 1: + self::assertCount(30, $email->getBcc()); + self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); + self::assertSame('bcc30@example.com', $email->getBcc()[29]->getAddress()); + break; + case 2: + self::assertCount(30, $email->getBcc()); + self::assertSame('bcc31@example.com', $email->getBcc()[0]->getAddress()); + self::assertSame('bcc60@example.com', $email->getBcc()[29]->getAddress()); + break; + case 3: + self::assertCount(1, $email->getBcc()); + self::assertSame('bcc61@example.com', $email->getBcc()[0]->getAddress()); + } + }); + + $emailService = new EmailService($mailer, 'test@example.com'); + $emailDto = (new EmailDto()) + ->setSubject('Test') + ->setToAddress('test@example.com') + ->setBcc(array_map( + fn (int $i) => new Address("bcc$i@example.com"), + range(1, 61) + )) + ->setKind(EmailKind::Test) + ->setText('Test TEXT') + ->setHtml('Test HTML'); + + $emailService->send($emailDto); + } +} From ffc82dcac398ce6f946a15765f3b0197c74cc71f Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 01:23:24 +0800 Subject: [PATCH 16/22] feat(email): Implement "app:email:send-login-reminder" command --- .../Email/SendLoginReminderCommand.php | 56 +++++++++++++++++++ src/Service/StrategicEmailService.php | 46 +++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/Command/Email/SendLoginReminderCommand.php create mode 100644 src/Service/StrategicEmailService.php diff --git a/src/Command/Email/SendLoginReminderCommand.php b/src/Command/Email/SendLoginReminderCommand.php new file mode 100644 index 0000000..bc00ed5 --- /dev/null +++ b/src/Command/Email/SendLoginReminderCommand.php @@ -0,0 +1,56 @@ +addOption('dry-run', 'd', InputOption::VALUE_NONE, 'Whether to send the email or not'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = $input->getOption('dry-run'); + + $result = $this->strategicEmailService->sendLoginReminderEmail($dryRun); + + if (null !== $result) { + $table = $io->createTable(); + + $table->setHeaderTitle('Email to be sent'); + $table->setHeaders(['Field', 'Value']); + $table->addRow(['Subject', $result->getSubject()]); + $table->addRow(['To', $result->getToAddress()->toString()]); + + $bcc = implode(', ', array_map(fn ($address) => $address->toString(), $result->getBcc())); + $table->addRow(['Bcc', $bcc]); + + $table->addRow(['Kind', $result->getKind()->value]); + $table->addRow(['Content', $result->getText()]); + $table->render(); + } else { + $io->success('Emails have been sent successfully.'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Service/StrategicEmailService.php b/src/Service/StrategicEmailService.php new file mode 100644 index 0000000..2155edf --- /dev/null +++ b/src/Service/StrategicEmailService.php @@ -0,0 +1,46 @@ + $lastLoginDto->getUser(), + $this->statisticsService->lastLoginAt() + ); + + $emailDto = $this->emailTemplateService->createLoginReminderDto($bccUsers); + if ($dryRun) { + return $emailDto; + } + + $this->emailService->send($emailDto); + + return null; + } +} From e6f258c410e69a85f5b8b44c86dd1a36ce9999f9 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 01:24:44 +0800 Subject: [PATCH 17/22] fix(email): Indent in $textContent --- src/Service/EmailTemplateService.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Service/EmailTemplateService.php b/src/Service/EmailTemplateService.php index 3af8100..b07754a 100644 --- a/src/Service/EmailTemplateService.php +++ b/src/Service/EmailTemplateService.php @@ -27,21 +27,21 @@ public function __construct( public function createLoginReminderDto(array $bccUsers): EmailDto { $textContent = << Date: Mon, 16 Dec 2024 09:20:26 +0800 Subject: [PATCH 18/22] feat(email): Workaround with the rate limit --- config/services.php | 7 +- src/Service/EmailService.php | 18 +++- tests/Service/EmailServiceTest.php | 129 +++-------------------------- 3 files changed, 31 insertions(+), 123 deletions(-) diff --git a/config/services.php b/config/services.php index 7687149..1105c1a 100644 --- a/config/services.php +++ b/config/services.php @@ -18,9 +18,11 @@ ->set('app.redis_uri', env('REDIS_URI')) ->set('app.openai_api_key', env('OPENAI_API_KEY')) ->set('app.server-mail', env('SERVER_EMAIL')) + ->set('app.mail.bcc-chunk', 10) ->set('app.features.hint', true) ->set('app.features.editable-profile', true) - ->set('app.features.comment', true); + ->set('app.features.comment', true) + ; $services = $containerConfigurator->services(); @@ -45,7 +47,8 @@ ->arg('$baseUrl', param('app.sqlrunner_url')); $services->set(EmailService::class) - ->arg('$serverMail', param('app.server-mail')); + ->arg('$serverMail', param('app.server-mail')) + ->arg('$chunkLimit', param('app.mail.bcc-chunk')); $services->set(EmailTemplateService::class) ->arg('$serverMail', param('app.server-mail')); diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index f3a0d3b..6e9ffab 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -8,15 +8,18 @@ use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; -use Symfony\Component\Mime\Email; final readonly class EmailService { private Address $fromAddress; + /** + * @param int<1, max> $chunkLimit + */ public function __construct( private MailerInterface $mailer, private string $serverMail, + private int $chunkLimit, ) { $this->fromAddress = new Address( address: $this->serverMail, @@ -29,17 +32,24 @@ public function __construct( * * @param EmailDto $emailDto the email to send * - * @throws TransportExceptionInterface + * @throws TransportExceptionInterface if the email cannot be sent */ public function send(EmailDto $emailDto): void { $recipients = $emailDto->getBcc(); if (\count($recipients) > 0) { - $chunks = array_chunk($recipients, 30); + $sendAt = new \DateTimeImmutable(); + $chunks = array_chunk($recipients, $this->chunkLimit); foreach ($chunks as $chunk) { - $email = $emailDto->toEmail()->from($this->fromAddress)->bcc(...$chunk); + $email = $emailDto + ->toEmail() + ->from($this->fromAddress) + ->bcc(...$chunk) + ->date($sendAt); $this->mailer->send($email); + + $sendAt = $sendAt->modify('+3 seconds'); } } else { $email = $emailDto->toEmail()->from($this->fromAddress); diff --git a/tests/Service/EmailServiceTest.php b/tests/Service/EmailServiceTest.php index 0b294a0..1615799 100644 --- a/tests/Service/EmailServiceTest.php +++ b/tests/Service/EmailServiceTest.php @@ -27,7 +27,7 @@ public function testSendWithoutBcc(): void self::assertSame('Test HTML', $email->getHtmlBody()); }); - $emailService = new EmailService($mailer, 'test@example.com'); + $emailService = new EmailService($mailer, 'test@example.com', 10); $emailDto = (new EmailDto()) ->setSubject('Test') ->setToAddress('test2@gmail.com') @@ -53,7 +53,7 @@ public function testSendWithBcc(): void self::assertSame('Test HTML', $email->getHtmlBody()); }); - $emailService = new EmailService($mailer, 'test@example.com'); + $emailService = new EmailService($mailer, 'test@example.com', 10); $emailDto = (new EmailDto()) ->setSubject('Test') ->setToAddress('test2@gmail.com') @@ -68,117 +68,15 @@ public function testSendWithBcc(): void $emailService->send($emailDto); } - public function testSendWith29Bcc(): void - { - $mailer = $this->createMock(MailerInterface::class); - $mailer->expects(self::once()) - ->method('send') - ->willReturnCallback(function (Email $email): void { - self::assertSame('Test', $email->getSubject()); - self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); - self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); - self::assertCount(29, $email->getBcc()); - self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); - self::assertSame('bcc29@example.com', $email->getBcc()[28]->getAddress()); - self::assertSame('Test TEXT', $email->getTextBody()); - self::assertSame('Test HTML', $email->getHtmlBody()); - }); - - $emailService = new EmailService($mailer, 'test@example.com'); - $emailDto = (new EmailDto()) - ->setSubject('Test') - ->setToAddress('test@example.com') - ->setBcc(array_map( - fn (int $i) => new Address("bcc$i@example.com"), - range(1, 29) - )) - ->setKind(EmailKind::Test) - ->setText('Test TEXT') - ->setHtml('Test HTML'); - - $emailService->send($emailDto); - } - - public function testSendWith30Bcc(): void - { - $mailer = $this->createMock(MailerInterface::class); - $mailer->expects(self::once()) - ->method('send') - ->willReturnCallback(function (Email $email): void { - self::assertSame('Test', $email->getSubject()); - self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); - self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); - self::assertCount(30, $email->getBcc()); - self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); - self::assertSame('bcc30@example.com', $email->getBcc()[29]->getAddress()); - self::assertSame('Test TEXT', $email->getTextBody()); - self::assertSame('Test HTML', $email->getHtmlBody()); - }); - - $emailService = new EmailService($mailer, 'test@example.com'); - $emailDto = (new EmailDto()) - ->setSubject('Test') - ->setToAddress('test@example.com') - ->setBcc(array_map( - fn (int $i) => new Address("bcc$i@example.com"), - range(1, 30) - )) - ->setKind(EmailKind::Test) - ->setText('Test TEXT') - ->setHtml('Test HTML'); - - $emailService->send($emailDto); - } - - public function testSendWith31Bcc(): void + public function testSendWithChunkedBcc(): void { $invokedCount = self::exactly(2); + $lastSendAt = new \DateTimeImmutable(); $mailer = $this->createMock(MailerInterface::class); $mailer->expects($invokedCount) ->method('send') - ->willReturnCallback(function (Email $email) use (&$invokedCount): void { - self::assertSame('Test', $email->getSubject()); - self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); - self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); - self::assertSame('Test TEXT', $email->getTextBody()); - self::assertSame('Test HTML', $email->getHtmlBody()); - - switch ($invokedCount->numberOfInvocations()) { - case 1: - self::assertCount(30, $email->getBcc()); - self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); - self::assertSame('bcc30@example.com', $email->getBcc()[29]->getAddress()); - break; - case 2: - self::assertCount(1, $email->getBcc()); - self::assertSame('bcc31@example.com', $email->getBcc()[0]->getAddress()); - } - }); - - $emailService = new EmailService($mailer, 'test@example.com'); - $emailDto = (new EmailDto()) - ->setSubject('Test') - ->setToAddress('test@example.com') - ->setBcc(array_map( - fn (int $i) => new Address("bcc$i@example.com"), - range(1, 31) - )) - ->setKind(EmailKind::Test) - ->setText('Test TEXT') - ->setHtml('Test HTML'); - - $emailService->send($emailDto); - } - - public function testSendWith61Bcc(): void - { - $invokedCount = self::exactly(3); - - $mailer = $this->createMock(MailerInterface::class); - $mailer->expects($invokedCount) - ->method('send') - ->willReturnCallback(function (Email $email) use (&$invokedCount): void { + ->willReturnCallback(function (Email $email) use (&$invokedCount, &$lastSendAt): void { self::assertSame('Test', $email->getSubject()); self::assertSame('test@example.com', $email->getFrom()[0]->getAddress()); self::assertSame('test@example.com', $email->getTo()[0]->getAddress()); @@ -187,28 +85,25 @@ public function testSendWith61Bcc(): void switch ($invokedCount->numberOfInvocations()) { case 1: - self::assertCount(30, $email->getBcc()); + self::assertCount(10, $email->getBcc()); self::assertSame('bcc1@example.com', $email->getBcc()[0]->getAddress()); - self::assertSame('bcc30@example.com', $email->getBcc()[29]->getAddress()); + self::assertSame('bcc10@example.com', $email->getBcc()[9]->getAddress()); + $lastSendAt = $email->getDate(); break; case 2: - self::assertCount(30, $email->getBcc()); - self::assertSame('bcc31@example.com', $email->getBcc()[0]->getAddress()); - self::assertSame('bcc60@example.com', $email->getBcc()[29]->getAddress()); - break; - case 3: self::assertCount(1, $email->getBcc()); - self::assertSame('bcc61@example.com', $email->getBcc()[0]->getAddress()); + self::assertSame('bcc11@example.com', $email->getBcc()[0]->getAddress()); + self::assertGreaterThan($lastSendAt, $email->getDate()); } }); - $emailService = new EmailService($mailer, 'test@example.com'); + $emailService = new EmailService($mailer, 'test@example.com', 10); $emailDto = (new EmailDto()) ->setSubject('Test') ->setToAddress('test@example.com') ->setBcc(array_map( fn (int $i) => new Address("bcc$i@example.com"), - range(1, 61) + range(1, 11) )) ->setKind(EmailKind::Test) ->setText('Test TEXT') From d58146cb3f37da607dd3517296cb5beabd0d27d0 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 12:28:43 +0800 Subject: [PATCH 19/22] chore(email): Correct PHPDoc --- src/Service/EmailService.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index 6e9ffab..c4c6586 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -32,7 +32,8 @@ public function __construct( * * @param EmailDto $emailDto the email to send * - * @throws TransportExceptionInterface if the email cannot be sent + * @throws TransportExceptionInterface if the email cannot be sent + * @throws \DateMalformedStringException if the date is malformed */ public function send(EmailDto $emailDto): void { From 6ef805c1f5e64e09990befbf3ff74d00a47d7721 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 12:36:53 +0800 Subject: [PATCH 20/22] fix(email): Filter out users who have logged in within the last 7 days --- src/Service/StrategicEmailService.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Service/StrategicEmailService.php b/src/Service/StrategicEmailService.php index 2155edf..c377706 100644 --- a/src/Service/StrategicEmailService.php +++ b/src/Service/StrategicEmailService.php @@ -29,9 +29,22 @@ public function __construct( */ public function sendLoginReminderEmail(bool $dryRun = false): ?EmailDto { + $lastLoginAt = array_filter( + // Filter out users who have logged in within the last 7 days + $this->statisticsService->lastLoginAt(), + function (LastLoginDto $lastLoginDto): bool { + $lastLoginAt = $lastLoginDto->getLastLoginAt(); + if (null === $lastLoginAt) { + return true; + } + + return $lastLoginAt->diff(new \DateTimeImmutable())->days >= 7; + } + ); + $bccUsers = array_map( fn (LastLoginDto $lastLoginDto) => $lastLoginDto->getUser(), - $this->statisticsService->lastLoginAt() + $lastLoginAt, ); $emailDto = $this->emailTemplateService->createLoginReminderDto($bccUsers); From a14a94b8aed05078a632dfd09592f78abb2297c7 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 13:30:59 +0800 Subject: [PATCH 21/22] fix(email): 7 days -> 5 days --- src/Service/StrategicEmailService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/StrategicEmailService.php b/src/Service/StrategicEmailService.php index c377706..968014b 100644 --- a/src/Service/StrategicEmailService.php +++ b/src/Service/StrategicEmailService.php @@ -38,7 +38,7 @@ function (LastLoginDto $lastLoginDto): bool { return true; } - return $lastLoginAt->diff(new \DateTimeImmutable())->days >= 7; + return $lastLoginAt->diff(new \DateTimeImmutable())->days >= 5; } ); From 05719ce082a1881a33d993af86440132e13a56d8 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 16 Dec 2024 18:35:43 +0800 Subject: [PATCH 22/22] chore: Update dependencies --- .idea/app-sf.iml | 2 -- .idea/php.xml | 3 --- composer.lock | 59 ++++++++++++++++++++++++------------------------ devenv.lock | 8 +++---- 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/.idea/app-sf.iml b/.idea/app-sf.iml index 0fc493a..77d0cc6 100644 --- a/.idea/app-sf.iml +++ b/.idea/app-sf.iml @@ -6,9 +6,7 @@ - - diff --git a/.idea/php.xml b/.idea/php.xml index 71f5d1b..638aee1 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -213,13 +213,10 @@ - - - diff --git a/composer.lock b/composer.lock index 1ee5088..f120a30 100644 --- a/composer.lock +++ b/composer.lock @@ -587,16 +587,16 @@ }, { "name": "doctrine/deprecations", - "version": "1.2.x-dev", + "version": "1.1.x-dev", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "e48ead528b87dcbb0bcc0d67d99265cb4b108b5c" + "reference": "67eb7938e7593456714f3d3de9df65a1a7b25c1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/e48ead528b87dcbb0bcc0d67d99265cb4b108b5c", - "reference": "e48ead528b87dcbb0bcc0d67d99265cb4b108b5c", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/67eb7938e7593456714f3d3de9df65a1a7b25c1d", + "reference": "67eb7938e7593456714f3d3de9df65a1a7b25c1d", "shasum": "" }, "require": { @@ -612,6 +612,7 @@ "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" }, + "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -628,7 +629,7 @@ "issues": "https://github.com/doctrine/deprecations/issues", "source": "https://github.com/doctrine/deprecations/tree/1.1.x" }, - "time": "2024-12-14T23:24:50+00:00" + "time": "2024-12-16T12:46:45+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -1465,12 +1466,12 @@ "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "579b67954ca6817a4d5a0a785c654fb7bc849b2e" + "reference": "1e04414f21f24483e95f48e6455aa7e67274decc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/579b67954ca6817a4d5a0a785c654fb7bc849b2e", - "reference": "579b67954ca6817a4d5a0a785c654fb7bc849b2e", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/1e04414f21f24483e95f48e6455aa7e67274decc", + "reference": "1e04414f21f24483e95f48e6455aa7e67274decc", "shasum": "" }, "require": { @@ -1513,7 +1514,7 @@ "issues": "https://github.com/doctrine/sql-formatter/issues", "source": "https://github.com/doctrine/sql-formatter/tree/1.5.x" }, - "time": "2024-12-02T22:10:47+00:00" + "time": "2024-12-16T10:43:20+00:00" }, { "name": "easycorp/easyadmin-bundle", @@ -7387,12 +7388,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "656c16cd1c61d2437b434d99b8b938742d9afe95" + "reference": "0415b1f7beee5bedd7b5aea82a5aed69a96ce885" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/656c16cd1c61d2437b434d99b8b938742d9afe95", - "reference": "656c16cd1c61d2437b434d99b8b938742d9afe95", + "url": "https://api.github.com/repos/symfony/routing/zipball/0415b1f7beee5bedd7b5aea82a5aed69a96ce885", + "reference": "0415b1f7beee5bedd7b5aea82a5aed69a96ce885", "shasum": "" }, "require": { @@ -7460,7 +7461,7 @@ "type": "tidelift" } ], - "time": "2024-12-14T16:08:18+00:00" + "time": "2024-12-16T08:20:38+00:00" }, { "name": "symfony/runtime", @@ -7547,12 +7548,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "626c686874aaad93d6460c976b49ff3826ba6e93" + "reference": "f3e75ef04ca541ca46fc31030c8f01f9c9215964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/626c686874aaad93d6460c976b49ff3826ba6e93", - "reference": "626c686874aaad93d6460c976b49ff3826ba6e93", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f3e75ef04ca541ca46fc31030c8f01f9c9215964", + "reference": "f3e75ef04ca541ca46fc31030c8f01f9c9215964", "shasum": "" }, "require": { @@ -7645,7 +7646,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T21:01:37+00:00" + "time": "2024-12-15T10:51:57+00:00" }, { "name": "symfony/security-core", @@ -7653,12 +7654,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "312a726ac8b92bf1544355eefd2d290211d3119b" + "reference": "9a04c4c5bf59a255f177b588bc2b3e1ab1683e57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/312a726ac8b92bf1544355eefd2d290211d3119b", - "reference": "312a726ac8b92bf1544355eefd2d290211d3119b", + "url": "https://api.github.com/repos/symfony/security-core/zipball/9a04c4c5bf59a255f177b588bc2b3e1ab1683e57", + "reference": "9a04c4c5bf59a255f177b588bc2b3e1ab1683e57", "shasum": "" }, "require": { @@ -7732,7 +7733,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T13:40:54+00:00" + "time": "2024-12-15T10:51:57+00:00" }, { "name": "symfony/security-csrf", @@ -9642,12 +9643,12 @@ "source": { "type": "git", "url": "https://github.com/twbs/bootstrap.git", - "reference": "ff7d1be0b73dc6a6c28df133607d8300c14b55aa" + "reference": "a353ed4b19a6849d021aa23b4bd6395f6bf3c2bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twbs/bootstrap/zipball/ff7d1be0b73dc6a6c28df133607d8300c14b55aa", - "reference": "ff7d1be0b73dc6a6c28df133607d8300c14b55aa", + "url": "https://api.github.com/repos/twbs/bootstrap/zipball/a353ed4b19a6849d021aa23b4bd6395f6bf3c2bb", + "reference": "a353ed4b19a6849d021aa23b4bd6395f6bf3c2bb", "shasum": "" }, "replace": { @@ -9685,7 +9686,7 @@ "issues": "https://github.com/twbs/bootstrap/issues", "source": "https://github.com/twbs/bootstrap/tree/main" }, - "time": "2024-12-10T12:03:18+00:00" + "time": "2024-12-16T09:19:39+00:00" }, { "name": "twig/extra-bundle", @@ -10828,12 +10829,12 @@ "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "de0553c00bf5268f59590dc6d33cfeff2e1d490a" + "reference": "549528309e8a7e214f0af2aeb385499fec1b31bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/de0553c00bf5268f59590dc6d33cfeff2e1d490a", - "reference": "de0553c00bf5268f59590dc6d33cfeff2e1d490a", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/549528309e8a7e214f0af2aeb385499fec1b31bb", + "reference": "549528309e8a7e214f0af2aeb385499fec1b31bb", "shasum": "" }, "require": { @@ -10879,7 +10880,7 @@ "type": "github" } ], - "time": "2024-12-15T13:10:46+00:00" + "time": "2024-12-16T13:24:01+00:00" }, { "name": "phpstan/phpstan-doctrine", diff --git a/devenv.lock b/devenv.lock index 6f47658..da14aa9 100644 --- a/devenv.lock +++ b/devenv.lock @@ -68,10 +68,10 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1734017764, + "lastModified": 1734202038, "owner": "NixOS", "repo": "nixpkgs", - "rev": "64e9404f308e0f0a0d8cdd7c358f74e34802494b", + "rev": "bcba2fbf6963bf6bed3a749f9f4cf5bff4adb96d", "type": "github" }, "original": { @@ -91,10 +91,10 @@ "nixpkgs-stable": "nixpkgs-stable" }, "locked": { - "lastModified": 1734261738, + "lastModified": 1734279981, "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "4c8e75efbbdcc6f9203f64b1f21f8a55d2285264", + "rev": "aa9f40c906904ebd83da78e7f328cd8aeaeae785", "type": "github" }, "original": {