diff --git a/.php-version b/.php-version new file mode 100644 index 0000000..b8eb026 --- /dev/null +++ b/.php-version @@ -0,0 +1 @@ +8.1 diff --git a/config/packages/app.yaml b/config/packages/app.yaml index b1d2dfd..c2cf954 100644 --- a/config/packages/app.yaml +++ b/config/packages/app.yaml @@ -15,6 +15,8 @@ core: - {name: 'Blog\PostController::rss', action: 'App\Controller\Blog\PostController::rss'} - {name: 'Blog\PostController::jsonApi', action: 'App\Controller\Blog\PostController::jsonApi'} - {name: 'Blog\CategoryController::categories', action: 'App\Controller\Blog\CategoryController::categories'} + - {name: 'Blog\PostFollowController::enable', action: 'App\Controller\Blog\PostFollowController::enable'} + - {name: 'Blog\PostFollowController::disable', action: 'App\Controller\Blog\PostFollowController::disable'} - {name: 'StlMeshController::meshes', action: 'App\Controller\StlMeshController::meshes'} pages: App\Entity\Page\SimplePage: @@ -62,7 +64,7 @@ core: templates: - {name: "Par défaut", file: "page/text/default.txt.twig"} App\Entity\Page\TitledPage: - name: '_' + name: 'Page avec titre' templates: - {name: "Par défaut", file: "page/text/default.txt.twig"} diff --git a/migrations/Version20220205165823.php b/migrations/Version20220205165823.php new file mode 100644 index 0000000..b6c3675 --- /dev/null +++ b/migrations/Version20220205165823.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE post_follow (id INT AUTO_INCREMENT NOT NULL, post_id INT NOT NULL, comment_id INT NOT NULL, hash VARCHAR(255) NOT NULL, is_enabled TINYINT(1) NOT NULL, INDEX IDX_5816A0154B89032C (post_id), INDEX IDX_5816A015F8697D13 (comment_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE post_follow ADD CONSTRAINT FK_5816A0154B89032C FOREIGN KEY (post_id) REFERENCES post (id)'); + $this->addSql('ALTER TABLE post_follow ADD CONSTRAINT FK_5816A015F8697D13 FOREIGN KEY (comment_id) REFERENCES comment (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE post_follow'); + } +} diff --git a/migrations/Version20220205172820.php b/migrations/Version20220205172820.php new file mode 100644 index 0000000..d01c1f3 --- /dev/null +++ b/migrations/Version20220205172820.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE post_follow ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE post_follow DROP created_at, DROP updated_at'); + } +} diff --git a/migrations/Version20220206181024.php b/migrations/Version20220206181024.php new file mode 100644 index 0000000..98e63de --- /dev/null +++ b/migrations/Version20220206181024.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE post_follow DROP FOREIGN KEY FK_5816A015F8697D13'); + $this->addSql('ALTER TABLE post_follow DROP FOREIGN KEY FK_5816A0154B89032C'); + $this->addSql('ALTER TABLE post_follow ADD CONSTRAINT FK_5816A015F8697D13 FOREIGN KEY (comment_id) REFERENCES comment (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE post_follow ADD CONSTRAINT FK_5816A0154B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE post_follow DROP FOREIGN KEY FK_5816A0154B89032C'); + $this->addSql('ALTER TABLE post_follow DROP FOREIGN KEY FK_5816A015F8697D13'); + $this->addSql('ALTER TABLE post_follow ADD CONSTRAINT FK_5816A0154B89032C FOREIGN KEY (post_id) REFERENCES post (id)'); + $this->addSql('ALTER TABLE post_follow ADD CONSTRAINT FK_5816A015F8697D13 FOREIGN KEY (comment_id) REFERENCES comment (id)'); + } +} diff --git a/src/Controller/Blog/PostController.php b/src/Controller/Blog/PostController.php index 0cfca84..42bc880 100644 --- a/src/Controller/Blog/PostController.php +++ b/src/Controller/Blog/PostController.php @@ -17,6 +17,8 @@ use App\UrlGenerator\PostGenerator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use App\Factory\Blog\PostFollowFactory; +use App\Manager\PostFollowManager; class PostController extends PageController { @@ -36,6 +38,7 @@ class PostController extends PageController Post $post, string $slug, CommentFactory $commentFactory, + PostFollowManager $postFollowManager, Request $request, EntityManager $entityManager ): Response { @@ -61,6 +64,7 @@ class PostController extends PageController if ($form->isValid()) { $data = $request->request->get($form->getName()); $parentCommentId = (int) $data['parentCommentId']; + $follow = (bool) ($data['follow'] ?? false); foreach ($post->getComments(['id' => $parentCommentId]) as $comment) { $form->getData()->setParentComment($comment); @@ -70,8 +74,13 @@ class PostController extends PageController $this->addFlash('success', 'Commentaire publié !'); + if ($follow && $form->getData()->getEmail()) { + $postFollowManager->create($post, $form->getData()); + } + return $this->redirect($request->getUri()); } + $this->addFlash('error', 'Le formulaire n\'est pas valide.'); } diff --git a/src/Controller/Blog/PostFollowController.php b/src/Controller/Blog/PostFollowController.php new file mode 100644 index 0000000..aca8b8c --- /dev/null +++ b/src/Controller/Blog/PostFollowController.php @@ -0,0 +1,47 @@ +manager = $manager; + } + + public function enable(PostFollow $postFollow) + { + $this->manager->enable($postFollow); + + $this->addFlash('success', 'Votre e-mail a bien été confirmé.'); + + return $this->redirectToRoute('blog_menu_post', [ + 'post' => $postFollow->getPost()->getId(), + 'slug' => $postFollow->getPost()->getSlug(), + '_domain' => $this->siteRequest->getDomain(), + ]); + } + + public function disable(PostFollow $postFollow) + { + $this->manager->disable($postFollow); + + $this->addFlash('success', 'Vous ne recevrez plus de notification pour cet article.'); + + return $this->redirectToRoute('blog_menu_post', [ + 'post' => $postFollow->getPost()->getId(), + 'slug' => $postFollow->getPost()->getSlug(), + '_domain' => $this->siteRequest->getDomain(), + ]); + } +} diff --git a/src/Entity/Blog/Comment.php b/src/Entity/Blog/Comment.php index 927bbe0..d6167c8 100644 --- a/src/Entity/Blog/Comment.php +++ b/src/Entity/Blog/Comment.php @@ -66,9 +66,15 @@ class Comment implements EntityInterface */ private $comments; + /** + * @ORM\OneToMany(targetEntity=PostFollow::class, mappedBy="comment", orphanRemoval=true) + */ + private $postFollows; + public function __construct() { $this->comments = new ArrayCollection(); + $this->postFollows = new ArrayCollection(); } public function getId(): ?int @@ -220,4 +226,34 @@ class Comment implements EntityInterface substr($this->getContent(), 0, 20).'…' ); } + + /** + * @return Collection|PostFollow[] + */ + public function getPostFollows(): Collection + { + return $this->postFollows; + } + + public function addPostFollow(PostFollow $postFollow): self + { + if (!$this->postFollows->contains($postFollow)) { + $this->postFollows[] = $postFollow; + $postFollow->setComment($this); + } + + return $this; + } + + public function removePostFollow(PostFollow $postFollow): self + { + if ($this->postFollows->removeElement($postFollow)) { + // set the owning side to null (unless already changed) + if ($postFollow->getComment() === $this) { + $postFollow->setComment(null); + } + } + + return $this; + } } diff --git a/src/Entity/Blog/Post.php b/src/Entity/Blog/Post.php index 1f157cd..a5b934d 100644 --- a/src/Entity/Blog/Post.php +++ b/src/Entity/Blog/Post.php @@ -118,10 +118,16 @@ class Post implements EntityInterface */ private $notebook; + /** + * @ORM\OneToMany(targetEntity=PostFollow::class, mappedBy="post", orphanRemoval=true) + */ + private $postFollows; + public function __construct() { $this->categories = new ArrayCollection(); $this->comments = new ArrayCollection(); + $this->postFollows = new ArrayCollection(); } public function getId(): ?int @@ -410,4 +416,34 @@ class Post implements EntityInterface return $this; } + + /** + * @return Collection|PostFollow[] + */ + public function getPostFollows(): Collection + { + return $this->postFollows; + } + + public function addPostFollow(PostFollow $postFollow): self + { + if (!$this->postFollows->contains($postFollow)) { + $this->postFollows[] = $postFollow; + $postFollow->setPost($this); + } + + return $this; + } + + public function removePostFollow(PostFollow $postFollow): self + { + if ($this->postFollows->removeElement($postFollow)) { + // set the owning side to null (unless already changed) + if ($postFollow->getPost() === $this) { + $postFollow->setPost(null); + } + } + + return $this; + } } diff --git a/src/Entity/Blog/PostFollow.php b/src/Entity/Blog/PostFollow.php new file mode 100644 index 0000000..c604d74 --- /dev/null +++ b/src/Entity/Blog/PostFollow.php @@ -0,0 +1,99 @@ +id; + } + + public function getHash(): ?string + { + return $this->hash; + } + + public function setHash(string $hash): self + { + $this->hash = $hash; + + return $this; + } + + public function getIsEnabled(): ?bool + { + return $this->isEnabled; + } + + public function setIsEnabled(bool $isEnabled): self + { + $this->isEnabled = $isEnabled; + + return $this; + } + + public function getPost(): ?Post + { + return $this->post; + } + + public function setPost(?Post $post): self + { + $this->post = $post; + + return $this; + } + + public function getComment(): ?Comment + { + return $this->comment; + } + + public function setComment(?Comment $comment): self + { + $this->comment = $comment; + + return $this; + } +} diff --git a/src/EventSuscriber/Blog/CommentEventSubscriber.php b/src/EventSuscriber/Blog/CommentEventSubscriber.php index f740306..e01ba07 100644 --- a/src/EventSuscriber/Blog/CommentEventSubscriber.php +++ b/src/EventSuscriber/Blog/CommentEventSubscriber.php @@ -9,6 +9,8 @@ use App\Core\Notification\MailNotifier; use App\Entity\Blog\Comment; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use App\Core\Setting\SettingManager; +use App\Repository\Blog\PostFollowRepositoryQuery; +use App\Core\Site\SiteRequest; /** * class CommentEventSubscriber. @@ -20,12 +22,22 @@ class CommentEventSubscriber extends EntityManagerEventSubscriber protected MailNotifier $notifier; protected UrlGeneratorInterface $urlGenerator; protected SettingManager $settingManager; + protected PostFollowRepository $postFollowRepository; + protected SiteRequest $siteRequest; - public function __construct(MailNotifier $notifier, UrlGeneratorInterface $urlGenerator, SettingManager $settingManager) + public function __construct( + MailNotifier $notifier, + UrlGeneratorInterface $urlGenerator, + SettingManager $settingManager, + PostFollowRepositoryQuery $postFollowRepositoryQuery, + SiteRequest $siteRequest + ) { $this->notifier = $notifier; $this->urlGenerator = $urlGenerator; $this->settingManager = $settingManager; + $this->postFollowRepositoryQuery = $postFollowRepositoryQuery; + $this->siteRequest = $siteRequest; } public function support(EntityInterface $entity) @@ -50,9 +62,41 @@ class CommentEventSubscriber extends EntityManagerEventSubscriber 'post' => $this->urlGenerator->generate('blog_menu_post', [ 'post' => $event->getEntity()->getPost()->getId(), 'slug' => $event->getEntity()->getPost()->getSlug(), + '_domain' => $this->siteRequest->getDomain(), ], UrlGeneratorInterface::ABSOLUTE_URL).'#review-'.$event->getEntity()->getId(), ], ], 'text/plain') ; + + $postFollows = $this->postFollowRepositoryQuery->create() + ->where('.post = :post') + ->andWhere('.isEnabled = 1') + ->setParameter(':post', $event->getEntity()->getPost()->getId()) + ->find(); + + foreach ($postFollows as $postFollow) { + if ($event->getEntity()->getEmail() !== $postFollow->getComment()->getEmail()) { + $this->notifier + ->init() + ->setFrom($this->settingManager->get('email_sender')->getValue()) + ->addRecipient($this->settingManager->get('email_comment')->getValue()) + ->setSubject('[Deblan] Nouveau commentaire') + ->notify('mail/post_follow_comment.html.twig', [ + 'post' => $event->getEntity()->getPost(), + 'links' => [ + 'post' => $this->urlGenerator->generate('blog_menu_post', [ + 'post' => $event->getEntity()->getPost()->getId(), + 'slug' => $event->getEntity()->getPost()->getSlug(), + '_domain' => $this->siteRequest->getDomain(), + ], UrlGeneratorInterface::ABSOLUTE_URL).'#review-'.$event->getEntity()->getId(), + 'disable' => $this->urlGenerator->generate('blog_tech_follow_disable', [ + 'hash' => $postFollow->getHash(), + '_domain' => $this->siteRequest->getDomain(), + ], UrlGeneratorInterface::ABSOLUTE_URL), + ], + ], 'text/plain') + ; + } + } } } diff --git a/src/EventSuscriber/Blog/PostFollowEventSubscriber.php b/src/EventSuscriber/Blog/PostFollowEventSubscriber.php new file mode 100644 index 0000000..cad2917 --- /dev/null +++ b/src/EventSuscriber/Blog/PostFollowEventSubscriber.php @@ -0,0 +1,75 @@ + + */ +class PostFollowEventSubscriber extends EntityManagerEventSubscriber +{ + protected MailNotifier $notifier; + protected UrlGeneratorInterface $urlGenerator; + protected SettingManager $settingManager; + protected SiteRequest $siteRequest; + + public function __construct( + MailNotifier $notifier, + UrlGeneratorInterface $urlGenerator, + SettingManager $settingManager, + SiteRequest $siteRequest + ) + { + $this->notifier = $notifier; + $this->urlGenerator = $urlGenerator; + $this->settingManager = $settingManager; + $this->siteRequest = $siteRequest; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof PostFollow; + } + + public function onCreate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $this->notifier + ->init() + ->setFrom($this->settingManager->get('email_sender')->getValue()) + ->addRecipient($event->getEntity()->getComment()->getEmail()) + ->setSubject('[Deblan] Confirmer votre e-mail') + ->notify('mail/post_follow_subscription.html.twig', [ + 'entity' => $event->getEntity(), + 'links' => [ + 'post' => $this->urlGenerator->generate('blog_menu_post', [ + 'post' => $event->getEntity()->getPost()->getId(), + 'slug' => $event->getEntity()->getPost()->getSlug(), + '_domain' => $this->siteRequest->getDomain(), + ], UrlGeneratorInterface::ABSOLUTE_URL), + 'enable' => $this->urlGenerator->generate('blog_tech_follow_enable', [ + 'hash' => $event->getEntity()->getHash(), + '_domain' => $this->siteRequest->getDomain(), + ], UrlGeneratorInterface::ABSOLUTE_URL), + 'disable' => $this->urlGenerator->generate('blog_tech_follow_disable', [ + 'hash' => $event->getEntity()->getHash(), + '_domain' => $this->siteRequest->getDomain(), + ], UrlGeneratorInterface::ABSOLUTE_URL), + ], + ], 'text/plain') + ; + } +} diff --git a/src/Factory/Blog/PostFollowFactory.php b/src/Factory/Blog/PostFollowFactory.php new file mode 100644 index 0000000..7f47af4 --- /dev/null +++ b/src/Factory/Blog/PostFollowFactory.php @@ -0,0 +1,24 @@ +setPost($post) + ->setComment($comment) + ->setIsEnabled(false) + ->setHash(hash('sha256', bin2hex(random_bytes(32)))) + ; + + return $entity; + } +} diff --git a/src/Form/Blog/UserCommentType.php b/src/Form/Blog/UserCommentType.php index cd365e0..efe5d8b 100644 --- a/src/Form/Blog/UserCommentType.php +++ b/src/Form/Blog/UserCommentType.php @@ -14,6 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Url; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; class UserCommentType extends AbstractType { @@ -75,6 +76,16 @@ class UserCommentType extends AbstractType ] ); + $builder->add( + 'follow', + CheckboxType::class, + [ + 'label' => 'Recevoir une notification par e-mail si un commentaire est déposé sur cet article', + 'mapped' => false, + 'required' => false, + ] + ); + $builder->add( 'parentCommentId', HiddenType::class, diff --git a/src/Manager/PostFollowManager.php b/src/Manager/PostFollowManager.php new file mode 100644 index 0000000..0d773e7 --- /dev/null +++ b/src/Manager/PostFollowManager.php @@ -0,0 +1,70 @@ + + */ +class PostFollowManager +{ + protected PostFollowFactory $factory; + protected PostFollowRepositoryQuery $repository; + protected EntityManager $manager; + + public function __construct( + PostFollowFactory $factory, + PostFollowRepositoryQuery $repository, + EntityManager $manager + ) { + $this->factory = $factory; + $this->repository = $repository; + $this->manager = $manager; + } + + public function create(Post $post, Comment $comment): PostFollow + { + $postFollow = $this->find($post, $comment); + + if (!$postFollow) { + $postFollow = $this->factory->create($post, $comment); + + $this->manager->create($postFollow); + } + + return $postFollow; + } + + public function find(Post $post, Comment $comment): ?PostFollow + { + return $this->repository->create() + ->where('.post = :post') + ->andWhere('.comment = :comment') + ->setParameters([ + ':post' => $post->getId(), + ':comment' => $comment->getId(), + ]) + ->findOne() + ; + } + + public function enable(PostFollow $postFollow): void + { + $postFollow->setIsEnabled(true); + + $this->manager->update($postFollow); + } + + public function disable(PostFollow $postFollow): void + { + $this->manager->delete($postFollow); + } +} diff --git a/src/Repository/Blog/PostFollowRepository.php b/src/Repository/Blog/PostFollowRepository.php new file mode 100644 index 0000000..a68cd39 --- /dev/null +++ b/src/Repository/Blog/PostFollowRepository.php @@ -0,0 +1,20 @@ +getEntityManager(); + } +} diff --git a/src/Repository/Blog/PostFollowRepositoryQuery.php b/src/Repository/Blog/PostFollowRepositoryQuery.php new file mode 100644 index 0000000..a14240c --- /dev/null +++ b/src/Repository/Blog/PostFollowRepositoryQuery.php @@ -0,0 +1,15 @@ + +
+
+ {{ form_label(form.follow) }} + {{ form_widget(form.follow) }} + {{ form_errors(form.follow) }} +
+

{{- 'Votre commentaire - Vous pouvez utiliser du markdown ' }}