diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 7f8c7f4..24e38e5 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -11,11 +11,6 @@ - - - - - diff --git a/src/Application/Cache/CacheInvalidator.php b/src/Application/Cache/CacheInvalidator.php index 5528a98..601ff5b 100644 --- a/src/Application/Cache/CacheInvalidator.php +++ b/src/Application/Cache/CacheInvalidator.php @@ -11,6 +11,9 @@ use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException; +/** + * @psalm-import-type Reason from CacheInvalidated + */ final class CacheInvalidator { public function __construct( @@ -21,7 +24,10 @@ public function __construct( ) { } - public function invalidateFully(): bool + /** + * @psalm-param Reason $reason + */ + public function invalidateFully(string $reason = CacheInvalidated::ENFORCED): bool { $result = $this->cache->clear(); if (!$result) { @@ -30,16 +36,17 @@ public function invalidateFully(): bool } $this->logger->info('Successful degree program full cache invalidation.'); - $this->eventDispatcher->dispatch(CacheInvalidated::fully()); + $this->eventDispatcher->dispatch(CacheInvalidated::fully($reason)); return true; } /** * @psalm-param array $ids + * @psalm-param Reason $reason * * @throws InvalidArgumentException */ - public function invalidatePartially(array $ids): bool + public function invalidatePartially(array $ids, string $reason = CacheInvalidated::ENFORCED): bool { if (count($ids) === 0) { $this->logger->debug( @@ -81,7 +88,7 @@ public function invalidatePartially(array $ids): bool implode(', ', $ids) ) ); - $this->eventDispatcher->dispatch(CacheInvalidated::partially($ids)); + $this->eventDispatcher->dispatch(CacheInvalidated::partially($ids, $reason)); return true; } diff --git a/src/Application/DegreeProgramViewTranslated.php b/src/Application/DegreeProgramViewTranslated.php index e1d50cd..f119eb3 100644 --- a/src/Application/DegreeProgramViewTranslated.php +++ b/src/Application/DegreeProgramViewTranslated.php @@ -79,11 +79,15 @@ * } * @psalm-type DegreeProgramViewTranslatedArrayType = DegreeProgramTranslation & array{ * id: int, + * date: string, + * modified: string, * translations: array, * } */ final class DegreeProgramViewTranslated implements JsonSerializable { + public const DATE = 'date'; + public const MODIFIED = 'modified'; public const LINK = 'link'; public const LANG = 'lang'; public const ADMISSION_REQUIREMENT_LINK = 'admission_requirement_link'; @@ -94,6 +98,8 @@ final class DegreeProgramViewTranslated implements JsonSerializable public function __construct( private DegreeProgramId $id, + private string $date, + private string $modified, private string $link, private string $slug, /** @@ -160,6 +166,8 @@ public static function empty(int $id, string $languageCode): self { return new self( DegreeProgramId::fromInt($id), + date: '', + modified: '', link: '', slug: '', lang: $languageCode, @@ -220,6 +228,8 @@ public static function empty(int $id, string $languageCode): self /** * @psalm-param DegreeProgramTranslation & array{ * id: int | numeric-string, + * date: string, + * modified: string, * translations?: array, * } $data * @@ -229,6 +239,8 @@ public static function fromArray(array $data): self { $main = new self( id: DegreeProgramId::fromInt((int) $data[DegreeProgram::ID]), + date: $data[self::DATE] ?? '', + modified: $data[self::MODIFIED] ?? '', link: $data[self::LINK], slug: $data[DegreeProgram::SLUG], lang: $data[self::LANG], @@ -285,12 +297,14 @@ public static function fromArray(array $data): self campoKeys: CampoKeys::fromArray($data[DegreeProgram::CAMPO_KEYS] ?? []), ); - if (empty($data[self::TRANSLATIONS])) { + if (!isset($data[self::TRANSLATIONS]) || count($data[self::TRANSLATIONS]) === 0) { return $main; } foreach ($data[self::TRANSLATIONS] as $translationData) { $translationData[DegreeProgram::ID] = $data[DegreeProgram::ID]; + $translationData[self::DATE] = $data[self::DATE]; + $translationData[self::MODIFIED] = $data[self::MODIFIED]; $main = $main->withTranslation(self::fromArray($translationData), $translationData[self::LANG]); } @@ -304,6 +318,8 @@ public function asArray(): array { return [ DegreeProgram::ID => $this->id->asInt(), + self::DATE => $this->date, + self::MODIFIED => $this->modified, self::LINK => $this->link, DegreeProgram::SLUG => $this->slug, self::LANG => $this->lang, @@ -408,7 +424,12 @@ private function translationsAsArray(): array { return array_map(static function (DegreeProgramViewTranslated $view): array { $result = $view->asArray(); - unset($result[DegreeProgram::ID], $result[self::TRANSLATIONS]); + unset( + $result[DegreeProgram::ID], + $result[self::DATE], + $result[self::MODIFIED], + $result[self::TRANSLATIONS] + ); return $result; }, $this->translations); @@ -419,6 +440,16 @@ public function id(): int return $this->id->asInt(); } + public function date(): string + { + return $this->date; + } + + public function modified(): string + { + return $this->modified; + } + public function link(): string { return $this->link; diff --git a/src/Application/Event/CacheInvalidated.php b/src/Application/Event/CacheInvalidated.php index c94b7da..a333745 100644 --- a/src/Application/Event/CacheInvalidated.php +++ b/src/Application/Event/CacheInvalidated.php @@ -6,30 +6,41 @@ use Stringable; +/** + * @psalm-type Reason = self::ENFORCED | self::DATA_CHANGED + */ final class CacheInvalidated implements Stringable { public const NAME = 'degree_program_cache_invalidated'; + public const ENFORCED = 'enforced'; + public const DATA_CHANGED = 'data_changed'; /** * @param array $ids + * @param Reason $reason */ private function __construct( private bool $isFully, private array $ids, + private string $reason, ) { } - public static function fully(): self + /** + * @param Reason $reason + */ + public static function fully(string $reason): self { - return new self(true, []); + return new self(true, [], $reason); } /** * @param array $ids + * @param Reason $reason */ - public static function partially(array $ids): self + public static function partially(array $ids, string $reason): self { - return new self(false, $ids); + return new self(false, $ids, $reason); } public function isFully(): bool @@ -45,6 +56,14 @@ public function ids(): array return $this->ids; } + /** + * @return Reason + */ + public function reason(): string + { + return $this->reason; + } + public function __toString(): string { return self::NAME; diff --git a/src/Infrastructure/Repository/TimestampRepository.php b/src/Infrastructure/Repository/TimestampRepository.php new file mode 100644 index 0000000..7affc30 --- /dev/null +++ b/src/Infrastructure/Repository/TimestampRepository.php @@ -0,0 +1,49 @@ +asInt()); + + return $postDateTime instanceof DateTimeInterface ? $postDateTime : null; + } + + /** + * The custom field is updated when the degree program or related settings or terms are updated. + * If the custom field does not exist, + * we fall back to the core WordPress "post modified" property. + */ + public function modified(DegreeProgramId $id): ?DateTimeInterface + { + $timestamp = (int) get_post_meta($id->asInt(), self::MODIFIED_META_KEY, true); + + if ($timestamp < 1) { + $timestamp = get_post_timestamp($id->asInt(), 'modified'); + } + + if (!is_int($timestamp)) { + return null; + } + + $dateTime = new DateTimeImmutable(); + $dateTime = $dateTime->setTimestamp($timestamp); + + return $dateTime->setTimezone(wp_timezone()); + } + + public function updateModified(DegreeProgramId $id): void + { + update_post_meta($id->asInt(), self::MODIFIED_META_KEY, time()); + } +} diff --git a/src/Infrastructure/Repository/WordPressDatabaseDegreeProgramViewRepository.php b/src/Infrastructure/Repository/WordPressDatabaseDegreeProgramViewRepository.php index 7d32c54..efdb713 100644 --- a/src/Infrastructure/Repository/WordPressDatabaseDegreeProgramViewRepository.php +++ b/src/Infrastructure/Repository/WordPressDatabaseDegreeProgramViewRepository.php @@ -4,6 +4,7 @@ namespace Fau\DegreeProgram\Common\Infrastructure\Repository; +use DateTimeInterface; use Fau\DegreeProgram\Common\Application\AdmissionRequirementsTranslated; use Fau\DegreeProgram\Common\Application\AdmissionRequirementTranslated; use Fau\DegreeProgram\Common\Application\ConditionalFieldsFilter; @@ -33,11 +34,14 @@ */ final class WordPressDatabaseDegreeProgramViewRepository implements DegreeProgramViewRepository { + private const DATE_TIME_FORMAT = DateTimeInterface::RFC3339; + public function __construct( private DegreeProgramRepository $degreeProgramRepository, private HtmlDegreeProgramSanitizer $htmlContentSanitizer, private ConditionalFieldsFilter $conditionalFieldsFilter, private FacultyRepository $facultyRepository, + private TimestampRepository $timestampRepository, ) { } @@ -100,6 +104,8 @@ private function translateDegreeProgram( return new DegreeProgramViewTranslated( id: $raw->id(), + date: (string) $this->timestampRepository->created($raw->id())?->format(self::DATE_TIME_FORMAT), + modified: (string) $this->timestampRepository->modified($raw->id())?->format(self::DATE_TIME_FORMAT), link: $this->link( $raw->id()->asInt(), $raw->slug(), diff --git a/src/Infrastructure/RestApi/TranslatedDegreeProgramController.php b/src/Infrastructure/RestApi/TranslatedDegreeProgramController.php index 5248144..848a2a9 100644 --- a/src/Infrastructure/RestApi/TranslatedDegreeProgramController.php +++ b/src/Infrastructure/RestApi/TranslatedDegreeProgramController.php @@ -315,6 +315,24 @@ public function get_item_schema(): array ), 'type' => 'integer', ], + DegreeProgramViewTranslated::DATE => [ + 'description' => _x( + 'The date the degree program was created.', + 'rest_api: schema item description', + 'fau-degree-program-common' + ), + 'type' => 'string', + 'format' => 'date-time', + ], + DegreeProgramViewTranslated::MODIFIED => [ + 'description' => _x( + 'The date the degree program was last modified.', + 'rest_api: schema item description', + 'fau-degree-program-common' + ), + 'type' => 'string', + 'format' => 'date-time', + ], DegreeProgram::FEATURED_IMAGE => [ 'description' => _x( 'Feature image.',