PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.19 trunk All 48 releases
← All changes | api/Resource/AttrGroupResource.php +194 -115 1.4.1 → 1.6.5 View file →
@@ -1,83 +1,31 @@
1 1 <?php
2 2
3 3 namespace FluentCart\Api\Resource;
4 4
5 +use FluentCart\Api\Resource\BaseResourceApi;
6 +use FluentCart\App\App;
5 7 use FluentCart\App\Helpers\HelperTrait;
6 8 use FluentCart\App\Models\AttributeGroup;
7 9 use FluentCart\App\Models\AttributeRelation;
8 10 use FluentCart\Framework\Database\Orm\Builder;
9 11 use FluentCart\Framework\Support\Arr;
12 +use FluentCart\App\Services\Filter\AttrGroupFilter;
10 13
11 14 class AttrGroupResource extends BaseResourceApi
12 15 {
13 16 use HelperTrait;
14 17
15 - private static array $orderByCols = ['title', 'id', 'slug', 'created_at'];
16 -
17 18 public static function getQuery(): Builder
18 19 {
19 20 return AttributeGroup::query();
20 21 }
21 22
22 - /**
23 - * Retrieve attribute groups based on the provided parameters.
24 - *
25 - * @param array $params Optional. Params containing the necessary parameters to retrieve.
26 - * [
27 - * 'params' => [
28 - * 'with' => (array) Optional. Relationships name to be eager loaded,
29 - * "search" => (array) Optional.
30 - * [ "column name(e.g., title|slug)" => [
31 - * "column" => "column name(e.g., title|slug)",
32 - * "operator" => "(string)(e.g., like_all|rlike|or_rlike)",
33 - * "value" => (string|array) ]
34 - * ],
35 - * "filters" => (array) Optional.
36 - * [ "column name(e.g., title|slug)" => [
37 - * "column" => "column name(e.g., title|slug)",
38 - * "operator" => "(string)(e.g., in)",
39 - * "value" => (string|array) ]
40 - * ],
41 - * 'order_by' => (string) Optional. Column to order by,
42 - * 'order_type' => (string) Optional. Order type for sorting (ASC or DESC),
43 - * 'per_page' => (int) Optional. Number of items for per page,
44 - * 'page' => (int) Optional. Page number for pagination
45 - * ]
46 - * ]
47 - *
48 - */
49 23 public static function get(array $params = [])
50 24 {
51 - $with = Arr::get($params["params"], 'with', []);
52 -
53 - return static::getQuery()->with($with)->withCount($with)
54 - ->when(Arr::get($params["params"], 'search'), function ($query) use ($params) {
55 - return $query->search(Arr::get($params["params"], 'search', ''));
56 - })
57 - ->when(!empty($with), function ($query) use ($params) {
58 - return $query->applyCustomFilters(Arr::get($params["params"], 'filters', []));
59 - })
60 - ->orderBy(
61 - sanitize_sql_orderby(static::getValWithinEnum(Arr::get($params["params"], 'order_by'), static::$orderByCols, 'title')),
62 - sanitize_sql_orderby(static::getValWithinEnum(Arr::get($params["params"], 'order_type'), static::$orderByEnum, 'ASC'))
63 - )
64 - ->paginate(Arr::get($params["params"], 'per_page', 10), ['*'], 'page', Arr::get($params["params"], 'page'));
65 -
25 + return AttrGroupFilter::make($params)->paginate();
66 26 }
67 27
68 - /**
69 - * Find and retrieve attribute group based on the ID and given params.
70 - *
71 - * @param int $id Required. The ID of the attribute group to find and retrieve.
72 - * @param array $params Optional. Additional parameters for finding attribute groups.
73 - * [
74 - * 'params' => [
75 - * 'with' => (array) Optional. Relationships name to be eager loaded
76 - * ]
77 - * ]
78 - *
79 - */
80 28 public static function find($id, $params = [])
81 29 {
82 30 $with = Arr::get($params, 'with', []);
83 31
@@ -90,31 +38,39 @@
90 38 $with = static::getArrValWithinEnum($with, ['terms'], 'terms');
91 39 return $query->with($with)->find($id);
92 40 }
93 41
94 - /**
95 - * Create attribute group with the provided data.
96 - *
97 - * @param array $data Required. Array containing the necessary parameters
98 - * $data => (array) Required. Array of attribute group data.
99 - * [
100 - * 'title' => (string) Required. The title of the attr group.
101 - * 'slug' => (string) Required. The slug of the attr group.
102 - * 'description' => (string) Optional. The description of the attr group.
103 - * ]
104 - * @param array $params Optional. Additional parameters for attribute group creation.
105 - * [
106 - * // Include optional parameters, if any.
107 - * ]
108 - *
109 - */
110 42 public static function create($data, $params = [])
111 43 {
112 - $groupCreated = static::getQuery()->create($data);
44 + if (empty($data['slug']) && !empty($data['title'])) {
45 + $data['slug'] = static::generateUniqueSlug($data['title']);
46 + }
113 47
114 - if ($groupCreated) {
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) {
115 71 return static::makeSuccessResponse(
116 - $groupCreated,
72 + $group,
117 73 __('Successfully created!', 'fluent-cart')
118 74 );
119 75 }
120 76
@@ -122,31 +78,45 @@
122 78 ['code' => 400, 'message' => __('Group creation failed.', 'fluent-cart')]
123 79 ]);
124 80 }
125 81
126 - /**
127 - * Update attribute group with the provided data.
128 - *
129 - * @param array $data Required. Array containing the necessary parameters
130 - * $data => (array) Required. Array of attribute group data.
131 - * [
132 - * 'title' => (string) Required. The title of the attr group.
133 - * 'description' => (string) Optional. The description of the attr group.
134 - * ]
135 - * @param int $groupId Required. The id of the attribute group to update.
136 - * @param array $params Optional. Additional parameters for attribute group creation.
137 - * [
138 - * // Include optional parameters, if any.
139 - * ]
140 - *
141 - */
142 82 public static function update($data, $groupId, $params = [])
143 83 {
144 - $isUpdated = static::getQuery()->findOrFail($groupId)->update($data);
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);
145 94
146 - if ($isUpdated) {
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) {
147 117 return static::makeSuccessResponse(
148 - $isUpdated,
118 + $wasUpdated,
149 119 __('Group updated successfully!', 'fluent-cart')
150 120 );
151 121 }
152 122
@@ -154,39 +124,148 @@
154 124 ['code' => 400, 'message' => __('Group info update failed.', 'fluent-cart')]
155 125 ]);
156 126 }
157 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);
158 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 +
159 155 /**
160 - * Delete attribute group by ID.
161 - *
162 - * @param int $groupId Required. The ID of the attribute group to delete.
163 - * @param array $params Optional. Additional parameters for attribute group deletion.
164 - * [
165 - * // Include optional parameters, if any.
166 - * ]
167 - *
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.
168 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 +
169 168 public static function delete($groupId, $params = [])
170 169 {
171 - $isUsed = AttributeRelation::query()->where('group_id', $groupId)->first();
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);
172 181
173 - if (!$isUsed) {
174 - $group = static::getQuery()->find($groupId);
175 - if ($group) {
176 - $group->delete();
177 - return static::makeSuccessResponse(
178 - '',
179 - __('Attribute group successfully deleted!', 'fluent-cart')
180 - );
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 + ]);
181 187 }
182 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();
183 209 return static::makeErrorResponse([
184 - ['code' => 404, 'message' => __('Attribute group not found in database, failed to remove.', 'fluent-cart')]
210 + ['code' => 500, 'message' => __('Failed to delete attribute group.', 'fluent-cart')]
185 211 ]);
186 212 }
187 213
188 - return static::makeErrorResponse([
189 - ['code' => 403, 'message' => __('This group is already in use, can not be deleted.', 'fluent-cart')]
190 - ]);
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 + }
191 270 }
192 271 }