PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Models / ProductReview.php

ProductReview.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.0, at app/Models/ProductReview.php

255 lines 7.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Models;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Concerns\CanSearch;
8
9 /**
10 * ProductReview Model - Backed by the fct_product_reviews custom table.
11 *
12 * Reviews and admin replies live in one table: a review row has
13 * parent_id = NULL; a reply has parent_id = the parent review id.
14 *
15 * Media files stay in the WP Media Library (wp_posts); their references
16 * live in other_info['media'] with the denormalized media_count column
17 * maintained on every attach/detach (same update, never separately).
18 *
19 * @property int $id
20 * @property int|null $parent_id
21 * @property string $type
22 * @property int $post_id
23 * @property int|null $item_id
24 * @property int|null $order_id
25 * @property int|null $customer_id
26 * @property int|null $user_id
27 * @property string $reviewer_name
28 * @property string $reviewer_email
29 * @property string|null $title
30 * @property string $review
31 * @property int|null $rating
32 * @property string $status
33 * @property int $is_verified
34 * @property int $is_admin_reply
35 * @property int $media_count
36 * @property string|null $ip_address
37 * @property array|null $other_info
38 * @property string|null $created_at
39 * @property string|null $updated_at
40 * @property-read string $content
41 * @property-read array|null $meta
42 * @property-read array $media
43 *
44 * @package FluentCart\App\Models
45 *
46 * @version 1.1.0
47 */
48 class ProductReview extends Model
49 {
50 use CanSearch;
51
52 protected $table = 'fct_product_reviews';
53
54 // Statuses live in Status::REVIEW_* (single source of truth).
55
56 /**
57 * Trust fields (user_id, customer_id, order_id, status, is_verified) and
58 * the system-maintained media_count are NOT mass-assignable — the write
59 * path (ProductReviewResource) sets them by explicit property assignment
60 * after deriving them server-side. Everything else is request-shaped data
61 * that the controllers whitelist and sanitize.
62 */
63 protected $fillable = [
64 'parent_id',
65 'post_id',
66 // The variation the review is about, or NULL for the product as a
67 // whole. Request-shaped, but the submit path validates it against
68 // the product and the order grant before it reaches create().
69 'item_id',
70 'reviewer_name',
71 'reviewer_email',
72 'title',
73 'review',
74 'rating',
75 'ip_address',
76 'other_info',
77 ];
78
79 protected $guarded = ['id', 'user_id', 'customer_id', 'order_id', 'status', 'is_verified', 'is_admin_reply', 'media_count'];
80
81 protected $casts = [
82 'parent_id' => 'integer',
83 'post_id' => 'integer',
84 'item_id' => 'integer',
85 'order_id' => 'integer',
86 'customer_id' => 'integer',
87 'user_id' => 'integer',
88 'rating' => 'integer',
89 'is_verified' => 'integer',
90 'is_admin_reply' => 'integer',
91 'media_count' => 'integer',
92 'other_info' => 'array',
93 ];
94
95 protected $hidden = ['review', 'ip_address', 'other_info'];
96
97 protected $appends = ['content', 'photo'];
98
99 /**
100 * The row's photo, appended under the same name Customer appends and
101 * resolved by the same service, on the review's own columns: the
102 * review's user when one is set, otherwise the reviewer email.
103 */
104 public function getPhotoAttribute(): string
105 {
106 return Helper::getUserAvatarUrl($this->user_id, $this->reviewer_email);
107 }
108
109 /**
110 * Public attribute `content` maps to the `review` column so the API
111 * response shape stays identical to the wp_comments-backed model.
112 */
113 public function getContentAttribute()
114 {
115 return $this->attributes['review'] ?? '';
116 }
117
118 public function setContentAttribute($value)
119 {
120 $this->attributes['review'] = $value;
121 }
122
123 /**
124 * Extra review data (kept under other_info, media refs excluded).
125 */
126 public function getMetaAttribute()
127 {
128 $info = $this->other_info;
129 if (!is_array($info)) {
130 return null;
131 }
132 unset($info['media']);
133 return $info ?: null;
134 }
135
136 /**
137 * Media references: [{id, type, order}, ...] — files are wp_posts attachments.
138 */
139 public function getMediaAttribute()
140 {
141 $info = $this->other_info;
142 return (is_array($info) && !empty($info['media']) && is_array($info['media'])) ? $info['media'] : [];
143 }
144
145
146 public function scopeTopLevel($query)
147 {
148 return $query->whereNull('parent_id');
149 }
150
151 public function scopeOfStatus($query, $status)
152 {
153 if (!$status || $status === 'all') {
154 return $query;
155 }
156 return $query->where('status', $status);
157 }
158
159 public function scopeApproved($query)
160 {
161 return $query->where('status', Status::REVIEW_APPROVED);
162 }
163
164 public function scopeOfProduct($query, $postId)
165 {
166 return $postId ? $query->where('post_id', (int) $postId) : $query;
167 }
168
169 public function scopeOfRating($query, $rating)
170 {
171 if (!$rating || !is_numeric($rating)) {
172 return $query;
173 }
174 return $query->where('rating', (int) $rating);
175 }
176
177 /**
178 * Reviews at or above a rating.
179 *
180 * The floor a list is willing to show, which is a different question from
181 * ofRating()'s "this star and no other" — that one answers the star chips,
182 * where picking 4 means four. A list set to a minimum of 4 shows fours and
183 * fives both.
184 *
185 * Anything outside 1-5 is not a floor, so it is ignored rather than
186 * narrowed to nothing: a stored 0 means "no minimum", and a stray 9 would
187 * otherwise empty the list with no way to see why.
188 */
189 public function scopeMinRating($query, $rating)
190 {
191 $rating = (int) $rating;
192
193 if ($rating < 1 || $rating > 5) {
194 return $query;
195 }
196
197 return $query->where('rating', '>=', $rating);
198 }
199
200 public function scopeWithMedia($query)
201 {
202 return $query->where('media_count', '>', 0);
203 }
204
205 /**
206 * Reviews that have at least one reply (admin or customer follow-up).
207 */
208 public function scopeHasReplies($query)
209 {
210 return $query->has('replies');
211 }
212
213 /**
214 * Reviews nobody has replied to yet.
215 */
216 public function scopeWithoutReplies($query)
217 {
218 return $query->doesntHave('replies');
219 }
220
221 public function product()
222 {
223 return $this->belongsTo(Product::class, 'post_id', 'ID');
224 }
225
226 public function replies()
227 {
228 return $this->hasMany(static::class, 'parent_id', 'id');
229 }
230
231 public function customer()
232 {
233 return $this->belongsTo(Customer::class, 'customer_id', 'id');
234 }
235
236 public function order()
237 {
238 return $this->belongsTo(Order::class, 'order_id', 'id');
239 }
240
241 /**
242 * Helpful/not-helpful votes — the related model ships in fluent-cart-pro
243 * (same free→pro pattern as Order::licenses()), so only Pro code paths
244 * (helpful sort, vote counts) invoke this relation. The interactions
245 * table also holds review reports, so the relation is scoped to the
246 * vote type.
247 */
248 public function votes()
249 {
250 return $this->hasMany(\FluentCartPro\App\Modules\Reviews\Models\ReviewInteraction::class, 'review_id', 'id')
251 ->where('type', 'vote');
252 }
253
254 }
255