PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / api / Resource / AttrGroupResource.php

AttrGroupResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at api/Resource/AttrGroupResource.php

272 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\Api\Resource;
4
5 use FluentCart\Api\Resource\BaseResourceApi;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\HelperTrait;
8 use FluentCart\App\Models\AttributeGroup;
9 use FluentCart\App\Models\AttributeRelation;
10 use FluentCart\Framework\Database\Orm\Builder;
11 use FluentCart\Framework\Support\Arr;
12 use FluentCart\App\Services\Filter\AttrGroupFilter;
13
14 class AttrGroupResource extends BaseResourceApi
15 {
16 use HelperTrait;
17
18 public static function getQuery(): Builder
19 {
20 return AttributeGroup::query();
21 }
22
23 public static function get(array $params = [])
24 {
25 return AttrGroupFilter::make($params)->paginate();
26 }
27
28 public static function find($id, $params = [])
29 {
30 $with = Arr::get($params, 'with', []);
31
32 $query = static::getQuery();
33
34 if (empty($with)) {
35 return $query->find($id);
36 }
37
38 $with = static::getArrValWithinEnum($with, ['terms'], 'terms');
39 return $query->with($with)->find($id);
40 }
41
42 public static function create($data, $params = [])
43 {
44 if (empty($data['slug']) && !empty($data['title'])) {
45 $data['slug'] = static::generateUniqueSlug($data['title']);
46 }
47
48 // Append new groups to the end of the merchant's manual order. max()+1
49 // keeps the dense 1..N sequence going so the sidebar (orderBy serial ASC)
50 // shows the new group last until the merchant drags it elsewhere — mirrors
51 // AttrTermResource::create's per-group serial assignment.
52 $data['serial'] = (int) AttributeGroup::query()->max('serial') + 1;
53
54 try {
55 $group = static::getQuery()->create($data);
56 } catch (\Throwable $e) {
57 // Concurrent POSTs with the same slug can both pass the unique
58 // validator (TOCTOU) and collide at the DB UNIQUE on slug. Surface
59 // a clean 422 instead of leaking the raw exception as a 500.
60 if (static::isUniqueViolation($e)) {
61 return static::makeErrorResponse([
62 ['code' => 422, 'message' => __('A group with this slug already exists.', 'fluent-cart')]
63 ]);
64 }
65 return static::makeErrorResponse([
66 ['code' => 500, 'message' => __('Group creation failed.', 'fluent-cart')]
67 ]);
68 }
69
70 if ($group) {
71 return static::makeSuccessResponse(
72 $group,
73 __('Successfully created!', 'fluent-cart')
74 );
75 }
76
77 return static::makeErrorResponse([
78 ['code' => 400, 'message' => __('Group creation failed.', 'fluent-cart')]
79 ]);
80 }
81
82 public static function update($data, $groupId, $params = [])
83 {
84 // Wrap the lookup and update in a transaction with a row lock on
85 // the group so concurrent writers can't split the read and the
86 // mutation. Also lets us return a clean 422 on slug UNIQUE collision.
87 // System-seeded templates (Color, Size, …) are editable now — the
88 // earlier is_system rename block was rolled back per product
89 // decision; in-use protection only fires on delete below.
90 $connection = static::getQuery()->getConnection();
91 $connection->beginTransaction();
92 try {
93 $group = static::getQuery()->lockForUpdate()->find($groupId);
94
95 if (!$group) {
96 $connection->rollBack();
97 return static::makeErrorResponse([
98 ['code' => 404, 'message' => __('Attribute group not found.', 'fluent-cart')]
99 ]);
100 }
101
102 $wasUpdated = $group->update($data);
103 $connection->commit();
104 } catch (\Throwable $e) {
105 $connection->rollBack();
106 if (static::isUniqueViolation($e)) {
107 return static::makeErrorResponse([
108 ['code' => 422, 'message' => __('A group with this slug already exists.', 'fluent-cart')]
109 ]);
110 }
111 return static::makeErrorResponse([
112 ['code' => 500, 'message' => __('Group info update failed.', 'fluent-cart')]
113 ]);
114 }
115
116 if ($wasUpdated) {
117 return static::makeSuccessResponse(
118 $wasUpdated,
119 __('Group updated successfully!', 'fluent-cart')
120 );
121 }
122
123 return static::makeErrorResponse([
124 ['code' => 400, 'message' => __('Group info update failed.', 'fluent-cart')]
125 ]);
126 }
127
128 /**
129 * Generate a unique slug for a new group. Mirrors AttrTermResource::generateUniqueSlug()
130 * but scoped to the groups table (global slug UNIQUE, not per-group).
131 */
132 private static function generateUniqueSlug(string $title): string
133 {
134 $baseSlug = sanitize_title($title);
135
136 $existingSlugs = AttributeGroup::query()
137 ->where('slug', 'LIKE', "{$baseSlug}%")
138 ->pluck('slug')
139 ->toArray();
140
141 if (!in_array($baseSlug, $existingSlugs, true)) {
142 return $baseSlug;
143 }
144
145 $maxSuffix = 1;
146 foreach ($existingSlugs as $existingSlug) {
147 if (preg_match('/^' . preg_quote($baseSlug, '/') . '-(\d+)$/', $existingSlug, $matches)) {
148 $maxSuffix = max($maxSuffix, (int) $matches[1]);
149 }
150 }
151
152 return "{$baseSlug}-" . ($maxSuffix + 1);
153 }
154
155 /**
156 * MySQL/MariaDB UNIQUE constraint violation detector. SQLSTATE 23000 covers
157 * duplicate-key errors on slug UNIQUE. Used so concurrent inserts/updates
158 * return a clean 422 instead of leaking a generic 500.
159 */
160 private static function isUniqueViolation(\Throwable $e): bool
161 {
162 $msg = $e->getMessage();
163 return strpos($msg, '1062') !== false
164 || strpos($msg, 'Duplicate entry') !== false
165 || strpos($msg, 'SQLSTATE[23000]') !== false;
166 }
167
168 public static function delete($groupId, $params = [])
169 {
170 // Wrap the lookup, the in-use guard, and the cascading
171 // delete in a single transaction with row-level locks. Without locking
172 // the group row + the relations lookup, a concurrent variant attach
173 // between the in-use check and the cascading delete could leave
174 // orphan relation rows pointing at the deleted group. The AttributeGroup
175 // deleting boot hook also cascades to terms in a separate query, so
176 // the wrap ensures group + terms commit atomically.
177 $connection = AttributeRelation::query()->getConnection();
178 $connection->beginTransaction();
179 try {
180 $group = static::getQuery()->lockForUpdate()->find($groupId);
181
182 if (!$group) {
183 $connection->rollBack();
184 return static::makeErrorResponse([
185 ['code' => 404, 'message' => __('Attribute group not found in database, failed to remove.', 'fluent-cart')]
186 ]);
187 }
188
189 // System-vs-merchant distinction no longer blocks deletion; the
190 // only protection that remains is the in-use guard below, which
191 // catches the case where any product variant still references
192 // this group via fct_atts_relations.
193 $existingRelation = AttributeRelation::query()
194 ->where('group_id', $groupId)
195 ->lockForUpdate()
196 ->first();
197
198 if ($existingRelation) {
199 $connection->rollBack();
200 return static::makeErrorResponse([
201 ['code' => 403, 'message' => __('This group is already in use, can not be deleted.', 'fluent-cart')]
202 ]);
203 }
204
205 $group->delete();
206 $connection->commit();
207 } catch (\Throwable $e) {
208 $connection->rollBack();
209 return static::makeErrorResponse([
210 ['code' => 500, 'message' => __('Failed to delete attribute group.', 'fluent-cart')]
211 ]);
212 }
213
214 return static::makeSuccessResponse(
215 '',
216 __('Attribute group successfully deleted!', 'fluent-cart')
217 );
218 }
219
220 /**
221 * Persist the merchant's drag-reorder of attribute groups in the library.
222 * Receives group IDs in the desired display order and writes a dense
223 * serial (1-indexed) to each. Mirrors AttrTermResource::reorder, one level
224 * up — the only difference is there's no parent group to scope ownership
225 * to, so we just confirm every ID is a real group before writing.
226 *
227 * The sidebar paginates (Load More), so the submitted set is the loaded
228 * prefix of the serial-ordered list; reassigning it to 1..k stays within
229 * the prefix's own range and never collides with unloaded groups.
230 */
231 public static function reorder($params = [])
232 {
233 // IDs are already sanitized by the controller (int-cast, positive-only,
234 // deduped, capped). Take them as-is here.
235 $ids = (array) Arr::get($params, 'ids', []);
236
237 if (empty($ids)) {
238 return static::makeErrorResponse([
239 ['code' => 422, 'message' => __('No group IDs provided.', 'fluent-cart')]
240 ]);
241 }
242
243 $ownedCount = AttributeGroup::query()
244 ->whereIn('id', $ids)
245 ->count();
246
247 if ($ownedCount !== count($ids)) {
248 return static::makeErrorResponse([
249 ['code' => 404, 'message' => __('One or more group IDs do not exist.', 'fluent-cart')]
250 ]);
251 }
252
253 $values = [];
254 foreach ($ids as $index => $id) {
255 $values[] = ['id' => $id, 'serial' => $index + 1];
256 }
257
258 $db = App::db();
259 $db->beginTransaction();
260 try {
261 AttributeGroup::query()->batchUpdate($values);
262 $db->commit();
263 return static::makeSuccessResponse([], __('Groups reordered.', 'fluent-cart'));
264 } catch (\Throwable $e) {
265 $db->rollBack();
266 return static::makeErrorResponse([
267 ['code' => 500, 'message' => __('Failed to reorder groups.', 'fluent-cart')]
268 ]);
269 }
270 }
271 }
272