diff --git a/migrations/Version20260526175316.php b/migrations/Version20260526175316.php new file mode 100644 index 0000000000..b87d11d32c --- /dev/null +++ b/migrations/Version20260526175316.php @@ -0,0 +1,51 @@ +addSql('ALTER TABLE entry ADD last_boosted_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT to_timestamp(0)::date'); + $this->addSql('CREATE INDEX entry_last_boosted_at_idx ON entry (last_boosted_at)'); + $this->addSql('ALTER TABLE entry_comment ADD last_boosted_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT to_timestamp(0)::date'); + $this->addSql('CREATE INDEX entry_comment_last_boosted_at_idx ON entry_comment (last_boosted_at)'); + $this->addSql('ALTER TABLE post ADD last_boosted_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT to_timestamp(0)::date'); + $this->addSql('CREATE INDEX post_last_boosted_at_idx ON post (last_boosted_at)'); + $this->addSql('ALTER TABLE post_comment ADD last_boosted_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT to_timestamp(0)::date'); + $this->addSql('CREATE INDEX post_comment_last_boosted_at_idx ON post_comment (last_boosted_at)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX entry_last_boosted_at_idx'); + $this->addSql('ALTER TABLE entry DROP last_boosted_at'); + $this->addSql('DROP INDEX entry_comment_last_boosted_at_idx'); + $this->addSql('ALTER TABLE entry_comment DROP last_boosted_at'); + $this->addSql('DROP INDEX post_last_boosted_at_idx'); + $this->addSql('ALTER TABLE post DROP last_boosted_at'); + $this->addSql('DROP INDEX post_comment_last_boosted_at_idx'); + $this->addSql('ALTER TABLE post_comment DROP last_boosted_at'); + } + + public function postUp(Schema $schema): void + { + $this->connection->transactional(function (): void { + $sqlTpl = 'UPDATE $e SET last_boosted_at = greatest((SELECT $e_vote.created_at FROM $e_vote WHERE $e_vote.$fk = $e.id ORDER BY $e_vote.created_at DESC LIMIT 1), created_at);'; + $this->connection->executeStatement(str_replace('$e', 'entry', str_replace('$fk', 'entry_id', $sqlTpl))); + $this->connection->executeStatement(str_replace('$e', 'entry_comment', str_replace('$fk', 'comment_id', $sqlTpl))); + $this->connection->executeStatement(str_replace('$e', 'post', str_replace('$fk', 'post_id', $sqlTpl))); + $this->connection->executeStatement(str_replace('$e', 'post_comment', str_replace('$fk', 'comment_id', $sqlTpl))); + }); + } +} diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000000..c7e8774aa0 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/src/Entity/Contracts/VotableInterface.php b/src/Entity/Contracts/VotableInterface.php index 42824bdb30..c5cd379911 100644 --- a/src/Entity/Contracts/VotableInterface.php +++ b/src/Entity/Contracts/VotableInterface.php @@ -38,4 +38,9 @@ public function countVotes(): int; public function getUserChoice(User $user): int; public function getUserVote(User $user): ?Vote; + + /** + * @psalm-external-mutation-free + */ + public function updateLastBoostDate(): self; } diff --git a/src/Entity/Entry.php b/src/Entity/Entry.php index accef09f89..ed041d956f 100644 --- a/src/Entity/Entry.php +++ b/src/Entity/Entry.php @@ -43,6 +43,7 @@ #[Index(columns: ['comment_count'], name: 'entry_comment_count_idx')] #[Index(columns: ['created_at'], name: 'entry_created_at_idx')] #[Index(columns: ['last_active'], name: 'entry_last_active_at_idx')] +#[Index(columns: ['last_boosted_at'], name: 'entry_last_boosted_at_idx')] #[Index(columns: ['body_ts'], name: 'entry_body_ts_idx')] #[Index(columns: ['title_ts'], name: 'entry_title_ts_idx')] class Entry implements VotableInterface, CommentInterface, DomainInterface, VisibilityInterface, RankingInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface, ContentVisibilityInterface @@ -173,8 +174,10 @@ public function __construct( $user->addEntry($this); $this->createdAtTraitConstruct(); - $this->updateLastActive(); + + /* @psalm-suppress UninitializedProperty */ + $this->lastBoostedAt = $this->createdAt; } public function updateLastActive(): void diff --git a/src/Entity/EntryComment.php b/src/Entity/EntryComment.php index c642b1137b..471030a1bc 100644 --- a/src/Entity/EntryComment.php +++ b/src/Entity/EntryComment.php @@ -37,6 +37,7 @@ #[Index(columns: ['up_votes'], name: 'entry_comment_up_votes_idx')] #[Index(columns: ['last_active'], name: 'entry_comment_last_active_at_idx')] #[Index(columns: ['created_at'], name: 'entry_comment_created_at_idx')] +#[Index(columns: ['last_boosted_at'], name: 'entry_comment_last_boosted_at_idx')] #[Index(columns: ['body_ts'], name: 'entry_comment_body_ts_idx')] class EntryComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface { @@ -127,6 +128,9 @@ public function __construct( $this->createdAtTraitConstruct(); $this->updateLastActive(); + + /* @psalm-suppress UninitializedProperty */ + $this->lastBoostedAt = $this->createdAt; } public function updateLastActive(): void diff --git a/src/Entity/Post.php b/src/Entity/Post.php index c7e1f00693..3d813d6618 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -40,6 +40,7 @@ #[Index(columns: ['comment_count'], name: 'post_comment_count_idx')] #[Index(columns: ['created_at'], name: 'post_created_at_idx')] #[Index(columns: ['last_active'], name: 'post_last_active_at_idx')] +#[Index(columns: ['last_boosted_at'], name: 'post_last_boosted_at_idx')] #[Index(columns: ['body_ts'], name: 'post_body_ts_idx')] class Post implements VotableInterface, CommentInterface, VisibilityInterface, RankingInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface { @@ -127,6 +128,8 @@ public function __construct( $this->createdAtTraitConstruct(); $this->updateLastActive(); + + $this->lastBoostedAt = $this->createdAt; } public function updateLastActive(): void diff --git a/src/Entity/PostComment.php b/src/Entity/PostComment.php index 2283beef7d..16c87a562c 100644 --- a/src/Entity/PostComment.php +++ b/src/Entity/PostComment.php @@ -37,6 +37,7 @@ #[Index(columns: ['up_votes'], name: 'post_comment_up_votes_idx')] #[Index(columns: ['last_active'], name: 'post_comment_last_active_at_idx')] #[Index(columns: ['created_at'], name: 'post_comment_created_at_idx')] +#[Index(columns: ['last_boosted_at'], name: 'post_comment_last_boosted_at_idx')] #[Index(columns: ['body_ts'], name: 'post_comment_body_ts_idx')] class PostComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface { @@ -123,6 +124,8 @@ public function __construct(string $body, ?Post $post, User $user, ?PostComment $this->createdAtTraitConstruct(); $this->updateLastActive(); + + $this->lastBoostedAt = $this->createdAt; } public function updateLastActive(): void diff --git a/src/Entity/Traits/VotableTrait.php b/src/Entity/Traits/VotableTrait.php index f553604dbc..a51222cc3a 100644 --- a/src/Entity/Traits/VotableTrait.php +++ b/src/Entity/Traits/VotableTrait.php @@ -28,6 +28,9 @@ trait VotableTrait #[Column(type: 'integer', nullable: true)] public ?int $apShareCount = null; + #[Column(type: 'datetimetz_immutable', nullable: false)] + public ?\DateTimeImmutable $lastBoostedAt; + public function countUpVotes(): int { return $this->apShareCount ?? $this->upVotes; @@ -66,6 +69,13 @@ public function updateVoteCounts(): self return $this; } + public function updateLastBoostDate(): self + { + $this->lastBoostedAt = new \DateTimeImmutable(); + + return $this; + } + public function getUpVotes(): Collection { $this->votes->get(-1); diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index a3d9703c4c..19bf7fa3a3 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -461,17 +461,17 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr // only join domain if we are explicitly looking at one $domainJoin = $criteria->domain ? 'LEFT JOIN domain d ON d.id = c.domain_id' : ''; - $entrySql = "SELECT c.id, 'entry' as type, c.type as content_type, c.created_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM entry c + $entrySql = "SELECT c.id, 'entry' as type, c.type as content_type, c.created_at, c.last_boosted_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM entry c LEFT JOIN magazine m ON c.magazine_id = m.id $domainJoin $entryWhere"; - $postSql = "SELECT c.id, 'post' as type, 'microblog' as content_type, c.created_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM post c + $postSql = "SELECT c.id, 'post' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM post c LEFT JOIN magazine m ON c.magazine_id = m.id $postWhere"; - $entryCommentSql = "SELECT c.id, 'entry_comment' as type, 'microblog' as content_type, c.created_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM entry_comment c + $entryCommentSql = "SELECT c.id, 'entry_comment' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM entry_comment c LEFT JOIN magazine m ON c.magazine_id = m.id $entryCommentWhere"; - $postCommentSql = "SELECT c.id, 'post_comment' as type, 'microblog' as content_type, c.created_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM post_comment c + $postCommentSql = "SELECT c.id, 'post_comment' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM post_comment c LEFT JOIN magazine m ON c.magazine_id = m.id $postCommentWhere"; @@ -523,7 +523,7 @@ private function getCursorFieldFromCriteria(Criteria $criteria): string Criteria::SORT_HOT => 'ranking', Criteria::SORT_COMMENTED => 'commentCount', Criteria::SORT_ACTIVE => 'lastActive', - default => 'createdAt', + default => $criteria->includeBoosts ? 'lastBoostedAt' : 'createdAt', }; } @@ -534,8 +534,8 @@ private function getCursorWhereFromCriteria(Criteria $criteria): string Criteria::SORT_HOT => 'c.ranking < :cursor', Criteria::SORT_COMMENTED => 'c.comment_count < :cursor', Criteria::SORT_ACTIVE => 'c.last_active < :cursor', - Criteria::SORT_OLD => 'c.created_at > :cursor', - default => 'c.created_at < :cursor', + Criteria::SORT_OLD => $criteria->includeBoosts ? 'c.last_boosted_at > :cursor' : 'c.created_at > :cursor', + default => $criteria->includeBoosts ? 'c.last_boosted_at < :cursor' : 'c.created_at < :cursor', }; } @@ -546,8 +546,8 @@ private function getCursorWhereInvertedFromCriteria(Criteria $criteria): string Criteria::SORT_HOT => 'c.ranking > :cursor', Criteria::SORT_COMMENTED => 'c.comment_count > :cursor', Criteria::SORT_ACTIVE => 'c.last_active > :cursor', - Criteria::SORT_OLD => 'c.created_at < :cursor', - default => 'c.created_at >= :cursor', + Criteria::SORT_OLD => $criteria->includeBoosts ? 'c.last_boosted_at < :cursor' : 'c.created_at < :cursor', + default => $criteria->includeBoosts ? 'c.last_boosted_at >= :cursor' : 'c.created_at >= :cursor', }; } @@ -608,11 +608,11 @@ private function getOrderings(Criteria $criteria): array switch ($criteria->sortOption) { case Criteria::SORT_OLD: - $orderings[] = 'created_at ASC'; + $orderings[] = $criteria->includeBoosts ? 'last_boosted_at ASC' : 'created_at ASC'; break; case Criteria::SORT_NEW: default: - $orderings[] = 'created_at DESC'; + $orderings[] = $criteria->includeBoosts ? 'last_boosted_at DESC' : 'created_at DESC'; } return $orderings; diff --git a/src/Service/VoteManager.php b/src/Service/VoteManager.php index 0be618a201..36f26eb9d2 100644 --- a/src/Service/VoteManager.php +++ b/src/Service/VoteManager.php @@ -64,6 +64,7 @@ public function vote(int $choice, VotableInterface $votable, User $user, $rateLi if (VotableInterface::VOTE_UP === $choice && null !== $votable->apShareCount) { ++$votable->apShareCount; + $votable->updateLastBoostDate(); } elseif (VotableInterface::VOTE_DOWN === $choice && null !== $votable->apDislikeCount) { ++$votable->apDislikeCount; } @@ -132,6 +133,7 @@ public function upvote(VotableInterface $votable, User $user): Vote $votable->updateVoteCounts(); + $votable->updateLastBoostDate(); $votable->lastActive = new \DateTime(); if ($votable instanceof PostComment) {