Skip to content

Commit

Permalink
fix: best answer counts not updated on post/discussion delete (#87)
Browse files Browse the repository at this point in the history
* chore: create tests to highlight the defect (failing)

* fix: listen for deletion, reduce ba count

* Apply fixes from StyleCI

* chore: enable backend testing

* feat: provide cli command to re-sync all user ba counts

* Apply fixes from StyleCI

---------

Co-authored-by: StyleCI Bot <[email protected]>
  • Loading branch information
imorland and StyleCIBot authored Oct 29, 2023
1 parent 192b198 commit 022f775
Show file tree
Hide file tree
Showing 17 changed files with 619 additions and 40 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
run:
uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@main
with:
enable_backend_testing: false
enable_backend_testing: true
enable_phpstan: true

backend_directory: .
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ js/dist
js/node_modules
vendor
composer.lock
.phpunit.result.cache
26 changes: 22 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,36 @@
},
"flarum-cli": {
"modules": {
"githubActions": true
"githubActions": true,
"backendTesting": true
}
}
},
"scripts": {
"analyse:phpstan": "phpstan analyse",
"clear-cache:phpstan": "phpstan clear-result-cache"
"clear-cache:phpstan": "phpstan clear-result-cache",
"test": [
"@test:unit",
"@test:integration"
],
"test:unit": "phpunit -c tests/phpunit.unit.xml",
"test:integration": "phpunit -c tests/phpunit.integration.xml",
"test:setup": "@php tests/integration/setup.php"
},
"scripts-descriptions": {
"analyse:phpstan": "Run static analysis"
"analyse:phpstan": "Run static analysis",
"test": "Runs all tests.",
"test:unit": "Runs all unit tests.",
"test:integration": "Runs all integration tests.",
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
},
"require-dev": {
"flarum/phpstan": "^1.8"
"flarum/phpstan": "^1.8",
"flarum/testing": "^1.0.0"
},
"autoload-dev": {
"psr-4": {
"FoF\\BestAnswer\\Tests\\": "tests/"
}
}
}
1 change: 1 addition & 0 deletions extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@

(new Extend\Console())
->command(Console\NotifyCommand::class)
->command(Console\UpdateBestAnswerCounts::class)
->schedule(Console\NotifyCommand::class, Console\NotifySchedule::class),

(new Extend\Filter(DiscussionFilterer::class))
Expand Down
27 changes: 27 additions & 0 deletions src/BestAnswerRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,31 @@ public function tagEnabledForBestAnswer(Discussion $discussion): bool

return $enabled;
}

/**
* Calculate the number of best answers for a user.
* This is used when `best_answer_count` is `null` on the user, usually because either the user
* has not already been awarded any best answers, or the extension was updated to include this feature
* and the user has not yet been serialized.
*
* @param User $user
*
* @return int
*/
public function calculateBestAnswersForUser(User $user): int
{
$count = Discussion::whereNotNull('best_answer_post_id')
->leftJoin('posts', 'posts.id', '=', 'discussions.best_answer_post_id')
->where('posts.user_id', $user->id)
->count();

// Use a standalone query and not attribute update+save because otherwise data added by extensions
// with Extend\ApiController::prepareDataForSerialization() ends up being added to the SQL UPDATE clause,
// and breaks Flarum since those are often not real columns
$user->newQuery()
->where('id', $user->id)
->update(['best_answer_count' => $count]);

return $count;
}
}
18 changes: 13 additions & 5 deletions src/Console/NotifySchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,31 @@

class NotifySchedule
{
public function __invoke(Event $event)
/**
* @var SettingsRepositoryInterface
*/
public $settings;

public function __construct(SettingsRepositoryInterface $settings)
{
$settings = resolve(SettingsRepositoryInterface::class);
$this->settings = $settings;
}

public function __invoke(Event $event)
{
$event->hourly()
->withoutOverlapping();

if ((bool) $settings->get('fof-best-answer.schedule_on_one_server')) {
if ((bool) $this->settings->get('fof-best-answer.schedule_on_one_server')) {
$event->onOneServer();
}

if ((bool) $settings->get('fof-best-answer.stop_overnight')) {
if ((bool) $this->settings->get('fof-best-answer.stop_overnight')) {
$event->between('8:00', '21:00');
//TODO expose times back to config options
}

if ((bool) $settings->get('fof-best-answer.store_log_output')) {
if ((bool) $this->settings->get('fof-best-answer.store_log_output')) {
$paths = resolve(Paths::class);
$event->appendOutputTo($paths->storage.(DIRECTORY_SEPARATOR.'logs'.DIRECTORY_SEPARATOR.'fof-best-answer.log'));
}
Expand Down
62 changes: 62 additions & 0 deletions src/Console/UpdateBestAnswerCounts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/*
* This file is part of fof/best-answer.
*
* Copyright (c) FriendsOfFlarum.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace FoF\BestAnswer\Console;

use Flarum\User\User;
use FoF\BestAnswer\BestAnswerRepository;
use Illuminate\Console\Command;

class UpdateBestAnswerCounts extends Command
{
/**
* @var BestAnswerRepository
*/
public $bestAnswers;

public function __construct(BestAnswerRepository $bestAnswers)
{
parent::__construct();
$this->bestAnswers = $bestAnswers;
}

/**
* @var string
*/
protected $signature = 'fof:best-answer:update-counts';

/**
* @var string
*/
protected $description = 'Update best answer counts for all users';

public function handle()
{
$this->info('Updating best answer counts...');

$userCount = User::count();

$bar = $this->output->createProgressBar($userCount);

User::query()->chunkById(100, function ($users) use ($bar) {
foreach ($users as $user) {
$user->best_answer_count = $this->bestAnswers->calculateBestAnswersForUser($user);
$user->save();

$bar->advance();
}
});

$bar->finish();

$this->info('Done!');
}
}
39 changes: 39 additions & 0 deletions src/Listeners/RecalculateBestAnswerCounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

namespace FoF\BestAnswer\Listeners;

use Flarum\Discussion\Event\Deleting as DiscussionDeleting;
use Flarum\Post\Event\Deleting as PostDeleting;
use Flarum\Post\Post;
use FoF\BestAnswer\Events\BestAnswerSet;
use FoF\BestAnswer\Events\BestAnswerUnset;
use Illuminate\Contracts\Events\Dispatcher;
Expand All @@ -21,6 +24,8 @@ public function subscribe(Dispatcher $events)
{
$events->listen(BestAnswerSet::class, [$this, 'addBestAnswerCount']);
$events->listen(BestAnswerUnset::class, [$this, 'subtractBestAnswerCount']);
$events->listen(PostDeleting::class, [$this, 'handlePostDeletion']);
$events->listen(DiscussionDeleting::class, [$this, 'handleDiscussionDeletion']);
}

public function addBestAnswerCount(BestAnswerSet $event): void
Expand All @@ -42,4 +47,38 @@ public function subtractBestAnswerCount(BestAnswerUnset $event): void
$author->save();
}
}

public function handlePostDeletion(PostDeleting $event): void
{
$post = $event->post;

if ($post->discussion->best_answer_post_id === $post->id) {
$post->discussion->best_answer_post_id = null;
$post->discussion->best_answer_user_id = null;
$post->discussion->best_answer_set_at = null;
$post->discussion->save();

$author = $post->user;

if ($author) {
$author->best_answer_count--;
$author->save();
}
}
}

public function handleDiscussionDeletion(DiscussionDeleting $event): void
{
$discussion = $event->discussion;

if ($discussion->best_answer_post_id) {
$post = Post::find($discussion->best_answer_post_id);
$author = $post->user;

if ($author) {
$author->best_answer_count--;
$author->save();
}
}
}
}
4 changes: 2 additions & 2 deletions src/Listeners/SaveBestAnswerToDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public function handle(Saving $event)
$this->notifications->delete(new SelectBestAnswerBlueprint($discussion));
}

private function removeBestAnswer(Discussion $discussion, User $actor): void
protected function removeBestAnswer(Discussion $discussion, User $actor): void
{
/** @var Post|null $post */
$post = $discussion->bestAnswerPost;
Expand All @@ -112,7 +112,7 @@ private function removeBestAnswer(Discussion $discussion, User $actor): void
});
}

private function setBestAnswer(Discussion $discussion, User $actor, int $id): void
protected function setBestAnswer(Discussion $discussion, User $actor, int $id): void
{
/** @var Post|null $post */
$post = $discussion->posts()->find($id);
Expand Down
38 changes: 10 additions & 28 deletions src/UserBestAnswerCount.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,42 +12,24 @@
namespace FoF\BestAnswer;

use Flarum\Api\Serializer\UserSerializer;
use Flarum\Discussion\Discussion;
use Flarum\User\User;

class UserBestAnswerCount
{
public function __invoke(UserSerializer $serializer, User $user, array $attributes): array
{
$attributes['bestAnswerCount'] = $user->best_answer_count ?? $this->calculateBestAnswersForUser($user);

return $attributes;
}

/**
* Calculate the number of best answers for a user.
* This is used when `best_answer_count` is `null` on the user, usually because either the user
* has not already been awarded any best answers, or the extension was updated to include this feature
* and the user has not yet been serialized.
*
* @param User $user
*
* @return int
* @var BestAnswerRepository
*/
private function calculateBestAnswersForUser(User $user): int
public $bestAnswers;

public function __construct(BestAnswerRepository $bestAnswers)
{
$count = Discussion::whereNotNull('best_answer_post_id')
->leftJoin('posts', 'posts.id', '=', 'discussions.best_answer_post_id')
->where('posts.user_id', $user->id)
->count();
$this->bestAnswers = $bestAnswers;
}

// Use a standalone query and not attribute update+save because otherwise data added by extensions
// with Extend\ApiController::prepareDataForSerialization() ends up being added to the SQL UPDATE clause,
// and breaks Flarum since those are often not real columns
$user->newQuery()
->where('id', $user->id)
->update(['best_answer_count' => $count]);
public function __invoke(UserSerializer $serializer, User $user, array $attributes): array
{
$attributes['bestAnswerCount'] = $user->best_answer_count ?? $this->bestAnswers->calculateBestAnswersForUser($user);

return $count;
return $attributes;
}
}
Empty file added tests/fixtures/.gitkeep
Empty file.
Loading

0 comments on commit 022f775

Please sign in to comment.