From 7973a3a91d3e6be9355607d7faf7e19383cc7088 Mon Sep 17 00:00:00 2001 From: tg666 Date: Thu, 14 Jul 2022 05:43:09 +0200 Subject: [PATCH] Dashboard - real data III - added database migration - added column `Category::$necessary` - added real data for statistics "Positive consents" and "Positive unique consents" - added domain event `CategoryNecessaryChanged` - added query `ScrollThroughConsentsPerPeriodQuery` --- config/model/consent/infrastructure.neon | 3 + .../Controller/StatisticsController.php | 35 ++--- src/Application/Fixture/resources/demo.php | 5 + .../ProjectStatisticsCalculator.php | 109 ++++++++++++++++ .../ProjectStatisticsCalculatorInterface.php | 9 ++ src/Domain/Category/Category.php | 33 ++++- .../Command/CreateCategoryCommand.php | 12 +- .../Command/UpdateCategoryCommand.php | 24 +++- src/Domain/Category/Event/CategoryCreated.php | 16 ++- .../Event/CategoryNecessaryChanged.php | 58 +++++++++ .../App.Domain.Category.Category.dcm.xml | 2 + ...lateConsentTotalsPerPeriodQueryHandler.php | 11 +- ...llThroughConsentsPerPeriodQueryHandler.php | 122 ++++++++++++++++++ .../Doctrine/Version20220714014443.php | 40 ++++++ src/ReadModel/Category/CategoryView.php | 3 + .../ScrollThroughConsentsPerPeriodQuery.php | 51 ++++++++ .../CategoryForm/CategoryFormControl.php | 5 + ...l_CategoryForm_CategoryFormControl.cs.neon | 3 + 18 files changed, 507 insertions(+), 34 deletions(-) create mode 100644 src/Domain/Category/Event/CategoryNecessaryChanged.php create mode 100644 src/Infrastructure/Consent/Doctrine/ReadModel/ScrollThroughConsentsPerPeriodQueryHandler.php create mode 100644 src/Infrastructure/Migration/Doctrine/Version20220714014443.php create mode 100644 src/ReadModel/Consent/ScrollThroughConsentsPerPeriodQuery.php diff --git a/config/model/consent/infrastructure.neon b/config/model/consent/infrastructure.neon index 8de57b2f..acb83b38 100644 --- a/config/model/consent/infrastructure.neon +++ b/config/model/consent/infrastructure.neon @@ -27,6 +27,9 @@ services: - autowired: no factory: App\Infrastructure\Consent\Doctrine\ReadModel\GetConsentByProjectIdAndUserIdentifierQueryHandler + - + autowired: no + factory: App\Infrastructure\Consent\Doctrine\ReadModel\ScrollThroughConsentsPerPeriodQueryHandler # infra: doctrine mapping nettrine.orm.xml: diff --git a/src/Api/Internal/Controller/StatisticsController.php b/src/Api/Internal/Controller/StatisticsController.php index f303c7a7..a88411e0 100644 --- a/src/Api/Internal/Controller/StatisticsController.php +++ b/src/Api/Internal/Controller/StatisticsController.php @@ -95,6 +95,8 @@ public function getProjectStatistics(ApiRequest $request, ApiResponse $response) } } + $projectIds = array_filter($projectIds); + // are some projects inaccessible for the current user? if (0 < count($inaccessible)) { return $response->withStatus(ApiResponse::S401_UNAUTHORIZED) @@ -111,24 +113,6 @@ public function getProjectStatistics(ApiRequest $request, ApiResponse $response) ]); } - $missingProjects = array_keys(array_filter($projectIds, static fn (?string $projectId): bool => NULL === $projectId)); - - // are some projects missing? - if (0 < count($missingProjects)) { - return $response->withStatus(ApiResponse::S422_UNPROCESSABLE_ENTITY) - ->writeJsonBody([ - 'status' => 'error', - 'data' => [ - 'code' => ApiResponse::S422_UNPROCESSABLE_ENTITY, - 'error' => sprintf( - 'Project%s %s not found.', - 1 < count($missingProjects) ? 's' : '', - implode(', ', $missingProjects) - ), - ], - ]); - } - try { [$startDate, $endDate] = $this->createRange($requestEntity, $userData->timezone); } catch (Exception $e) { @@ -161,13 +145,20 @@ public function getProjectStatistics(ApiRequest $request, ApiResponse $response) private function buildData(array $projectIdsByCodes, string $locale, DateTimeImmutable $startDate, DateTimeImmutable $endDate, DateTimeZone $userTz): array { $data = []; + + if (0 >= count($projectIdsByCodes)) { + return $data; + } + $projectIds = array_values($projectIdsByCodes); $allConsentPeriodStatistics = $this->projectStatisticsCalculator->calculateConsentPeriodStatistics($projectIds, $startDate, $endDate); + $allPositiveConsentPeriodStatistics = $this->projectStatisticsCalculator->calculatePositiveConsentPeriodStatistics($projectIds, $startDate, $endDate); $allCookieStatistics = $this->projectStatisticsCalculator->calculateCookieStatistics($projectIds); $allLastConsentDates = $this->projectStatisticsCalculator->calculateLastConsentDate($projectIds); foreach ($projectIdsByCodes as $code => $projectId) { $consentPeriodStatistics = $allConsentPeriodStatistics->get($projectId); + $positiveConsentPeriodStatistics = $allPositiveConsentPeriodStatistics->get($projectId); $cookieStatistics = $allCookieStatistics->get($projectId); $lastConsentDate = $allLastConsentDates->get($projectId); @@ -185,12 +176,12 @@ private function buildData(array $projectIdsByCodes, string $locale, DateTimeImm 'percentageDiff' => $consentPeriodStatistics->uniqueConsentsPeriodStatistics()->percentageDiff(), ], 'allPositive' => [ - 'value' => 'NaN', - 'percentageDiff' => 0, + 'value' => $positiveConsentPeriodStatistics->totalConsentsPeriodStatistics()->currentValue(), + 'percentageDiff' => $positiveConsentPeriodStatistics->totalConsentsPeriodStatistics()->percentageDiff(), ], 'uniquePositive' => [ - 'value' => 'NaN', - 'percentageDiff' => 0, + 'value' => $positiveConsentPeriodStatistics->uniqueConsentsPeriodStatistics()->currentValue(), + 'percentageDiff' => $positiveConsentPeriodStatistics->uniqueConsentsPeriodStatistics()->percentageDiff(), ], 'lastConsent' => [ 'value' => NULL !== $lastConsentDate ? $lastConsentDate->format(DateTimeInterface::ATOM) : NULL, diff --git a/src/Application/Fixture/resources/demo.php b/src/Application/Fixture/resources/demo.php index 6b2f87e1..77959654 100644 --- a/src/Application/Fixture/resources/demo.php +++ b/src/Application/Fixture/resources/demo.php @@ -30,6 +30,7 @@ 'en' => 'Functionality cookies', ], 'active' => TRUE, + 'necessary' => TRUE, ], 'personalization_storage' => [ 'category_id' => CategoryId::new()->toString(), @@ -39,6 +40,7 @@ 'en' => 'Personalization cookies', ], 'active' => TRUE, + 'necessary' => FALSE, ], 'security_storage' => [ 'category_id' => CategoryId::new()->toString(), @@ -48,6 +50,7 @@ 'en' => 'Security cookies', ], 'active' => TRUE, + 'necessary' => FALSE, ], 'ad_storage' => [ 'category_id' => CategoryId::new()->toString(), @@ -57,6 +60,7 @@ 'en' => 'Ad cookies', ], 'active' => TRUE, + 'necessary' => FALSE, ], 'analytics_storage' => [ 'category_id' => CategoryId::new()->toString(), @@ -66,6 +70,7 @@ 'en' => 'Analytics cookies', ], 'active' => TRUE, + 'necessary' => FALSE, ], ]; diff --git a/src/Application/Statistics/ProjectStatisticsCalculator.php b/src/Application/Statistics/ProjectStatisticsCalculator.php index 3d0c7be1..b27c2bc9 100644 --- a/src/Application/Statistics/ProjectStatisticsCalculator.php +++ b/src/Application/Statistics/ProjectStatisticsCalculator.php @@ -5,13 +5,17 @@ namespace App\Application\Statistics; use DateTimeImmutable; +use App\ReadModel\Category\CategoryView; use App\ReadModel\Consent\ConsentTotalsView; +use App\ReadModel\Category\AllCategoriesQuery; use App\ReadModel\Consent\LastConsentDateView; use App\ReadModel\Project\ProjectCookieTotalsView; use App\ReadModel\Consent\CalculateLastConsentDatesQuery; use App\ReadModel\Project\CalculateProjectCookieTotalsQuery; +use App\ReadModel\Consent\ScrollThroughConsentsPerPeriodQuery; use App\ReadModel\Consent\CalculateConsentTotalsPerPeriodQuery; use SixtyEightPublishers\ArchitectureBundle\Bus\QueryBusInterface; +use SixtyEightPublishers\ArchitectureBundle\ReadModel\Query\Batch; final class ProjectStatisticsCalculator implements ProjectStatisticsCalculatorInterface { @@ -71,6 +75,111 @@ public function calculateConsentPeriodStatistics(array $projectIds, DateTimeImmu return $result; } + /** + * {@inheritDoc} + */ + public function calculatePositiveConsentPeriodStatistics(array $projectIds, DateTimeImmutable $startDate, DateTimeImmutable $endDate): MultiProjectConsentPeriodStatistics + { + $diff = $startDate->diff($endDate); + $previousEndDate = $startDate->modify('-1 second'); + $previousStartDate = $previousEndDate->sub($diff); + + // fill values + $consentStatistics = array_fill_keys($projectIds, [ + 'total' => [ + 'previousPositive' => 0, + 'previousNegative' => 0, + 'currentPositive' => 0, + 'currentNegative' => 0, + ], + 'unique' => [ + 'previousPositive' => 0, + 'previousNegative' => 0, + 'currentPositive' => 0, + 'currentNegative' => 0, + ], + ]); + + // consent counter + $batchSize = 100; + $watched = $userIdentifiers = []; + $sumConsents = static function (array $consents, bool $positivity) use (&$watched): int { + return count( + array_filter( + $consents, + static fn (bool $consent, string $storageName): bool => $consent === $positivity && in_array($storageName, $watched, TRUE), + ARRAY_FILTER_USE_BOTH + ), + ); + }; + + // find watched categories + foreach ($this->queryBus->dispatch(AllCategoriesQuery::create()) as $categoryView) { + assert($categoryView instanceof CategoryView); + + if (!$categoryView->necessary) { + $watched[] = $categoryView->code->value(); + } + } + + // the previous period + foreach ($this->queryBus->dispatch(ScrollThroughConsentsPerPeriodQuery::create($projectIds, $previousStartDate, $previousEndDate)->withBatchSize($batchSize)) as $batch) { + assert($batch instanceof Batch); + + foreach ($batch->results() as $row) { + $consentStatistics[$row['projectId']]['total']['previousPositive'] += $sumConsents($row['consents'], TRUE); + $consentStatistics[$row['projectId']]['total']['previousNegative'] += $sumConsents($row['consents'], FALSE); + + if (!isset($userIdentifiers[$row['userIdentifier']])) { + $consentStatistics[$row['projectId']]['unique']['previousPositive'] += $sumConsents($row['consents'], TRUE); + $consentStatistics[$row['projectId']]['unique']['previousNegative'] += $sumConsents($row['consents'], FALSE); + $userIdentifiers[$row['userIdentifier']] = TRUE; + } + } + } + + // the current period + $userIdentifiers = []; + + foreach ($this->queryBus->dispatch(ScrollThroughConsentsPerPeriodQuery::create($projectIds, $startDate, $endDate)->withBatchSize($batchSize)) as $batch) { + assert($batch instanceof Batch); + + foreach ($batch->results() as $row) { + $consentStatistics[$row['projectId']]['total']['currentPositive'] += $sumConsents($row['consents'], TRUE); + $consentStatistics[$row['projectId']]['total']['currentNegative'] += $sumConsents($row['consents'], FALSE); + + if (!isset($userIdentifiers[$row['userIdentifier']])) { + $consentStatistics[$row['projectId']]['unique']['currentPositive'] += $sumConsents($row['consents'], TRUE); + $consentStatistics[$row['projectId']]['unique']['currentNegative'] += $sumConsents($row['consents'], FALSE); + $userIdentifiers[$row['userIdentifier']] = TRUE; + } + } + } + + // build result + $result = MultiProjectConsentPeriodStatistics::create(); + + foreach ($consentStatistics as $projectId => $statistic) { + $previousTotal = $statistic['total']['previousPositive'] + $statistic['total']['previousNegative']; + $currentTotal = $statistic['total']['currentPositive'] + $statistic['total']['currentNegative']; + $previousUnique = $statistic['unique']['previousPositive'] + $statistic['unique']['previousNegative']; + $currentUnique = $statistic['unique']['currentPositive'] + $statistic['unique']['currentNegative']; + + $result = $result->withStatistics($projectId, ConsentPeriodStatistics::create( + PeriodStatistics::create( + (int) round(0 === $previousTotal ? 0 : $statistic['total']['previousPositive'] / $previousTotal * 100), + (int) round(0 === $currentTotal ? 0 : $statistic['total']['currentPositive'] / $currentTotal * 100) + ), + PeriodStatistics::create( + (int) round(0 === $previousUnique ? 0 : $statistic['unique']['previousPositive'] / $previousUnique * 100), + (int) round(0 === $currentUnique ? 0 : $statistic['unique']['currentPositive'] / $currentUnique * 100) + ) + )); + } + + return $result; + } + /** * {@inheritDoc} */ diff --git a/src/Application/Statistics/ProjectStatisticsCalculatorInterface.php b/src/Application/Statistics/ProjectStatisticsCalculatorInterface.php index 894dcd27..f22dda16 100644 --- a/src/Application/Statistics/ProjectStatisticsCalculatorInterface.php +++ b/src/Application/Statistics/ProjectStatisticsCalculatorInterface.php @@ -17,6 +17,15 @@ interface ProjectStatisticsCalculatorInterface */ public function calculateConsentPeriodStatistics(array $projectIds, DateTimeImmutable $startDate, DateTimeImmutable $endDate): MultiProjectConsentPeriodStatistics; + /** + * @param array $projectIds + * @param \DateTimeImmutable $startDate + * @param \DateTimeImmutable $endDate + * + * @return \App\Application\Statistics\MultiProjectConsentPeriodStatistics + */ + public function calculatePositiveConsentPeriodStatistics(array $projectIds, DateTimeImmutable $startDate, DateTimeImmutable $endDate): MultiProjectConsentPeriodStatistics; + /** * @param string[] $projectIds * diff --git a/src/Domain/Category/Category.php b/src/Domain/Category/Category.php index f3e2e7ef..e9df794a 100644 --- a/src/Domain/Category/Category.php +++ b/src/Domain/Category/Category.php @@ -16,6 +16,7 @@ use App\Domain\Category\Event\CategoryNameUpdated; use App\Domain\Category\Command\CreateCategoryCommand; use App\Domain\Category\Command\UpdateCategoryCommand; +use App\Domain\Category\Event\CategoryNecessaryChanged; use App\Domain\Category\Event\CategoryActiveStateChanged; use SixtyEightPublishers\ArchitectureBundle\Domain\ValueObject\AggregateId; use SixtyEightPublishers\ArchitectureBundle\Domain\Aggregate\AggregateRootInterface; @@ -33,6 +34,8 @@ final class Category implements AggregateRootInterface private bool $active; + private bool $necessary; + private Collection $translations; /** @@ -48,11 +51,12 @@ public static function create(CreateCategoryCommand $command, CheckCodeUniquenes $categoryId = NULL !== $command->categoryId() ? CategoryId::fromString($command->categoryId()) : CategoryId::new(); $code = Code::fromValidCode($command->code()); $active = $command->active(); + $necessary = $command->necessary(); $names = $command->names(); $checkCodeUniqueness($categoryId, $code); - $category->recordThat(CategoryCreated::create($categoryId, $code, $active, $names)); + $category->recordThat(CategoryCreated::create($categoryId, $code, $active, $necessary, $names)); return $category; } @@ -73,6 +77,10 @@ public function update(UpdateCategoryCommand $command, CheckCodeUniquenessInterf $this->changeActiveState($command->active()); } + if (NULL !== $command->necessary()) { + $this->changeNecessary($command->necessary()); + } + if (NULL !== $command->names()) { foreach ($command->names() as $locale => $name) { $this->changeName(Locale::fromValue($locale), Name::fromValue($name)); @@ -115,6 +123,18 @@ public function changeActiveState(bool $active): void } } + /** + * @param bool $necessary + * + * @return void + */ + public function changeNecessary(bool $necessary): void + { + if ($this->necessary !== $necessary) { + $this->recordThat(CategoryNecessaryChanged::create($this->id, $necessary)); + } + } + /** * @param \App\Domain\Shared\ValueObject\Locale $locale * @param \App\Domain\Category\ValueObject\Name $name @@ -141,6 +161,7 @@ protected function whenCategoryCreated(CategoryCreated $event): void $this->createdAt = $event->createdAt(); $this->code = $event->code(); $this->active = $event->active(); + $this->necessary = $event->necessary(); $this->translations = new ArrayCollection(); foreach ($event->names() as $locale => $name) { @@ -168,6 +189,16 @@ protected function whenCategoryActiveStateChanged(CategoryActiveStateChanged $ev $this->active = $event->active(); } + /** + * @param \App\Domain\Category\Event\CategoryNecessaryChanged $event + * + * @return void + */ + protected function whenCategoryNecessaryChanged(CategoryNecessaryChanged $event): void + { + $this->necessary = $event->necessary(); + } + /** * @param \App\Domain\Category\Event\CategoryNameUpdated $event * diff --git a/src/Domain/Category/Command/CreateCategoryCommand.php b/src/Domain/Category/Command/CreateCategoryCommand.php index 2ded5a02..a4e3b2ab 100644 --- a/src/Domain/Category/Command/CreateCategoryCommand.php +++ b/src/Domain/Category/Command/CreateCategoryCommand.php @@ -12,16 +12,18 @@ final class CreateCategoryCommand extends AbstractCommand * @param string $code * @param array $names * @param bool $active + * @param bool $necessary * @param string|NULL $categoryId * * @return static */ - public static function create(string $code, array $names, bool $active, ?string $categoryId = NULL): self + public static function create(string $code, array $names, bool $active, bool $necessary, ?string $categoryId = NULL): self { return self::fromParameters([ 'code' => $code, 'names' => $names, 'active' => $active, + 'necessary' => $necessary, 'category_id' => $categoryId, ]); } @@ -50,6 +52,14 @@ public function active(): bool return $this->getParam('active'); } + /** + * @return bool + */ + public function necessary(): bool + { + return $this->getParam('necessary'); + } + /** * @return string|NULL */ diff --git a/src/Domain/Category/Command/UpdateCategoryCommand.php b/src/Domain/Category/Command/UpdateCategoryCommand.php index f78948d3..b4f3f4d4 100644 --- a/src/Domain/Category/Command/UpdateCategoryCommand.php +++ b/src/Domain/Category/Command/UpdateCategoryCommand.php @@ -29,7 +29,7 @@ public function categoryId(): string } /** - * @return string + * @return string|NULL */ public function code(): ?string { @@ -37,7 +37,7 @@ public function code(): ?string } /** - * @return array + * @return array|NULL */ public function names(): ?array { @@ -45,13 +45,21 @@ public function names(): ?array } /** - * @return bool + * @return bool|NULL */ public function active(): ?bool { return $this->getParam('active'); } + /** + * @return bool|NULL + */ + public function necessary(): ?bool + { + return $this->getParam('necessary'); + } + /** * @param string $code * @@ -81,4 +89,14 @@ public function withActive(bool $active): self { return $this->withParam('active', $active); } + + /** + * @param bool $necessary + * + * @return $this + */ + public function withNecessary(bool $necessary): self + { + return $this->withParam('necessary', $necessary); + } } diff --git a/src/Domain/Category/Event/CategoryCreated.php b/src/Domain/Category/Event/CategoryCreated.php index 394eff73..9112ba56 100644 --- a/src/Domain/Category/Event/CategoryCreated.php +++ b/src/Domain/Category/Event/CategoryCreated.php @@ -16,27 +16,32 @@ final class CategoryCreated extends AbstractDomainEvent private bool $active; + private bool $necessary; + private array $names; /** * @param \App\Domain\Category\ValueObject\CategoryId $categoryId * @param \App\Domain\Category\ValueObject\Code $code * @param bool $active + * @param bool $necessary * @param array $names * * @return static */ - public static function create(CategoryId $categoryId, Code $code, bool $active, array $names): self + public static function create(CategoryId $categoryId, Code $code, bool $active, bool $necessary, array $names): self { $event = self::occur($categoryId->toString(), [ 'code' => $code->value(), 'active' => $active, + 'necessary' => $necessary, 'names' => $names, ]); $event->categoryId = $categoryId; $event->code = $code; $event->active = $active; + $event->necessary = $necessary; $event->names = $names; return $event; @@ -66,6 +71,14 @@ public function active(): bool return $this->active; } + /** + * @return bool + */ + public function necessary(): bool + { + return $this->necessary; + } + /** * @return array */ @@ -82,6 +95,7 @@ protected function reconstituteState(array $parameters): void $this->categoryId = CategoryId::fromUuid($this->aggregateId()->id()); $this->code = Code::fromValue($parameters['code']); $this->active = (bool) $parameters['active']; + $this->necessary = (bool) $parameters['necessary']; $this->names = $parameters['names']; } } diff --git a/src/Domain/Category/Event/CategoryNecessaryChanged.php b/src/Domain/Category/Event/CategoryNecessaryChanged.php new file mode 100644 index 00000000..b6880778 --- /dev/null +++ b/src/Domain/Category/Event/CategoryNecessaryChanged.php @@ -0,0 +1,58 @@ +toString(), [ + 'necessary' => $necessary, + ]); + + $event->categoryId = $categoryId; + $event->necessary = $necessary; + + return $event; + } + + /** + * @return \App\Domain\Category\ValueObject\CategoryId + */ + public function categoryId(): CategoryId + { + return $this->categoryId; + } + + /** + * @return bool + */ + public function necessary(): bool + { + return $this->necessary; + } + + /** + * {@inheritDoc} + */ + protected function reconstituteState(array $parameters): void + { + $this->categoryId = CategoryId::fromUuid($this->aggregateId()->id()); + $this->necessary = (bool) $parameters['necessary']; + } +} diff --git a/src/Infrastructure/Category/Doctrine/Mapping/App.Domain.Category.Category.dcm.xml b/src/Infrastructure/Category/Doctrine/Mapping/App.Domain.Category.Category.dcm.xml index 09f4edc3..6772976f 100644 --- a/src/Infrastructure/Category/Doctrine/Mapping/App.Domain.Category.Category.dcm.xml +++ b/src/Infrastructure/Category/Doctrine/Mapping/App.Domain.Category.Category.dcm.xml @@ -28,6 +28,8 @@ + + diff --git a/src/Infrastructure/Consent/Doctrine/ReadModel/CalculateConsentTotalsPerPeriodQueryHandler.php b/src/Infrastructure/Consent/Doctrine/ReadModel/CalculateConsentTotalsPerPeriodQueryHandler.php index 4e7cb5fa..ab29fb15 100644 --- a/src/Infrastructure/Consent/Doctrine/ReadModel/CalculateConsentTotalsPerPeriodQueryHandler.php +++ b/src/Infrastructure/Consent/Doctrine/ReadModel/CalculateConsentTotalsPerPeriodQueryHandler.php @@ -40,17 +40,16 @@ public function __construct(EntityManagerInterface $em, ViewFactoryInterface $vi public function __invoke(CalculateConsentTotalsPerPeriodQuery $query): array { $sql = " - SELECT c.project_id, COUNT(DISTINCT es.id) AS total, COUNT(DISTINCT c.user_identifier) AS \"unique\" + SELECT p.id, COUNT(DISTINCT es.id) AS total, COUNT(DISTINCT c.user_identifier) AS \"unique\" FROM consent_event_stream es JOIN consent c ON c.id = es.aggregate_id - AND c.project_id IN (:projectIds) - AND es.event_name IN (:eventNames) - WHERE es.created_at BETWEEN :startDate AND :endDate - GROUP BY c.project_id; + JOIN project p ON p.id = c.project_id AND p.id IN (:projectIds) AND p.deleted_at IS NULL + WHERE es.event_name IN (:eventNames) AND es.created_at BETWEEN :startDate AND :endDate + GROUP BY p.id; "; $rsm = new ResultSetMappingBuilder($this->em); - $rsm->addScalarResult('project_id', 'projectId', ProjectId::class); + $rsm->addScalarResult('id', 'projectId', ProjectId::class); $rsm->addScalarResult('total', 'total', 'integer'); $rsm->addScalarResult('unique', 'unique', 'integer'); diff --git a/src/Infrastructure/Consent/Doctrine/ReadModel/ScrollThroughConsentsPerPeriodQueryHandler.php b/src/Infrastructure/Consent/Doctrine/ReadModel/ScrollThroughConsentsPerPeriodQueryHandler.php new file mode 100644 index 00000000..678c8ffc --- /dev/null +++ b/src/Infrastructure/Consent/Doctrine/ReadModel/ScrollThroughConsentsPerPeriodQueryHandler.php @@ -0,0 +1,122 @@ +em = $em; + } + + /** + * @param \App\ReadModel\Consent\ScrollThroughConsentsPerPeriodQuery $query + * + * @return iterable + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function __invoke(ScrollThroughConsentsPerPeriodQuery $query): iterable + { + $totalCount = $this->calculateTotalCount($query); + + foreach (BatchUtils::from($totalCount, $query->batchSize()) as [$limit, $offset]) { + $data = array_map(static fn (array $row) => [ + 'projectId' => $row['projectId'], + 'userIdentifier' => $row['userIdentifier'], + 'consents' => $row['parameters']['consents'] ?? [], + ], $this->fetchBatch($query, $limit, $offset)); + + yield Batch::create($query->batchSize(), $offset, $limit, $data); + } + } + + /** + * @param \App\ReadModel\Consent\ScrollThroughConsentsPerPeriodQuery $query + * @param int $limit + * @param int $offset + * + * @return array + */ + private function fetchBatch(ScrollThroughConsentsPerPeriodQuery $query, int $limit, int $offset): array + { + $sql = " + SELECT p.id, c.user_identifier, es.parameters + FROM consent_event_stream es + JOIN consent c ON c.id = es.aggregate_id + JOIN project p ON p.id = c.project_id AND p.id IN (:projectIds) AND p.deleted_at IS NULL + WHERE es.event_name IN (:eventNames) AND es.created_at BETWEEN :startDate AND :endDate + ORDER BY es.created_at DESC + LIMIT :limit OFFSET :offset + "; + + $rsm = new ResultSetMappingBuilder($this->em); + $rsm->addScalarResult('id', 'projectId', 'string'); + $rsm->addScalarResult('user_identifier', 'userIdentifier', 'string'); + $rsm->addScalarResult('parameters', 'parameters', 'json'); + + return $this->em->createNativeQuery($sql, $rsm) + ->setParameters([ + 'projectIds' => $query->projectIds(), + 'eventNames' => [ + ConsentCreated::class, + ConsentUpdated::class, + ], + 'startDate' => $query->startDate(), + 'endDate' => $query->endDate(), + 'limit' => $limit, + 'offset' => $offset, + ]) + ->getResult(AbstractQuery::HYDRATE_ARRAY); + } + + /** + * @param \App\ReadModel\Consent\ScrollThroughConsentsPerPeriodQuery $query + * + * @return int + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + private function calculateTotalCount(ScrollThroughConsentsPerPeriodQuery $query): int + { + $sql = " + SELECT COUNT(es.id) AS cnt + FROM consent_event_stream es + JOIN consent c ON c.id = es.aggregate_id + JOIN project p ON p.id = c.project_id AND p.id IN (:projectIds) AND p.deleted_at IS NULL + WHERE es.event_name IN (:eventNames) AND es.created_at BETWEEN :startDate AND :endDate + "; + + $rsm = new ResultSetMappingBuilder($this->em); + $rsm->addScalarResult('cnt', 'cnt', 'integer'); + + return $this->em->createNativeQuery($sql, $rsm) + ->setParameters([ + 'projectIds' => $query->projectIds(), + 'eventNames' => [ + ConsentCreated::class, + ConsentUpdated::class, + ], + 'startDate' => $query->startDate(), + 'endDate' => $query->endDate(), + ]) + ->getSingleScalarResult(); + } +} diff --git a/src/Infrastructure/Migration/Doctrine/Version20220714014443.php b/src/Infrastructure/Migration/Doctrine/Version20220714014443.php new file mode 100644 index 00000000..95040e9e --- /dev/null +++ b/src/Infrastructure/Migration/Doctrine/Version20220714014443.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE category ADD necessary BOOLEAN NOT NULL DEFAULT FALSE'); + $this->addSql('ALTER TABLE category ALTER COLUMN necessary DROP DEFAULT'); + } + + /** + * @param \Doctrine\DBAL\Schema\Schema $schema + * + * @return void + */ + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE category DROP necessary'); + } +} diff --git a/src/ReadModel/Category/CategoryView.php b/src/ReadModel/Category/CategoryView.php index bb96cea9..32328527 100644 --- a/src/ReadModel/Category/CategoryView.php +++ b/src/ReadModel/Category/CategoryView.php @@ -23,6 +23,8 @@ final class CategoryView extends AbstractView public bool $active; + public bool $necessary; + /** @var Name[] */ public array $names; @@ -37,6 +39,7 @@ public function jsonSerialize(): array 'deletedAt' => NULL !== $this->deletedAt ? $this->deletedAt->format(DateTimeInterface::ATOM) : NULL, 'code' => $this->code->value(), 'active' => $this->active, + 'necessary' => $this->necessary, 'names' => array_map(static fn (Name $name): string => $name->value(), $this->names), ]; } diff --git a/src/ReadModel/Consent/ScrollThroughConsentsPerPeriodQuery.php b/src/ReadModel/Consent/ScrollThroughConsentsPerPeriodQuery.php new file mode 100644 index 00000000..b9b8de8f --- /dev/null +++ b/src/ReadModel/Consent/ScrollThroughConsentsPerPeriodQuery.php @@ -0,0 +1,51 @@ + $projectIds, + 'start_date' => $startDate, + 'end_date' => $endDate, + ]); + } + + /** + * @return string[] + */ + public function projectIds(): array + { + return $this->getParam('project_ids'); + } + + /** + * @return \DateTimeInterface + */ + public function startDate(): DateTimeInterface + { + return $this->getParam('start_date'); + } + + /** + * @return \DateTimeInterface + */ + public function endDate(): DateTimeInterface + { + return $this->getParam('end_date'); + } +} diff --git a/src/Web/AdminModule/CookieModule/Control/CategoryForm/CategoryFormControl.php b/src/Web/AdminModule/CookieModule/Control/CategoryForm/CategoryFormControl.php index e16b5a69..e2c2484d 100644 --- a/src/Web/AdminModule/CookieModule/Control/CategoryForm/CategoryFormControl.php +++ b/src/Web/AdminModule/CookieModule/Control/CategoryForm/CategoryFormControl.php @@ -67,6 +67,8 @@ protected function createComponentForm(): Form $form->addCheckbox('active', 'active.field') ->setDefaultValue(TRUE); + $form->addCheckbox('necessary', 'necessary.field'); + $namesContainer = $form->addContainer('names'); foreach ($this->validLocalesProvider->getValidLocales() as $locale) { @@ -82,6 +84,7 @@ protected function createComponentForm(): Form $form->setDefaults([ 'code' => $this->default->code->value(), 'active' => $this->default->active, + 'necessary' => $this->default->necessary, 'names' => array_map(static fn (Name $name): string => $name->value(), $this->default->names), ]); } @@ -108,6 +111,7 @@ private function saveCategory(Form $form): void $values->code, (array) $values->names, $values->active, + $values->necessary, $categoryId->toString() ); } else { @@ -115,6 +119,7 @@ private function saveCategory(Form $form): void $command = UpdateCategoryCommand::create($categoryId->toString()) ->withCode($values->code) ->withActive($values->active) + ->withNecessary($values->necessary) ->withNames((array) $values->names); } diff --git a/translations/App_Web_AdminModule_CookieModule_Control_CategoryForm_CategoryFormControl.cs.neon b/translations/App_Web_AdminModule_CookieModule_Control_CategoryForm_CategoryFormControl.cs.neon index a92cd346..fabbe550 100644 --- a/translations/App_Web_AdminModule_CookieModule_Control_CategoryForm_CategoryFormControl.cs.neon +++ b/translations/App_Web_AdminModule_CookieModule_Control_CategoryForm_CategoryFormControl.cs.neon @@ -8,6 +8,9 @@ code: active: field: Aktivní +necessary: + field: Povinná kategorie + name: field: 'Název - %name%' required: Vyplňte prosím název.