| @@ -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 | } |