diff --git a/config/packages/easy_admin.yaml b/config/packages/easy_admin.yaml new file mode 100644 index 0000000..a0e6f2e --- /dev/null +++ b/config/packages/easy_admin.yaml @@ -0,0 +1,10 @@ +easy_admin: + site_name: 'پیشخوان ادمین' + site_url: '/admin' + favicon_path: 'favicon/favicon.ico' + default_locale: 'fa' + locales: ['fa', 'en'] + format_datetime: 'Y-m-d H:i:s' + format_date: 'Y-m-d' + format_time: 'H:i:s' + timezone: 'Asia/Tehran' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 09763d7..89896cf 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -19,7 +19,9 @@ security: provider: app_user_provider form_login: login_path: login - check_path: login + check_path: login_check + enable_csrf: true + default_target_path: admin logout: path: logout customer: @@ -61,6 +63,10 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + # اجازه دسترسی عمومی به صفحه ورود و خروج ادمین + - { path: ^/admin/login$, roles: PUBLIC_ACCESS } + - { path: ^/admin/logout$, roles: PUBLIC_ACCESS } + # محافظت از سایر مسیرهای /admin - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/customer/dashboard, roles: ROLE_CUSTOMER } # - { path: ^/profile, roles: ROLE_USER } diff --git a/config/routes/easyadmin.yaml b/config/routes/easyadmin.yaml deleted file mode 100644 index 7275307..0000000 --- a/config/routes/easyadmin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# config/routes/easyadmin.yaml -easyadmin: - resource: . - type: easyadmin.routes \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 82618cf..1ebd6aa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,4 +30,16 @@ services: # Markdown extension for Twig App\Twig\MarkdownExtension: - tags: ['twig.extension'] \ No newline at end of file + tags: ['twig.extension'] + + # Jdate extension for Twig + App\Twig\JdateExtension: + tags: ['twig.extension'] + + # Attachment service + App\Service\AttachmentService: + arguments: + $targetDirectory: '%kernel.project_dir%/public/uploads/attachments' + + # Email notification service + App\Service\EmailNotificationService: ~ \ No newline at end of file diff --git a/migrations/Version20250905092845.php b/migrations/Version20250905092845.php new file mode 100644 index 0000000..94c3eda --- /dev/null +++ b/migrations/Version20250905092845.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE comment CHANGE name name VARCHAR(255) NOT NULL, CHANGE publish publish TINYINT(1) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE comment CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE publish publish TINYINT(1) DEFAULT NULL'); + } +} diff --git a/migrations/Version20250905122328.php b/migrations/Version20250905122328.php new file mode 100644 index 0000000..1163067 --- /dev/null +++ b/migrations/Version20250905122328.php @@ -0,0 +1,34 @@ +addSql('UPDATE post SET date_submit = FROM_UNIXTIME(CAST(date_submit AS UNSIGNED)) WHERE date_submit REGEXP "^[0-9]+$"'); + // Then change the column type + $this->addSql('ALTER TABLE post CHANGE date_submit date_submit 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 CHANGE date_submit date_submit VARCHAR(50) NOT NULL'); + } +} diff --git a/migrations/Version20250905122520.php b/migrations/Version20250905122520.php new file mode 100644 index 0000000..034ec10 --- /dev/null +++ b/migrations/Version20250905122520.php @@ -0,0 +1,34 @@ +addSql('UPDATE comment SET date_submit = FROM_UNIXTIME(CAST(date_submit AS UNSIGNED)) WHERE date_submit REGEXP "^[0-9]+$"'); + // Then change the column type + $this->addSql('ALTER TABLE comment CHANGE date_submit date_submit 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 comment CHANGE date_submit date_submit VARCHAR(255) NOT NULL'); + } +} diff --git a/migrations/Version20250905124108.php b/migrations/Version20250905124108.php new file mode 100644 index 0000000..f6a45d3 --- /dev/null +++ b/migrations/Version20250905124108.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE attachment (id INT AUTO_INCREMENT NOT NULL, question_id INT DEFAULT NULL, answer_id INT DEFAULT NULL, uploaded_by_id INT NOT NULL, filename VARCHAR(255) NOT NULL, original_filename VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL, size INT NOT NULL, path VARCHAR(500) NOT NULL, uploaded_at DATETIME NOT NULL, INDEX IDX_795FD9BB1E27F6BF (question_id), INDEX IDX_795FD9BBAA334807 (answer_id), INDEX IDX_795FD9BBA2B28FE8 (uploaded_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE attachment ADD CONSTRAINT FK_795FD9BB1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)'); + $this->addSql('ALTER TABLE attachment ADD CONSTRAINT FK_795FD9BBAA334807 FOREIGN KEY (answer_id) REFERENCES answer (id)'); + $this->addSql('ALTER TABLE attachment ADD CONSTRAINT FK_795FD9BBA2B28FE8 FOREIGN KEY (uploaded_by_id) REFERENCES `user` (id)'); + $this->addSql('ALTER TABLE question ADD notify_on_answer TINYINT(1) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE attachment DROP FOREIGN KEY FK_795FD9BB1E27F6BF'); + $this->addSql('ALTER TABLE attachment DROP FOREIGN KEY FK_795FD9BBAA334807'); + $this->addSql('ALTER TABLE attachment DROP FOREIGN KEY FK_795FD9BBA2B28FE8'); + $this->addSql('DROP TABLE attachment'); + $this->addSql('ALTER TABLE question DROP notify_on_answer'); + } +} diff --git a/src/Controller/Admin/CommentCrudController.php b/src/Controller/Admin/CommentCrudController.php new file mode 100644 index 0000000..415d8ce --- /dev/null +++ b/src/Controller/Admin/CommentCrudController.php @@ -0,0 +1,63 @@ +hideOnForm(), + AssociationField::new('post', 'پست مربوطه'), + TextField::new('name', 'نام'), + EmailField::new('email', 'ایمیل'), + UrlField::new('website', 'وب‌سایت'), + TextareaField::new('body', 'متن کامنت') + ->setMaxLength(500) + ->hideOnIndex(), + DateTimeField::new('dateSubmit', 'تاریخ ارسال') + ->setFormat('Y/m/d H:i:s') + ->hideOnForm(), + BooleanField::new('publish', 'تایید شده') + ->setHelp('کامنت‌های تایید شده در سایت نمایش داده می‌شوند'), + ]; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud + ->setEntityLabelInSingular('کامنت') + ->setEntityLabelInPlural('کامنت‌ها') + ->setDefaultSort(['dateSubmit' => 'DESC']) + ->setPaginatorPageSize(20) + ->setSearchFields(['name', 'email', 'body']) + ->setHelp('index', 'کامنت‌های جدید نیاز به تایید دارند تا در سایت نمایش داده شوند'); + } + + public function configureFilters(Filters $filters): Filters + { + return $filters + ->add(EntityFilter::new('post', 'پست مربوطه')) + ->add(BooleanFilter::new('publish', 'وضعیت تایید')); + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 8194192..0288cee 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -2,6 +2,7 @@ namespace App\Controller\Admin; +use App\Entity\Comment; use App\Entity\Post; use App\Entity\Tree; use App\Entity\User; @@ -14,8 +15,8 @@ use Symfony\Component\Routing\Attribute\Route; class DashboardController extends AbstractDashboardController { - #[Route('/admin/{_locale}', name: 'admin')] - public function index($_locale = 'fa'): Response + #[Route('/admin', name: 'admin')] + public function index(): Response { //return parent::index(); @@ -45,10 +46,7 @@ class DashboardController extends AbstractDashboardController ->renderContentMaximized() ->setDefaultColorScheme('dark') ->generateRelativeUrls() - ->setLocales([ - 'fa' => Locale::new('fa', 'فارسی', 'fa_IR'), // زبان پیش‌فرض - 'en' => Locale::new('en', 'English', 'en_US'), - ]); + ; } public function configureMenuItems(): iterable @@ -59,6 +57,7 @@ class DashboardController extends AbstractDashboardController MenuItem::section('پست بلاگ'), MenuItem::linkToCrud('دسته بندی', 'fa fa-tags', Tree::class), MenuItem::linkToCrud('محتوا', 'fa fa-file-text', Post::class), + MenuItem::linkToCrud('کامنت‌ها', 'fa fa-comments', Comment::class), MenuItem::section('کاربران'), MenuItem::linkToCrud('کاربران', 'fa fa-user', User::class), diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index ba857d2..e53fe26 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Entity\Cat; +use App\Entity\Comment; use App\Entity\Post; use App\Entity\Tree; use Doctrine\ORM\EntityManagerInterface; @@ -10,6 +11,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RedirectResponse; class PageController extends AbstractController @@ -20,6 +22,16 @@ class PageController extends AbstractController $item = $entityManagerInterface->getRepository(Post::class)->findByUrlFilterCat($url, 'plain'); if (!$item) throw $this->createNotFoundException(); + + // افزایش آمار بازدید + if (!$item->getViews()) + $item->setViews(1); + else + $item->setViews($item->getViews() + 1); + + $entityManagerInterface->persist($item); + $entityManagerInterface->flush(); + return $this->render('post/page.html.twig', [ 'item' => $item, ]); @@ -76,15 +88,24 @@ class PageController extends AbstractController $item = $entityManagerInterface->getRepository(Post::class)->findByUrlFilterCat($url, 'blog'); if (!$item) throw $this->createNotFoundException(); + + // افزایش آمار بازدید if (!$item->getViews()) $item->setViews(1); else $item->setViews($item->getViews() + 1); + $entityManagerInterface->persist($item); + $entityManagerInterface->flush(); // ذخیره تغییرات در دیتابیس + + // دریافت کامنت‌های تایید شده + $comments = $entityManagerInterface->getRepository(Comment::class) + ->findBy(['post' => $item, 'publish' => true], ['dateSubmit' => 'DESC']); return $this->render('post/blog_post.html.twig', [ 'item' => $item, 'posts' => $entityManagerInterface->getRepository(Post::class)->findByCat('blog',3), + 'comments' => $comments, ]); } @@ -95,6 +116,15 @@ class PageController extends AbstractController if (!$item) throw $this->createNotFoundException(); + // افزایش آمار بازدید + if (!$item->getViews()) + $item->setViews(1); + else + $item->setViews($item->getViews() + 1); + + $entityManagerInterface->persist($item); + $entityManagerInterface->flush(); + //get list of trees $tress = $entityManagerInterface->getRepository(Tree::class)->findAllByCat('api'); return $this->render('post/api_docs.html.twig', [ @@ -110,6 +140,15 @@ class PageController extends AbstractController if (!$item) throw $this->createNotFoundException(); + // افزایش آمار بازدید + if (!$item->getViews()) + $item->setViews(1); + else + $item->setViews($item->getViews() + 1); + + $entityManagerInterface->persist($item); + $entityManagerInterface->flush(); + //get list of trees $tress = $entityManagerInterface->getRepository(Tree::class)->findAllByCat('guide'); return $this->render('post/guide.html.twig', [ @@ -118,6 +157,39 @@ class PageController extends AbstractController ]); } + #[Route('/blog/post/{url}/comment', name: 'app_blog_post_comment', methods: ['POST'])] + public function app_blog_post_comment(EntityManagerInterface $entityManagerInterface, Request $request, string $url): Response + { + $item = $entityManagerInterface->getRepository(Post::class)->findByUrlFilterCat($url, 'blog'); + if (!$item) { + throw $this->createNotFoundException(); + } + + // در Symfony 6، get فقط مقدارهای اسکالر می‌پذیرد؛ برای آرایه باید از all استفاده شود + $commentData = $request->request->all('comment'); + + if (!$commentData || !isset($commentData['name']) || !isset($commentData['body'])) { + $this->addFlash('error', 'نام و متن کامنت الزامی است.'); + return $this->redirectToRoute('app_blog_post', ['url' => $url]); + } + + $comment = new Comment(); + $comment->setPost($item); + $comment->setName(trim($commentData['name'])); + $comment->setBody(trim($commentData['body'])); + $comment->setEmail(trim($commentData['email'] ?? '')); + $comment->setWebsite(trim($commentData['website'] ?? '')); + $comment->setDateSubmit(date('Y-m-d H:i:s')); + $comment->setPublish(false); // نیاز به تایید مدیر + + $entityManagerInterface->persist($comment); + $entityManagerInterface->flush(); + + $this->addFlash('success', 'نظر شما با موفقیت ارسال شد و پس از تایید مدیر نمایش داده خواهد شد.'); + + return $this->redirectToRoute('app_blog_post', ['url' => $url]); + } + #[Route('/changes', name: 'app_changes')] public function app_changes(EntityManagerInterface $entityManagerInterface): Response { diff --git a/src/Controller/QA/QAController.php b/src/Controller/QA/QAController.php index adc8142..e991d54 100644 --- a/src/Controller/QA/QAController.php +++ b/src/Controller/QA/QAController.php @@ -15,6 +15,8 @@ use App\Repository\QuestionRepository; use App\Repository\QuestionTagRepository; use App\Repository\QuestionVoteRepository; use App\Repository\AnswerVoteRepository; +use App\Service\AttachmentService; +use App\Service\EmailNotificationService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -32,7 +34,9 @@ class QAController extends AbstractController private AnswerRepository $answerRepository, private QuestionTagRepository $tagRepository, private QuestionVoteRepository $questionVoteRepository, - private AnswerVoteRepository $answerVoteRepository + private AnswerVoteRepository $answerVoteRepository, + private AttachmentService $attachmentService, + private EmailNotificationService $emailNotificationService ) {} #[Route('', name: 'index', methods: ['GET'])] @@ -147,6 +151,15 @@ class QAController extends AbstractController $tag->incrementUsageCount(); } } + + // مدیریت پیوست فایل‌ها + $attachments = $form->get('attachments')->getData(); + if ($attachments) { + $uploadedAttachments = $this->attachmentService->uploadAttachments($attachments, $this->getUser(), $question); + foreach ($uploadedAttachments as $attachment) { + $question->addAttachment($attachment); + } + } $this->entityManager->persist($question); $this->entityManager->flush(); @@ -181,9 +194,21 @@ class QAController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + // مدیریت پیوست فایل‌ها + $attachments = $form->get('attachments')->getData(); + if ($attachments) { + $uploadedAttachments = $this->attachmentService->uploadAttachments($attachments, $this->getUser(), null, $answer); + foreach ($uploadedAttachments as $attachment) { + $answer->addAttachment($attachment); + } + } + $this->entityManager->persist($answer); $this->entityManager->flush(); + // ارسال ایمیل اطلاع‌رسانی + $this->emailNotificationService->sendAnswerNotification($answer); + $this->addFlash('success', 'پاسخ شما با موفقیت ثبت شد.'); return $this->redirectToRoute('qa_question_show', ['id' => $question->getId()]); } @@ -343,6 +368,11 @@ class QAController extends AbstractController $this->entityManager->flush(); + // ارسال ایمیل اطلاع‌رسانی در صورت پذیرش پاسخ + if ($answer->isAccepted()) { + $this->emailNotificationService->sendAnswerAcceptedNotification($answer); + } + return new JsonResponse([ 'accepted' => $answer->isAccepted(), 'solved' => $question->isSolved() diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 3ff6b11..cc819ac 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -8,30 +8,25 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; class SecurityController extends AbstractController { - #[Route('/hs/login', name: 'login')] + #[Route('/admin/login', name: 'login')] public function login(AuthenticationUtils $authenticationUtils): Response { $error = $authenticationUtils->getLastAuthenticationError(); $lastUsername = $authenticationUtils->getLastUsername(); - return $this->render('/admin/login.html.twig', [ + return $this->render('admin/login.html.twig', [ 'error' => $error, 'last_username' => $lastUsername, - 'translation_domain' => 'admin', - 'page_title' => 'ورود', - 'csrf_token_intention' => 'authenticate', - 'target_path' => $this->generateUrl('admin', ['_locale' => 'fa']), - 'username_label' => 'پست الکترونیکی', - 'password_label' => 'کلمه عبور', - 'sign_in_label' => 'ورود', - 'forgot_password_enabled' => false, - 'remember_me_enabled' => true, - 'remember_me_checked' => true, - 'remember_me_label' => 'مرا به یاد داشته باش', ]); } - #[Route('/logout', name: 'logout')] + #[Route('/admin/login_check', name: 'login_check')] + public function loginCheck(): void + { + // This method can be blank - it will be intercepted by the logout key on your firewall. + } + + #[Route('/admin/logout', name: 'logout')] public function logout(AuthenticationUtils $authenticationUtils): Response { return $this->redirectToRoute('app_home'); diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php index 377c47b..075f78f 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -52,9 +52,16 @@ class Answer #[ORM\OneToMany(targetEntity: AnswerVote::class, mappedBy: 'answer', orphanRemoval: true)] private Collection $answerVotes; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Attachment::class, mappedBy: 'answer', orphanRemoval: true, cascade: ['persist'])] + private Collection $attachments; + public function __construct() { $this->answerVotes = new ArrayCollection(); + $this->attachments = new ArrayCollection(); $this->createdAt = new \DateTime(); } @@ -177,4 +184,36 @@ class Answer } return $this; } + + /** + * @return Collection + */ + public function getAttachments(): Collection + { + return $this->attachments; + } + + public function addAttachment(Attachment $attachment): static + { + if (!$this->attachments->contains($attachment)) { + $this->attachments->add($attachment); + $attachment->setAnswer($this); + } + return $this; + } + + public function removeAttachment(Attachment $attachment): static + { + if ($this->attachments->removeElement($attachment)) { + if ($attachment->getAnswer() === $this) { + $attachment->setAnswer(null); + } + } + return $this; + } + + public function getAttachmentsCount(): int + { + return $this->attachments->count(); + } } diff --git a/src/Entity/Attachment.php b/src/Entity/Attachment.php new file mode 100644 index 0000000..a1adad1 --- /dev/null +++ b/src/Entity/Attachment.php @@ -0,0 +1,195 @@ +uploadedAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getFilename(): ?string + { + return $this->filename; + } + + public function setFilename(string $filename): static + { + $this->filename = $filename; + return $this; + } + + public function getOriginalFilename(): ?string + { + return $this->originalFilename; + } + + public function setOriginalFilename(string $originalFilename): static + { + $this->originalFilename = $originalFilename; + return $this; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(int $size): static + { + $this->size = $size; + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(string $path): static + { + $this->path = $path; + return $this; + } + + public function getUploadedAt(): ?\DateTimeInterface + { + return $this->uploadedAt; + } + + public function setUploadedAt(\DateTimeInterface $uploadedAt): static + { + $this->uploadedAt = $uploadedAt; + return $this; + } + + public function getQuestion(): ?Question + { + return $this->question; + } + + public function setQuestion(?Question $question): static + { + $this->question = $question; + return $this; + } + + public function getAnswer(): ?Answer + { + return $this->answer; + } + + public function setAnswer(?Answer $answer): static + { + $this->answer = $answer; + return $this; + } + + public function getUploadedBy(): ?User + { + return $this->uploadedBy; + } + + public function setUploadedBy(?User $uploadedBy): static + { + $this->uploadedBy = $uploadedBy; + return $this; + } + + public function getFormattedSize(): string + { + $bytes = $this->size; + $units = ['B', 'KB', 'MB', 'GB']; + + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, 2) . ' ' . $units[$i]; + } + + public function isImage(): bool + { + return str_starts_with($this->mimeType ?? '', 'image/'); + } + + public function isPdf(): bool + { + return $this->mimeType === 'application/pdf'; + } + + public function isDocument(): bool + { + $documentTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain' + ]; + + return in_array($this->mimeType, $documentTypes); + } +} diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php index 7668638..c753963 100644 --- a/src/Entity/Comment.php +++ b/src/Entity/Comment.php @@ -5,6 +5,7 @@ namespace App\Entity; use App\Repository\CommentRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: CommentRepository::class)] class Comment @@ -19,22 +20,33 @@ class Comment private ?Post $post = null; #[ORM\Column(type: Types::TEXT)] + #[Assert\NotBlank(message: 'متن کامنت الزامی است')] + #[Assert\Length(min: 10, minMessage: 'متن کامنت باید حداقل 10 کاراکتر باشد')] private ?string $body = null; - #[ORM\Column(length: 255, nullable: true)] + #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'نام الزامی است')] + #[Assert\Length(min: 2, max: 255, minMessage: 'نام باید حداقل 2 کاراکتر باشد', maxMessage: 'نام نمی‌تواند بیش از 255 کاراکتر باشد')] private ?string $name = null; #[ORM\Column(length: 255, nullable: true)] + #[Assert\Email(message: 'ایمیل معتبر نیست')] private ?string $email = null; #[ORM\Column(length: 255, nullable: true)] + #[Assert\Url(message: 'آدرس وب‌سایت معتبر نیست')] private ?string $website = null; - #[ORM\Column(length: 255)] - private ?string $dateSubmit = null; + #[ORM\Column(type: 'datetime')] + private ?\DateTimeInterface $dateSubmit = null; - #[ORM\Column(nullable: true)] - private ?bool $publish = null; + #[ORM\Column] + private bool $publish = false; + + public function __construct() + { + $this->dateSubmit = new \DateTime(); + } public function getId(): ?int { @@ -101,24 +113,24 @@ class Comment return $this; } - public function getDateSubmit(): ?string + public function getDateSubmit(): ?\DateTimeInterface { return $this->dateSubmit; } - public function setDateSubmit(string $dateSubmit): static + public function setDateSubmit(?\DateTimeInterface $dateSubmit): static { $this->dateSubmit = $dateSubmit; return $this; } - public function isPublish(): ?bool + public function isPublish(): bool { return $this->publish; } - public function setPublish(?bool $publish): static + public function setPublish(bool $publish): static { $this->publish = $publish; diff --git a/src/Entity/Post.php b/src/Entity/Post.php index 633aa8d..6c9439b 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -31,8 +31,8 @@ class Post #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $body = null; - #[ORM\Column(length: 50)] - private ?string $dateSubmit = null; + #[ORM\Column(type: 'datetime')] + private ?\DateTimeInterface $dateSubmit = null; #[ORM\Column(nullable: true)] private ?bool $publish = null; @@ -82,6 +82,7 @@ class Post { $this->tree = new ArrayCollection(); $this->comments = new ArrayCollection(); + $this->dateSubmit = new \DateTime(); } public function getId(): ?int @@ -122,12 +123,12 @@ class Post return $this; } - public function getDateSubmit(): ?string + public function getDateSubmit(): ?\DateTimeInterface { return $this->dateSubmit; } - public function setDateSubmit(string $dateSubmit): static + public function setDateSubmit(?\DateTimeInterface $dateSubmit): static { $this->dateSubmit = $dateSubmit; return $this; diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 57bfeb5..1b46a92 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -50,12 +50,21 @@ class Question #[ORM\Column(type: 'boolean')] private bool $isActive = true; + #[ORM\Column(type: 'boolean')] + private bool $notifyOnAnswer = false; + /** * @var Collection */ #[ORM\OneToMany(targetEntity: Answer::class, mappedBy: 'question', orphanRemoval: true, cascade: ['persist'])] private Collection $answers; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Attachment::class, mappedBy: 'question', orphanRemoval: true, cascade: ['persist'])] + private Collection $attachments; + /** * @var Collection */ @@ -73,6 +82,7 @@ class Question $this->answers = new ArrayCollection(); $this->questionVotes = new ArrayCollection(); $this->tagRelations = new ArrayCollection(); + $this->attachments = new ArrayCollection(); $this->createdAt = new \DateTime(); } @@ -281,4 +291,47 @@ class Question } return null; } + + public function isNotifyOnAnswer(): bool + { + return $this->notifyOnAnswer; + } + + public function setNotifyOnAnswer(bool $notifyOnAnswer): static + { + $this->notifyOnAnswer = $notifyOnAnswer; + return $this; + } + + /** + * @return Collection + */ + public function getAttachments(): Collection + { + return $this->attachments; + } + + public function addAttachment(Attachment $attachment): static + { + if (!$this->attachments->contains($attachment)) { + $this->attachments->add($attachment); + $attachment->setQuestion($this); + } + return $this; + } + + public function removeAttachment(Attachment $attachment): static + { + if ($this->attachments->removeElement($attachment)) { + if ($attachment->getQuestion() === $this) { + $attachment->setQuestion(null); + } + } + return $this; + } + + public function getAttachmentsCount(): int + { + return $this->attachments->count(); + } } diff --git a/src/Form/QA/AnswerFormType.php b/src/Form/QA/AnswerFormType.php index 2427308..bd9b343 100644 --- a/src/Form/QA/AnswerFormType.php +++ b/src/Form/QA/AnswerFormType.php @@ -4,6 +4,7 @@ namespace App\Form\QA; use App\Entity\Answer; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -28,6 +29,43 @@ class AnswerFormType extends AbstractType 'minMessage' => 'پاسخ باید حداقل 10 کاراکتر باشد' ]) ] + ]) + ->add('attachments', FileType::class, [ + 'label' => 'پیوست فایل (حداکثر 3 فایل، هر کدام 4 مگابایت)', + 'mapped' => false, + 'multiple' => true, + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'accept' => '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt', + 'multiple' => true + ], + 'constraints' => [ + new Assert\Count([ + 'max' => 3, + 'maxMessage' => 'حداکثر 3 فایل می‌توانید پیوست کنید' + ]), + new Assert\All([ + new Assert\File([ + 'maxSize' => '4M', + 'maxSizeMessage' => 'حجم هر فایل نمی‌تواند بیش از 4 مگابایت باشد', + 'mimeTypes' => [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain' + ], + 'mimeTypesMessage' => 'فرمت فایل مجاز نیست. فرمت‌های مجاز: JPG, PNG, GIF, PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT' + ]) + ]) + ] ]); } diff --git a/src/Form/QA/QuestionFormType.php b/src/Form/QA/QuestionFormType.php index 42ff653..d0d8168 100644 --- a/src/Form/QA/QuestionFormType.php +++ b/src/Form/QA/QuestionFormType.php @@ -6,6 +6,8 @@ use App\Entity\Question; use App\Entity\QuestionTag; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -48,6 +50,70 @@ class QuestionFormType extends AbstractType 'minMessage' => 'متن سوال باید حداقل 20 کاراکتر باشد' ]) ] + ]) + ->add('notifyOnAnswer', CheckboxType::class, [ + 'label' => 'اطلاع‌رسانی ایمیل هنگام دریافت پاسخ', + 'required' => false, + 'attr' => [ + 'class' => 'form-check-input' + ] + ]) + ->add('tags', EntityType::class, [ + 'class' => QuestionTag::class, + 'choice_label' => 'name', + 'multiple' => true, + 'expanded' => false, + 'required' => false, + 'mapped' => false, + 'attr' => [ + 'class' => 'form-control', + 'style' => 'display: none;' // مخفی کردن چون با JavaScript مدیریت می‌شود + ], + 'constraints' => [ + new Assert\Count([ + 'min' => 1, + 'max' => 5, + 'minMessage' => 'حداقل 1 تگ باید انتخاب کنید', + 'maxMessage' => 'حداکثر 5 تگ می‌توانید انتخاب کنید' + ]) + ] + ]) + ->add('attachments', FileType::class, [ + 'label' => 'پیوست فایل (حداکثر 3 فایل، هر کدام 4 مگابایت)', + 'mapped' => false, + 'multiple' => true, + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'accept' => '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt', + 'multiple' => true + ], + 'constraints' => [ + new Assert\Count([ + 'max' => 3, + 'maxMessage' => 'حداکثر 3 فایل می‌توانید پیوست کنید' + ]), + new Assert\All([ + new Assert\File([ + 'maxSize' => '4M', + 'maxSizeMessage' => 'حجم هر فایل نمی‌تواند بیش از 4 مگابایت باشد', + 'mimeTypes' => [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain' + ], + 'mimeTypesMessage' => 'فرمت فایل مجاز نیست. فرمت‌های مجاز: JPG, PNG, GIF, PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT' + ]) + ]) + ] ]); } diff --git a/src/Repository/AttachmentRepository.php b/src/Repository/AttachmentRepository.php new file mode 100644 index 0000000..840f1f9 --- /dev/null +++ b/src/Repository/AttachmentRepository.php @@ -0,0 +1,69 @@ + + */ +class AttachmentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Attachment::class); + } + + /** + * Find attachments by question + */ + public function findByQuestion(int $questionId): array + { + return $this->createQueryBuilder('a') + ->where('a.question = :questionId') + ->setParameter('questionId', $questionId) + ->orderBy('a.uploadedAt', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Find attachments by answer + */ + public function findByAnswer(int $answerId): array + { + return $this->createQueryBuilder('a') + ->where('a.answer = :answerId') + ->setParameter('answerId', $answerId) + ->orderBy('a.uploadedAt', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Find orphaned attachments (not linked to any question or answer) + */ + public function findOrphanedAttachments(): array + { + return $this->createQueryBuilder('a') + ->where('a.question IS NULL') + ->andWhere('a.answer IS NULL') + ->getQuery() + ->getResult(); + } + + /** + * Count attachments by user + */ + public function countByUser(int $userId): int + { + return $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.uploadedBy = :userId') + ->setParameter('userId', $userId) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Service/AttachmentService.php b/src/Service/AttachmentService.php new file mode 100644 index 0000000..4a0b9f8 --- /dev/null +++ b/src/Service/AttachmentService.php @@ -0,0 +1,149 @@ +targetDirectory = $targetDirectory; + $this->entityManager = $entityManager; + $this->slugger = $slugger; + } + + /** + * Upload multiple files and create attachment entities + */ + public function uploadAttachments(array $uploadedFiles, User $user, ?Question $question = null, ?Answer $answer = null): array + { + $attachments = []; + + foreach ($uploadedFiles as $uploadedFile) { + if ($uploadedFile instanceof UploadedFile) { + $attachment = $this->uploadSingleFile($uploadedFile, $user, $question, $answer); + if ($attachment) { + $attachments[] = $attachment; + } + } + } + + return $attachments; + } + + /** + * Upload a single file and create attachment entity + */ + public function uploadSingleFile(UploadedFile $file, User $user, ?Question $question = null, ?Answer $answer = null): ?Attachment + { + $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + $safeFilename = $this->slugger->slug($originalFilename); + $fileName = $safeFilename . '-' . uniqid() . '.' . $file->guessExtension(); + + try { + $file->move($this->targetDirectory, $fileName); + } catch (FileException $e) { + return null; + } + + $attachment = new Attachment(); + $attachment->setFilename($fileName); + $attachment->setOriginalFilename($file->getClientOriginalName()); + $attachment->setMimeType($file->getMimeType()); + $attachment->setSize($file->getSize()); + $attachment->setPath($this->targetDirectory . '/' . $fileName); + $attachment->setUploadedBy($user); + + if ($question) { + $attachment->setQuestion($question); + } + if ($answer) { + $attachment->setAnswer($answer); + } + + $this->entityManager->persist($attachment); + $this->entityManager->flush(); + + return $attachment; + } + + /** + * Delete attachment file and entity + */ + public function deleteAttachment(Attachment $attachment): bool + { + try { + // Delete file from filesystem + if (file_exists($attachment->getPath())) { + unlink($attachment->getPath()); + } + + // Remove from database + $this->entityManager->remove($attachment); + $this->entityManager->flush(); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Get attachment URL for display + */ + public function getAttachmentUrl(Attachment $attachment): string + { + return '/uploads/attachments/' . $attachment->getFilename(); + } + + /** + * Get allowed file types + */ + public function getAllowedMimeTypes(): array + { + return [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain' + ]; + } + + /** + * Get max file size in bytes + */ + public function getMaxFileSize(): int + { + return 4 * 1024 * 1024; // 4MB + } + + /** + * Get max number of files + */ + public function getMaxFiles(): int + { + return 3; + } +} diff --git a/src/Service/EmailNotificationService.php b/src/Service/EmailNotificationService.php new file mode 100644 index 0000000..43eb798 --- /dev/null +++ b/src/Service/EmailNotificationService.php @@ -0,0 +1,95 @@ +mailer = $mailer; + $this->twig = $twig; + } + + /** + * Send email notification when a new answer is posted + */ + public function sendAnswerNotification(Answer $answer): bool + { + $question = $answer->getQuestion(); + + // Only send notification if question author wants to be notified + if (!$question->isNotifyOnAnswer()) { + return false; + } + + $questionAuthor = $question->getAuthor(); + $answerAuthor = $answer->getAuthor(); + + // Don't send notification if the answer is from the same person who asked the question + if ($questionAuthor->getId() === $answerAuthor->getId()) { + return false; + } + + try { + $email = (new Email()) + ->from('noreply@hesabix.ir') + ->to($questionAuthor->getEmail()) + ->subject('پاسخ جدید برای سوال شما: ' . $question->getTitle()) + ->html($this->twig->render('emails/answer_notification.html.twig', [ + 'question' => $question, + 'answer' => $answer, + 'questionAuthor' => $questionAuthor, + 'answerAuthor' => $answerAuthor + ])); + + $this->mailer->send($email); + return true; + } catch (\Exception $e) { + // Log error if needed + return false; + } + } + + /** + * Send email notification when a question is accepted + */ + public function sendAnswerAcceptedNotification(Answer $answer): bool + { + $question = $answer->getQuestion(); + $answerAuthor = $answer->getAuthor(); + $questionAuthor = $question->getAuthor(); + + // Don't send notification if the answer is from the same person who asked the question + if ($questionAuthor->getId() === $answerAuthor->getId()) { + return false; + } + + try { + $email = (new Email()) + ->from('noreply@hesabix.ir') + ->to($answerAuthor->getEmail()) + ->subject('پاسخ شما پذیرفته شد: ' . $question->getTitle()) + ->html($this->twig->render('emails/answer_accepted_notification.html.twig', [ + 'question' => $question, + 'answer' => $answer, + 'questionAuthor' => $questionAuthor, + 'answerAuthor' => $answerAuthor + ])); + + $this->mailer->send($email); + return true; + } catch (\Exception $e) { + // Log error if needed + return false; + } + } +} diff --git a/src/Twig/JdateExtension.php b/src/Twig/JdateExtension.php new file mode 100644 index 0000000..4e17ddf --- /dev/null +++ b/src/Twig/JdateExtension.php @@ -0,0 +1,37 @@ +jdate = $jdate; + } + + public function getFilters(): array + { + return [ + new TwigFilter('jdate', [$this, 'formatJdate']), + ]; + } + + public function formatJdate($timestamp, string $format = 'Y/m/d H:i'): string + { + if (is_string($timestamp)) { + $timestamp = (int) $timestamp; + } + + if (!$timestamp) { + return ''; + } + + return $this->jdate->jdate($format, $timestamp); + } +} diff --git a/templates/admin/login.html.twig b/templates/admin/login.html.twig index b3bbdf1..7e12e6d 100644 --- a/templates/admin/login.html.twig +++ b/templates/admin/login.html.twig @@ -1,7 +1,68 @@ -{# templates/easy_admin/page/login.html.twig #} -{% extends '@!EasyAdmin/page/login.html.twig' %} +{% extends 'customer/base.html.twig' %} -{% block head %} - {{ parent() }} - +{% block page_title %}ورود به پنل مدیریت{% endblock %} +{% block page_subtitle %}وارد پنل مدیریت حسابیکس شوید{% endblock %} + +{% block auth_content %} + {% if error %} + + {% endif %} + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + + + +
+ + {% endblock %} \ No newline at end of file diff --git a/templates/base.html.twig b/templates/base.html.twig index 8678757..6dd22cf 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -398,6 +398,18 @@ داشبورد + {% if is_granted('ROLE_ADMIN') %} + +
+ + + + +
+ پنل مدیریت +
+ {% endif %}
@@ -470,6 +482,27 @@ class="block px-4 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 rounded-lg transition-colors duration-200"> تماس با ما + + {% if app.user and app.user.roles is defined and 'ROLE_CUSTOMER' in app.user.roles %} +
+
{{ app.user.name }}
+
عضو باشگاه مشتریان
+ + داشبورد + + {% if is_granted('ROLE_ADMIN') %} + + پنل مدیریت + + {% endif %} + + خروج + +
+ {% endif %}
diff --git a/templates/customer/base.html.twig b/templates/customer/base.html.twig index 1bbca7f..9711cb6 100644 --- a/templates/customer/base.html.twig +++ b/templates/customer/base.html.twig @@ -5,194 +5,6 @@ {% block stylesheets %} {{ parent() }} {% endblock %} {% block body %} -
-
-
- -

باشگاه مشتریان حسابیکس

-

{{ block('page_subtitle') }}

-
- -
- {% for message in app.flashes('success') %} - - {% endfor %} +
+
+
+ +
+ حسابیکس +

باشگاه مشتریان حسابیکس

+

{{ block('page_subtitle') }}

+
- {% for message in app.flashes('error') %} - - {% endfor %} - - {% for message in app.flashes('info') %} - - {% endfor %} - - {% block auth_content %}{% endblock %} + +
+ + {% for message in app.flashes('success') %} + + {% endfor %} + + {% for message in app.flashes('error') %} + + {% endfor %} + + {% for message in app.flashes('info') %} + + {% endfor %} + + {% block auth_content %}{% endblock %} +
diff --git a/templates/customer/dashboard.html.twig b/templates/customer/dashboard.html.twig index 0fb25ec..964f05f 100644 --- a/templates/customer/dashboard.html.twig +++ b/templates/customer/dashboard.html.twig @@ -55,13 +55,13 @@
تاریخ عضویت: - {{ user.createdAt|date('Y/m/d') }} + {{ user.createdAt|date('U')|jdate('Y/m/d') }}
آخرین ورود: - {{ user.lastLoginAt ? user.lastLoginAt|date('Y/m/d H:i') : 'هنوز وارد نشده' }} + {{ user.lastLoginAt ? user.lastLoginAt|date('U')|jdate('Y/m/d H:i') : 'هنوز وارد نشده' }}
@@ -97,8 +97,8 @@
اتصال کیف پول جدید
- - @@ -108,13 +108,13 @@
- +
{% endif %} - -
@@ -218,15 +218,21 @@ diff --git a/templates/customer/forgot_password.html.twig b/templates/customer/forgot_password.html.twig index a8d06ff..96eb74d 100644 --- a/templates/customer/forgot_password.html.twig +++ b/templates/customer/forgot_password.html.twig @@ -4,30 +4,39 @@ {% block page_subtitle %}کلمه عبور خود را بازیابی کنید{% endblock %} {% block auth_content %} -
- کلید -

ایمیل خود را وارد کنید تا لینک بازیابی کلمه عبور برای شما ارسال شود.

+
+ کلید +

ایمیل خود را وارد کنید تا لینک بازیابی کلمه عبور برای شما ارسال شود.

- {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + {{ form_start(form, {'attr': {'novalidate': 'novalidate', 'class': 'space-y-6'}}) }} -
- {{ form_widget(form.email, {'attr': {'class': 'form-control', 'placeholder': 'ایمیل خود را وارد کنید'}}) }} - {{ form_label(form.email) }} +
+ {{ form_label(form.email, null, {'label_attr': {'class': 'block text-sm font-medium text-gray-700 mb-2'}}) }} + {{ form_widget(form.email, {'attr': {'class': 'w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 text-left', 'placeholder': 'ایمیل خود را وارد کنید'}}) }} {{ form_errors(form.email) }}
+ + {{ form_end(form) }} -