diff --git a/backend/src/Api/Sudo/Controller/IssueController.php b/backend/src/Api/Sudo/Controller/IssueController.php new file mode 100644 index 00000000..ac2c0098 --- /dev/null +++ b/backend/src/Api/Sudo/Controller/IssueController.php @@ -0,0 +1,45 @@ +query->has('subdomain') ? $request->query->getString('subdomain') : null; + $status = $request->query->has('status') + ? IssueStatus::tryFrom($request->query->getString('status')) + : null; + $limit = $request->query->getInt('limit', 50); + $offset = $request->query->getInt('offset', 0); + + return new JsonResponse( + array_map( + fn($issue) => new SudoIssueObject($issue), + $this->issueService->getIssuesGlobal($subdomain, $status, $limit, $offset) + ) + ); + } + + #[Route('/issues/{id}', methods: ['GET'])] + public function getIssue(Issue $issue): JsonResponse + { + return new JsonResponse(new SudoIssueObject($issue)); + } +} diff --git a/backend/src/Api/Sudo/Controller/NewsletterController.php b/backend/src/Api/Sudo/Controller/NewsletterController.php new file mode 100644 index 00000000..305ed75b --- /dev/null +++ b/backend/src/Api/Sudo/Controller/NewsletterController.php @@ -0,0 +1,44 @@ +query->has('name') ? $request->query->getString('name') : null; + $limit = $request->query->getInt('limit', 50); + $offset = $request->query->getInt('offset', 0); + + return new JsonResponse( + array_map( + fn($newsletter) => new NewsletterObject($newsletter), + $this->newsletterService->getNewsletters($name, $limit, $offset) + ) + ); + } + + #[Route('/newsletters/{id}', methods: ['GET'])] + public function getNewsletter(Newsletter $newsletter): JsonResponse + { + return new JsonResponse([ + 'newsletter' => new NewsletterObject($newsletter), + 'stats' => $this->newsletterService->getNewsletterStats($newsletter), + ]); + } +} diff --git a/backend/src/Api/Sudo/Object/NewsletterObject.php b/backend/src/Api/Sudo/Object/NewsletterObject.php new file mode 100644 index 00000000..341e3b40 --- /dev/null +++ b/backend/src/Api/Sudo/Object/NewsletterObject.php @@ -0,0 +1,29 @@ +id = $newsletter->getId(); + $this->created_at = $newsletter->getCreatedAt()->getTimestamp(); + $this->subdomain = $newsletter->getSubdomain(); + $this->name = $newsletter->getName(); + $this->user_id = $newsletter->getUserId(); + $this->organization_id = $newsletter->getOrganizationId(); + $this->language_code = $newsletter->getLanguageCode(); + $this->is_rtl = $newsletter->isRtl(); + } +} diff --git a/backend/src/Api/Sudo/Object/SudoIssueObject.php b/backend/src/Api/Sudo/Object/SudoIssueObject.php new file mode 100644 index 00000000..cd567cf1 --- /dev/null +++ b/backend/src/Api/Sudo/Object/SudoIssueObject.php @@ -0,0 +1,38 @@ +id = $issue->getId(); + $this->created_at = $issue->getCreatedAt()->getTimestamp(); + $this->uuid = $issue->getUuid(); + $this->subject = $issue->getSubject(); + $this->status = $issue->getStatus(); + $this->newsletter_subdomain = $issue->getNewsletter()->getSubdomain(); + $this->newsletter_id = $issue->getNewsletter()->getId(); + $this->scheduled_at = $issue->getScheduledAt()?->getTimestamp(); + $this->sending_at = $issue->getSendingAt()?->getTimestamp(); + $this->sent_at = $issue->getSentAt()?->getTimestamp(); + $this->total_sendable = $issue->getTotalSendable(); + $this->error_private = $issue->getErrorPrivate(); + } +} diff --git a/backend/src/Api/Sudo/Resolver/EntityResolver.php b/backend/src/Api/Sudo/Resolver/EntityResolver.php index 321adba2..4708492d 100644 --- a/backend/src/Api/Sudo/Resolver/EntityResolver.php +++ b/backend/src/Api/Sudo/Resolver/EntityResolver.php @@ -3,6 +3,8 @@ namespace App\Api\Sudo\Resolver; use App\Entity\Approval; +use App\Entity\Issue; +use App\Entity\Newsletter; use App\Entity\SubscriberImport; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Exception\BadRequestException; @@ -16,6 +18,8 @@ class EntityResolver implements ValueResolverInterface public const ENTITIES = [ 'approvals' => Approval::class, 'subscriber-imports' => SubscriberImport::class, + 'newsletters' => Newsletter::class, + 'issues' => Issue::class, ]; public function __construct( diff --git a/backend/src/Service/Issue/IssueService.php b/backend/src/Service/Issue/IssueService.php index 953ff277..a83198d3 100644 --- a/backend/src/Service/Issue/IssueService.php +++ b/backend/src/Service/Issue/IssueService.php @@ -106,6 +106,38 @@ public function updateIssue(Issue $issue, UpdateIssueDto $updates): Issue return $issue; } + /** + * @return Issue[] + */ + public function getIssuesGlobal( + ?string $subdomain, + ?IssueStatus $status, + int $limit, + int $offset, + ): array + { + $qb = $this->em->createQueryBuilder() + ->select('i') + ->from(Issue::class, 'i') + ->join('i.newsletter', 'n') + ->orderBy('i.id', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + if ($subdomain) { + $qb->andWhere('n.subdomain = :subdomain') + ->setParameter('subdomain', $subdomain); + } + + if ($status) { + $qb->andWhere('i.status = :status') + ->setParameter('status', $status); + } + + /** @var Issue[] */ + return $qb->getQuery()->getResult(); + } + /** * @return ArrayCollection */ diff --git a/backend/src/Service/Newsletter/NewsletterService.php b/backend/src/Service/Newsletter/NewsletterService.php index a14e5bee..854f6235 100644 --- a/backend/src/Service/Newsletter/NewsletterService.php +++ b/backend/src/Service/Newsletter/NewsletterService.php @@ -7,6 +7,7 @@ use App\Entity\NewsletterList; use App\Entity\Newsletter; use App\Entity\Send; +use App\Entity\SendingProfile; use App\Entity\Subscriber; use App\Entity\Type\IssueStatus; use App\Entity\Type\SendStatus; @@ -102,6 +103,25 @@ public function deleteNewsletter(Newsletter $newsletter): void $this->em->flush(); } + /** + * @return Newsletter[] + */ + public function getNewsletters(?string $name, int $limit, int $offset): array + { + $qb = $this->em->getRepository(Newsletter::class)->createQueryBuilder('n') + ->orderBy('n.id', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + if ($name) { + $qb->andWhere('LOWER(n.name) LIKE LOWER(:name)') + ->setParameter('name', '%' . $name . '%'); + } + + /** @var Newsletter[] */ + return $qb->getQuery()->getResult(); + } + public function getNewsletterById(int $id): ?Newsletter { return $this->em->getRepository(Newsletter::class)->find($id); @@ -164,7 +184,7 @@ public function getNewsletterUser(Newsletter $newsletter, int $userId): User } /** - * @return array + * @return array{subscribers: array{total: int, last_30_days: int}, issues: array{total: int, last_30_days: int}, bounced_rate: array{total: float, last_30_days: float}, complained_rate: array{total: float, last_30_days: float}, lists_count: int, sending_profiles_count: int} */ public function getNewsletterStats(Newsletter $newsletter): array { @@ -224,6 +244,20 @@ public function getNewsletterStats(Newsletter $newsletter): array $bouncedRateLast30d = $totalSendsLast30d > 0 ? round(($bouncedSendsLast30d / $totalSendsLast30d) * 100, 2) : 0.0; $complainedRateLast30d = $totalSendsLast30d > 0 ? round(($complainedSendsLast30d / $totalSendsLast30d) * 100, 2) : 0.0; + $listsCount = (int) $this->em->getRepository(NewsletterList::class)->createQueryBuilder('l') + ->select('COUNT(l.id)') + ->where('l.newsletter = :newsletter') + ->setParameter('newsletter', $newsletter) + ->getQuery() + ->getSingleScalarResult(); + + $sendingProfilesCount = (int) $this->em->getRepository(SendingProfile::class)->createQueryBuilder('sp') + ->select('COUNT(sp.id)') + ->where('sp.newsletter = :newsletter') + ->setParameter('newsletter', $newsletter) + ->getQuery() + ->getSingleScalarResult(); + return [ 'subscribers' => [ 'total' => $subscribers, @@ -241,6 +275,8 @@ public function getNewsletterStats(Newsletter $newsletter): array 'total' => $complainedRate, 'last_30_days' => $complainedRateLast30d, ], + 'lists_count' => $listsCount, + 'sending_profiles_count' => $sendingProfilesCount, ]; } diff --git a/backend/tests/Api/Sudo/Issue/GetIssueTest.php b/backend/tests/Api/Sudo/Issue/GetIssueTest.php new file mode 100644 index 00000000..cb35a004 --- /dev/null +++ b/backend/tests/Api/Sudo/Issue/GetIssueTest.php @@ -0,0 +1,51 @@ +sudoApi( + 'GET', + '/issues/' . $issue->getId() + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $this->getJson(); + $this->assertArrayHasKey('id', $data); + $this->assertArrayHasKey('created_at', $data); + $this->assertArrayHasKey('uuid', $data); + $this->assertArrayHasKey('subject', $data); + $this->assertArrayHasKey('status', $data); + $this->assertArrayHasKey('newsletter_subdomain', $data); + $this->assertArrayHasKey('newsletter_id', $data); + $this->assertArrayHasKey('scheduled_at', $data); + $this->assertArrayHasKey('sending_at', $data); + $this->assertArrayHasKey('sent_at', $data); + $this->assertArrayHasKey('total_sendable', $data); + $this->assertArrayHasKey('error_private', $data); + } + + public function test_get_issue_not_found(): void + { + $response = $this->sudoApi( + 'GET', + '/issues/99999' + ); + + $this->assertSame(404, $response->getStatusCode()); + } +} diff --git a/backend/tests/Api/Sudo/Issue/GetIssuesTest.php b/backend/tests/Api/Sudo/Issue/GetIssuesTest.php new file mode 100644 index 00000000..4b200452 --- /dev/null +++ b/backend/tests/Api/Sudo/Issue/GetIssuesTest.php @@ -0,0 +1,76 @@ + IssueStatus::SENT, + ]); + IssueFactory::createMany(2, [ + 'status' => IssueStatus::DRAFT, + ]); + + $response = $this->sudoApi( + 'GET', + '/issues?status=sent' + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $this->getJson(); + $this->assertCount(3, $data); + + $issue = $data[0]; + $this->assertIsArray($issue); + $this->assertCount(12, $issue); + $this->assertArrayHasKey('id', $issue); + $this->assertArrayHasKey('created_at', $issue); + $this->assertArrayHasKey('uuid', $issue); + $this->assertArrayHasKey('subject', $issue); + $this->assertArrayHasKey('status', $issue); + $this->assertArrayHasKey('newsletter_subdomain', $issue); + $this->assertArrayHasKey('newsletter_id', $issue); + $this->assertArrayHasKey('scheduled_at', $issue); + $this->assertArrayHasKey('sending_at', $issue); + $this->assertArrayHasKey('sent_at', $issue); + $this->assertArrayHasKey('total_sendable', $issue); + $this->assertArrayHasKey('error_private', $issue); + } + + public function test_get_issues_by_subdomain(): void + { + $newsletter = NewsletterFactory::createOne(); + + $issue = IssueFactory::createOne([ + 'newsletter' => $newsletter, + ]); + IssueFactory::createMany(3); + + $response = $this->sudoApi( + 'GET', + "/issues?subdomain={$newsletter->getSubdomain()}" + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $this->getJson(); + $this->assertCount(1, $data); + + $item = $data[0]; + $this->assertIsArray($item); + $this->assertSame($issue->getId(), $item['id']); + } +} diff --git a/backend/tests/Api/Sudo/Newsletter/GetNewsletterTest.php b/backend/tests/Api/Sudo/Newsletter/GetNewsletterTest.php new file mode 100644 index 00000000..76d40801 --- /dev/null +++ b/backend/tests/Api/Sudo/Newsletter/GetNewsletterTest.php @@ -0,0 +1,41 @@ +sudoApi( + 'GET', + '/newsletters/' . $newsletter->getId() + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $this->getJson(); + $this->assertArrayHasKey('newsletter', $data); + $this->assertArrayHasKey('stats', $data); + } + + public function test_get_newsletter_not_found(): void + { + $response = $this->sudoApi( + 'GET', + '/newsletters/99999' + ); + + $this->assertSame(404, $response->getStatusCode()); + } +} diff --git a/backend/tests/Api/Sudo/Newsletter/GetNewslettersTest.php b/backend/tests/Api/Sudo/Newsletter/GetNewslettersTest.php new file mode 100644 index 00000000..d4445e3c --- /dev/null +++ b/backend/tests/Api/Sudo/Newsletter/GetNewslettersTest.php @@ -0,0 +1,63 @@ +sudoApi( + 'GET', + '/newsletters' + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $this->getJson(); + $this->assertCount(5, $data); + + $newsletter = $data[0]; + $this->assertIsArray($newsletter); + $this->assertCount(8, $newsletter); + $this->assertArrayHasKey('id', $newsletter); + $this->assertArrayHasKey('created_at', $newsletter); + $this->assertArrayHasKey('subdomain', $newsletter); + $this->assertArrayHasKey('name', $newsletter); + $this->assertArrayHasKey('user_id', $newsletter); + $this->assertArrayHasKey('organization_id', $newsletter); + $this->assertArrayHasKey('language_code', $newsletter); + $this->assertArrayHasKey('is_rtl', $newsletter); + } + + public function test_get_newsletters_by_name(): void + { + $newsletter = NewsletterFactory::createOne([ + 'name' => 'My Unique Newsletter', + ]); + NewsletterFactory::createMany(3); + + $response = $this->sudoApi( + 'GET', + '/newsletters?name=My Unique Newsletter' + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $this->getJson(); + $this->assertCount(1, $data); + + $item = $data[0]; + $this->assertIsArray($item); + $this->assertSame($newsletter->getId(), $item['id']); + } +} diff --git a/frontend/src/routes/sudo/Nav.svelte b/frontend/src/routes/sudo/Nav.svelte index bbbb1a0d..b5f1da13 100644 --- a/frontend/src/routes/sudo/Nav.svelte +++ b/frontend/src/routes/sudo/Nav.svelte @@ -7,6 +7,8 @@ import { statsStore } from './lib/stores/sudoStore.js'; import { Tag } from '@hyvor/design/components'; import IconSend from '@hyvor/icons/IconSend'; + import IconJournal from '@hyvor/icons/IconJournal'; + import IconEnvelope from '@hyvor/icons/IconEnvelope';