Test Failed
Push — main ( aaf533...e1db50 )
by Paul
08:45
created

ReviewController::onEditReview()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 13
c 1
b 1
f 0
dl 0
loc 19
ccs 0
cts 11
cp 0
rs 8.4444
cc 8
nc 6
nop 3
crap 72
1
<?php
2
3
namespace GeminiLabs\SiteReviews\Controllers;
4
5
use GeminiLabs\SiteReviews\Commands\AssignPosts;
6
use GeminiLabs\SiteReviews\Commands\AssignTerms;
7
use GeminiLabs\SiteReviews\Commands\AssignUsers;
8
use GeminiLabs\SiteReviews\Commands\CreateReview;
9
use GeminiLabs\SiteReviews\Commands\ToggleStatus;
10
use GeminiLabs\SiteReviews\Commands\UnassignPosts;
11
use GeminiLabs\SiteReviews\Commands\UnassignTerms;
12
use GeminiLabs\SiteReviews\Commands\UnassignUsers;
13
use GeminiLabs\SiteReviews\Database;
14
use GeminiLabs\SiteReviews\Database\Cache;
15
use GeminiLabs\SiteReviews\Database\CountManager;
16
use GeminiLabs\SiteReviews\Database\Query;
17
use GeminiLabs\SiteReviews\Database\ReviewManager;
18
use GeminiLabs\SiteReviews\Defaults\RatingDefaults;
19
use GeminiLabs\SiteReviews\Helper;
20
use GeminiLabs\SiteReviews\Helpers\Arr;
21
use GeminiLabs\SiteReviews\Helpers\Cast;
22
use GeminiLabs\SiteReviews\Metaboxes\ResponseMetabox;
23
use GeminiLabs\SiteReviews\Modules\Avatar;
24
use GeminiLabs\SiteReviews\Modules\Html\ReviewHtml;
25
use GeminiLabs\SiteReviews\Modules\Queue;
26
use GeminiLabs\SiteReviews\Request;
27
use GeminiLabs\SiteReviews\Review;
28
29
class ReviewController extends AbstractController
30
{
31
    /**
32
     * @param \WP_Post[] $posts
33
     *
34
     * @return \WP_Post[]
35
     *
36
     * @filter the_posts
37
     */
38
    public function filterPostsToCacheReviews(array $posts): array
39
    {
40
        $reviews = array_filter($posts, fn ($post) => glsr()->post_type === $post->post_type);
41
        if ($postIds = wp_list_pluck($reviews, 'ID')) {
42
            glsr(Query::class)->reviews([], $postIds); // this caches the associated Review objects
43
        }
44
        return $posts;
45
    }
46
47
    /**
48
     * @filter wp_insert_post_data
49
     */
50
    public function filterReviewPostData(array $data, array $sanitized): array
51
    {
52
        if (empty($sanitized['ID']) || empty($sanitized['action']) || glsr()->post_type !== Arr::get($sanitized, 'post_type')) {
53
            return $data;
54
        }
55
        if (!empty(filter_input(INPUT_GET, 'bulk_edit'))) {
56
            if (is_numeric(filter_input(INPUT_GET, 'post_author'))) {
57
                $data['post_author'] = filter_input(INPUT_GET, 'post_author');
58
            } else {
59
                unset($data['post_author']);
60
            }
61
        }
62
        if (is_numeric(filter_input(INPUT_POST, 'post_author_override'))) {
63
            // use the value from the author meta box
64
            $data['post_author'] = filter_input(INPUT_POST, 'post_author_override');
65
        }
66
        return $data;
67
    }
68 28
69
    /**
70 28
     * @filter site-reviews/rendered/template/review
71 28
     */
72
    public function filterReviewTemplate(string $template, array $data): string
73
    {
74
        $search = 'id="review-';
75
        $dataType = Arr::get($data, 'review.type', 'local');
76
        $replace = sprintf('data-type="%s" %s', $dataType, $search);
77
        if (Arr::get($data, 'review.is_pinned')) {
78
            $replace = 'data-pinned="1" '.$replace;
79
        }
80
        if (Arr::get($data, 'review.is_verified')) {
81
            $replace = 'data-verified="1" '.$replace;
82
        }
83
        return str_replace($search, $replace, $template);
84
    }
85
86
    /**
87
     * @filter site-reviews/query/sql/clause/operator
88
     */
89
    public function filterSqlClauseOperator(string $operator): string
90 8
    {
91
        $operators = ['loose' => 'OR', 'strict' => 'AND'];
92 8
        return Arr::get($operators, glsr_get_option('reviews.assignment', 'strict', 'string'), $operator);
93 8
    }
94 8
95 8
    /**
96
     * @filter site-reviews/review/build/after
97
     */
98 8
    public function filterTemplateTags(array $tags, Review $review, ReviewHtml $reviewHtml): array
99
    {
100
        $tags['assigned_links'] = $reviewHtml->buildTemplateTag($review, 'assigned_links', $review->assigned_posts);
101 8
        return $tags;
102
    }
103
104
    /**
105
     * Triggered when one or more categories are added or removed from a review.
106
     *
107 8
     * @action set_object_terms
108
     */
109 8
    public function onAfterChangeAssignedTerms(
110 8
        int $postId,
111
        array $terms,
112
        array $newTTIds,
113
        string $taxonomy,
114
        bool $append,
115
        array $oldTTIds
116 8
    ): void {
117
        if (Review::isReview($postId)) {
118 8
            $review = glsr(ReviewManager::class)->get($postId);
119 8
            $diff = $this->getAssignedDiffs($oldTTIds, $newTTIds);
120
            $this->execute(new UnassignTerms($review, $diff['old']));
121
            $this->execute(new AssignTerms($review, $diff['new']));
122
        }
123
    }
124
125
    /**
126
     * Triggered when a post status changes or when a review is approved|unapproved|trashed.
127
     *
128
     * @action transition_post_status
129
     */
130
    public function onAfterChangeStatus(string $new, string $old, ?\WP_Post $post): void
131
    {
132
        if (is_null($post)) {
133 28
            return; // This should never happen, but some plugins are bad actors so...
134
        }
135 28
        if (in_array($old, ['new', $new])) {
136 24
            return;
137 24
        }
138 24
        if (Review::isReview($post)) {
139 24
            $isAutoDraft = 'auto-draft' === $old && 'auto-draft' !== $new;
140
            if ($isAutoDraft) {
141
                glsr(ReviewManager::class)->createFromPost($post->ID);
142
            }
143
            $isPublished = 'publish' === $new;
144
            glsr(ReviewManager::class)->updateRating($post->ID, ['is_approved' => $isPublished]);
145
            glsr(Cache::class)->delete($post->ID, 'reviews');
146
            glsr(CountManager::class)->recalculate();
147
            if ($isAutoDraft) {
148
                return;
149
            }
150
            $review = glsr_get_review($post->ID);
151 28
            if ('publish' === $new) {
152
                glsr()->action('review/approved', $review, $old, $new);
153 28
            } elseif ('pending' === $new) {
154 28
                glsr()->action('review/unapproved', $review, $old, $new);
155
            } elseif ('trash' === $new) {
156
                glsr()->action('review/trashed', $review, $old, $new);
157
            }
158
            glsr()->action('review/transitioned', $review, $new, $old);
159
        } else {
160
            glsr(ReviewManager::class)->updateAssignedPost($post->ID);
161
        }
162
    }
163
164
    /**
165
     * Fallback action if ajax is not working for any reason.
166
     *
167
     * @action admin_action_approve
168
     */
169
    public function onApprove(): void
170
    {
171
        if (glsr()->id === filter_input(INPUT_GET, 'plugin')) {
172
            check_admin_referer('approve-review_'.($postId = $this->getPostId()));
173
            $this->execute(new ToggleStatus(new Request([
174
                'post_id' => $postId,
175
                'status' => 'publish',
176
            ])));
177
            wp_safe_redirect(wp_get_referer());
178
            exit;
179
        }
180
    }
181
182
    /**
183
     * Triggered when a review's assigned post IDs are updated.
184
     *
185
     * @action site-reviews/review/updated/post_ids
186
     */
187
    public function onChangeAssignedPosts(Review $review, array $postIds = []): void
188
    {
189
        $diff = $this->getAssignedDiffs($review->assigned_posts, $postIds);
190
        $this->execute(new UnassignPosts($review, $diff['old']));
191
        $this->execute(new AssignPosts($review, $diff['new']));
192
    }
193
194
    /**
195
     * Triggered when a review's assigned users IDs are updated.
196
     *
197
     * @action site-reviews/review/updated/user_ids
198
     */
199
    public function onChangeAssignedUsers(Review $review, array $userIds = []): void
200
    {
201
        $diff = $this->getAssignedDiffs($review->assigned_users, $userIds);
202
        $this->execute(new UnassignUsers($review, $diff['old']));
203
        $this->execute(new AssignUsers($review, $diff['new']));
204
    }
205
206
    /**
207
     * Triggered after a review is created.
208
     *
209
     * @action site-reviews/review/created
210 24
     */
211
    public function onCreatedReview(Review $review, CreateReview $command): void
212 24
    {
213 24
        $this->execute(new AssignPosts($review, $command->assigned_posts));
214
        $this->execute(new AssignUsers($review, $command->assigned_users));
215
    }
216
217
    /**
218
     * Triggered when a review is created.
219
     *
220 24
     * @action site-reviews/review/create
221
     */
222 24
    public function onCreateReview(int $postId, CreateReview $command): void
223 24
    {
224 24
        $values = glsr()->args($command->toArray()); // this filters the values
225 24
        $data = glsr(RatingDefaults::class)->restrict($values->toArray());
226 24
        $data['review_id'] = $postId;
227
        $data['is_approved'] = 'publish' === get_post_status($postId);
228
        if (false === glsr(Database::class)->insert('ratings', $data)) {
229
            glsr_log()->error('A review could not be created. Here are some things to try which may fix the problem:'.
230
                PHP_EOL.'1. First, deactivate Site Reviews and then reactivate it (this should fix any broken database table indexes).'.
231
                PHP_EOL.'2. Next, hold down the ALT key (Option key if using a Mac) and run the Migrate Plugin tool.'.
232
                PHP_EOL.'3. Finally, run the "Repair Review Relations" tool.'.
233
                PHP_EOL.'4. If the problem persists, please use the "Contact Support" section on the Help page.'
234
            );
235
            glsr_log()->debug($data);
236
            wp_delete_post($postId, true); // remove post as review was not created
237 24
            return;
238 24
        }
239
        $termIds = wp_set_object_terms($postId, $values->assigned_terms, glsr()->taxonomy);
240
        if (is_wp_error($termIds)) {
241 24
            glsr_log()->error($termIds->get_error_message());
242
        }
243
        if (!empty($values->response)) { // save the response if one is provided
244
            glsr(Database::class)->metaSet($postId, 'response', $values->response);
245 24
            glsr(Database::class)->metaSet($postId, 'response_by', $values->response_by); // @phpstan-ignore-line
246
        }
247
        foreach ($values->custom as $key => $value) {
248
            glsr(Database::class)->metaSet($postId, "custom_{$key}", $value);
249
        }
250
    }
251
252
    /**
253
     * Triggered when a review or other post type is deleted and the posts table uses the MyISAM engine.
254
     *
255
     * @action deleted_post
256
     */
257
    public function onDeletePost(int $postId, \WP_Post $post): void
258
    {
259
        if (glsr()->post_type === $post->post_type) {
260
            $this->onDeleteReview($postId);
261
            return;
262
        }
263
        $reviewIds = glsr(Query::class)->reviewIds([
264
            'assigned_posts' => $postId,
265
            'per_page' => -1,
266
            'status' => 'all',
267
        ]);
268
        if (glsr(Database::class)->delete('assigned_posts', ['post_id' => $postId])) {
269
            array_walk($reviewIds, function ($reviewId) {
270
                glsr(Cache::class)->delete($reviewId, 'reviews');
271
            });
272
        }
273
    }
274
275
    /**
276
     * Triggered when a review is deleted and the posts table uses the MyISAM engine.
277
     *
278
     * @see $this->onDeletePost()
279
     */
280
    public function onDeleteReview(int $reviewId): void
281
    {
282
        glsr(ReviewManager::class)->deleteRating($reviewId);
283
    }
284
285
    /**
286
     * Triggered when a user is deleted and the users table uses the MyISAM engine.
287
     *
288
     * @action deleted_user
289
     */
290
    public function onDeleteUser(int $userId = 0): void
291
    {
292
        $reviewIds = glsr(Query::class)->reviewIds([
293
            'assigned_users' => $userId,
294
            'per_page' => -1,
295
            'status' => 'all',
296
        ]);
297
        if (glsr(Database::class)->delete('assigned_users', ['user_id' => $userId])) {
298
            array_walk($reviewIds, function ($reviewId) {
299
                glsr(Cache::class)->delete($reviewId, 'reviews');
300
            });
301
        }
302
    }
303
304
    /**
305
     * Triggered when a review is edited or trashed.
306
     * It's unnecessary to trigger a term recount as this is done by the set_object_terms hook
307
     * We need to use "post_updated" to support revisions (vs "save_post").
308
     *
309
     * @action post_updated
310
     */
311
    public function onEditReview(int $postId, ?\WP_Post $post, ?\WP_Post $oldPost): void
312
    {
313
        if (is_null($post) || is_null($oldPost)) {
314
            return; // This should never happen, but some plugins are bad actors so...
315
        }
316
        if (!glsr()->can('edit_posts') || !$this->isEditedReview($post, $oldPost)) {
317
            return;
318
        }
319
        if (glsr()->id === filter_input(INPUT_GET, 'plugin')) {
320
            return; // the fallback approve/unapprove action is being run
321
        }
322
        if (!in_array(glsr_current_screen()->base, ['edit', 'post'])) {
323
            return; // only trigger this action from the Site Reviews edit/post screens
324
        }
325
        $review = glsr(ReviewManager::class)->get($postId);
326
        if ('edit' === glsr_current_screen()->base) {
327
            $this->bulkUpdateReview($review, $oldPost);
328
        } else {
329
            $this->updateReview($review, $oldPost);
330
        }
331
    }
332
333 24
    /**
334
     * Fallback action if ajax is not working for any reason.
335 24
     *
336
     * @action admin_action_unapprove
337
     */
338 24
    public function onUnapprove(): void
339 24
    {
340
        if (glsr()->id === filter_input(INPUT_GET, 'plugin')) {
341
            $postId = $this->getPostId();
342
            check_admin_referer("unapprove-review_{$postId}");
343
            $this->execute(new ToggleStatus(new Request([
344
                'post_id' => $postId,
345
                'status' => 'publish',
346
            ])));
347
            wp_safe_redirect(wp_get_referer());
348
            exit;
349
        }
350
    }
351
352
    /**
353
     * @action site-reviews/review/created
354
     */
355
    public function sendNotification(Review $review): void
356
    {
357
        if (defined('WP_IMPORTING')) {
358
            return;
359
        }
360
        if (empty(glsr_get_option('general.notifications'))) {
361
            return;
362
        }
363
        if (!in_array($review->status, ['pending', 'publish'])) {
364
            return; // this review is likely a draft made in the wp-admin
365
        }
366
        glsr(Queue::class)->async('queue/notification', ['review_id' => $review->ID]);
367
    }
368
369
    protected function bulkUpdateReview(Review $review, \WP_Post $oldPost): void
370
    {
371
        if ($assignedPostIds = filter_input(INPUT_GET, 'post_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY)) {
372
            glsr()->action('review/updated/post_ids', $review, Cast::toArray($assignedPostIds)); // trigger a recount of assigned posts
373
        }
374
        if ($assignedUserIds = filter_input(INPUT_GET, 'user_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY)) {
375
            glsr()->action('review/updated/user_ids', $review, Cast::toArray($assignedUserIds)); // trigger a recount of assigned users
376 24
        }
377
        $review = glsr(ReviewManager::class)->get($review->ID); // get a fresh copy of the review
378 24
        glsr()->action('review/updated', $review, [], $oldPost); // pass an empty array since review values are unchanged
379 24
    }
380 24
381 24
    protected function getAssignedDiffs(array $existing, array $replacements): array
382 2
    {
383 2
        sort($existing);
384 2
        sort($replacements);
385
        $new = $old = [];
386 24
        if ($existing !== $replacements) {
387 24
            $ignored = array_intersect($existing, $replacements);
388 24
            $new = array_diff($replacements, $ignored);
389 24
            $old = array_diff($existing, $ignored);
390
        }
391
        return [
392
            'new' => $new,
393
            'old' => $old,
394
        ];
395
    }
396
397
    protected function isEditedReview(\WP_Post $post, \WP_Post $oldPost): bool
398
    {
399
        if (glsr()->post_type !== $post->post_type) {
400
            return false;
401
        }
402
        if (in_array('trash', [$post->post_status, $oldPost->post_status])) {
403
            return false; // trashed posts cannot be edited
404
        }
405
        $input = 'edit' === glsr_current_screen()->base ? INPUT_GET : INPUT_POST;
406
        return filter_input($input, 'action') !== glsr()->prefix.'admin_action'; // abort if not a proper post update (i.e. approve/unapprove)
407
    }
408
409
    protected function refreshAvatar(array $data, Review $review): string
410
    {
411
        $avatarUrl = Cast::toString($data['avatar'] ?? '');
412
        if ($review->author === ($data['name'] ?? false)) {
413
            return $avatarUrl;
414
        }
415
        $url = preg_replace('/(.*)\/site-reviews\/avatars\/[\p{L&}]+\.svg$/u', '', $avatarUrl);
416
        if (empty($url)) { // only update the initials fallback avatar
417
            $review->set('author', $data['name'] ?? '');
418
            $avatarUrl = glsr(Avatar::class)->generateInitials($review);
419
        }
420
        return $avatarUrl;
421
    }
422
423
    protected function updateReview(Review $review, \WP_Post $oldPost): void
424
    {
425
        $customDefaults = array_fill_keys(array_keys($review->custom()->toArray()), '');
426
        $data = Helper::filterInputArray(glsr()->id);
427
        $data = wp_parse_args($data, $customDefaults); // this ensures we save all empty custom values
428
        if (Arr::get($data, 'is_editing_review')) {
429
            $data['avatar'] = $this->refreshAvatar($data, $review);
430
            $data['rating'] = Arr::get($data, 'rating');
431
            $data['terms'] = Arr::get($data, 'terms', 0);
432
            if (!glsr()->filterBool('verification/enabled', false)) {
433
                unset($data['is_verified']);
434
            }
435
            glsr(ReviewManager::class)->updateRating($review->ID, $data); // values are sanitized here
436
            glsr(ReviewManager::class)->updateCustom($review->ID, $data); // values are sanitized here
437
            $review = glsr(ReviewManager::class)->get($review->ID); // get a fresh copy of the review
438
        }
439
        $assignedPostIds = filter_input(INPUT_POST, 'post_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY);
440
        $assignedUserIds = filter_input(INPUT_POST, 'user_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY);
441
        glsr()->action('review/updated/post_ids', $review, Cast::toArray($assignedPostIds)); // trigger a recount of assigned posts
442
        glsr()->action('review/updated/user_ids', $review, Cast::toArray($assignedUserIds)); // trigger a recount of assigned users
443
        glsr(ResponseMetabox::class)->save($review);
444
        $review = glsr(ReviewManager::class)->get($review->ID); // get a fresh copy of the review
445
        glsr()->action('review/updated', $review, $data, $oldPost);
446
    }
447
}
448