ReviewController::onApprove()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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